@protoyard/blueprint 0.0.0-stage → 1.1.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/LICENSE +21 -0
- package/README.md +1149 -2
- package/dist/aggregate.d.ts +83 -0
- package/dist/aggregate.js +268 -0
- package/dist/app-model.d.ts +76 -0
- package/dist/app-model.js +287 -0
- package/dist/archetypes/index.d.ts +1 -0
- package/dist/archetypes/index.js +1 -0
- package/dist/archetypes/schema.d.ts +126 -0
- package/dist/archetypes/schema.js +77 -0
- package/dist/audit.d.ts +52 -0
- package/dist/audit.js +149 -0
- package/dist/authoring/deck-lint.d.ts +32 -0
- package/dist/authoring/deck-lint.js +567 -0
- package/dist/authoring/index.d.ts +1 -0
- package/dist/authoring/index.js +1 -0
- package/dist/authoring/select-deck-layouts.d.ts +50 -0
- package/dist/authoring/select-deck-layouts.js +213 -0
- package/dist/authoring-apis.d.ts +13 -0
- package/dist/authoring-apis.js +13 -0
- package/dist/authoring-validation.d.ts +65 -0
- package/dist/authoring-validation.js +267 -0
- package/dist/avatar.d.ts +25 -0
- package/dist/avatar.js +30 -0
- package/dist/brand.d.ts +70 -0
- package/dist/brand.js +294 -0
- package/dist/brief/archetypes.d.ts +28 -0
- package/dist/brief/archetypes.js +798 -0
- package/dist/brief/content-synthesize.d.ts +31 -0
- package/dist/brief/content-synthesize.js +210 -0
- package/dist/brief/deck-outlines.d.ts +96 -0
- package/dist/brief/deck-outlines.js +561 -0
- package/dist/brief/draft-bodies.d.ts +29 -0
- package/dist/brief/draft-bodies.js +224 -0
- package/dist/brief/fields.d.ts +19 -0
- package/dist/brief/fields.js +229 -0
- package/dist/brief/index.d.ts +8 -0
- package/dist/brief/index.js +8 -0
- package/dist/brief/kind-detection.d.ts +69 -0
- package/dist/brief/kind-detection.js +353 -0
- package/dist/brief/limits.d.ts +117 -0
- package/dist/brief/limits.js +217 -0
- package/dist/brief/page-doc-outlines.d.ts +80 -0
- package/dist/brief/page-doc-outlines.js +485 -0
- package/dist/brief/synthesize.d.ts +59 -0
- package/dist/brief/synthesize.js +511 -0
- package/dist/brief/text.d.ts +13 -0
- package/dist/brief/text.js +59 -0
- package/dist/canvas-screen.d.ts +31 -0
- package/dist/canvas-screen.js +33 -0
- package/dist/canvas-seed.d.ts +58 -0
- package/dist/canvas-seed.js +82 -0
- package/dist/computed/evaluate.d.ts +11 -0
- package/dist/computed/evaluate.js +101 -0
- package/dist/computed/index.d.ts +4 -0
- package/dist/computed/index.js +3 -0
- package/dist/computed/public.d.ts +14 -0
- package/dist/computed/public.js +19 -0
- package/dist/computed/schema.d.ts +10 -0
- package/dist/computed/schema.js +140 -0
- package/dist/computed/types.d.ts +70 -0
- package/dist/computed/types.js +1 -0
- package/dist/computed/validate.d.ts +6 -0
- package/dist/computed/validate.js +146 -0
- package/dist/csv-guard.d.ts +15 -0
- package/dist/csv-guard.js +29 -0
- package/dist/csv.d.ts +85 -0
- package/dist/csv.js +288 -0
- package/dist/files.d.ts +35 -0
- package/dist/files.js +246 -0
- package/dist/form-schema.d.ts +124 -0
- package/dist/form-schema.js +300 -0
- package/dist/generator-version.d.ts +11 -0
- package/dist/generator-version.js +51 -0
- package/dist/generator.d.ts +34 -0
- package/dist/generator.js +445 -0
- package/dist/identity.d.ts +34 -0
- package/dist/identity.js +91 -0
- package/dist/import/csv-shape.d.ts +40 -0
- package/dist/import/csv-shape.js +265 -0
- package/dist/import/json-shape.d.ts +55 -0
- package/dist/import/json-shape.js +491 -0
- package/dist/import/limits.d.ts +13 -0
- package/dist/import/limits.js +14 -0
- package/dist/import/sqlite-schema.d.ts +36 -0
- package/dist/import/sqlite-schema.js +729 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +58 -0
- package/dist/kinds/anchors.d.ts +18 -0
- package/dist/kinds/anchors.js +46 -0
- package/dist/kinds/bodies.d.ts +6 -0
- package/dist/kinds/bodies.js +32 -0
- package/dist/kinds/brand-runtime.d.ts +19 -0
- package/dist/kinds/brand-runtime.js +35 -0
- package/dist/kinds/deck.d.ts +8 -0
- package/dist/kinds/deck.js +34 -0
- package/dist/kinds/doc.d.ts +19 -0
- package/dist/kinds/doc.js +41 -0
- package/dist/kinds/embed/index.d.ts +39 -0
- package/dist/kinds/embed/index.js +91 -0
- package/dist/kinds/envelope.d.ts +159 -0
- package/dist/kinds/envelope.js +78 -0
- package/dist/kinds/index.d.ts +11 -0
- package/dist/kinds/index.js +11 -0
- package/dist/kinds/kind.d.ts +36 -0
- package/dist/kinds/kind.js +55 -0
- package/dist/kinds/limits.d.ts +64 -0
- package/dist/kinds/limits.js +190 -0
- package/dist/kinds/page-doc-runtime.d.ts +37 -0
- package/dist/kinds/page-doc-runtime.js +55 -0
- package/dist/kinds/page.d.ts +16 -0
- package/dist/kinds/page.js +67 -0
- package/dist/kinds/parse.d.ts +18 -0
- package/dist/kinds/parse.js +27 -0
- package/dist/kinds/runtime.d.ts +128 -0
- package/dist/kinds/runtime.js +206 -0
- package/dist/kinds/services.d.ts +49 -0
- package/dist/kinds/services.js +249 -0
- package/dist/kinds/testing.d.ts +9 -0
- package/dist/kinds/testing.js +16 -0
- package/dist/migrations.d.ts +97 -0
- package/dist/migrations.js +351 -0
- package/dist/motion.d.ts +50 -0
- package/dist/motion.js +52 -0
- package/dist/notifications.d.ts +19 -0
- package/dist/notifications.js +177 -0
- package/dist/patch.d.ts +122 -0
- package/dist/patch.js +322 -0
- package/dist/policy.d.ts +23 -0
- package/dist/policy.js +105 -0
- package/dist/runtime-definition.d.ts +39 -0
- package/dist/runtime-definition.js +74 -0
- package/dist/scenarios.d.ts +9 -0
- package/dist/scenarios.js +49 -0
- package/dist/schema/blueprint.schema.json +10191 -0
- package/dist/schema-json.d.ts +6 -0
- package/dist/schema-json.js +36 -0
- package/dist/schema.d.ts +6348 -0
- package/dist/schema.js +1269 -0
- package/dist/search.d.ts +16 -0
- package/dist/search.js +96 -0
- package/dist/services.d.ts +89 -0
- package/dist/services.js +898 -0
- package/dist/share.d.ts +78 -0
- package/dist/share.js +234 -0
- package/dist/sheet-screen.d.ts +49 -0
- package/dist/sheet-screen.js +91 -0
- package/dist/storage.d.ts +43 -0
- package/dist/storage.js +179 -0
- package/dist/stream.d.ts +26 -0
- package/dist/stream.js +173 -0
- package/dist/untrusted.d.ts +63 -0
- package/dist/untrusted.js +502 -0
- package/dist/validation-tasks.d.ts +178 -0
- package/dist/validation-tasks.js +199 -0
- package/dist/validation.d.ts +35 -0
- package/dist/validation.js +312 -0
- package/dist/variants.d.ts +112 -0
- package/dist/variants.js +283 -0
- package/dist/workflows/index.d.ts +3 -0
- package/dist/workflows/index.js +3 -0
- package/dist/workflows/schema.d.ts +50 -0
- package/dist/workflows/schema.js +34 -0
- package/dist/workflows/transition.d.ts +15 -0
- package/dist/workflows/transition.js +25 -0
- package/dist/workflows/validate.d.ts +17 -0
- package/dist/workflows/validate.js +140 -0
- package/package.json +120 -3
package/dist/patch.d.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { DeckBlueprint, DocBlueprint, PageBlueprint } from './kinds/envelope.ts';
|
|
3
|
+
import { type BlueprintMigration, type DiffBlueprintsOptions } from './migrations.ts';
|
|
4
|
+
import { type Blueprint } from './schema.ts';
|
|
5
|
+
/**
|
|
6
|
+
* RFC 6902 JSON Patch for blueprints (`protoyard quick iterate`), with no
|
|
7
|
+
* dependency. A patch is untrusted input: it is shape-checked with zod, its
|
|
8
|
+
* pointers may never name `__proto__`, `constructor` or `prototype`, it runs
|
|
9
|
+
* on a copy so a failing operation changes nothing, the result must parse as
|
|
10
|
+
* a blueprint, and the data migration it implies is returned, so callers know
|
|
11
|
+
* whether the current world survives. A change that loses data (a dropped
|
|
12
|
+
* entity or field, an incompatible field change) needs `allowDrop`.
|
|
13
|
+
*/
|
|
14
|
+
export declare const JSON_PATCH_MAX_OPS = 1000;
|
|
15
|
+
/** Maximum JSON nodes traversed while copying a document or patch value. */
|
|
16
|
+
export declare const JSON_PATCH_MAX_NODES = 100000;
|
|
17
|
+
export declare const JSON_POINTER_MAX_LENGTH = 1024;
|
|
18
|
+
export declare const JsonPatchOperationSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
19
|
+
op: z.ZodLiteral<"add">;
|
|
20
|
+
path: z.ZodString;
|
|
21
|
+
value: z.ZodUnknown;
|
|
22
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
23
|
+
op: z.ZodLiteral<"remove">;
|
|
24
|
+
path: z.ZodString;
|
|
25
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
26
|
+
op: z.ZodLiteral<"replace">;
|
|
27
|
+
path: z.ZodString;
|
|
28
|
+
value: z.ZodUnknown;
|
|
29
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
30
|
+
op: z.ZodLiteral<"move">;
|
|
31
|
+
from: z.ZodString;
|
|
32
|
+
path: z.ZodString;
|
|
33
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
34
|
+
op: z.ZodLiteral<"copy">;
|
|
35
|
+
from: z.ZodString;
|
|
36
|
+
path: z.ZodString;
|
|
37
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
38
|
+
op: z.ZodLiteral<"test">;
|
|
39
|
+
path: z.ZodString;
|
|
40
|
+
value: z.ZodUnknown;
|
|
41
|
+
}, z.core.$strip>], "op">;
|
|
42
|
+
export declare const JsonPatchSchema: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
43
|
+
op: z.ZodLiteral<"add">;
|
|
44
|
+
path: z.ZodString;
|
|
45
|
+
value: z.ZodUnknown;
|
|
46
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
47
|
+
op: z.ZodLiteral<"remove">;
|
|
48
|
+
path: z.ZodString;
|
|
49
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
50
|
+
op: z.ZodLiteral<"replace">;
|
|
51
|
+
path: z.ZodString;
|
|
52
|
+
value: z.ZodUnknown;
|
|
53
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
54
|
+
op: z.ZodLiteral<"move">;
|
|
55
|
+
from: z.ZodString;
|
|
56
|
+
path: z.ZodString;
|
|
57
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
58
|
+
op: z.ZodLiteral<"copy">;
|
|
59
|
+
from: z.ZodString;
|
|
60
|
+
path: z.ZodString;
|
|
61
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
62
|
+
op: z.ZodLiteral<"test">;
|
|
63
|
+
path: z.ZodString;
|
|
64
|
+
value: z.ZodUnknown;
|
|
65
|
+
}, z.core.$strip>], "op">>;
|
|
66
|
+
export type JsonPatchOperation = z.output<typeof JsonPatchOperationSchema>;
|
|
67
|
+
export type BlueprintPatchErrorCode = 'invalid-patch' | 'unsafe-pointer' | 'path-not-found' | 'test-failed' | 'invalid-result' | 'drop-requires-allow';
|
|
68
|
+
export declare class BlueprintPatchError extends Error {
|
|
69
|
+
readonly code: BlueprintPatchErrorCode;
|
|
70
|
+
/** Index of the failing operation, when one operation is to blame. */
|
|
71
|
+
readonly index: number | null;
|
|
72
|
+
/** Blueprint validation issues for `invalid-result`. */
|
|
73
|
+
readonly issues: readonly {
|
|
74
|
+
path: (string | number)[];
|
|
75
|
+
message: string;
|
|
76
|
+
}[];
|
|
77
|
+
constructor(code: BlueprintPatchErrorCode, message: string, options?: {
|
|
78
|
+
index?: number | null;
|
|
79
|
+
issues?: BlueprintPatchError['issues'];
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** RFC 6901: `""` is the whole document; otherwise `/`-separated, `~1` → `/`, `~0` → `~`. */
|
|
83
|
+
export declare function parseJsonPointer(pointer: string): string[];
|
|
84
|
+
/** JSON equality: same type, arrays in order, objects by key set regardless of order. */
|
|
85
|
+
export declare function jsonEqual(a: unknown, b: unknown): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Applies RFC 6902 operations in order to a copy of `document` and returns
|
|
88
|
+
* the result. Any failure throws `BlueprintPatchError` and leaves the input
|
|
89
|
+
* untouched (the operations are atomic as a whole).
|
|
90
|
+
*/
|
|
91
|
+
export declare function applyJsonPatch(document: unknown, operations: unknown): unknown;
|
|
92
|
+
export interface ApplyBlueprintPatchOptions extends Pick<DiffBlueprintsOptions, 'fromVersion' | 'renamedFrom'> {
|
|
93
|
+
/** Accept a patch that drops entities or fields, or resets incompatible fields. */
|
|
94
|
+
allowDrop?: boolean;
|
|
95
|
+
}
|
|
96
|
+
export interface BlueprintPatchResult {
|
|
97
|
+
blueprint: Blueprint;
|
|
98
|
+
/** The next database version, or null when the stored data shape is unchanged. */
|
|
99
|
+
migration: BlueprintMigration | null;
|
|
100
|
+
/** True when the migration drops tables or fields or resets values (only with allowDrop). */
|
|
101
|
+
destructive: boolean;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Applies a JSON Patch to a blueprint and validates the result. The returned
|
|
105
|
+
* migration says how the existing world moves to the new schema; `destructive`
|
|
106
|
+
* says whether that loses data, which needs `allowDrop`.
|
|
107
|
+
*/
|
|
108
|
+
export declare function applyBlueprintPatch(blueprint: Blueprint, operations: unknown, options?: ApplyBlueprintPatchOptions): BlueprintPatchResult;
|
|
109
|
+
/** A deck, page or doc blueprint. */
|
|
110
|
+
type ContentKindBlueprint = DeckBlueprint | PageBlueprint | DocBlueprint;
|
|
111
|
+
export interface ContentPatchResult<B extends ContentKindBlueprint> {
|
|
112
|
+
blueprint: B;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Applies a JSON Patch to a deck, page or doc and validates the result as
|
|
116
|
+
* untrusted input (`loadUntrustedDocument`: kind limits, text sanitizing and
|
|
117
|
+
* the kind's body schema). Content kinds share one store layout, so there is
|
|
118
|
+
* never a data migration: block state and form responses stay keyed by block
|
|
119
|
+
* id. The patch may not change `kind`.
|
|
120
|
+
*/
|
|
121
|
+
export declare function applyContentPatch<B extends ContentKindBlueprint>(blueprint: B, operations: unknown): Promise<ContentPatchResult<B>>;
|
|
122
|
+
export {};
|
package/dist/patch.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { diffBlueprints, MigrationError, } from "./migrations.js";
|
|
3
|
+
import { safeParseBlueprint } from "./schema.js";
|
|
4
|
+
import { loadUntrustedDocument } from "./untrusted.js";
|
|
5
|
+
/**
|
|
6
|
+
* RFC 6902 JSON Patch for blueprints (`protoyard quick iterate`), with no
|
|
7
|
+
* dependency. A patch is untrusted input: it is shape-checked with zod, its
|
|
8
|
+
* pointers may never name `__proto__`, `constructor` or `prototype`, it runs
|
|
9
|
+
* on a copy so a failing operation changes nothing, the result must parse as
|
|
10
|
+
* a blueprint, and the data migration it implies is returned, so callers know
|
|
11
|
+
* whether the current world survives. A change that loses data (a dropped
|
|
12
|
+
* entity or field, an incompatible field change) needs `allowDrop`.
|
|
13
|
+
*/
|
|
14
|
+
export const JSON_PATCH_MAX_OPS = 1000;
|
|
15
|
+
/** Maximum JSON nodes traversed while copying a document or patch value. */
|
|
16
|
+
export const JSON_PATCH_MAX_NODES = 100_000;
|
|
17
|
+
export const JSON_POINTER_MAX_LENGTH = 1024;
|
|
18
|
+
/** Pointer tokens and value keys that could reach an object prototype. */
|
|
19
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
20
|
+
const Pointer = z.string().max(JSON_POINTER_MAX_LENGTH);
|
|
21
|
+
// RFC 6902 §4: members an operation does not define are ignored, so these are
|
|
22
|
+
// not strict. `value` is checked for presence separately (it may be null).
|
|
23
|
+
export const JsonPatchOperationSchema = z.discriminatedUnion('op', [
|
|
24
|
+
z.object({ op: z.literal('add'), path: Pointer, value: z.unknown() }),
|
|
25
|
+
z.object({ op: z.literal('remove'), path: Pointer }),
|
|
26
|
+
z.object({ op: z.literal('replace'), path: Pointer, value: z.unknown() }),
|
|
27
|
+
z.object({ op: z.literal('move'), from: Pointer, path: Pointer }),
|
|
28
|
+
z.object({ op: z.literal('copy'), from: Pointer, path: Pointer }),
|
|
29
|
+
z.object({ op: z.literal('test'), path: Pointer, value: z.unknown() }),
|
|
30
|
+
]);
|
|
31
|
+
export const JsonPatchSchema = z.array(JsonPatchOperationSchema).max(JSON_PATCH_MAX_OPS);
|
|
32
|
+
export class BlueprintPatchError extends Error {
|
|
33
|
+
code;
|
|
34
|
+
/** Index of the failing operation, when one operation is to blame. */
|
|
35
|
+
index;
|
|
36
|
+
/** Blueprint validation issues for `invalid-result`. */
|
|
37
|
+
issues;
|
|
38
|
+
constructor(code, message, options = {}) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = 'BlueprintPatchError';
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.index = options.index ?? null;
|
|
43
|
+
this.issues = options.issues ?? [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** RFC 6901: `""` is the whole document; otherwise `/`-separated, `~1` → `/`, `~0` → `~`. */
|
|
47
|
+
export function parseJsonPointer(pointer) {
|
|
48
|
+
if (pointer === '')
|
|
49
|
+
return [];
|
|
50
|
+
if (!pointer.startsWith('/'))
|
|
51
|
+
throw new Error(`"${pointer}" must be empty or start with "/"`);
|
|
52
|
+
return pointer
|
|
53
|
+
.slice(1)
|
|
54
|
+
.split('/')
|
|
55
|
+
.map((token) => {
|
|
56
|
+
if (/~[^01]|~$/.test(token))
|
|
57
|
+
throw new Error(`"${pointer}" has an invalid ~ escape`);
|
|
58
|
+
const key = token.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
59
|
+
if (FORBIDDEN_KEYS.has(key))
|
|
60
|
+
throw new Error(`"${pointer}" names "${key}"`);
|
|
61
|
+
return key;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function isContainer(value) {
|
|
65
|
+
return typeof value === 'object' && value !== null;
|
|
66
|
+
}
|
|
67
|
+
/** Array index token: `0` or digits without a leading zero; `-` (append) only when allowed. */
|
|
68
|
+
function arrayIndex(array, token, forAdd) {
|
|
69
|
+
if (token === '-')
|
|
70
|
+
return forAdd ? array.length : null;
|
|
71
|
+
if (!/^(?:0|[1-9]\d*)$/.test(token))
|
|
72
|
+
return null;
|
|
73
|
+
const index = Number(token);
|
|
74
|
+
return index < (forAdd ? array.length + 1 : array.length) ? index : null;
|
|
75
|
+
}
|
|
76
|
+
function hasMember(container, token) {
|
|
77
|
+
return Array.isArray(container)
|
|
78
|
+
? arrayIndex(container, token, false) !== null
|
|
79
|
+
: Object.hasOwn(container, token);
|
|
80
|
+
}
|
|
81
|
+
function memberAt(container, token) {
|
|
82
|
+
return Array.isArray(container)
|
|
83
|
+
? container[arrayIndex(container, token, false)]
|
|
84
|
+
: container[token];
|
|
85
|
+
}
|
|
86
|
+
/** Walks to the parent of `tokens`; throws `path-not-found` when it is missing. */
|
|
87
|
+
function parentOf(root, tokens, fail) {
|
|
88
|
+
let node = root;
|
|
89
|
+
for (const token of tokens.slice(0, -1)) {
|
|
90
|
+
if (!isContainer(node) || !hasMember(node, token))
|
|
91
|
+
fail('a parent along the path does not exist');
|
|
92
|
+
node = memberAt(node, token);
|
|
93
|
+
}
|
|
94
|
+
if (!isContainer(node))
|
|
95
|
+
fail('its parent is not an object or array');
|
|
96
|
+
return node;
|
|
97
|
+
}
|
|
98
|
+
function getAt(root, tokens, fail) {
|
|
99
|
+
if (tokens.length === 0)
|
|
100
|
+
return root;
|
|
101
|
+
const parent = parentOf(root, tokens, fail);
|
|
102
|
+
const last = tokens[tokens.length - 1];
|
|
103
|
+
if (!hasMember(parent, last))
|
|
104
|
+
fail('the target does not exist');
|
|
105
|
+
return memberAt(parent, last);
|
|
106
|
+
}
|
|
107
|
+
/** Returns the new root (an `add` at `""` replaces the whole document). */
|
|
108
|
+
function addAt(root, tokens, value, fail) {
|
|
109
|
+
if (tokens.length === 0)
|
|
110
|
+
return value;
|
|
111
|
+
const parent = parentOf(root, tokens, fail);
|
|
112
|
+
const last = tokens[tokens.length - 1];
|
|
113
|
+
if (Array.isArray(parent)) {
|
|
114
|
+
const index = arrayIndex(parent, last, true);
|
|
115
|
+
if (index === null)
|
|
116
|
+
fail('the array index is out of range');
|
|
117
|
+
parent.splice(index, 0, value);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
parent[last] = value;
|
|
121
|
+
}
|
|
122
|
+
return root;
|
|
123
|
+
}
|
|
124
|
+
function removeAt(root, tokens, fail) {
|
|
125
|
+
if (tokens.length === 0)
|
|
126
|
+
fail('the whole document cannot be removed');
|
|
127
|
+
const parent = parentOf(root, tokens, fail);
|
|
128
|
+
const last = tokens[tokens.length - 1];
|
|
129
|
+
if (!hasMember(parent, last))
|
|
130
|
+
fail('the target does not exist');
|
|
131
|
+
const removed = memberAt(parent, last);
|
|
132
|
+
if (Array.isArray(parent))
|
|
133
|
+
parent.splice(arrayIndex(parent, last, false), 1);
|
|
134
|
+
else
|
|
135
|
+
delete parent[last];
|
|
136
|
+
return removed;
|
|
137
|
+
}
|
|
138
|
+
/** JSON equality: same type, arrays in order, objects by key set regardless of order. */
|
|
139
|
+
export function jsonEqual(a, b) {
|
|
140
|
+
if (a === b)
|
|
141
|
+
return true;
|
|
142
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object')
|
|
143
|
+
return false;
|
|
144
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
145
|
+
return false;
|
|
146
|
+
if (Array.isArray(a) && Array.isArray(b))
|
|
147
|
+
return a.length === b.length && a.every((item, i) => jsonEqual(item, b[i]));
|
|
148
|
+
const left = a;
|
|
149
|
+
const right = b;
|
|
150
|
+
const keys = Object.keys(left);
|
|
151
|
+
return (keys.length === Object.keys(right).length &&
|
|
152
|
+
keys.every((key) => Object.hasOwn(right, key) && jsonEqual(left[key], right[key])));
|
|
153
|
+
}
|
|
154
|
+
/** Deep-copies a JSON value, refusing non-JSON values and prototype-reaching keys. */
|
|
155
|
+
function toJson(value, fail, budget, depth = 0) {
|
|
156
|
+
budget.used += 1;
|
|
157
|
+
if (budget.used > JSON_PATCH_MAX_NODES) {
|
|
158
|
+
fail(`the patch exceeds the ${JSON_PATCH_MAX_NODES.toLocaleString()}-node budget`);
|
|
159
|
+
}
|
|
160
|
+
if (depth > 64)
|
|
161
|
+
fail('the value is nested too deeply');
|
|
162
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
163
|
+
return value;
|
|
164
|
+
if (typeof value === 'number') {
|
|
165
|
+
if (!Number.isFinite(value))
|
|
166
|
+
fail('the value has a non-finite number');
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
if (Array.isArray(value))
|
|
170
|
+
return value.map((item) => toJson(item, fail, budget, depth + 1));
|
|
171
|
+
if (typeof value === 'object') {
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const key of Object.keys(value)) {
|
|
174
|
+
if (FORBIDDEN_KEYS.has(key))
|
|
175
|
+
fail(`the value has a "${key}" key`);
|
|
176
|
+
const member = value[key];
|
|
177
|
+
// Dropped like JSON.stringify does: parsed blueprints may carry undefined optionals.
|
|
178
|
+
if (member !== undefined)
|
|
179
|
+
out[key] = toJson(member, fail, budget, depth + 1);
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
return fail('the value is not JSON');
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Applies RFC 6902 operations in order to a copy of `document` and returns
|
|
187
|
+
* the result. Any failure throws `BlueprintPatchError` and leaves the input
|
|
188
|
+
* untouched (the operations are atomic as a whole).
|
|
189
|
+
*/
|
|
190
|
+
export function applyJsonPatch(document, operations) {
|
|
191
|
+
const parsed = JsonPatchSchema.safeParse(operations);
|
|
192
|
+
if (!parsed.success) {
|
|
193
|
+
const issue = parsed.error.issues[0];
|
|
194
|
+
const index = typeof issue?.path[0] === 'number' ? issue.path[0] : null;
|
|
195
|
+
throw new BlueprintPatchError('invalid-patch', `Invalid JSON Patch${index === null ? '' : ` operation ${index}`}: ${issue?.path.slice(1).join('.') || 'patch'} ${issue?.message ?? ''}`.trim(), { index });
|
|
196
|
+
}
|
|
197
|
+
const raw = Array.isArray(operations) ? operations : [];
|
|
198
|
+
const budget = { used: 0 };
|
|
199
|
+
let root = toJson(document, (why) => {
|
|
200
|
+
throw new BlueprintPatchError('invalid-patch', `The document is not JSON: ${why}.`);
|
|
201
|
+
}, budget);
|
|
202
|
+
parsed.data.forEach((operation, index) => {
|
|
203
|
+
const where = `operation ${index} (${operation.op} ${operation.path})`;
|
|
204
|
+
const fail = (why, code = 'path-not-found') => {
|
|
205
|
+
throw new BlueprintPatchError(code, `JSON Patch ${where} failed: ${why}.`, { index });
|
|
206
|
+
};
|
|
207
|
+
const pointer = (text) => {
|
|
208
|
+
try {
|
|
209
|
+
return parseJsonPointer(text);
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
return fail(error.message, 'unsafe-pointer');
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
const value = () => {
|
|
216
|
+
if (!Object.hasOwn(raw[index], 'value'))
|
|
217
|
+
fail('it has no "value" member', 'invalid-patch');
|
|
218
|
+
return toJson(operation.value, (why) => fail(why, 'invalid-patch'), budget);
|
|
219
|
+
};
|
|
220
|
+
const path = pointer(operation.path);
|
|
221
|
+
switch (operation.op) {
|
|
222
|
+
case 'add':
|
|
223
|
+
root = addAt(root, path, value(), fail);
|
|
224
|
+
break;
|
|
225
|
+
case 'remove':
|
|
226
|
+
removeAt(root, path, fail);
|
|
227
|
+
break;
|
|
228
|
+
case 'replace': {
|
|
229
|
+
const next = value();
|
|
230
|
+
if (path.length === 0) {
|
|
231
|
+
root = next;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
removeAt(root, path, fail);
|
|
235
|
+
root = addAt(root, path, next, fail);
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
case 'move': {
|
|
239
|
+
const from = pointer(operation.from);
|
|
240
|
+
if (from.length < path.length && from.every((token, i) => token === path[i]))
|
|
241
|
+
fail('a value cannot move into one of its own children', 'invalid-patch');
|
|
242
|
+
if (from.length === path.length && from.every((token, i) => token === path[i]))
|
|
243
|
+
break;
|
|
244
|
+
const moved = from.length === 0 ? root : removeAt(root, from, fail);
|
|
245
|
+
root = addAt(root, path, moved, fail);
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case 'copy': {
|
|
249
|
+
const copied = toJson(getAt(root, pointer(operation.from), fail), (why) => fail(why, 'invalid-patch'), budget);
|
|
250
|
+
root = addAt(root, path, copied, fail);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
case 'test':
|
|
254
|
+
if (!jsonEqual(getAt(root, path, fail), value()))
|
|
255
|
+
fail('the value is different', 'test-failed');
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
return root;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Applies a JSON Patch to a blueprint and validates the result. The returned
|
|
263
|
+
* migration says how the existing world moves to the new schema; `destructive`
|
|
264
|
+
* says whether that loses data, which needs `allowDrop`.
|
|
265
|
+
*/
|
|
266
|
+
export function applyBlueprintPatch(blueprint, operations, options = {}) {
|
|
267
|
+
const patched = applyJsonPatch(blueprint, operations);
|
|
268
|
+
const result = safeParseBlueprint(patched);
|
|
269
|
+
if (!result.success) {
|
|
270
|
+
const issues = result.error.issues.map((issue) => ({
|
|
271
|
+
path: issue.path.filter((part) => typeof part !== 'symbol'),
|
|
272
|
+
message: issue.message,
|
|
273
|
+
}));
|
|
274
|
+
const first = issues[0];
|
|
275
|
+
throw new BlueprintPatchError('invalid-result', `The patched blueprint is invalid: ${first ? `${first.path.join('.') || '(root)'}: ${first.message}` : 'unknown issue'}${issues.length > 1 ? ` (+${issues.length - 1} more)` : ''}.`, { issues });
|
|
276
|
+
}
|
|
277
|
+
const next = result.data;
|
|
278
|
+
const diff = { fromVersion: options.fromVersion, renamedFrom: options.renamedFrom };
|
|
279
|
+
try {
|
|
280
|
+
return {
|
|
281
|
+
blueprint: next,
|
|
282
|
+
migration: diffBlueprints(blueprint, next, { ...diff, allowDrop: false }),
|
|
283
|
+
destructive: false,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (!(error instanceof MigrationError) || error.code !== 'drop-requires-allow')
|
|
288
|
+
throw error;
|
|
289
|
+
if (options.allowDrop !== true) {
|
|
290
|
+
throw new BlueprintPatchError('drop-requires-allow', `${error.message}. Pass allowDrop to accept losing that data.`);
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
blueprint: next,
|
|
294
|
+
migration: diffBlueprints(blueprint, next, { ...diff, allowDrop: true }),
|
|
295
|
+
destructive: true,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Applies a JSON Patch to a deck, page or doc and validates the result as
|
|
301
|
+
* untrusted input (`loadUntrustedDocument`: kind limits, text sanitizing and
|
|
302
|
+
* the kind's body schema). Content kinds share one store layout, so there is
|
|
303
|
+
* never a data migration: block state and form responses stay keyed by block
|
|
304
|
+
* id. The patch may not change `kind`.
|
|
305
|
+
*/
|
|
306
|
+
export async function applyContentPatch(blueprint, operations) {
|
|
307
|
+
const patched = applyJsonPatch(blueprint, operations);
|
|
308
|
+
const kind = patched?.kind;
|
|
309
|
+
if (kind !== blueprint.kind) {
|
|
310
|
+
throw new BlueprintPatchError('invalid-result', `A patch may not change the blueprint kind (${blueprint.kind} → ${String(kind)}).`, { issues: [{ path: ['kind'], message: 'kind is fixed for an exploration' }] });
|
|
311
|
+
}
|
|
312
|
+
const loaded = await loadUntrustedDocument(patched);
|
|
313
|
+
if (!loaded.success) {
|
|
314
|
+
const issues = loaded.issues.map((issue) => ({
|
|
315
|
+
path: [...issue.path],
|
|
316
|
+
message: issue.message,
|
|
317
|
+
}));
|
|
318
|
+
const first = issues[0];
|
|
319
|
+
throw new BlueprintPatchError('invalid-result', `The patched ${blueprint.kind} is invalid: ${first ? `${first.path.join('.') || '(root)'}: ${first.message}` : 'unknown issue'}${issues.length > 1 ? ` (+${issues.length - 1} more)` : ''}.`, { issues });
|
|
320
|
+
}
|
|
321
|
+
return { blueprint: loaded.blueprint };
|
|
322
|
+
}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PolicyDefinition } from '@protoyard/core';
|
|
2
|
+
import { type Blueprint } from './schema.ts';
|
|
3
|
+
export type EntityCapabilityMap = Record<string, {
|
|
4
|
+
read: string;
|
|
5
|
+
create: string;
|
|
6
|
+
update: string;
|
|
7
|
+
updateOwn: string;
|
|
8
|
+
delete: string;
|
|
9
|
+
deleteOwn: string;
|
|
10
|
+
import: string;
|
|
11
|
+
export: string;
|
|
12
|
+
}> & {
|
|
13
|
+
members: {
|
|
14
|
+
all: 'members:*';
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
/** Builds the capability names used by generated UIs and services. */
|
|
18
|
+
export declare const CAPABILITIES: (blueprint: Blueprint) => EntityCapabilityMap;
|
|
19
|
+
/**
|
|
20
|
+
* Converts declarative blueprint permissions to the runtime's capability policy.
|
|
21
|
+
* Policies are a UX simulation and are never a security boundary.
|
|
22
|
+
*/
|
|
23
|
+
export declare function derivePolicy(blueprint: Blueprint): PolicyDefinition;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ENTITY_ACTIONS, } from "./schema.js";
|
|
2
|
+
import { relationProperty } from "./storage.js";
|
|
3
|
+
/** Builds the capability names used by generated UIs and services. */
|
|
4
|
+
export const CAPABILITIES = (blueprint) => {
|
|
5
|
+
const result = {};
|
|
6
|
+
for (const entity of blueprint.entities) {
|
|
7
|
+
const capability = (action) => `${entity.name}:${action}`;
|
|
8
|
+
result[entity.name] = {
|
|
9
|
+
read: capability('read'),
|
|
10
|
+
create: capability('create'),
|
|
11
|
+
update: capability('update'),
|
|
12
|
+
updateOwn: capability('update:own'),
|
|
13
|
+
delete: capability('delete'),
|
|
14
|
+
deleteOwn: capability('delete:own'),
|
|
15
|
+
import: capability('import'),
|
|
16
|
+
export: capability('export'),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return Object.assign(result, { members: { all: 'members:*' } });
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Converts declarative blueprint permissions to the runtime's capability policy.
|
|
23
|
+
* Policies are a UX simulation and are never a security boundary.
|
|
24
|
+
*/
|
|
25
|
+
export function derivePolicy(blueprint) {
|
|
26
|
+
const roles = {};
|
|
27
|
+
const rules = {};
|
|
28
|
+
for (const entity of blueprint.entities) {
|
|
29
|
+
const ownerField = entity.fields.find((field) => field.name === entity.ownerField);
|
|
30
|
+
const ownerProperty = ownerField ? relationProperty(ownerField) : undefined;
|
|
31
|
+
if (!ownerProperty)
|
|
32
|
+
continue;
|
|
33
|
+
for (const action of ['update:own', 'delete:own']) {
|
|
34
|
+
const capability = `${entity.name}:${action}`;
|
|
35
|
+
rules[capability] = (principal, resource) => {
|
|
36
|
+
if (principal.role === 'owner' || principal.role === 'admin')
|
|
37
|
+
return true;
|
|
38
|
+
if (resource === null || typeof resource !== 'object')
|
|
39
|
+
return false;
|
|
40
|
+
return resource[ownerProperty] === principal.persona.id;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
for (const role of blueprint.roles) {
|
|
45
|
+
const capabilities = new Set();
|
|
46
|
+
for (const [target, actions] of Object.entries(role.permissions)) {
|
|
47
|
+
const entities = target === '*'
|
|
48
|
+
? blueprint.entities
|
|
49
|
+
: blueprint.entities.filter((entity) => entity.name === target);
|
|
50
|
+
for (const rawAction of actions) {
|
|
51
|
+
addActionCapabilities(capabilities, target, entities, rawAction);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Team membership is a platform capability rather than a blueprint entity.
|
|
55
|
+
// Owners and admins manage memberships; a global wildcard grant also opts in.
|
|
56
|
+
if (role.name === 'owner' || role.name === 'admin' || capabilities.has('*')) {
|
|
57
|
+
capabilities.add('members:*');
|
|
58
|
+
}
|
|
59
|
+
roles[role.name] = [...capabilities];
|
|
60
|
+
}
|
|
61
|
+
return { roles, rules };
|
|
62
|
+
}
|
|
63
|
+
function addActionCapabilities(capabilities, target, entities, rawAction) {
|
|
64
|
+
if (rawAction === '*' && target === '*') {
|
|
65
|
+
capabilities.add('*');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (rawAction.startsWith('-')) {
|
|
69
|
+
const denied = rawAction.slice(1);
|
|
70
|
+
const entityQualified = entities.some((entity) => denied.startsWith(`${entity.name}:`));
|
|
71
|
+
if (denied.includes(':') && entityQualified) {
|
|
72
|
+
capabilities.add(`-${denied}`);
|
|
73
|
+
if (denied.endsWith(':delete'))
|
|
74
|
+
capabilities.add(`-${denied}:own`);
|
|
75
|
+
if (denied.endsWith(':update'))
|
|
76
|
+
capabilities.add(`-${denied}:own`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (denied === '*') {
|
|
80
|
+
for (const entity of entities) {
|
|
81
|
+
for (const action of ENTITY_ACTIONS)
|
|
82
|
+
capabilities.add(`-${entity.name}:${action}`);
|
|
83
|
+
capabilities.add(`-${entity.name}:delete:own`);
|
|
84
|
+
capabilities.add(`-${entity.name}:update:own`);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const entity of entities) {
|
|
89
|
+
capabilities.add(`-${entity.name}:${denied}`);
|
|
90
|
+
if (denied === 'delete' || denied === 'update') {
|
|
91
|
+
capabilities.add(`-${entity.name}:${denied}:own`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (rawAction === '*') {
|
|
97
|
+
for (const entity of entities) {
|
|
98
|
+
for (const action of ENTITY_ACTIONS)
|
|
99
|
+
capabilities.add(`${entity.name}:${action}`);
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
for (const entity of entities)
|
|
104
|
+
capabilities.add(`${entity.name}:${rawAction}`);
|
|
105
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type PrototypeDefinition, type Theme } from '@protoyard/core';
|
|
2
|
+
import { type AppModel } from './app-model.ts';
|
|
3
|
+
import type { Blueprint } from './schema.ts';
|
|
4
|
+
/**
|
|
5
|
+
* A complete prototype definition derived from a blueprint at runtime
|
|
6
|
+
* (ADR-029): what `protoyard generate` writes as files, built in memory so
|
|
7
|
+
* the player can run a blueprint with no build. Codegen and the player share
|
|
8
|
+
* every derivation (database versions, policy, integrity, scenarios, brand
|
|
9
|
+
* theme, app model), so a blueprint behaves the same at both levels.
|
|
10
|
+
*/
|
|
11
|
+
export interface RuntimeDefinitionOptions {
|
|
12
|
+
/** Storage namespace (IndexedDB names): usually the blueprint's content namespace (`bp-…`). */
|
|
13
|
+
namespace: string;
|
|
14
|
+
/** Manifest slug; defaults to the namespace (which must then be a valid slug of at most 48 characters). */
|
|
15
|
+
slug?: string;
|
|
16
|
+
/** Manifest version (default `0.1.0`). */
|
|
17
|
+
version?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface RuntimeDefinition {
|
|
20
|
+
definition: PrototypeDefinition;
|
|
21
|
+
appModel: AppModel;
|
|
22
|
+
}
|
|
23
|
+
/** Scenario a blueprint prototype opens in (one of `blueprintScenarios`). */
|
|
24
|
+
export declare const BLUEPRINT_DEFAULT_SCENARIO = "active";
|
|
25
|
+
/**
|
|
26
|
+
* The theme id a blueprint opens in: its library theme, the id of the theme
|
|
27
|
+
* its brand builds, or null without a brand. `protoyard generate` writes it to
|
|
28
|
+
* `protoyard.json` and the player uses it as `defaultTheme`. Throws
|
|
29
|
+
* `BrandThemeError` for an unknown library theme or a brand that fails AA.
|
|
30
|
+
*/
|
|
31
|
+
export declare function brandDefaultThemeId(blueprint: Blueprint): string | null;
|
|
32
|
+
/** The theme a blueprint's brand builds, or null for no brand or a library brand (shipped by id). */
|
|
33
|
+
export declare function blueprintBrandTheme(blueprint: Blueprint): Theme | null;
|
|
34
|
+
/**
|
|
35
|
+
* Builds a `definePrototype`-valid definition from a parsed blueprint, plus
|
|
36
|
+
* its app model. The world generator loads lazily (`@protoyard/blueprint/generator`
|
|
37
|
+
* stays out of the caller's initial chunk until a scenario seeds).
|
|
38
|
+
*/
|
|
39
|
+
export declare function definePrototypeFromBlueprint(blueprint: Blueprint, options: RuntimeDefinitionOptions): RuntimeDefinition;
|