@cloudcrane/app-spec 0.1.1 → 0.4.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,17 @@ 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`, or `page` for a whole HTML page), the inputs
23
+ it's given and the events it sends. A game kit's code is at most 64 KB and
24
+ can't contain `</script>`; a page kit's is an HTML page of up to 1 MB.
25
+ - Device features (`uses`): what the app uses, by CloudCrane's names
26
+ (`camera`, `microphone`, `location`, `motion`, `screen-awake`, `gamepad`,
27
+ `voice`, `contacts`, `nfc`), each once with why. Anything not listed is
28
+ blocked. The camera and microphone need a page-kit component.
29
+ A screen places one with the catalog's `Coded` component, binding each input
30
+ to the data model and each event to an action. Its code is exempt from the
31
+ leak scan's code rules, but not from the others.
21
32
 
22
33
  `zod` 3.25 is a peer dependency. Includes A2UI v0.9.1's schemas, unchanged,
23
34
  under the Apache License 2.0 (`vendor/a2ui`). MIT licensed.
@@ -16,6 +16,7 @@ export type BlueprintV1 = {
16
16
  journeys: Spec["journeys"];
17
17
  promises: Spec["promises"];
18
18
  network: Spec["network"];
19
+ uses?: Spec["uses"];
19
20
  icon?: string;
20
21
  category?: string;
21
22
  tags?: string[];
@@ -54,6 +54,7 @@ export function deriveBlueprint(spec) {
54
54
  journeys: spec.journeys,
55
55
  promises: spec.promises,
56
56
  network: spec.network,
57
+ ...(spec.uses.length ? { uses: spec.uses } : {}),
57
58
  ...(spec.module.icon ? { icon: spec.module.icon } : {}),
58
59
  ...(spec.module.category ? { category: spec.module.category } : {}),
59
60
  ...(spec.module.tags?.length ? { tags: spec.module.tags } : {}),
@@ -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();
@@ -7,3 +7,4 @@ export { dates, logicProblems, Rules, fromCel, toCel, MAX_RANGE } from "./logic.
7
7
  export { leakProblems } from "./leak.js";
8
8
  export { CLOUDCRANE_CATALOG, CLOUDCRANE_CATALOG_ID, catalogFor, cloudcraneCatalog } from "./catalog.js";
9
9
  export { deriveBlueprint, type BlueprintV1 } from "./blueprint.js";
10
+ export { USES, USE_NAMES, NEEDS_ORIGIN, featuresOf, type Use } from "./uses.js";
package/dist/src/index.js CHANGED
@@ -9,3 +9,4 @@ export { dates, logicProblems, Rules, fromCel, toCel, MAX_RANGE } from "./logic.
9
9
  export { leakProblems } from "./leak.js";
10
10
  export { CLOUDCRANE_CATALOG, CLOUDCRANE_CATALOG_ID, catalogFor, cloudcraneCatalog } from "./catalog.js";
11
11
  export { deriveBlueprint } from "./blueprint.js";
12
+ export { USES, USE_NAMES, NEEDS_ORIGIN, featuresOf } from "./uses.js";
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,7 +14,61 @@ export type TokenNode = {
14
14
  [name: string]: unknown;
15
15
  };
16
16
  export declare const Tokens: z.ZodType<TokenNode>;
17
- export declare const SpecSchema: z.ZodObject<{
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
+ * `page`: a whole HTML page, full screen). Either saves through `localStorage`,
23
+ * which the frame's bridge keeps on the page under the spec's data key.
24
+ * It's given only its declared inputs and may only send its declared events;
25
+ * a screen places it with the catalog's Coded component.
26
+ */
27
+ export declare const CodedComponent: z.ZodEffects<z.ZodEffects<z.ZodObject<{
28
+ kind: z.ZodLiteral<"coded">;
29
+ /** game: a canvas, a loop and touch, the code a script; page: a whole HTML page, drawn full screen (what an app written as HTML becomes). */
30
+ kit: z.ZodEnum<["game", "page"]>;
31
+ inputs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEnum<["number", "string", "boolean", "list", "map"]>>>;
32
+ events: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodEnum<["number", "string", "boolean", "list", "map"]>>>>;
33
+ code: z.ZodString;
34
+ }, "strict", z.ZodTypeAny, {
35
+ code: string;
36
+ kind: "coded";
37
+ kit: "game" | "page";
38
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
39
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
40
+ }, {
41
+ code: string;
42
+ kind: "coded";
43
+ kit: "game" | "page";
44
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
45
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
46
+ }>, {
47
+ code: string;
48
+ kind: "coded";
49
+ kit: "game" | "page";
50
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
51
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
52
+ }, {
53
+ code: string;
54
+ kind: "coded";
55
+ kit: "game" | "page";
56
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
57
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
58
+ }>, {
59
+ code: string;
60
+ kind: "coded";
61
+ kit: "game" | "page";
62
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
63
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
64
+ }, {
65
+ code: string;
66
+ kind: "coded";
67
+ kit: "game" | "page";
68
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
69
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
70
+ }>;
71
+ export declare const SpecSchema: z.ZodEffects<z.ZodObject<{
18
72
  v: z.ZodLiteral<1>;
19
73
  manifest: z.ZodObject<{
20
74
  runtime: z.ZodString;
@@ -212,6 +266,17 @@ export declare const SpecSchema: z.ZodObject<{
212
266
  site: string;
213
267
  why: string;
214
268
  }>, "many">>;
269
+ /** The device features the app uses, by CloudCrane's names, each once with why; anything not listed is blocked. */
270
+ uses: z.ZodDefault<z.ZodArray<z.ZodObject<{
271
+ what: z.ZodEnum<["camera" | "microphone" | "location" | "motion" | "screen-awake" | "gamepad" | "voice" | "contacts" | "nfc", ...("camera" | "microphone" | "location" | "motion" | "screen-awake" | "gamepad" | "voice" | "contacts" | "nfc")[]]>;
272
+ why: z.ZodString;
273
+ }, "strict", z.ZodTypeAny, {
274
+ why: string;
275
+ what: "camera" | "microphone" | "location" | "motion" | "screen-awake" | "gamepad" | "voice" | "contacts" | "nfc";
276
+ }, {
277
+ why: string;
278
+ what: "camera" | "microphone" | "location" | "motion" | "screen-awake" | "gamepad" | "voice" | "contacts" | "nfc";
279
+ }>, "many">>;
215
280
  journeys: z.ZodArray<z.ZodObject<{
216
281
  id: z.ZodString;
217
282
  title: z.ZodString;
@@ -255,6 +320,63 @@ export declare const SpecSchema: z.ZodObject<{
255
320
  }>, "many">>;
256
321
  /** CloudCrane's test steps, as its blueprint tests take them; CloudCrane checks their shape. */
257
322
  tests: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>, "many">>;
323
+ /** Coded components, by name: the spec's own code for what the catalog can't draw. */
324
+ components: z.ZodDefault<z.ZodEffects<z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodEffects<z.ZodObject<{
325
+ kind: z.ZodLiteral<"coded">;
326
+ /** game: a canvas, a loop and touch, the code a script; page: a whole HTML page, drawn full screen (what an app written as HTML becomes). */
327
+ kit: z.ZodEnum<["game", "page"]>;
328
+ inputs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEnum<["number", "string", "boolean", "list", "map"]>>>;
329
+ events: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodEnum<["number", "string", "boolean", "list", "map"]>>>>;
330
+ code: z.ZodString;
331
+ }, "strict", z.ZodTypeAny, {
332
+ code: string;
333
+ kind: "coded";
334
+ kit: "game" | "page";
335
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
336
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
337
+ }, {
338
+ code: string;
339
+ kind: "coded";
340
+ kit: "game" | "page";
341
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
342
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
343
+ }>, {
344
+ code: string;
345
+ kind: "coded";
346
+ kit: "game" | "page";
347
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
348
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
349
+ }, {
350
+ code: string;
351
+ kind: "coded";
352
+ kit: "game" | "page";
353
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
354
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
355
+ }>, {
356
+ code: string;
357
+ kind: "coded";
358
+ kit: "game" | "page";
359
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
360
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
361
+ }, {
362
+ code: string;
363
+ kind: "coded";
364
+ kit: "game" | "page";
365
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
366
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
367
+ }>>, Record<string, {
368
+ code: string;
369
+ kind: "coded";
370
+ kit: "game" | "page";
371
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
372
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
373
+ }>, Record<string, {
374
+ code: string;
375
+ kind: "coded";
376
+ kit: "game" | "page";
377
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
378
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
379
+ }>>>;
258
380
  /** What a remix may change and what stays as the partner made it. */
259
381
  mods: z.ZodDefault<z.ZodObject<{
260
382
  open: z.ZodArray<z.ZodString, "many">;
@@ -267,6 +389,183 @@ export declare const SpecSchema: z.ZodObject<{
267
389
  locked: string[];
268
390
  }>>;
269
391
  }, "strict", z.ZodTypeAny, {
392
+ components: Record<string, {
393
+ code: string;
394
+ kind: "coded";
395
+ kit: "game" | "page";
396
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
397
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
398
+ }>;
399
+ surface: {
400
+ catalog: string;
401
+ messages: Record<string, unknown>[];
402
+ };
403
+ v: 1;
404
+ manifest: {
405
+ runtime: string;
406
+ generator: string;
407
+ source: {
408
+ fingerprint: string;
409
+ };
410
+ };
411
+ module: {
412
+ name: string;
413
+ id: string;
414
+ purpose: string;
415
+ icon?: string | undefined;
416
+ category?: string | undefined;
417
+ tags?: string[] | undefined;
418
+ };
419
+ tokens: TokenNode;
420
+ data: {
421
+ version: number;
422
+ key: string;
423
+ persist: string;
424
+ schema: Record<string, unknown>;
425
+ views: {
426
+ path: string;
427
+ source: string;
428
+ }[];
429
+ migrations: {
430
+ from: number;
431
+ to: number;
432
+ cel: string;
433
+ }[];
434
+ initial?: unknown;
435
+ };
436
+ logic: {
437
+ consts: Record<string, string | number | boolean>;
438
+ functions: Record<string, {
439
+ params: string[];
440
+ cel: string;
441
+ }>;
442
+ computed: Record<string, string>;
443
+ vectors: {
444
+ from: "recorded" | "hint";
445
+ fn: string;
446
+ in: unknown[];
447
+ out?: unknown;
448
+ }[];
449
+ };
450
+ network: {
451
+ site: string;
452
+ why: string;
453
+ }[];
454
+ uses: {
455
+ why: string;
456
+ what: "camera" | "microphone" | "location" | "motion" | "screen-awake" | "gamepad" | "voice" | "contacts" | "nfc";
457
+ }[];
458
+ journeys: {
459
+ title: string;
460
+ id: string;
461
+ steps: {
462
+ text: string;
463
+ id: string;
464
+ }[];
465
+ }[];
466
+ promises: {
467
+ text: string;
468
+ id: string;
469
+ kind?: "data" | undefined;
470
+ }[];
471
+ tests: Record<string, unknown>[];
472
+ mods: {
473
+ open: string[];
474
+ locked: string[];
475
+ };
476
+ }, {
477
+ surface: {
478
+ catalog: string;
479
+ messages: Record<string, unknown>[];
480
+ };
481
+ v: 1;
482
+ manifest: {
483
+ runtime: string;
484
+ generator: string;
485
+ source: {
486
+ fingerprint: string;
487
+ };
488
+ };
489
+ module: {
490
+ name: string;
491
+ id: string;
492
+ purpose: string;
493
+ icon?: string | undefined;
494
+ category?: string | undefined;
495
+ tags?: string[] | undefined;
496
+ };
497
+ tokens: TokenNode;
498
+ data: {
499
+ version: number;
500
+ key: string;
501
+ persist: string;
502
+ schema: Record<string, unknown>;
503
+ migrations: {
504
+ from: number;
505
+ to: number;
506
+ cel: string;
507
+ }[];
508
+ initial?: unknown;
509
+ views?: {
510
+ path: string;
511
+ source: string;
512
+ }[] | undefined;
513
+ };
514
+ logic: {
515
+ consts?: Record<string, string | number | boolean> | undefined;
516
+ functions?: Record<string, {
517
+ params: string[];
518
+ cel: string;
519
+ }> | undefined;
520
+ computed?: Record<string, string> | undefined;
521
+ vectors?: {
522
+ from: "recorded" | "hint";
523
+ fn: string;
524
+ in: unknown[];
525
+ out?: unknown;
526
+ }[] | undefined;
527
+ };
528
+ journeys: {
529
+ title: string;
530
+ id: string;
531
+ steps: {
532
+ text: string;
533
+ id: string;
534
+ }[];
535
+ }[];
536
+ components?: Record<string, {
537
+ code: string;
538
+ kind: "coded";
539
+ kit: "game" | "page";
540
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
541
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
542
+ }> | undefined;
543
+ network?: {
544
+ site: string;
545
+ why: string;
546
+ }[] | undefined;
547
+ uses?: {
548
+ why: string;
549
+ what: "camera" | "microphone" | "location" | "motion" | "screen-awake" | "gamepad" | "voice" | "contacts" | "nfc";
550
+ }[] | undefined;
551
+ promises?: {
552
+ text: string;
553
+ id: string;
554
+ kind?: "data" | undefined;
555
+ }[] | undefined;
556
+ tests?: Record<string, unknown>[] | undefined;
557
+ mods?: {
558
+ open: string[];
559
+ locked: string[];
560
+ } | undefined;
561
+ }>, {
562
+ components: Record<string, {
563
+ code: string;
564
+ kind: "coded";
565
+ kit: "game" | "page";
566
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
567
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
568
+ }>;
270
569
  surface: {
271
570
  catalog: string;
272
571
  messages: Record<string, unknown>[];
@@ -322,6 +621,10 @@ export declare const SpecSchema: z.ZodObject<{
322
621
  site: string;
323
622
  why: string;
324
623
  }[];
624
+ uses: {
625
+ why: string;
626
+ what: "camera" | "microphone" | "location" | "motion" | "screen-awake" | "gamepad" | "voice" | "contacts" | "nfc";
627
+ }[];
325
628
  journeys: {
326
629
  title: string;
327
630
  id: string;
@@ -400,10 +703,21 @@ export declare const SpecSchema: z.ZodObject<{
400
703
  id: string;
401
704
  }[];
402
705
  }[];
706
+ components?: Record<string, {
707
+ code: string;
708
+ kind: "coded";
709
+ kit: "game" | "page";
710
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
711
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
712
+ }> | undefined;
403
713
  network?: {
404
714
  site: string;
405
715
  why: string;
406
716
  }[] | undefined;
717
+ uses?: {
718
+ why: string;
719
+ what: "camera" | "microphone" | "location" | "motion" | "screen-awake" | "gamepad" | "voice" | "contacts" | "nfc";
720
+ }[] | undefined;
407
721
  promises?: {
408
722
  text: string;
409
723
  id: string;
@@ -1,9 +1,12 @@
1
1
  import { z } from "zod";
2
+ import { NEEDS_ORIGIN, USE_NAMES } from "./uses.js";
2
3
  // The CloudCrane app spec: an A2UI surface (the screens) in a small envelope for
3
4
  // what A2UI doesn't cover. Nothing in it is code from the app it came from:
4
5
  // screens are A2UI components, the look is DTCG design tokens, rules are CEL
5
6
  // 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.
7
+ // field the spec doesn't define (where code could hide) is refused. The one
8
+ // place code belongs is a coded component's, run sandboxed, said so where the
9
+ // app is installed.
7
10
  /** A neutral id: lower-case words, dots, dashes, underscores. Partner symbol names live only in provenance. */
8
11
  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
12
  /** A CEL identifier (constants and parameters): letters, digits and underscores. */
@@ -42,6 +45,41 @@ export const Tokens = z.lazy(() => z
42
45
  }));
43
46
  /** A CEL expression (cel.dev); parsed and checked by the logic validator. */
44
47
  const Cel = z.string().min(1).max(4000);
48
+ /** The kinds of value a coded component is given or sends: JSON's, by name. */
49
+ export const CODED_TYPES = ["number", "string", "boolean", "list", "map"];
50
+ const CodedType = z.enum(CODED_TYPES);
51
+ /**
52
+ * A coded component: what the catalog can't draw, as the spec's own code, run
53
+ * sandboxed in a frame with a kit (`game`: a loop, a canvas, touch and keys;
54
+ * `page`: a whole HTML page, full screen). Either saves through `localStorage`,
55
+ * which the frame's bridge keeps on the page under the spec's data key.
56
+ * It's given only its declared inputs and may only send its declared events;
57
+ * a screen places it with the catalog's Coded component.
58
+ */
59
+ export const CodedComponent = z
60
+ .object({
61
+ kind: z.literal("coded"),
62
+ /** game: a canvas, a loop and touch, the code a script; page: a whole HTML page, drawn full screen (what an app written as HTML becomes). */
63
+ kit: z.enum(["game", "page"]),
64
+ inputs: z.record(CelName, CodedType).default({}),
65
+ events: z.record(CelName, z.record(CelName, CodedType)).default({}),
66
+ code: z
67
+ .string()
68
+ .min(1)
69
+ .max(1024 * 1024),
70
+ })
71
+ .strict()
72
+ .superRefine((c, ctx) => {
73
+ if (c.kit === "game" && /<\/script/i.test(c.code))
74
+ ctx.addIssue({
75
+ code: z.ZodIssueCode.custom,
76
+ path: ["code"],
77
+ message: "can't contain </script>, which would end the frame's script (a page kit's code can)",
78
+ });
79
+ if (c.kit === "game" && c.code.length > 64 * 1024)
80
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["code"], message: "a game kit's code is at most 64 KB" });
81
+ })
82
+ .refine((c) => Object.keys(c.inputs).length <= 10 && Object.keys(c.events).length <= 10, "at most 10 inputs and 10 events");
45
83
  export const SpecSchema = z
46
84
  .object({
47
85
  v: z.literal(1),
@@ -116,6 +154,11 @@ export const SpecSchema = z
116
154
  .array(z.object({ site: z.string().regex(/^[a-z0-9.-]+\.[a-z]{2,}$/), why: Text(300) }).strict())
117
155
  .max(5)
118
156
  .default([]),
157
+ /** The device features the app uses, by CloudCrane's names, each once with why; anything not listed is blocked. */
158
+ uses: z
159
+ .array(z.object({ what: z.enum(USE_NAMES), why: Text(300) }).strict())
160
+ .max(USE_NAMES.length)
161
+ .default([]),
119
162
  journeys: z
120
163
  .array(z
121
164
  .object({
@@ -128,10 +171,26 @@ export const SpecSchema = z
128
171
  promises: z.array(z.object({ id: z.string().regex(/^P-\d+$/), text: Text(400), kind: z.literal("data").optional() }).strict()).default([]),
129
172
  /** CloudCrane's test steps, as its blueprint tests take them; CloudCrane checks their shape. */
130
173
  tests: z.array(z.record(z.unknown())).max(30).default([]),
174
+ /** Coded components, by name: the spec's own code for what the catalog can't draw. */
175
+ components: z
176
+ .record(Id, CodedComponent)
177
+ .refine((c) => Object.keys(c).length <= 5, "at most 5 components")
178
+ .default({}),
131
179
  /** What a remix may change and what stays as the partner made it. */
132
180
  mods: z
133
181
  .object({ open: z.array(PointerPattern), locked: z.array(PointerPattern) })
134
182
  .strict()
135
183
  .default({ open: [], locked: [] }),
136
184
  })
137
- .strict();
185
+ .strict()
186
+ .superRefine((s, ctx) => {
187
+ const seen = new Set();
188
+ for (const u of s.uses) {
189
+ if (seen.has(u.what))
190
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["uses"], message: `${u.what} is listed twice` });
191
+ seen.add(u.what);
192
+ }
193
+ const needsOrigin = s.uses.some((u) => NEEDS_ORIGIN.includes(u.what));
194
+ if (needsOrigin && !Object.values(s.components).some((c) => c.kit === "page"))
195
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["uses"], message: "the camera and microphone work only in a page-kit component" });
196
+ });
@@ -0,0 +1,19 @@
1
+ export declare const USES: {
2
+ readonly camera: readonly ["camera"];
3
+ readonly microphone: readonly ["microphone"];
4
+ readonly location: readonly ["geolocation"];
5
+ readonly motion: readonly ["accelerometer", "gyroscope", "magnetometer"];
6
+ readonly "screen-awake": readonly ["screen-wake-lock"];
7
+ readonly gamepad: readonly ["gamepad"];
8
+ readonly voice: readonly ["microphone"];
9
+ readonly contacts: readonly [];
10
+ readonly nfc: readonly [];
11
+ };
12
+ export type Use = keyof typeof USES;
13
+ export declare const USE_NAMES: [Use, ...Use[]];
14
+ /** What a frame with no origin of its own can't have, whatever it's allowed: the browser gives it no camera or microphone. */
15
+ export declare const NEEDS_ORIGIN: readonly Use[];
16
+ /** The browser features for the uses declared, each once, in a fixed order. */
17
+ export declare function featuresOf(uses: readonly {
18
+ what: string;
19
+ }[]): string[];
@@ -0,0 +1,28 @@
1
+ // The device features an app can declare, by CloudCrane's names, and the
2
+ // browser's Permissions-Policy features each needs. A coded component's frame
3
+ // is allowed those of the features the spec declares. The contact picker and
4
+ // NFC have no browser feature: CloudCrane's lockdown is their only block.
5
+ export const USES = {
6
+ camera: ["camera"],
7
+ microphone: ["microphone"],
8
+ location: ["geolocation"],
9
+ motion: ["accelerometer", "gyroscope", "magnetometer"],
10
+ "screen-awake": ["screen-wake-lock"],
11
+ gamepad: ["gamepad"],
12
+ voice: ["microphone"],
13
+ contacts: [],
14
+ nfc: [],
15
+ };
16
+ export const USE_NAMES = Object.keys(USES);
17
+ /** What a frame with no origin of its own can't have, whatever it's allowed: the browser gives it no camera or microphone. */
18
+ export const NEEDS_ORIGIN = ["camera", "microphone"];
19
+ /** The browser features for the uses declared, each once, in a fixed order. */
20
+ export function featuresOf(uses) {
21
+ const declared = new Set(uses.map((u) => u.what));
22
+ const out = new Set();
23
+ for (const name of USE_NAMES)
24
+ if (declared.has(name))
25
+ for (const f of USES[name])
26
+ out.add(f);
27
+ return [...out];
28
+ }
@@ -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.4.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",
@@ -46,6 +46,10 @@
46
46
  "types": "./dist/src/blueprint.d.ts",
47
47
  "default": "./dist/src/blueprint.js"
48
48
  },
49
+ "./uses": {
50
+ "types": "./dist/src/uses.d.ts",
51
+ "default": "./dist/src/uses.js"
52
+ },
49
53
  "./schema.json": "./schema/app-spec.v1.json",
50
54
  "./package.json": "./package.json"
51
55
  },
@@ -333,6 +333,40 @@
333
333
  "maxItems": 5,
334
334
  "default": []
335
335
  },
336
+ "uses": {
337
+ "type": "array",
338
+ "items": {
339
+ "type": "object",
340
+ "properties": {
341
+ "what": {
342
+ "type": "string",
343
+ "enum": [
344
+ "camera",
345
+ "microphone",
346
+ "location",
347
+ "motion",
348
+ "screen-awake",
349
+ "gamepad",
350
+ "voice",
351
+ "contacts",
352
+ "nfc"
353
+ ]
354
+ },
355
+ "why": {
356
+ "type": "string",
357
+ "minLength": 1,
358
+ "maxLength": 300
359
+ }
360
+ },
361
+ "required": [
362
+ "what",
363
+ "why"
364
+ ],
365
+ "additionalProperties": false
366
+ },
367
+ "maxItems": 9,
368
+ "default": []
369
+ },
336
370
  "journeys": {
337
371
  "type": "array",
338
372
  "items": {
@@ -416,6 +450,80 @@
416
450
  "maxItems": 30,
417
451
  "default": []
418
452
  },
453
+ "components": {
454
+ "type": "object",
455
+ "additionalProperties": {
456
+ "type": "object",
457
+ "properties": {
458
+ "kind": {
459
+ "type": "string",
460
+ "const": "coded"
461
+ },
462
+ "kit": {
463
+ "type": "string",
464
+ "enum": [
465
+ "game",
466
+ "page"
467
+ ]
468
+ },
469
+ "inputs": {
470
+ "type": "object",
471
+ "additionalProperties": {
472
+ "type": "string",
473
+ "enum": [
474
+ "number",
475
+ "string",
476
+ "boolean",
477
+ "list",
478
+ "map"
479
+ ]
480
+ },
481
+ "propertyNames": {
482
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,63}$"
483
+ },
484
+ "default": {}
485
+ },
486
+ "events": {
487
+ "type": "object",
488
+ "additionalProperties": {
489
+ "type": "object",
490
+ "additionalProperties": {
491
+ "type": "string",
492
+ "enum": [
493
+ "number",
494
+ "string",
495
+ "boolean",
496
+ "list",
497
+ "map"
498
+ ]
499
+ },
500
+ "propertyNames": {
501
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,63}$"
502
+ }
503
+ },
504
+ "propertyNames": {
505
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,63}$"
506
+ },
507
+ "default": {}
508
+ },
509
+ "code": {
510
+ "type": "string",
511
+ "minLength": 1,
512
+ "maxLength": 1048576
513
+ }
514
+ },
515
+ "required": [
516
+ "kind",
517
+ "kit",
518
+ "code"
519
+ ],
520
+ "additionalProperties": false
521
+ },
522
+ "propertyNames": {
523
+ "pattern": "^[a-z][a-zA-Z0-9_.-]{0,63}$"
524
+ },
525
+ "default": {}
526
+ },
419
527
  "mods": {
420
528
  "type": "object",
421
529
  "properties": {