Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
ba5860c
commit wip for a sanity check
ubik2 Apr 18, 2025
451f43f
Added Tarjan's to enabled SCCs if we need.
ubik2 Apr 20, 2025
d1fb1e4
Change back to ifc as the schema property.
ubik2 Apr 21, 2025
50d89e8
Merge branch 'main' into feat/cfc
ubik2 Apr 23, 2025
530bd98
Propagate ifc properties through node graphs and from argumentSchema …
ubik2 Apr 24, 2025
9c5f28e
replace FIXME with a tagged TODO
ubik2 Apr 24, 2025
67750ae
Remove cfcTest, since it's not useful enough
ubik2 Apr 24, 2025
b3bedfd
Merge branch 'main' into feat/cfc
ubik2 Apr 24, 2025
0cd0929
Make constants for Classifications.
ubik2 Apr 25, 2025
567c0e4
Merge branch 'main' into feat/cfc2
ubik2 Apr 28, 2025
666b8d5
add test that result schema has had the confidential flag added
ubik2 Apr 28, 2025
9b7d90b
submit classification assertions in our storage pull
ubik2 Apr 29, 2025
cd10dd5
Merge branch 'main' into feat/cfc2
ubik2 Apr 29, 2025
e834723
fixed unintentional removal of export
ubik2 Apr 29, 2025
c44c166
minor cleanup
ubik2 Apr 29, 2025
f0aa7fa
check classification from labels
ubik2 Apr 29, 2025
644f3b2
More changes to support classification
ubik2 Apr 30, 2025
0b9b777
removed stray log statement
ubik2 Apr 30, 2025
36739f3
Change invocation return handler to return false if it should be remo…
ubik2 May 1, 2025
7a54971
Added a command line utility to let me get and put data to storage.
ubik2 May 2, 2025
e1827d6
Allow passing the spaceName in the URL
ubik2 May 2, 2025
9cec592
Use classification to mark mail token secret and prevent access.
ubik2 May 7, 2025
62f0d89
Merge branch 'main' into feat/cfc2
ubik2 May 7, 2025
bf4b159
Use the rootSchema argument as the rootSchema for getting the childSc…
ubik2 May 8, 2025
0ad20f4
Added comment about return velue here
ubik2 May 8, 2025
087354e
Clean up AuthorizationError (still ugly, but better).
ubik2 May 8, 2025
0ef70a0
added some comments to the SchemaQueryArgs
ubik2 May 8, 2025
4ec1ead
Change this back to just Result, since we can't use AwaitResult in ot…
ubik2 May 8, 2025
d5422a6
Updated tests to use the new string (Insufficient access), since I pr…
ubik2 May 8, 2025
5180e20
Use $defs instead of older definitions property
ubik2 May 8, 2025
90a7bfc
exclude seeder templates from deno fmt action
ubik2 May 8, 2025
d960821
Feat/cfc2 integration (#1141)
ubik2 May 8, 2025
7e2c203
Back out this stringify, since we have a better solution available now.
ubik2 May 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@
"singleQuote": false,
"proseWrap": "always",
"exclude": [
"packages/jumble/integration/cache/llm-api-cache/"
"packages/jumble/integration/cache/llm-api-cache/",
"packages/seeder/templates/"
]
},
"imports": {
Expand Down Expand Up @@ -114,4 +115,4 @@
"zod-to-json-schema": "npm:zod-to-json-schema@^3.24.1",
"zod": "npm:zod@^3.24.1"
}
}
}
2 changes: 1 addition & 1 deletion packages/background-charm-service/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const BGCharmEntrySchema = {
"lastRun",
"status",
],
} as const as JSONSchema;
} as const satisfies JSONSchema;
export type BGCharmEntry = Schema<typeof BGCharmEntrySchema>;

