@cloudcrane/app-spec 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,11 @@ if (!spec) for (const p of problems) console.log(`${p.at}: ${p.problem}`);
18
18
  - `Rules` (`@cloudcrane/app-spec/logic`): evaluates a spec's CEL rules, with
19
19
  date functions and a bounded `fold`.
20
20
  - `@cloudcrane/app-spec/schema.json`: the spec as JSON Schema (2020-12).
21
+ - Coded components (`components`): code for what the catalog can't draw, with
22
+ the kit it runs with (`game`), the inputs it's given and the events it sends.
23
+ A screen places one with the catalog's `Coded` component, binding each input
24
+ to the data model and each event to an action. Its code is exempt from the
25
+ leak scan's code rules, but not from the others.
21
26
 
22
27
  `zod` 3.25 is a peer dependency. Includes A2UI v0.9.1's schemas, unchanged,
23
28
  under the Apache License 2.0 (`vendor/a2ui`). MIT licensed.
@@ -10,6 +10,8 @@ import { BASIC_CATALOG } from "./a2ui.js";
10
10
  // Components:
11
11
  // DateStrip: the last N days as chips, today on the right, one selectable,
12
12
  // each with a status dot.
13
+ // Coded: places one of the spec's coded components, sandboxed in a frame; its
14
+ // state shows at /ui/coded/<id>/state.
13
15
  export const CLOUDCRANE_CATALOG_ID = "https://cloudcrane.ai/catalogs/app/v1.json";
14
16
  const COMMON = "https://a2ui.org/specification/v0_9/common_types.json#/$defs";
15
17
  const path = { $ref: `${COMMON}/DynamicString`, description: "A JSON Pointer into the data model, like /saved/days/2026-10-27." };
@@ -60,6 +62,46 @@ const COMPONENTS = {
60
62
  ],
61
63
  unevaluatedProperties: false,
62
64
  },
65
+ Coded: {
66
+ type: "object",
67
+ allOf: [
68
+ { $ref: `${COMMON}/ComponentCommon` },
69
+ { $ref: "#/$defs/CatalogComponentCommon" },
70
+ {
71
+ type: "object",
72
+ properties: {
73
+ component: { const: "Coded" },
74
+ use: { type: "string", pattern: "^[a-z][a-zA-Z0-9_.-]{0,63}$", description: "The coded component to place, by its name in the spec's components." },
75
+ label: { $ref: `${COMMON}/DynamicString`, description: "What it is, for people who can't see it." },
76
+ aspect: { type: "number", minimum: 0.25, maximum: 4, description: "Width over height; 1 (square) if left out." },
77
+ inputs: {
78
+ type: "array",
79
+ maxItems: 10,
80
+ description: "What it's given, each a declared input bound to the data model.",
81
+ items: {
82
+ type: "object",
83
+ properties: { name: { type: "string" }, value: { $ref: `${COMMON}/DynamicValue` } },
84
+ required: ["name", "value"],
85
+ additionalProperties: false,
86
+ },
87
+ },
88
+ on: {
89
+ type: "array",
90
+ maxItems: 10,
91
+ description: "What happens when it sends a declared event; the event's fields are at /ui/coded/<this id>/<event> while the action runs.",
92
+ items: {
93
+ type: "object",
94
+ properties: { name: { type: "string" }, action: { $ref: `${COMMON}/Action` } },
95
+ required: ["name", "action"],
96
+ additionalProperties: false,
97
+ },
98
+ },
99
+ },
100
+ required: ["component", "use", "label"],
101
+ },
102
+ ],
103
+ unevaluatedProperties: false,
104
+ },
63
105
  };
64
106
  /** The CloudCrane catalog document, built from A2UI's basic catalog. */
65
107
  export function cloudcraneCatalog() {
@@ -73,7 +115,7 @@ export function cloudcraneCatalog() {
73
115
  $id: CLOUDCRANE_CATALOG_ID,
74
116
  catalogId: CLOUDCRANE_CATALOG_ID,
75
117
  title: "CloudCrane app catalog",
76
- description: "A2UI's basic catalog, plus local actions (set, push, removeAt, batch) and DateStrip, for apps that run on a phone.",
118
+ description: "A2UI's basic catalog, plus local actions (set, push, removeAt, batch), DateStrip and Coded, for apps that run on a phone.",
77
119
  };
78
120
  }
79
121
  export const CLOUDCRANE_CATALOG = cloudcraneCatalog();
