@cloudcrane/app-spec 0.2.0 → 0.5.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
@@ -19,7 +19,13 @@ if (!spec) for (const p of problems) console.log(`${p.at}: ${p.problem}`);
19
19
  date functions and a bounded `fold`.
20
20
  - `@cloudcrane/app-spec/schema.json`: the spec as JSON Schema (2020-12).
21
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.
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.
23
29
  A screen places one with the catalog's `Coded` component, binding each input
24
30
  to the data model and each event to an action. Its code is exempt from the
25
31
  leak scan's code rules, but not from the others.
@@ -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 } : {}),
@@ -7,3 +7,5 @@ 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";
11
+ export { pageOf, pageProblems, wrapPage, type PageApp } from "./page.js";
package/dist/src/index.js CHANGED
@@ -9,3 +9,5 @@ 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";
13
+ export { pageOf, pageProblems, wrapPage } from "./page.js";
@@ -0,0 +1,35 @@
1
+ import type { Spec } from "./schema.js";
2
+ /** The spec as the page it is, or null when it isn't one. */
3
+ export declare function pageOf(spec: Spec): string | null;
4
+ type Problem = {
5
+ at: string;
6
+ problem: string;
7
+ };
8
+ /** What a spec that says it's a page may hold: the page, full screen, and no logic of its own. */
9
+ export declare function pageProblems(spec: Spec): Problem[];
10
+ /** What an HTML app says about itself, to make its spec from. */
11
+ export type PageApp = {
12
+ id: string;
13
+ name: string;
14
+ purpose: string;
15
+ html: string;
16
+ /** What made the spec, like "cloudcrane, from an HTML app" (the leak scan refuses one shaped like a call). */
17
+ generator: string;
18
+ /** The runtime version the spec is for; a page needs none, but the spec names the compiler's. */
19
+ runtime?: string;
20
+ data: {
21
+ version: number;
22
+ key: string;
23
+ };
24
+ journeys: Spec["journeys"];
25
+ promises?: Spec["promises"];
26
+ network?: Spec["network"];
27
+ uses?: Spec["uses"];
28
+ tests?: Spec["tests"];
29
+ icon?: string;
30
+ category?: string;
31
+ tags?: string[];
32
+ };
33
+ /** An HTML app as a spec whose one component is the page, full screen. Check it with validateSpec. */
34
+ export declare function wrapPage(app: PageApp): Record<string, unknown>;
35
+ export {};
@@ -0,0 +1,72 @@
1
+ import { CLOUDCRANE_CATALOG_ID } from "./catalog.js";
2
+ // An app written as one HTML page, as a spec. The page is its one page-kit
3
+ // component, and `page` names it: the spec is then the page, and the compiler
4
+ // gives back its HTML as written, with no runtime. It runs, saves and is
5
+ // tested exactly as it would on its own (the same storage keys, the same
6
+ // address, the same tests), while it's described, versioned and checked like
7
+ // any spec. Without `page`, the same component runs in a frame among the
8
+ // spec's own screens.
9
+ /** The spec as the page it is, or null when it isn't one. */
10
+ export function pageOf(spec) {
11
+ if (!spec.page)
12
+ return null;
13
+ const c = spec.components[spec.page];
14
+ return c?.kit === "page" ? c.code : null;
15
+ }
16
+ /** What a spec that says it's a page may hold: the page, full screen, and no logic of its own. */
17
+ export function pageProblems(spec) {
18
+ if (!spec.page)
19
+ return [];
20
+ const c = spec.components[spec.page];
21
+ if (c?.kit !== "page")
22
+ return [{ at: "page", problem: "names no page-kit component" }];
23
+ const problems = [];
24
+ if (Object.keys(spec.components).length !== 1)
25
+ problems.push({ at: "page", problem: "the page is the spec's only component" });
26
+ if (Object.keys(c.inputs).length || Object.keys(c.events).length)
27
+ problems.push({ at: "page", problem: "the page takes no inputs and sends no events" });
28
+ const placed = spec.surface.messages.flatMap((m) => (m["updateComponents"]?.components ?? []));
29
+ if (placed.length !== 1 || placed[0]["component"] !== "Coded" || placed[0]["use"] !== spec.page)
30
+ problems.push({ at: "page", problem: "the screen can hold nothing but the page" });
31
+ const { functions, computed, vectors } = spec.logic;
32
+ if (Object.keys(functions).length || Object.keys(computed).length || vectors.length || spec.data.views.length || spec.data.migrations.length)
33
+ problems.push({ at: "page", problem: "a page has no logic, views or migrations of the spec's own: its code keeps its data" });
34
+ return problems;
35
+ }
36
+ // A page draws itself, so these only satisfy the spec's shape; they're never shown.
37
+ const TOKENS = {
38
+ color: { $type: "color", background: { $value: "#ffffff" }, "on-background": { $value: "#111111" }, primary: { $value: "#0f766e" } },
39
+ };
40
+ /** An HTML app as a spec whose one component is the page, full screen. Check it with validateSpec. */
41
+ export function wrapPage(app) {
42
+ return {
43
+ v: 1,
44
+ manifest: { runtime: app.runtime ?? "0.0.0", generator: app.generator, source: { fingerprint: `sha256:${"0".repeat(64)}` } },
45
+ module: {
46
+ id: app.id,
47
+ name: app.name,
48
+ purpose: app.purpose,
49
+ ...(app.icon ? { icon: app.icon } : {}),
50
+ ...(app.category ? { category: app.category } : {}),
51
+ ...(app.tags?.length ? { tags: app.tags } : {}),
52
+ },
53
+ tokens: TOKENS,
54
+ surface: {
55
+ catalog: CLOUDCRANE_CATALOG_ID,
56
+ messages: [
57
+ { version: "v0.9.1", createSurface: { surfaceId: app.id, catalogId: CLOUDCRANE_CATALOG_ID } },
58
+ { version: "v0.9.1", updateComponents: { surfaceId: app.id, components: [{ id: "root", component: "Coded", use: "page", label: app.name }] } },
59
+ ],
60
+ },
61
+ data: { version: app.data.version, key: app.data.key, persist: "/saved", views: [], schema: { type: "object" }, initial: {}, migrations: [] },
62
+ logic: { consts: {}, functions: {}, computed: {}, vectors: [] },
63
+ network: app.network ?? [],
64
+ uses: app.uses ?? [],
65
+ journeys: app.journeys,
66
+ promises: app.promises ?? [],
67
+ tests: app.tests ?? [],
68
+ components: { page: { kind: "coded", kit: "page", inputs: {}, events: {}, code: app.html } },
69
+ page: "page",
70
+ mods: { open: [], locked: [] },
71
+ };
72
+ }
@@ -18,42 +18,57 @@ export declare const Tokens: z.ZodType<TokenNode>;
18
18
  export declare const CODED_TYPES: readonly ["number", "string", "boolean", "list", "map"];
