@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 +7 -1
- package/dist/src/blueprint.d.ts +1 -0
- package/dist/src/blueprint.js +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/page.d.ts +35 -0
- package/dist/src/page.js +72 -0
- package/dist/src/schema.d.ts +243 -20
- package/dist/src/schema.js +36 -5
- package/dist/src/uses.d.ts +19 -0
- package/dist/src/uses.js +28 -0
- package/dist/src/validate.js +2 -1
- package/package.json +5 -1
- package/schema/app-spec.v1.json +41 -2
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`
|
|
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.
|
package/dist/src/blueprint.d.ts
CHANGED
package/dist/src/blueprint.js
CHANGED
|
@@ -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 } : {}),
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/src/page.js
ADDED
|
@@ -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
|
+
}
|
package/dist/src/schema.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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;
|
package/dist/src/schema.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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[];
|
package/dist/src/uses.js
ADDED
|
@@ -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
|
+
}
|
package/dist/src/validate.js
CHANGED
|
@@ -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.
|
|
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
|
},
|
package/schema/app-spec.v1.json
CHANGED
|
@@ -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":
|
|
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": {
|