export const BGCharmEntriesSchema = {
Expand Down
41 changes: 21 additions & 20 deletions packages/builder/src/opaque-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { hasValueAtPath, setValueAtPath } from "./utils.ts";
import { getTopFrame, recipe } from "./recipe.ts";
import { createNodeFactory } from "./module.ts";
import { SchemaWithoutCell } from "./schema-to-ts.ts";
import { ContextualFlowControl } from "../../runner/src/index.ts";

let mapFactory: NodeFactory<any, any>;

Expand Down Expand Up @@ -54,12 +55,13 @@ export function opaqueRef<T>(
};

let unsafe_binding: { recipe: Recipe; path: PropertyKey[] } | undefined;
const cfc = new ContextualFlowControl();

function createNestedProxy(
path: PropertyKey[],
target?: any,
schema?: JSONSchema,
rootSchema: JSONSchema | undefined = schema,
target: any,
nestedSchema: JSONSchema | undefined,
rootSchema: JSONSchema | undefined,
): OpaqueRef<any> {
const methods: OpaqueRefMethods<any> = {
get: () => unsafe_materialize(unsafe_binding, path),
Expand All @@ -70,23 +72,14 @@ export function opaqueRef<T>(
},
key: (key: PropertyKey) => {
// Determine child schema when accessing a property
let childSchema: JSONSchema | undefined;

if (schema?.type === "object") {
// For root object properties
childSchema = schema.properties?.[key as string] ||
(typeof schema.additionalProperties === "object"
? schema.additionalProperties
: undefined);
} else if (schema?.type === "array") {
// For root array elements
childSchema = schema.items;
}
const childSchema = key in methods
? undefined
: cfc.getSchemaAtPath(nestedSchema, [key.toString()], rootSchema);
return createNestedProxy(
[...path, key],
key in methods ? methods[key as keyof OpaqueRefMethods<any>] : store,
childSchema,
childSchema ? rootSchema : undefined, // Only pass rootSchema if we have a child schema
childSchema === undefined ? undefined : rootSchema,
);
},
setDefault: (newValue: Opaque<any>) => {
Expand All @@ -102,13 +95,13 @@ export function opaqueRef<T>(
setSchema: (newSchema: JSONSchema) => {
// This sets the schema of the nested proxy, but does not alter the parent store's
// schema. Our schema variable shadows that one.
schema = newSchema;
nestedSchema = newSchema;
},
connect: (node: NodeRef) => store.nodes.add(node),
export: () => {
// Store's schema won't be the same as ours as a nested proxy
// We also don't adjust the defaultValue to be relative to our path
return { cell: top, path, rootSchema, ...store, schema };
return { cell: top, ...store, path, rootSchema, schema: nestedSchema };
},
unsafe_bindToRecipeAndPath: (
recipe: Recipe,
Expand Down Expand Up @@ -159,9 +152,17 @@ export function opaqueRef<T>(
"Can't use iterator over an opaque value in an unlimited loop.",
);
}
const childSchema = cfc.getSchemaAtPath(nestedSchema, [
index.toString(),
], rootSchema);
return {
done: false,
value: createNestedProxy([...path, index++]),
value: createNestedProxy(
[...path, index++],
target,
childSchema,
childSchema === undefined ? undefined : rootSchema,
),
};
},
};
Expand Down Expand Up @@ -191,7 +192,7 @@ export function opaqueRef<T>(
return proxy;
}

const top = createNestedProxy([], store, schema) as OpaqueRef<T>;
const top = createNestedProxy([], store, schema, schema) as OpaqueRef<T>;

store.frame.opaqueRefs.add(top);

Expand Down
8 changes: 6 additions & 2 deletions packages/builder/src/recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ export function recipe<T, R>(

const inputs = opaqueRef<Required<T>>(
undefined,
argumentSchema as JSONSchema | undefined,
typeof argumentSchema === "string"
? undefined
: argumentSchema as JSONSchema | undefined,
);

const outputs = fn!(inputs);
Expand All @@ -125,7 +127,9 @@ export function recipeFromFrame<T, R>(
): RecipeFactory<T, R> {
const inputs = opaqueRef<Required<T>>(
undefined,
argumentSchema as JSONSchema | undefined,
typeof argumentSchema === "string"
? undefined
: argumentSchema as JSONSchema | undefined,
);
const outputs = fn(inputs);
return factoryFromRecipe<T, R>(argumentSchema, resultSchema, inputs, outputs);
Expand Down
1 change: 1 addition & 0 deletions packages/builder/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export type JSONSchema = {
readonly enum?: readonly string[];
readonly items?: Readonly<JSONSchema>;
readonly $ref?: string;
readonly $defs?: Readonly<Record<string, JSONSchema>>;
readonly asCell?: boolean;
readonly asStream?: boolean;
readonly anyOf?: readonly JSONSchema[];
Expand Down
3 changes: 2 additions & 1 deletion packages/builder/test/opaque-ref-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ describe("OpaqueRef Schema Support", () => {
expect(exported.rootSchema).toEqual(schema);
});

it("should return undefined schema for properties that aren't included in the schema", () => {
it("should return undefined schema for properties that aren't allowed by the schema", () => {
// Set a schema with nested objects
const schema = {
type: "object",
Expand All @@ -228,6 +228,7 @@ describe("OpaqueRef Schema Support", () => {
},
},
},
additionalProperties: false,
} as const satisfies JSONSchema;

// Create an opaque ref with nested objects, and a property that isn't in the schema
Expand Down
14 changes: 11 additions & 3 deletions packages/builder/test/recipe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,18 +380,22 @@ describe("recipe with ifc property", () => {
schema: { ifc: ArgumentSchema.properties.x.ifc },
},
});
// I don't like that we don't know our input is classified here
expect(nodes[1].inputs).toEqual({
x: {
$alias: {
path: ["internal", "__#0", "double"],
schema: {
ifc: ArgumentSchema.properties.x.ifc,
},
},
},
});
// I don't like that we don't know our output is classified here
expect(nodes[1].outputs).toEqual({
$alias: {
path: ["internal", "__#1"],
schema: {
ifc: ArgumentSchema.properties.x.ifc,
},
},
});
});
Expand Down Expand Up @@ -459,7 +463,7 @@ describe("recipe with mixed ifc properties", () => {
);

it("has the correct classification in the schema of the ssn result", () => {
const { result, nodes, argumentSchema } = capitalizeSsnRecipe;
const { result, nodes, argumentSchema, resultSchema } = capitalizeSsnRecipe;
expect(isRecipe(capitalizeSsnRecipe)).toBe(true);
expect(argumentSchema).toMatchObject(UserSchema);
expect(nodes).toHaveLength(1);
Expand All @@ -477,6 +481,10 @@ describe("recipe with mixed ifc properties", () => {
},
},
});
expect(resultSchema).toEqual({
...ResultSchema,
...{ ifc: { classification: ["confidential"] } },
});
});

// Perhaps I should handle a similar recipe that only accesses the name
Expand Down
9 changes: 7 additions & 2 deletions packages/charm/src/iterate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,11 @@ export function scrub(data: any): any {
// If this resulted in an empty schema, return without a schema
return data.asSchema(
Object.keys(scrubbed).length > 0
? { ...data.schema, properties: scrubbed }
? {
...data.schema,
properties: scrubbed,
additionalProperties: false,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't additionalProperties: false the default? I saw you added that here and in a few other places, curious why.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn't the default, though I thought it was prior to this patch.
The text in the doc isn't very direct, but leaving it out is the same as the empty schema (which always matches).
https://json-schema.org/draft/2020-12/json-schema-core#section-10.3.2.3-5

Omitting this keyword has the same assertion behavior as an empty schema.

The guide page is a bit more direct:
https://json-schema.org/understanding-json-schema/reference/object#additionalproperties

By default any additional properties are allowed.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh!, thanks for clarifying, I did indeed have it the other way around. Sigh. Makes schemas even longer by default…

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, or maybe we should flip it in our case, at least for the query case: true would mean that we want to always read all other fields, which maximizes taint unless this is specified. On write it means that any other property is accepted, which is less problematic, but also not a great default behavior.

We need to start documenting our version of schema and the differences to json schema and openapi schema.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm inclined to align with the standard here, even though it's more verbose.
We do have the scrubbed versions of our schema, and if we wanted to use that as the transformer between ours and the standard, we could, However, I think it's likely to cause confusion, especially as we use the LLMs to generate schema.

}
: undefined,
);
} else {
Expand All @@ -238,7 +242,8 @@ export function scrub(data: any): any {
(key) => [key, {}],
),
),
} satisfies JSONSchema;
additionalProperties: false,
} as const satisfies JSONSchema;
console.log("scrubbed generated schema", scrubbed);
// Only if we found any properties, return the scrubbed schema
return Object.keys(scrubbed).length > 0
Expand Down
137 changes: 137 additions & 0 deletions packages/cli/curl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Load .env file
import { parseArgs } from "@std/cli/parse-args";
import { type DID, Identity } from "@commontools/identity";
import { Provider as CachedStorageProvider } from "../runner/src/storage/cache.ts";
// Some examples of how you can use this to play with the classification labels
// Store the empty list
// > deno task curl --spaceName robin --data '[]' ct://did:key:z6MkjMowGqCog2ZfvNBNrx32p2Fa2bKR1nT7pUWiPQFWzVAg/baedreih5ute2slgsylwtbszccarx6ky2ca3mtticxug6sfj3nwamacefmn/application/json
// Mark the entity secret
// > deno task curl --spaceName robin --raw --data '{"classification": ["secret"]}' ct://did:key:z6MkjMowGqCog2ZfvNBNrx32p2Fa2bKR1nT7pUWiPQFWzVAg/baedreih5ute2slgsylwtbszccarx6ky2ca3mtticxug6sfj3nwamacefmn/application/label+json
// Check the classification labels (requires the classification labels)
// > deno task curl --spaceName robin --raw --schema '{"ifc": {"classification": ["secret"]}}' ct://did:key:z6MkjMowGqCog2ZfvNBNrx32p2Fa2bKR1nT7pUWiPQFWzVAg/baedreih5ute2slgsylwtbszccarx6ky2ca3mtticxug6sfj3nwamacefmn/application/label+json
// Get the original empty list back (requires the classification labels)
// > deno task curl --spaceName robin --schema '{"ifc": {"classification": ["secret"]}}' ct://did:key:z6MkjMowGqCog2ZfvNBNrx32p2Fa2bKR1nT7pUWiPQFWzVAg/baedreih5ute2slgsylwtbszccarx6ky2ca3mtticxug6sfj3nwamacefmn/application/json
const flags = parseArgs(Deno.args, {
string: [
"spaceName",
"key",
"data",
"schema",
],
boolean: ["admin"],
default: { the: "application/json", admin: false, raw: false },
});

const toolshedUrl = Deno.env.get("TOOLSHED_API_URL") ??
"http://localhost:8000/";

const ANYONE = "common user";
const remoteStorageUrl = new URL(toolshedUrl);

function usage() {
console.log(
"Usage: curl [--key <keyfile>] [--spaceName <spaceName>] [--admin] [--raw] [--schema <schema>] [--data <data>] url\n" +
"Example URL: ct://did:key:z6MkjMowGqCog2ZfvNBNrx32p2Fa2bKR1nT7pUWiPQFWzVAg/baedreihxpwcmhvzpf5weuf4ceow4zbahqikvu5ploox36ipeuvqnminyba/application/json\n" +
"If you provide a spaceDID in the URL, you must either be using the admin flag, or provide the --spaceName option.\n" +
"You can also provide a spaceName in the URL if it does not include any colon or slash characters. In this case, you do not need to provide the --spaceName option.",
);
}
async function main() {
const url = flags._[0];
if (!url || typeof url !== "string") {
console.error("Must provide an url");
usage();
Deno.exit(1);
}
// Parse the url like ct://spaceDID/entityID/attribute
// did key is base58btc; entity id is base32; attribute is mime type-ish
const urlRegex: RegExp =
/^(ct:\/\/)?((?<spaceDID>(did:key:[1-9A-HJ-NP-Za-km-z]+))|(?<spaceName>[^/:]+))\/(?<of>[a-z2-7]+)(\/(?<the>\w+\/[-+.\w]+))?$/;
const match = url.match(urlRegex);
if (match === null || match.groups === undefined) {
console.error("Invalid url");
Deno.exit(1);
}
const entityId = { "/": match.groups.of };
const the = (match.groups.the && match.groups.the !== "")
? match.groups.the
: "application/json";
if (!match.groups.spaceName && !match.groups.spaceDID) {
console.error("No space name or space DID found");
Deno.exit(1);
}

let identity: Identity;
if (flags.key) {
try {
const pkcs8Key = await Deno.readFile(flags.key);
identity = await Identity.fromPkcs8(pkcs8Key);
} catch (e) {
console.error(
`Could not read key at ${flags.key}.`,
);
Deno.exit(1);
}
} else {
identity = await Identity.fromPassphrase(ANYONE);
}

// Actual identity is derived from space name if we don't provide an admin key
if (!flags.admin) {
const spaceName = match.groups.spaceName ?? flags.spaceName;
identity = await identity.derive(spaceName);
}
const spaceDID = match.groups.spaceDID
? match.groups.spaceDID as DID
: identity.did();
const schema = flags.schema ? JSON.parse(flags.schema) : {};

// TODO(@ubik2) - this constrains us to values that are json
// need to revisit for image or blob support
const putData = flags.data ? JSON.parse(flags.data) : undefined;

const storageId = crypto.randomUUID();
const provider = new CachedStorageProvider({
id: storageId,
address: new URL("/api/storage/memory", remoteStorageUrl),
space: spaceDID,
as: identity,
the: the,
settings: {
maxSubscriptionsPerSpace: 50_000,
connectionTimeout: 30_000,
useSchemaQueries: true,
},
});
if (!putData) {
const result = await provider.sync(entityId, true, {
schema: schema,
rootSchema: schema,
});
if (result.error) {
console.log("Failed to sync object", result.error);
}
const storageValue = provider.get(entityId);
const data = flags.raw ? storageValue : storageValue?.value;

console.log(JSON.stringify(data));
Deno.exit(0);
} else {
// The entries in the database all get a value key and store their value there,
// so we need to do the same for the value we provide to StorageValue for send.
const result = await provider.send([{
entityId: entityId,
value: flags.raw ? putData : { value: putData },
}]);
if (result.ok) {
const putDataJSON = JSON.stringify(putData);
console.log(
`Stored ${putDataJSON} at ct://${spaceDID}/${entityId["/"]}/${the}`,
);
} else {
console.error("Failed to put data:", result.error);
}
Deno.exit(0);
}
}
main();
1 change: 1 addition & 0 deletions packages/cli/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"google-importer": "deno run --allow-read --allow-env --allow-net google-importer.ts",
"background-runner": "deno run --allow-read --allow-env --allow-net background-charm-runner.ts",
"memorydemo": "deno run --allow-read --allow-env --allow-net memory_demo.ts",
"curl": "deno run --allow-read --allow-env --allow-net curl.ts",
"test": "deno test",
"charmdemo": "deno run --allow-read --allow-env --allow-net charm_demo.ts",
"name2did": "deno run -A name2did.ts",
Expand Down
Loading