19
19
  /**
20
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).
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.
22
24
  * It's given only its declared inputs and may only send its declared events;
23
25
  * a screen places it with the catalog's Coded component.
24
26
  */
25
- export declare const CodedComponent: z.ZodEffects<z.ZodObject<{
27
+ export declare const CodedComponent: z.ZodEffects<z.ZodEffects<z.ZodObject<{
26
28
  kind: z.ZodLiteral<"coded">;
27
- kit: z.ZodEnum<["game"]>;
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"]>;
28
31
  inputs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEnum<["number", "string", "boolean", "list", "map"]>>>;
29
32
  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>;
33
+ code: z.ZodString;
31
34
  }, "strict", z.ZodTypeAny, {
32
35
  code: string;
33
36
  kind: "coded";
34
- kit: "game";
37
+ kit: "game" | "page";
35
38
  inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
36
39
  events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
37
40
  }, {
38
41
  code: string;
39
42
  kind: "coded";
40
- kit: "game";
43
+ kit: "game" | "page";
41
44
  inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
42
45
  events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
43
46
  }>, {
44
47
  code: string;
45
48
  kind: "coded";
46
- kit: "game";
49
+ kit: "game" | "page";
47
50
  inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
48
51
  events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
49
52
  }, {
50
53
  code: string;
51
54
  kind: "coded";
52
- kit: "game";
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";
53
68
  inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
54
69
  events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
55
70
  }>;
56
- export declare const SpecSchema: z.ZodObject<{
71
+ export declare const SpecSchema: z.ZodEffects<z.ZodObject<{
57
72
  v: z.ZodLiteral<1>;
58
73
  manifest: z.ZodObject<{
59
74
  runtime: z.ZodString;
@@ -251,6 +266,17 @@ export declare const SpecSchema: z.ZodObject<{
251
266
  site: string;
252
267
  why: string;
253
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">>;
254
280
  journeys: z.ZodArray<z.ZodObject<{
255
281
  id: z.ZodString;
256
282
  title: z.ZodString;
@@ -295,49 +321,64 @@ export declare const SpecSchema: z.ZodObject<{
295
321
  /** CloudCrane's test steps, as its blueprint tests take them; CloudCrane checks their shape. */
296
322
  tests: z.ZodDefault<z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>, "many">>;
297
323
  /** 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<{
324
+ components: z.ZodDefault<z.ZodEffects<z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodEffects<z.ZodObject<{
299
325
  kind: z.ZodLiteral<"coded">;
300
- kit: z.ZodEnum<["game"]>;
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"]>;
301
328
  inputs: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodEnum<["number", "string", "boolean", "list", "map"]>>>;
302
329
  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>;
330
+ code: z.ZodString;
304
331
  }, "strict", z.ZodTypeAny, {
305
332
  code: string;
306
333
  kind: "coded";
307
- kit: "game";
334
+ kit: "game" | "page";
308
335
  inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
309
336
  events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
310
337
  }, {
311
338
  code: string;
312
339
  kind: "coded";
313
- kit: "game";
340
+ kit: "game" | "page";
314
341
  inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
315
342
  events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
316
343
  }>, {
317
344
  code: string;
318
345
  kind: "coded";
319
- kit: "game";
346
+ kit: "game" | "page";
320
347
  inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
321
348
  events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
322
349
  }, {
323
350
  code: string;
324
351
  kind: "coded";
325
- kit: "game";
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";
326
365
  inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
327
366
  events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
328
367
  }>>, Record<string, {
329
368
  code: string;
330
369
  kind: "coded";
331
- kit: "game";
370
+ kit: "game" | "page";
332
371
  inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
333
372
  events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
334
373
  }>, Record<string, {
335
374
  code: string;
336
375
  kind: "coded";
337
- kit: "game";
376
+ kit: "game" | "page";
338
377
  inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
339
378
  events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
340
379
  }>>>;
380
+ /** The page-kit component that is the whole app: the spec is then that page, compiled to its HTML as written (see page.ts). */
381
+ page: z.ZodOptional<z.ZodString>;
341
382
  /** What a remix may change and what stays as the partner made it. */
342
383
  mods: z.ZodDefault<z.ZodObject<{
343
384
  open: z.ZodArray<z.ZodString, "many">;
@@ -353,7 +394,179 @@ export declare const SpecSchema: z.ZodObject<{
353
394
  components: Record<string, {
354
395
  code: string;
355
396
  kind: "coded";
356
- kit: "game";
397
+ kit: "game" | "page";
398
+ inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
399
+ events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
400
+ }>;
401
+ surface: {
402
+ catalog: string;
403
+ messages: Record<string, unknown>[];
404
+ };
405
+ v: 1;
406
+ manifest: {
407
+ runtime: string;
408
+ generator: string;
409
+ source: {
410
+ fingerprint: string;
411
+ };
412
+ };
413
+ module: {
414
+ name: string;
415
+ id: string;
416
+ purpose: string;
417
+ icon?: string | undefined;
418
+ category?: string | undefined;
419
+ tags?: string[] | undefined;
420
+ };
421
+ tokens: TokenNode;
422
+ data: {
423
+ version: number;
424
+ key: string;
425
+ persist: string;
426
+ schema: Record<string, unknown>;
427
+ views: {
428
+ path: string;
429
+ source: string;
430
+ }[];
431
+ migrations: {
432
+ from: number;
433
+ to: number;
434
+ cel: string;
435
+ }[];
436
+ initial?: unknown;
437
+ };
438
+ logic: {
439
+ consts: Record<string, string | number | boolean>;
440
+ functions: Record<string, {
441
+ params: string[];
442
+ cel: string;
443
+ }>;
444
+ computed: Record<string, string>;
445
+ vectors: {
446
+ from: "recorded" | "hint";
447
+ fn: string;
448
+ in: unknown[];
449
+ out?: unknown;
450
+ }[];
451
+ };
452
+ network: {
453
+ site: string;
454
+ why: string;
455
+ }[];
456
+ uses: {
457
+ why: string;
458
+ what: "camera" | "microphone" | "location" | "motion" | "screen-awake" | "gamepad" | "voice" | "contacts" | "nfc";
459
+ }[];
460
+ journeys: {
461
+ title: string;
462
+ id: string;
463
+ steps: {
464
+ text: string;
465
+ id: string;
466
+ }[];
467
+ }[];
468
+ promises: {
469
+ text: string;
470
+ id: string;
471
+ kind?: "data" | undefined;
472
+ }[];
473
+ tests: Record<string, unknown>[];
474
+ mods: {
475
+ open: string[];
476
+ locked: string[];
477
+ };
478
+ page?: string | undefined;
479
+ }, {
480
+ surface: {
481
+ catalog: string;
482
+ messages: Record<string, unknown>[];
483
+ };
484
+ v: 1;
485
+ manifest: {
486
+ runtime: string;
487
+ generator: string;
488
+ source: {
489
+ fingerprint: string;
490
+ };
491
+ };
492
+ module: {
493
+ name: string;
494
+ id: string;
495
+ purpose: string;
496
+ icon?: string | undefined;
497
+ category?: string | undefined;
498
+ tags?: string[] | undefined;
499
+ };
500
+ tokens: TokenNode;
501
+ data: {
502
+ version: number;
503
+ key: string;
504
+ persist: string;
505
+ schema: Record<string, unknown>;
506
+ migrations: {
507
+ from: number;
508
+ to: number;
509
+ cel: string;
510
+ }[];
511
+ initial?: unknown;
512
+ views?: {
513
+ path: string;
514
+ source: string;
515
+ }[] | undefined;
516
+ };
517
+ logic: {
518
+ consts?: Record<string, string | number | boolean> | undefined;
519
+ functions?: Record<string, {
520
+ params: string[];
521
+ cel: string;
522
+ }> | undefined;
523
+ computed?: Record<string, string> | undefined;
524
+ vectors?: {
525
+ from: "recorded" | "hint";
526
+ fn: string;
527
+ in: unknown[];
528
+ out?: unknown;
529
+ }[] | undefined;
530
+ };
531
+ journeys: {
532
+ title: string;
533
+ id: string;
534
+ steps: {
535
+ text: string;
536
+ id: string;
537
+ }[];
538
+ }[];
539
+ components?: Record<string, {
540
+ code: string;
541
+ kind: "coded";
542
+ kit: "game" | "page";
543
+ inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
544
+ events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
545
+ }> | undefined;
546
+ page?: string | undefined;
547
+ network?: {
548
+ site: string;
549
+ why: string;
550
+ }[] | undefined;
551
+ uses?: {
552
+ why: string;
553
+ what: "camera" | "microphone" | "location" | "motion" | "screen-awake" | "gamepad" | "voice" | "contacts" | "nfc";
554
+ }[] | undefined;
555
+ promises?: {
556
+ text: string;
557
+ id: string;
558
+ kind?: "data" | undefined;
559
+ }[] | undefined;
560
+ tests?: Record<string, unknown>[] | undefined;
561
+ mods?: {
562
+ open: string[];
563
+ locked: string[];
564
+ } | undefined;
565
+ }>, {
566
+ components: Record<string, {
567
+ code: string;
568
+ kind: "coded";
569
+ kit: "game" | "page";
357
570
  inputs: Record<string, "string" | "number" | "boolean" | "map" | "list">;
358
571
  events: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">>;
359
572
  }>;
@@ -412,6 +625,10 @@ export declare const SpecSchema: z.ZodObject<{
412
625
  site: string;
413
626
  why: string;
414
627
  }[];
628
+ uses: {
629
+ why: string;
630
+ what: "camera" | "microphone" | "location" | "motion" | "screen-awake" | "gamepad" | "voice" | "contacts" | "nfc";
631
+ }[];
415
632
  journeys: {
416
633
  title: string;
417
634
  id: string;
@@ -430,6 +647,7 @@ export declare const SpecSchema: z.ZodObject<{
430
647
  open: string[];
431
648
  locked: string[];
432
649
  };
650
+ page?: string | undefined;
433
651
  }, {
434
652
  surface: {
435
653
  catalog: string;
@@ -493,14 +711,19 @@ export declare const SpecSchema: z.ZodObject<{
493
711
  components?: Record<string, {
494
712
  code: string;
495
713
  kind: "coded";
496
- kit: "game";
714
+ kit: "game" | "page";
497
715
  inputs?: Record<string, "string" | "number" | "boolean" | "map" | "list"> | undefined;
498
716
  events?: Record<string, Record<string, "string" | "number" | "boolean" | "map" | "list">> | undefined;
499
717
  }> | undefined;
718
+ page?: string | undefined;
500
719
  network?: {
501
720
  site: string;
502
721
  why: string;
503
722
  }[] | undefined;
723
+ uses?: {
724
+ why: string;
725
+ what: "camera" | "microphone" | "location" | "motion" | "screen-awake" | "gamepad" | "voice" | "contacts" | "nfc";
726
+ }[] | undefined;
504
727
  promises?: {
505
728
  text: string;
506
729
  id: string;
@@ -1,4 +1,5 @@
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
@@ -49,23 +50,35 @@ export const CODED_TYPES = ["number", "string", "boolean", "list", "map"];
49
50
  const CodedType = z.enum(CODED_TYPES);
50
51
  /**
51
52
  * 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
+ * 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.
53
56
  * It's given only its declared inputs and may only send its declared events;
54
57
  * a screen places it with the catalog's Coded component.
55
58
  */
56
59
  export const CodedComponent = z
57
60
  .object({
58
61
  kind: z.literal("coded"),
59
- kit: z.enum(["game"]),
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"]),
60
64
  inputs: z.record(CelName, CodedType).default({}),
61
65
  events: z.record(CelName, z.record(CelName, CodedType)).default({}),
62
66
  code: z
63
67
  .string()
64
68
  .min(1)
65
- .max(64 * 1024)
66
- .refine((c) => !/<\/script/i.test(c), "can't contain </script>, which would end the frame's script"),
69
+ .max(1024 * 1024),
67
70
  })
68
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
+ })
69
82
  .refine((c) => Object.keys(c.inputs).length <= 10 && Object.keys(c.events).length <= 10, "at most 10 inputs and 10 events");
70
83
  export const SpecSchema = z
71
84
  .object({
@@ -141,6 +154,11 @@ export const SpecSchema = z
141
154
  .array(z.object({ site: z.string().regex(/^[a-z0-9.-]+\.[a-z]{2,}$/), why: Text(300) }).strict())
142
155
  .max(5)
143
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([]),
144
162
  journeys: z
145
163
  .array(z
146
164
  .object({
@@ -158,10 +176,23 @@ export const SpecSchema = z
158
176
  .record(Id, CodedComponent)
159
177
  .refine((c) => Object.keys(c).length <= 5, "at most 5 components")
160
178
  .default({}),
179
+ /** The page-kit component that is the whole app: the spec is then that page, compiled to its HTML as written (see page.ts). */
180
+ page: Id.optional(),
161
181
  /** What a remix may change and what stays as the partner made it. */
162
182
  mods: z
163
183
  .object({ open: z.array(PointerPattern), locked: z.array(PointerPattern) })
164
184
  .strict()
165
185
  .default({ open: [], locked: [] }),
166
186
  })
167
- .strict();
187
+ .strict()
188
+ .superRefine((s, ctx) => {
189
+ const seen = new Set();
190
+ for (const u of s.uses) {
191
+ if (seen.has(u.what))
192
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["uses"], message: `${u.what} is listed twice` });
193
+ seen.add(u.what);
194
+ }
195
+ const needsOrigin = s.uses.some((u) => NEEDS_ORIGIN.includes(u.what));
196
+ if (needsOrigin && !Object.values(s.components).some((c) => c.kit === "page"))
197
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["uses"], message: "the camera and microphone work only in a page-kit component" });
198
+ });
@@ -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
+ }
@@ -3,6 +3,7 @@ import { catalogFor } from "./catalog.js";
3
3
  import { leakProblems } from "./leak.js";
4
4
  import { logicProblems } from "./logic.js";
5
5
  import { SpecSchema } from "./schema.js";
6
+ import { pageProblems } from "./page.js";
6
7
  // Everything wrong with a spec, collected rather than stopping at the first: the
7
8
  // envelope's shape, then the A2UI surface, then references between parts.
8
9
  const pathOf = (path) => path
@@ -46,7 +47,7 @@ export function validateSpec(input, catalog) {
46
47
  for (const m of spec.data.migrations)
47
48
  if (m.to !== m.from + 1 || m.to > spec.data.version)
48
49
  problems.push({ at: `data.migrations ${m.from}`, problem: "steps go one version at a time, up to data.version" });
49
- problems.push(...codedProblems(spec), ...logicProblems(spec), ...leakProblems(spec));
50
+ problems.push(...codedProblems(spec), ...pageProblems(spec), ...logicProblems(spec), ...leakProblems(spec));
50
51
  for (const locked of spec.mods.locked)
51
52
  if (spec.mods.open.includes(locked))
52
53
  problems.push({ at: `mods ${locked}`, problem: "both open and locked" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudcrane/app-spec",
3
- "version": "0.2.0",
3
+ "version": "0.5.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": {
@@ -428,7 +462,8 @@
428
462
  "kit": {
429
463
  "type": "string",
430
464
  "enum": [
431
- "game"
465
+ "game",
466
+ "page"
432
467
  ]
433
468
  },
434
469
  "inputs": {
@@ -474,7 +509,7 @@
474
509
  "code": {
475
510
  "type": "string",
476
511
  "minLength": 1,
477
- "maxLength": 65536
512
+ "maxLength": 1048576
478
513
  }
479
514
  },
480
515
  "required": [
@@ -489,6 +524,10 @@
489
524
  },
490
525
  "default": {}
491
526
  },
527
+ "page": {
528
+ "type": "string",
529
+ "pattern": "^[a-z][a-zA-Z0-9_.-]{0,63}$"
530
+ },
492
531
  "mods": {
493
532
  "type": "object",
494
533
  "properties": {