package/dist/src/leak.js CHANGED
@@ -94,6 +94,8 @@ const RULES = [
94
94
  const EXEMPT = new Set(["/manifest/source/fingerprint"]);
95
95
  /** Where CEL lives: rules may look like code there, but nothing else may. */
96
96
  const CEL_AT = [/^\/logic\/functions\/[^/]+\/cel$/, /^\/logic\/computed\/.+$/, /^\/data\/migrations\/\d+\/cel$/];
97
+ /** A coded component's code: code by design (it runs sandboxed), so only the code rules skip it. */
98
+ const CODE_AT = /^\/components\/[^/]+\/code$/;
97
99
  /** A test step's state path: a dotted path into what the app exposes (CloudCrane's form), which reads like a member access by design. */
98
100
  const STATE_PATH_AT = /^\/tests\/\d+\/(steps|setup|check)\/\d+\/path$/;
99
101
  const STATE_PATH = /^[A-Za-z0-9_$]+(\.[A-Za-z0-9_$]+)*$/;
@@ -125,7 +127,7 @@ export function leakProblems(spec) {
125
127
  continue;
126
128
  // A2UI's bindings inside text (${/path}, ${fn(…)}) are the standard's syntax.
127
129
  const s = at.startsWith("/surface/") || at.startsWith("/data/views/") ? raw.replace(/\$\{[^}]*\}/g, " ") : raw;
128
- const codeShaped = CEL_AT.some((r) => r.test(at)) || (STATE_PATH_AT.test(at) && STATE_PATH.test(raw));
130
+ const codeShaped = CEL_AT.some((r) => r.test(at)) || CODE_AT.test(at) || (STATE_PATH_AT.test(at) && STATE_PATH.test(raw));
129
131
  // Web addresses are checked as addresses (hosts, secrets), never as code.
130
132
  const withoutUrls = s.replace(/https?:\/\/[^\s"'<>]+/g, " ");
131
133
  for (const rule of RULES) {
@@ -14,6 +14,45 @@ export type TokenNode = {
14
14
  [name: string]: unknown;
15
15
  };
16
16
  export declare const Tokens: z.ZodType<TokenNode>;
17
+ /** The kinds of value a coded component is given or sends: JSON's, by name. */
18
+ export declare const CODED_TYPES: readonly ["number", "string", "boolean", "list", "map"];
19
+ /**
20
+ * A coded component: what the catalog can't draw, as the spec's own code, run
21
+ * sandboxed in a frame with a kit (`game`: a loop, a canvas, touch and keys).
22
+ * It's given only its declared inputs and may only send its declared events;
23
+ * a screen places it with the catalog's Coded component.
24
+ */
25
+ export declare const CodedComponent: z.ZodEffects<z.ZodObject<{
26
+ kind: z.ZodLiteral<"coded">;
27
+ kit: z.ZodEnum<["game"]>;
28
+ inputs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEnum<["number", "string", "boolean", "list", "map"]>>>;
29
+ events: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodEnum<["number", "string", "boolean", "list", "map"]>>>>;
30
+ code: z.ZodEffects<z.ZodString, string, string>;
31
+ }, "strict", z.ZodTypeAny, {
32
+ code: string;
33
+ kind: "coded";
34
+ kit: "game";
35
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
36
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
37
+ }, {
38
+ code: string;
39
+ kind: "coded";
40
+ kit: "game";
41
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
42
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
43
+ }>, {
44
+ code: string;
45
+ kind: "coded";
46
+ kit: "game";
47
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
48
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
49
+ }, {
50
+ code: string;
51
+ kind: "coded";
52
+ kit: "game";
53
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
54
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
55
+ }>;
17
56
  export declare const SpecSchema: z.ZodObject<{
18
57
  v: z.ZodLiteral<1>;
19
58
  manifest: z.ZodObject<{
@@ -255,6 +294,50 @@ export declare const SpecSchema: z.ZodObject<{
255
294
  }>, "many">>;
256
295
  /** CloudCrane's test steps, as its blueprint tests take them; CloudCrane checks their shape. */
257
296
  tests: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>, "many">>;
297
+ /** Coded components, by name: the spec's own code for what the catalog can't draw. */
298
+ components: z.ZodDefault<z.ZodEffects<z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodObject<{
299
+ kind: z.ZodLiteral<"coded">;
300
+ kit: z.ZodEnum<["game"]>;
301
+ inputs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEnum<["number", "string", "boolean", "list", "map"]>>>;
302
+ events: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodEnum<["number", "string", "boolean", "list", "map"]>>>>;
303
+ code: z.ZodEffects<z.ZodString, string, string>;
304
+ }, "strict", z.ZodTypeAny, {
305
+ code: string;
306
+ kind: "coded";
307
+ kit: "game";
308
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
309
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
310
+ }, {
311
+ code: string;
312
+ kind: "coded";
313
+ kit: "game";
314
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
315
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
316
+ }>, {
317
+ code: string;
318
+ kind: "coded";
319
+ kit: "game";
320
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
321
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
322
+ }, {
323
+ code: string;
324
+ kind: "coded";
325
+ kit: "game";
326
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
327
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
328
+ }>>, Record<string, {
329
+ code: string;
330
+ kind: "coded";
331
+ kit: "game";
332
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
333
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
334
+ }>, Record<string, {
335
+ code: string;
336
+ kind: "coded";
337
+ kit: "game";
338
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
339
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
340
+ }>>>;
258
341
  /** What a remix may change and what stays as the partner made it. */
259
342
  mods: z.ZodDefault<z.ZodObject<{
260
343
  open: z.ZodArray<z.ZodString, "many">;
@@ -267,6 +350,13 @@ export declare const SpecSchema: z.ZodObject<{
267
350
  locked: string[];
268
351
  }>>;
269
352
  }, "strict", z.ZodTypeAny, {
353
+ components: Record<string, {
354
+ code: string;
355
+ kind: "coded";
356
+ kit: "game";
357
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
358
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
359
+ }>;
270
360
  surface: {
271
361
  catalog: string;
272
362
  messages: Record<string, unknown>[];
@@ -400,6 +490,13 @@ export declare const SpecSchema: z.ZodObject<{
400
490
  id: string;
401
491
  }[];
402
492
  }[];
493
+ components?: Record<string, {
494
+ code: string;
495
+ kind: "coded";
496
+ kit: "game";
497
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
498
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
499
+ }> | undefined;
403
500
  network?: {
404
501
  site: string;
405
502
  why: string;
@@ -3,7 +3,9 @@ import { z } from "zod";
3
3
  // what A2UI doesn't cover. Nothing in it is code from the app it came from:
4
4
  // screens are A2UI components, the look is DTCG design tokens, rules are CEL
5
5
  // expressions, saved data is described by a JSON Schema. Strict throughout, so a
6
- // field the spec doesn't define (where code could hide) is refused.
6
+ // field the spec doesn't define (where code could hide) is refused. The one
7
+ // place code belongs is a coded component's, run sandboxed, said so where the
8
+ // app is installed.
7
9
  /** A neutral id: lower-case words, dots, dashes, underscores. Partner symbol names live only in provenance. */
8
10
  export const Id = z.string().regex(/^[a-z][a-zA-Z0-9_.-]{0,63}$/, "an id: a letter, then letters, digits, . _ or -, at most 64");
9
11
  /** A CEL identifier (constants and parameters): letters, digits and underscores. */
@@ -42,6 +44,29 @@ export const Tokens = z.lazy(() => z
42
44
  }));
43
45
  /** A CEL expression (cel.dev); parsed and checked by the logic validator. */
44
46
  const Cel = z.string().min(1).max(4000);
47
+ /** The kinds of value a coded component is given or sends: JSON's, by name. */
48
+ export const CODED_TYPES = ["number", "string", "boolean", "list", "map"];
49
+ const CodedType = z.enum(CODED_TYPES);
50
+ /**
51
+ * A coded component: what the catalog can't draw, as the spec's own code, run
52
+ * sandboxed in a frame with a kit (`game`: a loop, a canvas, touch and keys).
53
+ * It's given only its declared inputs and may only send its declared events;
54
+ * a screen places it with the catalog's Coded component.
55
+ */
56
+ export const CodedComponent = z
57
+ .object({
58
+ kind: z.literal("coded"),
59
+ kit: z.enum(["game"]),
60
+ inputs: z.record(CelName, CodedType).default({}),
61
+ events: z.record(CelName, z.record(CelName, CodedType)).default({}),
62
+ code: z
63
+ .string()
64
+ .min(1)
65
+ .max(64 * 1024)
66
+ .refine((c) => !/<\/script/i.test(c), "can't contain </script>, which would end the frame's script"),
67
+ })
68
+ .strict()
69
+ .refine((c) => Object.keys(c.inputs).length <= 10 && Object.keys(c.events).length <= 10, "at most 10 inputs and 10 events");
45
70
  export const SpecSchema = z
46
71
  .object({
47
72
  v: z.literal(1),
@@ -128,6 +153,11 @@ export const SpecSchema = z
128
153
  promises: z.array(z.object({ id: z.string().regex(/^P-\d+$/), text: Text(400), kind: z.literal("data").optional() }).strict()).default([]),
129
154
  /** CloudCrane's test steps, as its blueprint tests take them; CloudCrane checks their shape. */
130
155
  tests: z.array(z.record(z.unknown())).max(30).default([]),
156
+ /** Coded components, by name: the spec's own code for what the catalog can't draw. */
157
+ components: z
158
+ .record(Id, CodedComponent)
159
+ .refine((c) => Object.keys(c).length <= 5, "at most 5 components")
160
+ .default({}),
131
161
  /** What a remix may change and what stays as the partner made it. */
132
162
  mods: z
133
163
  .object({ open: z.array(PointerPattern), locked: z.array(PointerPattern) })
@@ -46,9 +46,38 @@ export function validateSpec(input, catalog) {
46
46
  for (const m of spec.data.migrations)
47
47
  if (m.to !== m.from + 1 || m.to > spec.data.version)
48
48
  problems.push({ at: `data.migrations ${m.from}`, problem: "steps go one version at a time, up to data.version" });
49
- problems.push(...logicProblems(spec), ...leakProblems(spec));
49
+ problems.push(...codedProblems(spec), ...logicProblems(spec), ...leakProblems(spec));
50
50
  for (const locked of spec.mods.locked)
51
51
  if (spec.mods.open.includes(locked))
52
52
  problems.push({ at: `mods ${locked}`, problem: "both open and locked" });
53
53
  return { spec: problems.length ? null : spec, problems };
54
54
  }
55
+ /** Each Coded placement names a component the spec defines, and only its declared inputs and events; each component is placed. */
56
+ function codedProblems(spec) {
57
+ const problems = [];
58
+ const placed = new Set();
59
+ for (const m of spec.surface.messages) {
60
+ const comps = m.updateComponents?.components ?? [];
61
+ for (const c of comps) {
62
+ if (c.component !== "Coded" || typeof c.use !== "string")
63
+ continue;
64
+ const at = `surface ${c.id}`;
65
+ const def = spec.components[c.use];
66
+ if (!def) {
67
+ problems.push({ at, problem: `no coded component "${c.use}" in components` });
68
+ continue;
69
+ }
70
+ placed.add(c.use);
71
+ for (const i of c.inputs ?? [])
72
+ if (!(i.name in def.inputs))
73
+ problems.push({ at, problem: `${c.use} declares no input "${i.name}"` });
74
+ for (const e of c.on ?? [])
75
+ if (!(e.name in def.events))
76
+ problems.push({ at, problem: `${c.use} declares no event "${e.name}"` });
77
+ }
78
+ }
79
+ for (const name of Object.keys(spec.components))
80
+ if (!placed.has(name))
81
+ problems.push({ at: `components.${name}`, problem: "no screen places it" });
82
+ return problems;
83
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudcrane/app-spec",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "The CloudCrane app spec: its schema, the expression language its rules are written in, and the checks that keep partner code out of it.",
5
5
  "keywords": [
6
6
  "cloudcrane",
@@ -416,6 +416,79 @@
416
416
  "maxItems": 30,
417
417
  "default": []
418
418
  },
419
+ "components": {
420
+ "type": "object",
421
+ "additionalProperties": {
422
+ "type": "object",
423
+ "properties": {
424
+ "kind": {
425
+ "type": "string",
426
+ "const": "coded"
427
+ },
428
+ "kit": {
429
+ "type": "string",
430
+ "enum": [
431
+ "game"
432
+ ]
433
+ },
434
+ "inputs": {
435
+ "type": "object",
436
+ "additionalProperties": {
437
+ "type": "string",
438
+ "enum": [
439
+ "number",
440
+ "string",
441
+ "boolean",
442
+ "list",
443
+ "map"
444
+ ]
445
+ },
446
+ "propertyNames": {
447
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,63}$"
448
+ },
449
+ "default": {}
450
+ },
451
+ "events": {
452
+ "type": "object",
453
+ "additionalProperties": {
454
+ "type": "object",
455
+ "additionalProperties": {
456
+ "type": "string",
457
+ "enum": [
458
+ "number",
459
+ "string",
460
+ "boolean",
461
+ "list",
462
+ "map"
463
+ ]
464
+ },
465
+ "propertyNames": {
466
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,63}$"
467
+ }
468
+ },
469
+ "propertyNames": {
470
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,63}$"
471
+ },
472
+ "default": {}
473
+ },
474
+ "code": {
475
+ "type": "string",
476
+ "minLength": 1,
477
+ "maxLength": 65536
478
+ }
479
+ },
480
+ "required": [
481
+ "kind",
482
+ "kit",
483
+ "code"
484
+ ],
485
+ "additionalProperties": false
486
+ },
487
+ "propertyNames": {
488
+ "pattern": "^[a-z][a-zA-Z0-9_.-]{0,63}$"
489
+ },
490
+ "default": {}
491
+ },
419
492
  "mods": {
420
493
  "type": "object",
421
494
  "properties": {