@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
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import { classifyMediaSource, isUnsafeTextCharacter, normalizeLineSeparators, } from '@protoyard/blocks/safety';
|
|
2
|
+
import { safeParseAnyBlueprint } from "./kinds/envelope.js";
|
|
3
|
+
import { detectBlueprintKind } from "./kinds/kind.js";
|
|
4
|
+
import { checkContentLimits, containsEmbeddedApp, DEFAULT_UNTRUSTED_DOCUMENT_LIMITS, textContextFor, } from "./kinds/limits.js";
|
|
5
|
+
import { safeParseBlueprint } from "./schema.js";
|
|
6
|
+
export const DEFAULT_UNTRUSTED_BLUEPRINT_LIMITS = Object.freeze({
|
|
7
|
+
maxBytes: 256 * 1024,
|
|
8
|
+
maxDepth: 32,
|
|
9
|
+
maxEntities: 64,
|
|
10
|
+
maxFieldsPerEntity: 64,
|
|
11
|
+
maxScreens: 128,
|
|
12
|
+
maxRoles: 32,
|
|
13
|
+
maxNotifications: 128,
|
|
14
|
+
maxHistory: 32,
|
|
15
|
+
maxWorldRows: 100_000,
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* Removes C0/C1 controls, bidi overrides, BOMs and invisible characters, and
|
|
19
|
+
* turns U+2028/U+2029 into line breaks or spaces. `multiline` text keeps
|
|
20
|
+
* `\n` (after normalizing `\r\n`), and `code` keeps `\t` as well.
|
|
21
|
+
*/
|
|
22
|
+
function stripControlAndBidi(value, context = 'plain') {
|
|
23
|
+
const keepNewlines = context !== 'plain';
|
|
24
|
+
const lines = normalizeLineSeparators(value, keepNewlines);
|
|
25
|
+
return [...(keepNewlines ? lines.replace(/\r\n?/g, '\n') : lines)]
|
|
26
|
+
.filter((char) => {
|
|
27
|
+
return !isUnsafeTextCharacter(char, keepNewlines, context === 'code');
|
|
28
|
+
})
|
|
29
|
+
.join('');
|
|
30
|
+
}
|
|
31
|
+
const FATAL = (code, message, fix) => ({
|
|
32
|
+
code,
|
|
33
|
+
path: [],
|
|
34
|
+
message,
|
|
35
|
+
fix,
|
|
36
|
+
});
|
|
37
|
+
function utf8Length(value) {
|
|
38
|
+
return new TextEncoder().encode(value).byteLength;
|
|
39
|
+
}
|
|
40
|
+
function checkJsonDepth(text, maxDepth) {
|
|
41
|
+
let depth = 0;
|
|
42
|
+
let inString = false;
|
|
43
|
+
let escaped = false;
|
|
44
|
+
for (const char of text) {
|
|
45
|
+
if (inString) {
|
|
46
|
+
if (escaped)
|
|
47
|
+
escaped = false;
|
|
48
|
+
else if (char === '\\')
|
|
49
|
+
escaped = true;
|
|
50
|
+
else if (char === '"')
|
|
51
|
+
inString = false;
|
|
52
|
+
}
|
|
53
|
+
else if (char === '"')
|
|
54
|
+
inString = true;
|
|
55
|
+
else if (char === '{' || char === '[') {
|
|
56
|
+
depth += 1;
|
|
57
|
+
if (depth > maxDepth)
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
else if (char === '}' || char === ']')
|
|
61
|
+
depth -= 1;
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A plain, acyclic, prototype-free copy with every string cleaned. With
|
|
67
|
+
* `textContexts` (content kinds), strings in schema-declared multiline fields
|
|
68
|
+
* keep their newlines; without it (apps), every string is one line.
|
|
69
|
+
*/
|
|
70
|
+
function sanitizeJson(value, maxDepth, textContexts = false) {
|
|
71
|
+
const ancestors = new WeakSet();
|
|
72
|
+
const visit = (current, path, depth, context = 'plain', inListItems = false) => {
|
|
73
|
+
if (depth > maxDepth) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
issue: {
|
|
77
|
+
code: 'too-deep',
|
|
78
|
+
path,
|
|
79
|
+
message: 'Blueprint JSON exceeds the maximum nesting depth.',
|
|
80
|
+
fix: `Flatten the blueprint so it is at most ${maxDepth} levels deep.`,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (typeof current === 'string') {
|
|
85
|
+
return { ok: true, value: stripControlAndBidi(current, context) };
|
|
86
|
+
}
|
|
87
|
+
if (current === null || typeof current === 'boolean' || typeof current === 'number') {
|
|
88
|
+
return { ok: true, value: current };
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(current)) {
|
|
91
|
+
if (ancestors.has(current)) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
issue: FATAL('invalid-input', 'Input must be acyclic JSON data.', 'Pass JSON text or a plain JSON object.'),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
ancestors.add(current);
|
|
98
|
+
const output = [];
|
|
99
|
+
for (let i = 0; i < current.length; i += 1) {
|
|
100
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, String(i));
|
|
101
|
+
if (!descriptor || !('value' in descriptor)) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
issue: {
|
|
105
|
+
...FATAL('invalid-input', 'Input arrays must contain plain JSON values.', 'Remove holes, accessors, and non-data properties.'),
|
|
106
|
+
path: [...path, i],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const item = visit(descriptor.value, [...path, i], depth + 1, context, inListItems);
|
|
111
|
+
if (!item.ok)
|
|
112
|
+
return item;
|
|
113
|
+
output.push(item.value);
|
|
114
|
+
}
|
|
115
|
+
ancestors.delete(current);
|
|
116
|
+
return { ok: true, value: output };
|
|
117
|
+
}
|
|
118
|
+
if (typeof current !== 'object' || current === null) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
issue: FATAL('invalid-input', 'Input must be JSON text or plain JSON data.', 'Provide a JSON blueprint object or its JSON text.'),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const proto = Object.getPrototypeOf(current);
|
|
125
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
issue: FATAL('invalid-input', 'Input objects must be plain JSON data.', 'Remove custom prototypes and pass a plain object.'),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (ancestors.has(current)) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
issue: FATAL('invalid-input', 'Input must be acyclic JSON data.', 'Pass JSON text or a plain JSON object.'),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
ancestors.add(current);
|
|
138
|
+
const output = Object.create(null);
|
|
139
|
+
for (const key of Object.keys(current)) {
|
|
140
|
+
if (key === '__proto__' || key === 'constructor') {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
issue: {
|
|
144
|
+
code: 'unsafe-key',
|
|
145
|
+
path: [...path, key],
|
|
146
|
+
message: `Blueprint contains the unsafe key "${key}".`,
|
|
147
|
+
fix: 'Remove prototype-related keys from the input.',
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, key);
|
|
152
|
+
if (!descriptor || !('value' in descriptor)) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
issue: {
|
|
156
|
+
...FATAL('invalid-input', 'Input objects must contain plain JSON values.', 'Remove getters and setters from the input.'),
|
|
157
|
+
path: [...path, key],
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const safeKey = stripControlAndBidi(key);
|
|
162
|
+
if (safeKey === '__proto__' || safeKey === 'constructor') {
|
|
163
|
+
return {
|
|
164
|
+
ok: false,
|
|
165
|
+
issue: {
|
|
166
|
+
code: 'unsafe-key',
|
|
167
|
+
path: [...path, safeKey],
|
|
168
|
+
message: `Blueprint contains the unsafe key "${safeKey}" after sanitization.`,
|
|
169
|
+
fix: 'Remove prototype-related keys from the input.',
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (Object.hasOwn(output, safeKey)) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
issue: {
|
|
177
|
+
...FATAL('invalid-input', 'Sanitization produced duplicate object keys.', 'Rename keys so they remain unique after control characters are removed.'),
|
|
178
|
+
path: [...path, key],
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const record = current;
|
|
183
|
+
const item = textContexts
|
|
184
|
+
? visit(descriptor.value, [...path, safeKey], depth + 1, textContextFor(key, record, inListItems), key === 'items' && record.type === 'list')
|
|
185
|
+
: visit(descriptor.value, [...path, safeKey], depth + 1);
|
|
186
|
+
if (!item.ok)
|
|
187
|
+
return item;
|
|
188
|
+
output[safeKey] = item.value;
|
|
189
|
+
}
|
|
190
|
+
ancestors.delete(current);
|
|
191
|
+
return { ok: true, value: output };
|
|
192
|
+
};
|
|
193
|
+
return visit(value, [], 0);
|
|
194
|
+
}
|
|
195
|
+
/** Validate and sanitize data before any player derives or renders a blueprint. */
|
|
196
|
+
export function loadUntrustedBlueprint(input, limits = {}) {
|
|
197
|
+
const caps = Object.fromEntries(Object.entries(DEFAULT_UNTRUSTED_BLUEPRINT_LIMITS).map(([key, defaultValue]) => {
|
|
198
|
+
const requested = limits[key];
|
|
199
|
+
return [
|
|
200
|
+
key,
|
|
201
|
+
requested === undefined || !Number.isFinite(requested)
|
|
202
|
+
? defaultValue
|
|
203
|
+
: Math.min(defaultValue, requested),
|
|
204
|
+
];
|
|
205
|
+
}));
|
|
206
|
+
try {
|
|
207
|
+
let parsed;
|
|
208
|
+
if (typeof input === 'string') {
|
|
209
|
+
if (utf8Length(input) > caps.maxBytes) {
|
|
210
|
+
return {
|
|
211
|
+
success: false,
|
|
212
|
+
blueprint: null,
|
|
213
|
+
issues: [
|
|
214
|
+
FATAL('too-large', 'Blueprint input exceeds the byte limit.', `Keep the input at or below ${caps.maxBytes} UTF-8 bytes.`),
|
|
215
|
+
],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (!checkJsonDepth(input, caps.maxDepth)) {
|
|
219
|
+
return {
|
|
220
|
+
success: false,
|
|
221
|
+
blueprint: null,
|
|
222
|
+
issues: [
|
|
223
|
+
FATAL('too-deep', 'Blueprint JSON exceeds the maximum nesting depth.', `Flatten the blueprint so it is at most ${caps.maxDepth} levels deep.`),
|
|
224
|
+
],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
parsed = JSON.parse(input);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return {
|
|
232
|
+
success: false,
|
|
233
|
+
blueprint: null,
|
|
234
|
+
issues: [
|
|
235
|
+
FATAL('invalid-json', 'Blueprint input is not valid JSON.', 'Fix the JSON syntax and try again.'),
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
parsed = input;
|
|
242
|
+
}
|
|
243
|
+
const safe = sanitizeJson(parsed, caps.maxDepth);
|
|
244
|
+
if (!safe.ok)
|
|
245
|
+
return { success: false, blueprint: null, issues: [safe.issue] };
|
|
246
|
+
const serialized = JSON.stringify(safe.value);
|
|
247
|
+
if (serialized === undefined || utf8Length(serialized) > caps.maxBytes) {
|
|
248
|
+
return {
|
|
249
|
+
success: false,
|
|
250
|
+
blueprint: null,
|
|
251
|
+
issues: [
|
|
252
|
+
FATAL('too-large', 'Blueprint input exceeds the byte limit.', `Keep the input at or below ${caps.maxBytes} UTF-8 bytes.`),
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const root = safe.value;
|
|
257
|
+
const countChecks = [
|
|
258
|
+
['entities', caps.maxEntities, Array.isArray(root.entities) ? root.entities.length : 0],
|
|
259
|
+
['roles', caps.maxRoles, Array.isArray(root.roles) ? root.roles.length : 0],
|
|
260
|
+
['screens', caps.maxScreens, Array.isArray(root.screens) ? root.screens.length : 0],
|
|
261
|
+
[
|
|
262
|
+
'notifications',
|
|
263
|
+
caps.maxNotifications,
|
|
264
|
+
Array.isArray(root.notifications) ? root.notifications.length : 0,
|
|
265
|
+
],
|
|
266
|
+
['history', caps.maxHistory, Array.isArray(root.history) ? root.history.length : 0],
|
|
267
|
+
];
|
|
268
|
+
for (const [key, max, actual] of countChecks) {
|
|
269
|
+
if (actual > max) {
|
|
270
|
+
return {
|
|
271
|
+
success: false,
|
|
272
|
+
blueprint: null,
|
|
273
|
+
issues: [
|
|
274
|
+
{
|
|
275
|
+
code: 'too-many',
|
|
276
|
+
path: [key],
|
|
277
|
+
message: `Blueprint has ${actual} ${key}; the limit is ${max}.`,
|
|
278
|
+
fix: `Reduce ${key} to at most ${max}.`,
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (Array.isArray(root.entities)) {
|
|
285
|
+
for (const [index, entity] of root.entities.entries()) {
|
|
286
|
+
const fields = entity && typeof entity === 'object' && 'fields' in entity ? entity.fields : undefined;
|
|
287
|
+
if (Array.isArray(fields) && fields.length > caps.maxFieldsPerEntity) {
|
|
288
|
+
return {
|
|
289
|
+
success: false,
|
|
290
|
+
blueprint: null,
|
|
291
|
+
issues: [
|
|
292
|
+
{
|
|
293
|
+
code: 'too-many',
|
|
294
|
+
path: ['entities', index, 'fields'],
|
|
295
|
+
message: `Entity has ${fields.length} fields; the limit is ${caps.maxFieldsPerEntity}.`,
|
|
296
|
+
fix: `Reduce this entity to at most ${caps.maxFieldsPerEntity} fields.`,
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const parsedBlueprint = safeParseBlueprint(safe.value);
|
|
304
|
+
if (!parsedBlueprint.success) {
|
|
305
|
+
return {
|
|
306
|
+
success: false,
|
|
307
|
+
blueprint: null,
|
|
308
|
+
issues: parsedBlueprint.error.issues.map((issue) => ({
|
|
309
|
+
code: 'invalid-blueprint',
|
|
310
|
+
path: issue.path.filter((part) => typeof part === 'string' || typeof part === 'number'),
|
|
311
|
+
message: issue.message,
|
|
312
|
+
fix: 'Correct this value to match the blueprint schema, then try again.',
|
|
313
|
+
})),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const result = parsedBlueprint.data;
|
|
317
|
+
const estimatedRows = result.entities.reduce((total, entity) => {
|
|
318
|
+
const isDependent = result.entities.some((other) => other.fields.some((field) => field.kind === 'relation' && field.to === entity.name));
|
|
319
|
+
const baseCount = entity.seedCount ?? (isDependent ? 8 : 4);
|
|
320
|
+
return total + Math.max(1, Math.round(baseCount * 25));
|
|
321
|
+
}, 0);
|
|
322
|
+
if (estimatedRows > caps.maxWorldRows) {
|
|
323
|
+
return {
|
|
324
|
+
success: false,
|
|
325
|
+
blueprint: null,
|
|
326
|
+
issues: [
|
|
327
|
+
{
|
|
328
|
+
code: 'too-many',
|
|
329
|
+
path: ['entities'],
|
|
330
|
+
message: `The massive scenario could generate about ${estimatedRows} rows; the limit is ${caps.maxWorldRows}.`,
|
|
331
|
+
fix: `Lower seedCount values so the estimated massive world stays at or below ${caps.maxWorldRows} rows.`,
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return { success: true, blueprint: result, issues: [] };
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return {
|
|
340
|
+
success: false,
|
|
341
|
+
blueprint: null,
|
|
342
|
+
issues: [
|
|
343
|
+
FATAL('invalid-input', 'Blueprint could not be safely loaded.', 'Provide plain JSON data that matches the blueprint schema.'),
|
|
344
|
+
],
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const failed = (...issues) => ({
|
|
349
|
+
success: false,
|
|
350
|
+
blueprint: null,
|
|
351
|
+
issues,
|
|
352
|
+
});
|
|
353
|
+
function relativeMediaIssue(value, path = []) {
|
|
354
|
+
if (Array.isArray(value)) {
|
|
355
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
356
|
+
const found = relativeMediaIssue(value[index], [...path, index]);
|
|
357
|
+
if (found)
|
|
358
|
+
return found;
|
|
359
|
+
}
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
if (typeof value !== 'object' || value === null)
|
|
363
|
+
return null;
|
|
364
|
+
for (const [key, child] of Object.entries(value)) {
|
|
365
|
+
const childPath = [...path, key];
|
|
366
|
+
if ((key === 'src' || key === 'poster' || key === 'image') && typeof child === 'string') {
|
|
367
|
+
const kinds = key === 'image' || key === 'poster' ? ['image'] : ['image', 'video'];
|
|
368
|
+
for (const kind of kinds) {
|
|
369
|
+
const result = classifyMediaSource(child, kind);
|
|
370
|
+
if (result.ok && result.kind === 'relative') {
|
|
371
|
+
return {
|
|
372
|
+
code: 'invalid-blueprint',
|
|
373
|
+
path: childPath,
|
|
374
|
+
message: 'Level-1 documents cannot load relative media paths.',
|
|
375
|
+
fix: 'Use an avatar id or an allowed raster data URI; relative assets require a level-2 prototype.',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const nested = relativeMediaIssue(child, childPath);
|
|
381
|
+
if (nested)
|
|
382
|
+
return nested;
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
function capsOf(limits) {
|
|
387
|
+
return Object.fromEntries(Object.entries(DEFAULT_UNTRUSTED_DOCUMENT_LIMITS).map(([key, defaultValue]) => {
|
|
388
|
+
const requested = limits[key];
|
|
389
|
+
return [
|
|
390
|
+
key,
|
|
391
|
+
requested === undefined || !Number.isFinite(requested)
|
|
392
|
+
? defaultValue
|
|
393
|
+
: Math.min(defaultValue, requested),
|
|
394
|
+
];
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Validates and sanitizes a blueprint of any kind before a player derives or
|
|
399
|
+
* renders it (ADR-036). Apps keep their exact `loadUntrustedBlueprint`
|
|
400
|
+
* contract and limits. Content kinds (deck, page, doc):
|
|
401
|
+
*
|
|
402
|
+
* - size and depth are checked before parsing (`maxBytes`, `maxDepth`);
|
|
403
|
+
* - relative block media is refused at level 1 (generated level-2 schemas
|
|
404
|
+
* continue to accept deck-local paths);
|
|
405
|
+
* - strings lose C0/C1 controls and bidi overrides, except `\n` in the
|
|
406
|
+
* schema-declared multiline fields (markdown, speaker notes, quotes, list
|
|
407
|
+
* items) and `\t` in code; `\r\n` becomes `\n`;
|
|
408
|
+
* - slides, blocks per slide, blocks in total (nested blocks count), chart
|
|
409
|
+
* points and embedded apps are capped (`too-many`);
|
|
410
|
+
* - each embedded app must be an app (no kind or `"app"`), may not embed
|
|
411
|
+
* another app (`too-deep`), and passes `loadUntrustedBlueprint` with the
|
|
412
|
+
* app caps;
|
|
413
|
+
* - then the kind's body schema (loaded on demand) validates the document.
|
|
414
|
+
*
|
|
415
|
+
* Never throws.
|
|
416
|
+
*/
|
|
417
|
+
export async function loadUntrustedDocument(input, limits = {}) {
|
|
418
|
+
const caps = capsOf(limits);
|
|
419
|
+
try {
|
|
420
|
+
let parsed;
|
|
421
|
+
if (typeof input === 'string') {
|
|
422
|
+
if (utf8Length(input) > caps.maxBytes) {
|
|
423
|
+
return failed(FATAL('too-large', 'Document input exceeds the byte limit.', `Keep the input at or below ${caps.maxBytes} UTF-8 bytes.`));
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
parsed = JSON.parse(input);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// Not JSON at all: the app loader's contract (size, depth, then JSON) answers.
|
|
430
|
+
return loadUntrustedBlueprint(input, limits);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
parsed = input;
|
|
435
|
+
}
|
|
436
|
+
const detected = detectBlueprintKind(parsed);
|
|
437
|
+
if (!detected.ok) {
|
|
438
|
+
return failed({
|
|
439
|
+
code: 'invalid-blueprint',
|
|
440
|
+
path: ['kind'],
|
|
441
|
+
message: detected.message,
|
|
442
|
+
fix: detected.fix,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
// Apps: the app loader, its limits and its result, unchanged, on the original input.
|
|
446
|
+
if (detected.kind === 'app')
|
|
447
|
+
return loadUntrustedBlueprint(input, limits);
|
|
448
|
+
if (typeof input === 'string' && !checkJsonDepth(input, caps.maxDepth)) {
|
|
449
|
+
return failed(FATAL('too-deep', 'Document JSON exceeds the maximum nesting depth.', `Flatten the document so it is at most ${caps.maxDepth} levels deep.`));
|
|
450
|
+
}
|
|
451
|
+
const safe = sanitizeJson(parsed, caps.maxDepth, true);
|
|
452
|
+
if (!safe.ok)
|
|
453
|
+
return failed(safe.issue);
|
|
454
|
+
const disallowedMedia = relativeMediaIssue(safe.value);
|
|
455
|
+
if (disallowedMedia)
|
|
456
|
+
return failed(disallowedMedia);
|
|
457
|
+
const serialized = JSON.stringify(safe.value);
|
|
458
|
+
if (serialized === undefined || utf8Length(serialized) > caps.maxBytes) {
|
|
459
|
+
return failed(FATAL('too-large', 'Document input exceeds the byte limit.', `Keep the input at or below ${caps.maxBytes} UTF-8 bytes.`));
|
|
460
|
+
}
|
|
461
|
+
const root = safe.value;
|
|
462
|
+
const counted = checkContentLimits(root, detected.kind, caps);
|
|
463
|
+
if (counted.issue)
|
|
464
|
+
return failed({ code: 'too-many', ...counted.issue });
|
|
465
|
+
for (const { path, app } of counted.embeddedApps) {
|
|
466
|
+
const nestedKind = detectBlueprintKind(app);
|
|
467
|
+
if (!nestedKind.ok || nestedKind.kind !== 'app') {
|
|
468
|
+
return failed({
|
|
469
|
+
code: 'invalid-blueprint',
|
|
470
|
+
path: [...path, 'kind'],
|
|
471
|
+
message: 'An embedded app must be an app blueprint (no kind, or kind "app").',
|
|
472
|
+
fix: 'Embed an app blueprint, or link to the deck, page or doc instead.',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
if (containsEmbeddedApp(app)) {
|
|
476
|
+
return failed({
|
|
477
|
+
code: 'too-deep',
|
|
478
|
+
path,
|
|
479
|
+
message: 'An embedded app cannot embed another app (one level only).',
|
|
480
|
+
fix: 'Remove the nested embed-app block.',
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
const nested = loadUntrustedBlueprint(app, limits);
|
|
484
|
+
if (!nested.success) {
|
|
485
|
+
return failed(...nested.issues.map((issue) => ({ ...issue, path: [...path, ...issue.path] })));
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const result = await safeParseAnyBlueprint(root);
|
|
489
|
+
if (!result.success) {
|
|
490
|
+
return failed(...result.error.issues.map((issue) => ({
|
|
491
|
+
code: 'invalid-blueprint',
|
|
492
|
+
path: issue.path.filter((part) => typeof part === 'string' || typeof part === 'number'),
|
|
493
|
+
message: issue.message,
|
|
494
|
+
fix: 'Correct this value to match the schema for this kind, then try again.',
|
|
495
|
+
})));
|
|
496
|
+
}
|
|
497
|
+
return { success: true, blueprint: result.data, issues: [] };
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return failed(FATAL('invalid-input', 'Document could not be safely loaded.', 'Provide plain JSON data that matches the blueprint schema.'));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Validation tasks (ADR-031): an optional `validation.tasks` block that turns
|
|
4
|
+
* an exploration into a small usability session ("book a class for Friday").
|
|
5
|
+
* A task succeeds on a route match and/or one restricted data predicate:
|
|
6
|
+
* an entity, a field, an operator from a closed list and a literal value.
|
|
7
|
+
* There is no expression language and nothing is ever evaluated as code, so
|
|
8
|
+
* a blueprint from a link stays data.
|
|
9
|
+
*/
|
|
10
|
+
export declare const VALIDATION_DATA_OPS: readonly ["eq", "contains", "count>="];
|
|
11
|
+
export type ValidationDataOp = (typeof VALIDATION_DATA_OPS)[number];
|
|
12
|
+
export declare const VALIDATION_MAX_TASKS = 20;
|
|
13
|
+
export declare const ValidationRouteSchema: z.ZodObject<{
|
|
14
|
+
path: z.ZodString;
|
|
15
|
+
match: z.ZodDefault<z.ZodEnum<{
|
|
16
|
+
exact: "exact";
|
|
17
|
+
prefix: "prefix";
|
|
18
|
+
}>>;
|
|
19
|
+
}, z.core.$strict>;
|
|
20
|
+
export declare const ValidationDataSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
21
|
+
op: z.ZodLiteral<"eq">;
|
|
22
|
+
entity: z.ZodString;
|
|
23
|
+
field: z.ZodString;
|
|
24
|
+
value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
|
|
25
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
26
|
+
op: z.ZodLiteral<"contains">;
|
|
27
|
+
entity: z.ZodString;
|
|
28
|
+
field: z.ZodString;
|
|
29
|
+
value: z.ZodString;
|
|
30
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
31
|
+
op: z.ZodLiteral<"count>=">;
|
|
32
|
+
entity: z.ZodString;
|
|
33
|
+
value: z.ZodNumber;
|
|
34
|
+
from: z.ZodDefault<z.ZodEnum<{
|
|
35
|
+
"task-start": "task-start";
|
|
36
|
+
zero: "zero";
|
|
37
|
+
}>>;
|
|
38
|
+
}, z.core.$strict>], "op">;
|
|
39
|
+
export declare const ValidationSuccessSchema: z.ZodObject<{
|
|
40
|
+
route: z.ZodOptional<z.ZodObject<{
|
|
41
|
+
path: z.ZodString;
|
|
42
|
+
match: z.ZodDefault<z.ZodEnum<{
|
|
43
|
+
exact: "exact";
|
|
44
|
+
prefix: "prefix";
|
|
45
|
+
}>>;
|
|
46
|
+
}, z.core.$strict>>;
|
|
47
|
+
data: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
48
|
+
op: z.ZodLiteral<"eq">;
|
|
49
|
+
entity: z.ZodString;
|
|
50
|
+
field: z.ZodString;
|
|
51
|
+
value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
|
|
52
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
53
|
+
op: z.ZodLiteral<"contains">;
|
|
54
|
+
entity: z.ZodString;
|
|
55
|
+
field: z.ZodString;
|
|
56
|
+
value: z.ZodString;
|
|
57
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
58
|
+
op: z.ZodLiteral<"count>=">;
|
|
59
|
+
entity: z.ZodString;
|
|
60
|
+
value: z.ZodNumber;
|
|
61
|
+
from: z.ZodDefault<z.ZodEnum<{
|
|
62
|
+
"task-start": "task-start";
|
|
63
|
+
zero: "zero";
|
|
64
|
+
}>>;
|
|
65
|
+
}, z.core.$strict>], "op">>;
|
|
66
|
+
}, z.core.$strict>;
|
|
67
|
+
export declare const ValidationTaskSchema: z.ZodObject<{
|
|
68
|
+
id: z.ZodString;
|
|
69
|
+
prompt: z.ZodString;
|
|
70
|
+
timeLimitSeconds: z.ZodOptional<z.ZodNumber>;
|
|
71
|
+
success: z.ZodObject<{
|
|
72
|
+
route: z.ZodOptional<z.ZodObject<{
|
|
73
|
+
path: z.ZodString;
|
|
74
|
+
match: z.ZodDefault<z.ZodEnum<{
|
|
75
|
+
exact: "exact";
|
|
76
|
+
prefix: "prefix";
|
|
77
|
+
}>>;
|
|
78
|
+
}, z.core.$strict>>;
|
|
79
|
+
data: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
80
|
+
op: z.ZodLiteral<"eq">;
|
|
81
|
+
entity: z.ZodString;
|
|
82
|
+
field: z.ZodString;
|
|
83
|
+
value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
|
|
84
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
85
|
+
op: z.ZodLiteral<"contains">;
|
|
86
|
+
entity: z.ZodString;
|
|
87
|
+
field: z.ZodString;
|
|
88
|
+
value: z.ZodString;
|
|
89
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
90
|
+
op: z.ZodLiteral<"count>=">;
|
|
91
|
+
entity: z.ZodString;
|
|
92
|
+
value: z.ZodNumber;
|
|
93
|
+
from: z.ZodDefault<z.ZodEnum<{
|
|
94
|
+
"task-start": "task-start";
|
|
95
|
+
zero: "zero";
|
|
96
|
+
}>>;
|
|
97
|
+
}, z.core.$strict>], "op">>;
|
|
98
|
+
}, z.core.$strict>;
|
|
99
|
+
}, z.core.$strict>;
|
|
100
|
+
export declare const ValidationSchema: z.ZodObject<{
|
|
101
|
+
tasks: z.ZodArray<z.ZodObject<{
|
|
102
|
+
id: z.ZodString;
|
|
103
|
+
prompt: z.ZodString;
|
|
104
|
+
timeLimitSeconds: z.ZodOptional<z.ZodNumber>;
|
|
105
|
+
success: z.ZodObject<{
|
|
106
|
+
route: z.ZodOptional<z.ZodObject<{
|
|
107
|
+
path: z.ZodString;
|
|
108
|
+
match: z.ZodDefault<z.ZodEnum<{
|
|
109
|
+
exact: "exact";
|
|
110
|
+
prefix: "prefix";
|
|
111
|
+
}>>;
|
|
112
|
+
}, z.core.$strict>>;
|
|
113
|
+
data: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
114
|
+
op: z.ZodLiteral<"eq">;
|
|
115
|
+
entity: z.ZodString;
|
|
116
|
+
field: z.ZodString;
|
|
117
|
+
value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
|
|
118
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
119
|
+
op: z.ZodLiteral<"contains">;
|
|
120
|
+
entity: z.ZodString;
|
|
121
|
+
field: z.ZodString;
|
|
122
|
+
value: z.ZodString;
|
|
123
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
124
|
+
op: z.ZodLiteral<"count>=">;
|
|
125
|
+
entity: z.ZodString;
|
|
126
|
+
value: z.ZodNumber;
|
|
127
|
+
from: z.ZodDefault<z.ZodEnum<{
|
|
128
|
+
"task-start": "task-start";
|
|
129
|
+
zero: "zero";
|
|
130
|
+
}>>;
|
|
131
|
+
}, z.core.$strict>], "op">>;
|
|
132
|
+
}, z.core.$strict>;
|
|
133
|
+
}, z.core.$strict>>;
|
|
134
|
+
}, z.core.$strict>;
|
|
135
|
+
export type ValidationRoute = z.output<typeof ValidationRouteSchema>;
|
|
136
|
+
export type ValidationData = z.output<typeof ValidationDataSchema>;
|
|
137
|
+
export type ValidationSuccess = z.output<typeof ValidationSuccessSchema>;
|
|
138
|
+
export type ValidationTask = z.output<typeof ValidationTaskSchema>;
|
|
139
|
+
export type BlueprintValidation = z.output<typeof ValidationSchema>;
|
|
140
|
+
/** Field kinds `contains` can read: text values, or a string array for `tags`. */
|
|
141
|
+
export declare const CONTAINS_FIELD_KINDS: ReadonlySet<string>;
|
|
142
|
+
type IssuePath = (string | number)[];
|
|
143
|
+
/**
|
|
144
|
+
* Cross-reference checks the zod shape cannot express: unique task ids,
|
|
145
|
+
* entities and fields that exist, and values whose type fits the field.
|
|
146
|
+
*/
|
|
147
|
+
export declare function validationIssues(validation: BlueprintValidation, entities: ReadonlyMap<string, {
|
|
148
|
+
fields: readonly {
|
|
149
|
+
name: string;
|
|
150
|
+
kind: string;
|
|
151
|
+
}[];
|
|
152
|
+
}>, add: (path: IssuePath, message: string) => void): void;
|
|
153
|
+
/** What the evaluator looks at: plain data, no runtime handles. */
|
|
154
|
+
export interface ValidationObservation {
|
|
155
|
+
/** The current app route (`/bookings/b-1`); a query or fragment is ignored. */
|
|
156
|
+
route: string;
|
|
157
|
+
/** Current rows per entity name. Entities not listed count as empty. */
|
|
158
|
+
rows: Readonly<Record<string, readonly Readonly<Record<string, unknown>>[]>>;
|
|
159
|
+
/** Row counts per entity when the task started (for `count>=` from `task-start`). */
|
|
160
|
+
baseline?: Readonly<Record<string, number>>;
|
|
161
|
+
}
|
|
162
|
+
export interface ValidationOutcome {
|
|
163
|
+
met: boolean;
|
|
164
|
+
/** null when the condition has no route part. */
|
|
165
|
+
route: boolean | null;
|
|
166
|
+
/** null when the condition has no data part. */
|
|
167
|
+
data: boolean | null;
|
|
168
|
+
}
|
|
169
|
+
export declare function routeMatches(condition: ValidationRoute, route: string): boolean;
|
|
170
|
+
export declare function dataHolds(condition: ValidationData, observation: ValidationObservation): boolean;
|
|
171
|
+
/**
|
|
172
|
+
* Pure and deterministic: the same success condition and observation always
|
|
173
|
+
* give the same outcome. No clock, no randomness, no code from the blueprint.
|
|
174
|
+
*/
|
|
175
|
+
export declare function evaluateValidationSuccess(success: ValidationSuccess, observation: ValidationObservation): ValidationOutcome;
|
|
176
|
+
/** Row counts per entity, to record as the `baseline` when a task starts. */
|
|
177
|
+
export declare function validationBaseline(rows: ValidationObservation['rows']): Record<string, number>;
|
|
178
|
+
export {};
|