@volter/world-core 2.0.36 → 3.0.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 +4 -5
- package/app-route.cjs +12 -6
- package/app-route.d.cts +1 -1
- package/dist/app-route.cjs +12 -6
- package/dist/app-route.d.cts +1 -1
- package/dist/generated/pack-facts.json +1410 -3069
- package/dist/inject.cjs +64 -9
- package/dist/pack-facts.cjs +44 -0
- package/dist/src/actions.d.ts +3 -3
- package/dist/src/actions.js +22 -16
- package/dist/src/ancestry.d.ts +14 -2
- package/dist/src/ancestry.js +92 -2
- package/dist/src/anthropic-wire.d.ts +39 -0
- package/dist/src/anthropic-wire.js +136 -0
- package/dist/src/bytes.d.ts +7 -0
- package/dist/src/bytes.js +35 -0
- package/dist/src/changeset.d.ts +1 -1
- package/dist/src/changeset.js +0 -0
- package/dist/src/clickhouse/index.d.ts +3 -0
- package/dist/src/clickhouse/index.js +6 -0
- package/dist/src/clickhouse/sql.d.ts +233 -0
- package/dist/src/clickhouse/sql.js +4329 -0
- package/dist/src/clickhouse/types.d.ts +18 -0
- package/dist/src/clickhouse/types.js +47 -0
- package/dist/src/clickhouse/values.d.ts +146 -0
- package/dist/src/clickhouse/values.js +858 -0
- package/dist/src/client-bundle.js +2 -3
- package/dist/src/cors.d.ts +15 -0
- package/dist/src/cors.js +31 -0
- package/dist/src/derived-core.d.ts +487 -24
- package/dist/src/derived-core.js +788 -144
- package/dist/src/derived-real.d.ts +13 -0
- package/dist/src/derived-real.js +518 -0
- package/dist/src/derived.d.ts +35 -1
- package/dist/src/derived.js +61 -9
- package/dist/src/emit.js +1 -2
- package/dist/src/events.d.ts +206 -0
- package/dist/src/events.js +341 -0
- package/dist/src/executor.d.ts +3 -0
- package/dist/src/executor.js +19 -2
- package/dist/src/file-response.d.ts +6 -0
- package/dist/src/file-response.js +30 -0
- package/dist/src/fork.js +3 -2
- package/dist/src/git/history.d.ts +7 -0
- package/dist/src/git/history.js +24 -0
- package/dist/src/git/index.d.ts +1 -0
- package/dist/src/git/index.js +1 -0
- package/dist/src/git/lfs.d.ts +28 -0
- package/dist/src/git/lfs.js +66 -0
- package/dist/src/git/objects.js +3 -8
- package/dist/src/git/smart-http.d.ts +3 -1
- package/dist/src/git/smart-http.js +67 -6
- package/dist/src/graphql-wire.d.ts +29 -0
- package/dist/src/graphql-wire.js +101 -0
- package/dist/src/grpc-wire.d.ts +67 -0
- package/dist/src/grpc-wire.js +170 -0
- package/dist/src/h2.d.ts +40 -0
- package/dist/src/h2.js +656 -0
- package/dist/src/head.d.ts +32 -3
- package/dist/src/head.js +161 -40
- package/dist/src/history.d.ts +1 -1
- package/dist/src/history.js +6 -6
- package/dist/src/hpack.json +1 -0
- package/dist/src/index.d.ts +64 -75
- package/dist/src/index.js +58 -101
- package/dist/src/log.js +28 -19
- package/dist/src/machines.d.ts +50 -0
- package/dist/src/machines.js +151 -0
- package/dist/src/managed-database.d.ts +86 -0
- package/dist/src/managed-database.js +283 -0
- package/dist/src/multipart.d.ts +11 -0
- package/dist/src/multipart.js +51 -0
- package/dist/src/observe.d.ts +15 -5
- package/dist/src/observe.js +23 -9
- package/dist/src/openai-wire.d.ts +108 -0
- package/dist/src/openai-wire.js +337 -0
- package/dist/src/pack-assets.d.ts +3 -4
- package/dist/src/pack-assets.js +15 -10
- package/dist/src/pack-fetch.d.ts +77 -0
- package/dist/src/pack-fetch.js +449 -0
- package/dist/src/pack-paths.d.ts +12 -0
- package/dist/src/pack-paths.js +86 -0
- package/dist/src/packRegistry.d.ts +69 -162
- package/dist/src/packRegistry.js +55 -20
- package/dist/src/people.d.ts +13 -0
- package/dist/src/people.js +18 -0
- package/dist/src/placeholder-image.d.ts +5 -0
- package/dist/src/placeholder-image.js +114 -0
- package/dist/src/protobuf.d.ts +28 -0
- package/dist/src/protobuf.js +332 -0
- package/dist/src/redis/engine.js +1 -1
- package/dist/src/request-scope.d.ts +1 -1
- package/dist/src/request-scope.js +6 -4
- package/dist/src/resource-blob.d.ts +5 -0
- package/dist/src/resource-blob.js +11 -0
- package/dist/src/runtime.d.ts +85 -0
- package/dist/src/runtime.js +104 -0
- package/dist/src/s3/wire.d.ts +60 -0
- package/dist/src/s3/wire.js +157 -0
- package/dist/src/scenario.d.ts +3 -0
- package/dist/src/scenario.js +2 -0
- package/dist/src/schema-sample.d.ts +1 -0
- package/dist/src/schema-sample.js +21 -0
- package/dist/src/sealed-box.d.ts +14 -0
- package/dist/src/sealed-box.js +225 -0
- package/dist/src/serve-http.d.ts +14 -0
- package/dist/src/serve-http.js +27 -3
- package/dist/src/serve.d.ts +6 -0
- package/dist/src/serve.js +69 -14
- package/dist/src/signing.d.ts +135 -0
- package/dist/src/signing.js +222 -0
- package/dist/src/sigv4.d.ts +48 -0
- package/dist/src/sigv4.js +167 -0
- package/dist/src/smtp.d.ts +16 -0
- package/dist/src/smtp.js +72 -0
- package/dist/src/sockets.d.ts +51 -0
- package/dist/src/sockets.js +90 -0
- package/dist/src/state-system.d.ts +1 -0
- package/dist/src/state-system.js +1 -1
- package/dist/src/storage.d.ts +1 -1
- package/dist/src/storage.js +3 -3
- package/dist/src/trace-context.js +1 -1
- package/dist/src/twin-fetch.d.ts +0 -7
- package/dist/src/twin-fetch.js +0 -14
- package/dist/src/vendor-call.d.ts +6 -0
- package/dist/src/vendor-call.js +41 -0
- package/dist/src/world-store.js +1 -1
- package/dist/vendor-hosts.cjs +36 -125
- package/dist/vendor-hosts.d.cts +8 -0
- package/generated/pack-facts.json +1410 -3069
- package/inject.cjs +64 -9
- package/pack-facts.cjs +44 -0
- package/package.json +17 -3
- package/src/actions.ts +23 -16
- package/src/ancestry.ts +74 -2
- package/src/anthropic-wire.ts +137 -0
- package/src/bytes.ts +42 -0
- package/src/changeset.ts +5 -5
- package/src/clickhouse/index.ts +6 -0
- package/src/clickhouse/sql.ts +3059 -0
- package/src/clickhouse/types.ts +44 -0
- package/src/clickhouse/values.ts +697 -0
- package/src/client-bundle.ts +2 -3
- package/src/cors.ts +34 -0
- package/src/derived-core.ts +1013 -146
- package/src/derived-real.ts +434 -0
- package/src/derived.ts +73 -3
- package/src/emit.ts +1 -2
- package/src/events.ts +449 -0
- package/src/executor.ts +24 -2
- package/src/file-response.ts +27 -0
- package/src/fork.ts +3 -2
- package/src/git/history.ts +19 -0
- package/src/git/index.ts +1 -0
- package/src/git/lfs.ts +67 -0
- package/src/git/objects.ts +3 -5
- package/src/git/smart-http.ts +56 -6
- package/src/graphql-wire.ts +106 -0
- package/src/grpc-wire.ts +159 -0
- package/src/h2.ts +627 -0
- package/src/head.ts +132 -41
- package/src/history.ts +6 -6
- package/src/hpack.json +1 -0
- package/src/index.ts +82 -329
- package/src/log.ts +27 -18
- package/src/machines.ts +151 -0
- package/src/managed-database.ts +299 -0
- package/src/multipart.ts +51 -0
- package/src/observe.ts +31 -15
- package/src/openai-wire.ts +371 -0
- package/src/pack-assets.ts +15 -11
- package/src/pack-fetch.ts +458 -0
- package/src/pack-paths.ts +72 -0
- package/src/packRegistry.ts +79 -167
- package/src/people.ts +31 -0
- package/src/placeholder-image.ts +88 -0
- package/src/protobuf.ts +251 -0
- package/src/redis/engine.ts +1 -1
- package/src/request-scope.ts +8 -4
- package/src/resource-blob.ts +13 -0
- package/src/runtime.ts +344 -0
- package/src/s3/wire.ts +172 -0
- package/src/scenario.ts +4 -0
- package/src/schema-sample.ts +24 -0
- package/src/sealed-box.ts +182 -0
- package/src/serve-http.ts +31 -3
- package/src/serve.ts +58 -14
- package/src/signing.ts +231 -0
- package/src/sigv4.ts +158 -0
- package/src/smtp.ts +76 -0
- package/src/sockets.ts +140 -0
- package/src/state-system.ts +2 -2
- package/src/storage.ts +3 -3
- package/src/trace-context.ts +1 -1
- package/src/twin-fetch.ts +0 -20
- package/src/vendor-call.ts +41 -0
- package/src/world-store.ts +1 -1
- package/vendor-hosts.cjs +36 -125
- package/vendor-hosts.d.cts +8 -0
- package/dist/src/mirror-shell.d.ts +0 -2
- package/dist/src/mirror-shell.js +0 -13
- package/dist/src/v1-removed.d.ts +0 -159
- package/dist/src/v1-removed.js +0 -124
- package/src/mirror-shell.ts +0 -15
- package/src/v1-removed.ts +0 -172
package/dist/src/derived-core.js
CHANGED
|
@@ -7,13 +7,28 @@
|
|
|
7
7
|
import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from "./serve.js";
|
|
8
8
|
import { copyResource, ownFields, parentStamp, subjectHistory, treeChangesSince, treeStamp } from "./log.js";
|
|
9
9
|
import { getActiveWorldStore } from "./world-store.js";
|
|
10
|
+
import { getActiveBlobStore } from "./blob-store.js";
|
|
11
|
+
import { worldPaths } from "./storage.js";
|
|
12
|
+
import { GitObjectStore } from "./git/objects.js";
|
|
13
|
+
import { GitRefs } from "./git/refs.js";
|
|
10
14
|
import { worldNow } from "./world-clock.js";
|
|
11
|
-
import {
|
|
15
|
+
import { worldEnvValue } from "./world-env.js";
|
|
16
|
+
import { openSessions, socketWrite } from "./sockets.js";
|
|
17
|
+
import { MACHINE_POOL, poolFor } from "./machines.js";
|
|
18
|
+
import { askApplication, deliverEvents, deliverToApplication } from "./events.js";
|
|
19
|
+
import { handlerCrypto, hmac, lettersFrom, sha256, uuidFrom } from "./signing.js";
|
|
12
20
|
import { hashFieldValue } from "./hash.js";
|
|
13
|
-
import {
|
|
21
|
+
import { readResourceBlob, removeResourceBlob, writeResourceBlob } from "./resource-blob.js";
|
|
22
|
+
import { managedDatabase, unboundDatabase } from "./managed-database.js";
|
|
23
|
+
import { execRedisRun } from "./redis/engine.js";
|
|
24
|
+
import { sendMail } from "./smtp.js";
|
|
25
|
+
import { multipartBoundary, multipartParts } from "./multipart.js";
|
|
26
|
+
import { projectOwnerResources, resolveSubjectId, subjectAliases } from "./actions.js";
|
|
27
|
+
import { vendorFetch } from "./vendor-call.js";
|
|
14
28
|
import { packReferences } from "./references.js";
|
|
15
29
|
import { twinPublicBase, withRequestScopes } from "./twin-fetch.js";
|
|
16
|
-
import { isReadOnlyRequest } from "./request-scope.js";
|
|
30
|
+
import { inVendorMove, isReadOnlyRequest, runAsVendorMove } from "./request-scope.js";
|
|
31
|
+
import { bodyTextOf, checkJsonBody, takeParsedBody } from "./derived.js";
|
|
17
32
|
/** What a move to `to` stores in the field: a boolean field's value is a boolean, and a derived field's
|
|
18
33
|
* value is written by the move's effects (a state name is not a timestamp), so it stores nothing itself. */
|
|
19
34
|
export function storedState(decl, to) {
|
|
@@ -37,6 +52,74 @@ export function stateOf(decl, value) {
|
|
|
37
52
|
return value === undefined || value === null ? String(decl.initial) : String(value);
|
|
38
53
|
}
|
|
39
54
|
// ── request parsing ──────────────────────────────────────────────────────────────────────────
|
|
55
|
+
/** A body's fields as a strict vendor checks them (`ctx.fields`, the manifest's `body.strict`). Without `strict`, every
|
|
56
|
+
* field passes. An object or array field is not typed here: its own shape is the handler's. */
|
|
57
|
+
export function strictFields(m, operation, body) {
|
|
58
|
+
const strict = m.body.strict;
|
|
59
|
+
if (!strict)
|
|
60
|
+
return { fields: { ...body } };
|
|
61
|
+
const declared = new Map((operation.body ?? []).map((f) => [f.name, f]));
|
|
62
|
+
const refuse = (template, values) => ({ refused: vendorError(m, { status: strict.status, ...(strict.code ? { code: strict.code } : {}), message: String(fill(template, values)) }) });
|
|
63
|
+
const fields = {};
|
|
64
|
+
for (const [name, value] of Object.entries(body)) {
|
|
65
|
+
const field = declared.get(name);
|
|
66
|
+
if (!field)
|
|
67
|
+
return refuse(strict.unknown, { name });
|
|
68
|
+
const { type } = field;
|
|
69
|
+
const fits = value === null
|
|
70
|
+
|| (type === 'boolean' && typeof value === 'boolean')
|
|
71
|
+
|| (type === 'integer' && Number.isInteger(value))
|
|
72
|
+
|| (type === 'number' && typeof value === 'number')
|
|
73
|
+
|| (type === 'string' && typeof value === 'string')
|
|
74
|
+
|| !['boolean', 'integer', 'number', 'string'].includes(type);
|
|
75
|
+
if (!fits)
|
|
76
|
+
return refuse(strict.type, { name, type: type === 'integer' ? 'an integer' : `a ${type}` });
|
|
77
|
+
if (typeof value === 'number' && ((field.minimum !== undefined && value < field.minimum) || (field.maximum !== undefined && value > field.maximum))) {
|
|
78
|
+
return refuse(strict.range, { name, min: field.minimum ?? null, max: field.maximum ?? null });
|
|
79
|
+
}
|
|
80
|
+
fields[name] = value;
|
|
81
|
+
}
|
|
82
|
+
return { fields };
|
|
83
|
+
}
|
|
84
|
+
/** Top-level schema checks with vendor-supplied response templates; unknown fields pass to semantics. */
|
|
85
|
+
function validateBody(m, operation, body) {
|
|
86
|
+
const rule = m.body.validation;
|
|
87
|
+
if (!rule || (rule.operations && !rule.operations.includes(operation.id)))
|
|
88
|
+
return undefined;
|
|
89
|
+
const refuse = (template, values) => Response.json(fill(template, values), { status: rule.status });
|
|
90
|
+
if (!body || typeof body !== 'object' || Array.isArray(body))
|
|
91
|
+
return refuse(rule.type, { name: '$body', type: 'object', input: body ?? null });
|
|
92
|
+
const values = body;
|
|
93
|
+
for (const field of operation.body ?? []) {
|
|
94
|
+
const value = values[field.name];
|
|
95
|
+
if (value === undefined) {
|
|
96
|
+
if (field.required)
|
|
97
|
+
return refuse(rule.missing, { name: field.name, input: values });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const types = field.type.split('|');
|
|
101
|
+
const fits = types.some((type) => type === 'null' ? value === null : type === 'integer' ? Number.isInteger(value) : type === 'array' ? Array.isArray(value) : type === 'object' ? value !== null && typeof value === 'object' && !Array.isArray(value) : type === 'string' ? typeof value === 'string' : type === 'number' ? typeof value === 'number' : type === 'boolean' ? typeof value === 'boolean' : true);
|
|
102
|
+
if (!fits)
|
|
103
|
+
return refuse(rule.type, { name: field.name, type: field.type, input: value });
|
|
104
|
+
if (typeof value === 'number' && ((field.minimum !== undefined && value < field.minimum) || (field.maximum !== undefined && value > field.maximum)))
|
|
105
|
+
return refuse(rule.range, { name: field.name, min: field.minimum ?? null, max: field.maximum ?? null, input: value });
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
/** A value as the vendor reads a boolean (`ctx.flag`, the manifest's `booleans`). */
|
|
110
|
+
export function flagOf(m, value) {
|
|
111
|
+
if (typeof value === 'boolean')
|
|
112
|
+
return value;
|
|
113
|
+
if (value === undefined || value === null)
|
|
114
|
+
return undefined;
|
|
115
|
+
const text = String(value).toLowerCase();
|
|
116
|
+
const { truthy, falsy } = m.booleans ?? { truthy: ['true'], falsy: ['false'] };
|
|
117
|
+
if (truthy.some((t) => t.toLowerCase() === text))
|
|
118
|
+
return true;
|
|
119
|
+
if (falsy.some((f) => f.toLowerCase() === text))
|
|
120
|
+
return false;
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
40
123
|
/** A form body's bracket notation (`a[b][0][c]=v`, `expand[]=x`) as nested objects and arrays. A form carries only
|
|
41
124
|
* text: a field the operation's spec types as a number or a boolean (`scalars`, by bracket path, written by
|
|
42
125
|
* world-tooling's formScalarsOf) is read as one when its text is that literal; every other field stays the text sent
|
|
@@ -98,25 +181,42 @@ export async function readParams(manifest, request, operation) {
|
|
|
98
181
|
// an upload: text fields as given, each file as its name, media type, size and content (its text), with its raw
|
|
99
182
|
// `bytes` beside them for a pack that must tell an image or audio file from anything else; the bytes are not
|
|
100
183
|
// enumerated, so a write's record of the request keeps the text alone
|
|
101
|
-
|
|
184
|
+
// read from its bytes (multipart.ts): a runtime's form parser drops a part with an empty field name
|
|
185
|
+
const boundary = multipartBoundary(type);
|
|
186
|
+
const parts = boundary === null ? [] : multipartParts(new Uint8Array(await request.arrayBuffer()), boundary);
|
|
102
187
|
const out = { ...query };
|
|
103
|
-
for (const
|
|
104
|
-
if (
|
|
105
|
-
out[
|
|
188
|
+
for (const part of parts) {
|
|
189
|
+
if (part.filename === null) {
|
|
190
|
+
out[part.name] = new TextDecoder().decode(part.body);
|
|
106
191
|
continue;
|
|
107
192
|
}
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
out[key] = Object.defineProperty(parsed, 'bytes', { value: bytes, enumerable: false });
|
|
193
|
+
const bytes = part.body;
|
|
194
|
+
const parsed = { name: part.filename, type: part.type ?? '', size: bytes.byteLength, content: new TextDecoder().decode(bytes) };
|
|
195
|
+
out[part.name] = Object.defineProperty(parsed, 'bytes', { value: bytes, enumerable: false });
|
|
112
196
|
}
|
|
113
197
|
return out;
|
|
114
198
|
}
|
|
115
|
-
const text = await request
|
|
199
|
+
const text = await bodyTextOf(request);
|
|
116
200
|
if (!text)
|
|
117
201
|
return query;
|
|
118
|
-
|
|
119
|
-
const
|
|
202
|
+
// NDJSON (`application/x-ndjson`) is lines of JSON, not a JSON document: the handler reads its text
|
|
203
|
+
const isJson = type.includes('json') && !type.includes('ndjson');
|
|
204
|
+
const asJson = isJson || manifest.body.json === 'always';
|
|
205
|
+
// `json: 'always'` reads a body labelled otherwise as JSON (GitHub reads curl -d's); a body that is not JSON is read
|
|
206
|
+
// as the form it is labelled, never a crash (a sign-in page's own form, posted to the vendor's page)
|
|
207
|
+
let body;
|
|
208
|
+
if (asJson) {
|
|
209
|
+
try {
|
|
210
|
+
body = JSON.parse(text);
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
if (isJson)
|
|
214
|
+
throw new SyntaxError('malformed JSON body');
|
|
215
|
+
body = parseBracketForm(text, scalars);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else
|
|
219
|
+
body = parseBracketForm(text, scalars);
|
|
120
220
|
// a body that is not an object (GitHub's set-labels takes a bare array) is the body, not fields
|
|
121
221
|
return body && typeof body === 'object' && !Array.isArray(body) ? { ...query, ...body } : { ...query };
|
|
122
222
|
}
|
|
@@ -132,21 +232,27 @@ function fill(template, values, omitAbsent = false) {
|
|
|
132
232
|
if (Array.isArray(template))
|
|
133
233
|
return template.map((t) => fill(t, values, omitAbsent)).filter((v) => v !== ABSENT);
|
|
134
234
|
if (template && typeof template === 'object') {
|
|
135
|
-
|
|
235
|
+
const entries = Object.entries(template).map(([k, v]) => [k, fill(v, values, omitAbsent)]).filter(([, v]) => v !== ABSENT);
|
|
236
|
+
// an object every one of whose placeholders was absent is absent itself (Clerk's `meta` with no parameter to name)
|
|
237
|
+
if (omitAbsent && entries.length === 0 && Object.keys(template).length > 0)
|
|
238
|
+
return ABSENT;
|
|
239
|
+
return Object.fromEntries(entries);
|
|
136
240
|
}
|
|
137
241
|
return template;
|
|
138
242
|
}
|
|
139
243
|
export function vendorError(manifest, e) {
|
|
140
244
|
// the status as the answer's number, and as text for a vendor whose body spells it as a string (GitHub's "404")
|
|
141
245
|
const values = { message: e.message, code: e.code ?? null, param: e.param ?? null, kind: e.kind ?? manifest.defaultKind ?? null, status: e.status, statusText: String(e.status) };
|
|
246
|
+
// the HTTP status, which a vendor may keep apart from the one its body names (errorsAnsweredAs)
|
|
247
|
+
const answered = manifest.errorsAnsweredAs && e.status < manifest.errorsAnsweredAs.below ? manifest.errorsAnsweredAs.status : e.status;
|
|
142
248
|
// a vendor that answers its errors as a document, not JSON (S3's XML `<Error><Code>…`): the template is that text,
|
|
143
249
|
// each value escaped into it
|
|
144
250
|
if (typeof manifest.error === 'string') {
|
|
145
251
|
const escape = (v) => String(v ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
146
252
|
const body = manifest.error.replace(/\{(\w+)\}/g, (_, k) => escape(values[k]));
|
|
147
|
-
return new Response(body, { status:
|
|
253
|
+
return new Response(body, { status: answered, headers: { 'content-type': body.trimStart().startsWith('<') ? 'application/xml' : 'text/plain; charset=utf-8' } });
|
|
148
254
|
}
|
|
149
|
-
return Response.json(fill(manifest.error, values, manifest.errorOmitsAbsent ?? false), { status:
|
|
255
|
+
return Response.json(fill(manifest.error, values, manifest.errorOmitsAbsent ?? false), { status: answered });
|
|
150
256
|
}
|
|
151
257
|
function notFound(manifest, object, id, param) {
|
|
152
258
|
const nf = manifest.notFound;
|
|
@@ -158,7 +264,7 @@ function notFound(manifest, object, id, param) {
|
|
|
158
264
|
return vendorError(manifest, { status: nf.status, message: String(fill(message, { object, id })), ...(code ? { code } : {}), ...(nf.kind ? { kind: nf.kind } : {}), ...(param ? { param } : {}) });
|
|
159
265
|
}
|
|
160
266
|
// ── the tree ─────────────────────────────────────────────────────────────────────────────────
|
|
161
|
-
const storedType = (m, resource) => m.resources[resource]?.storedAs ?? resource;
|
|
267
|
+
export const storedType = (m, resource) => m.resources[resource]?.storedAs ?? resource;
|
|
162
268
|
// A service's resources by type, folded once per World state (the tree's stamp; any write moves it), not on every read:
|
|
163
269
|
// a read of one resource type projected the service's whole tree, so a pack whose tree also holds many subjects of
|
|
164
270
|
// other types (PlanetScale's SQL rows beside its management records) paid for all of them on each read. A read gets its
|
|
@@ -232,17 +338,16 @@ function missingParent(m, resource, call, root) {
|
|
|
232
338
|
export function render(r) {
|
|
233
339
|
return Object.fromEntries(Object.entries({ id: r.id, ...ownFields(r) }).filter(([k]) => !k.startsWith('_') && k !== 'updatedAt'));
|
|
234
340
|
}
|
|
341
|
+
/** A stored subject as the vendor answers it: its own fields, its id under the name the vendor gives it. */
|
|
235
342
|
function view(m, resource, r) {
|
|
236
|
-
const
|
|
237
|
-
if (
|
|
238
|
-
return own(render(r), { id: r.id, type: r.type });
|
|
239
|
-
if (!m.view)
|
|
343
|
+
const idAs = m.resources[resource]?.idAs;
|
|
344
|
+
if (!idAs)
|
|
240
345
|
return render(r);
|
|
241
|
-
|
|
242
|
-
return
|
|
346
|
+
const { id, ...rest } = render(r);
|
|
347
|
+
return { [idAs]: id, ...rest };
|
|
243
348
|
}
|
|
244
349
|
function now(m, at) {
|
|
245
|
-
return m.time === 'unix' ? Math.floor(Date.parse(at) / 1000) : at;
|
|
350
|
+
return m.time === 'unix' ? Math.floor(Date.parse(at) / 1000) : m.time === 'unix-ms' ? Date.parse(at) : at;
|
|
246
351
|
}
|
|
247
352
|
function applyRule(m, rule, id, values, at) {
|
|
248
353
|
if ('now' in rule)
|
|
@@ -251,15 +356,85 @@ function applyRule(m, rule, id, values, at) {
|
|
|
251
356
|
return id;
|
|
252
357
|
if ('value' in rule)
|
|
253
358
|
return rule.value;
|
|
254
|
-
|
|
359
|
+
const text = fill(rule.template, { ...values, id });
|
|
360
|
+
return rule.encode === 'base64' ? btoa(String.fromCharCode(...new TextEncoder().encode(String(text)))) : text;
|
|
255
361
|
}
|
|
256
362
|
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
257
363
|
/** The next id the manifest's template gives this resource: one past the highest it already minted. */
|
|
258
|
-
|
|
364
|
+
/** The World's seed for a service's secrets (ctx.secret): made once from the platform's random source, then read. The
|
|
365
|
+
* making is one per service store in the process: two first calls at once share it, so no secret handed out under one
|
|
366
|
+
* seed is lost to another written after it (a service's store is one process's). */
|
|
367
|
+
const SEED = '_world_seed';
|
|
368
|
+
const seeding = new Map();
|
|
369
|
+
function worldSeed(service, root, at) {
|
|
370
|
+
const held = resourcesOfType(service, SEED, root).find((r) => r.id === 'seed');
|
|
371
|
+
if (typeof held?.value === 'string')
|
|
372
|
+
return Promise.resolve(held.value);
|
|
373
|
+
const key = `${root ?? ''}\u0000${service}`;
|
|
374
|
+
const pending = seeding.get(key);
|
|
375
|
+
if (pending)
|
|
376
|
+
return pending;
|
|
377
|
+
const made = (async () => {
|
|
378
|
+
const value = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(32))).toString('hex');
|
|
379
|
+
// the World's own bookkeeping, made on first use whatever the request: a read-only one lands it too (a vendor's move)
|
|
380
|
+
await runAsVendorMove(() => applyTwinWrite(service, { operation: 'world_seed.record', subjectType: SEED, subjectId: 'seed', fields: { value }, occurredAt: at, actor: { kind: 'system' } }, root));
|
|
381
|
+
return value;
|
|
382
|
+
})().finally(() => seeding.delete(key));
|
|
383
|
+
seeding.set(key, made);
|
|
384
|
+
return made;
|
|
385
|
+
}
|
|
386
|
+
/** A World's signing key for a label (ctx.signingKey): made once from the platform's random source, then read; the
|
|
387
|
+
* making is one per service store and label in the process, as the seed's is. */
|
|
388
|
+
const SIGNING_KEY = '_world_signing_key';
|
|
389
|
+
const keying = new Map();
|
|
390
|
+
const pem = (kind, der) => `-----BEGIN ${kind}-----\n${Buffer.from(der).toString('base64').replace(/(.{64})/g, '$1\n').replace(/\n$/, '')}\n-----END ${kind}-----\n`;
|
|
391
|
+
function heldWorldSigningKey(service, root, label, alg) {
|
|
392
|
+
const id = alg === 'RS256' ? label : `${label}#${alg}`;
|
|
393
|
+
const held = resourcesOfType(service, SIGNING_KEY, root).find((r) => r.id === id);
|
|
394
|
+
return typeof held?.privatePem === 'string' && typeof held.publicPem === 'string' ? { privatePem: held.privatePem, publicPem: held.publicPem } : undefined;
|
|
395
|
+
}
|
|
396
|
+
function worldSigningKey(service, root, at, label, alg) {
|
|
397
|
+
// an RSA key keeps its label; another algorithm's is kept under the label and the algorithm, so one label never names two
|
|
398
|
+
const id = alg === 'RS256' ? label : `${label}#${alg}`;
|
|
399
|
+
const held = resourcesOfType(service, SIGNING_KEY, root).find((r) => r.id === id);
|
|
400
|
+
if (typeof held?.privatePem === 'string' && typeof held.publicPem === 'string')
|
|
401
|
+
return Promise.resolve({ privatePem: held.privatePem, publicPem: held.publicPem });
|
|
402
|
+
const key = `${root ?? ''}\u0000${service}\u0000${id}`;
|
|
403
|
+
const pending = keying.get(key);
|
|
404
|
+
if (pending)
|
|
405
|
+
return pending;
|
|
406
|
+
const made = (async () => {
|
|
407
|
+
const pair = (alg === 'Ed25519'
|
|
408
|
+
? await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])
|
|
409
|
+
: await globalThis.crypto.subtle.generateKey({ name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, ['sign', 'verify']));
|
|
410
|
+
const fields = {
|
|
411
|
+
privatePem: pem('PRIVATE KEY', await globalThis.crypto.subtle.exportKey('pkcs8', pair.privateKey)),
|
|
412
|
+
publicPem: pem('PUBLIC KEY', await globalThis.crypto.subtle.exportKey('spki', pair.publicKey)),
|
|
413
|
+
};
|
|
414
|
+
await runAsVendorMove(() => applyTwinWrite(service, { operation: 'world_signing_key.record', subjectType: SIGNING_KEY, subjectId: id, fields, occurredAt: at, actor: { kind: 'system' } }, root));
|
|
415
|
+
return fields;
|
|
416
|
+
})().finally(() => keying.delete(key));
|
|
417
|
+
keying.set(key, made);
|
|
418
|
+
return made;
|
|
419
|
+
}
|
|
420
|
+
function mintId(m, resource, root, at) {
|
|
421
|
+
if (!m.resources[resource])
|
|
422
|
+
throw new Error(`mint: ${resource} is not a resource ${m.vendor}'s manifest declares`);
|
|
259
423
|
const prefix = m.resources[resource].idPrefix;
|
|
260
|
-
|
|
424
|
+
const template = m.resources[resource].ids ?? m.ids.template;
|
|
425
|
+
if (template === '{uuid}')
|
|
261
426
|
return mintUuid(m, resource, root);
|
|
262
|
-
const
|
|
427
|
+
const snowflake = /^\{snowflake:(\d+)\}$/.exec(template);
|
|
428
|
+
if (snowflake)
|
|
429
|
+
return mintSnowflake(m, BigInt(snowflake[1]), root, at);
|
|
430
|
+
// `{letters:N}` and `{hex:N}`, alone or after the resource's prefix (`{prefix}{letters:16}`: Fly's `vol_…`)
|
|
431
|
+
const letters = /^(\{prefix\})?\{letters:(\d+)\}$/.exec(template);
|
|
432
|
+
if (letters)
|
|
433
|
+
return mintLetters(m, resource, Number(letters[2]), root, letters[1] ? prefix : '');
|
|
434
|
+
const hex = /^(\{prefix\})?\{hex:(\d+)\}$/.exec(template);
|
|
435
|
+
if (hex)
|
|
436
|
+
return mintHex(m, resource, Number(hex[2]), root, hex[1] ? prefix : '');
|
|
437
|
+
const [head, tail = ''] = template.split('{n}');
|
|
263
438
|
const pattern = new RegExp(`^${escapeRe(String(fill(head, { prefix })))}(\\d+)${escapeRe(String(fill(tail, { prefix })))}$`);
|
|
264
439
|
let max = 0;
|
|
265
440
|
// the one type, from the index that follows writes (a scan of the whole World per create grew with it)
|
|
@@ -268,7 +443,55 @@ function mintId(m, resource, root) {
|
|
|
268
443
|
if (hit)
|
|
269
444
|
max = Math.max(max, Number(hit[1]));
|
|
270
445
|
}
|
|
271
|
-
return String(fill(
|
|
446
|
+
return String(fill(template, { prefix, n: max + 1 }));
|
|
447
|
+
}
|
|
448
|
+
/** The next snowflake (Discord's, X's): the milliseconds since the vendor's epoch shifted left 22 bits, the low bits an
|
|
449
|
+
* increment. One space over every snowflake resource of the vendor, as its one generator's: the instant's first, or one
|
|
450
|
+
* past the greatest the World holds when that is later (a frozen clock, or several ids in one millisecond), so ids
|
|
451
|
+
* rise with time and never repeat. */
|
|
452
|
+
/** The last snowflake minted per vendor and World in this process: two mints before the first is written (a message
|
|
453
|
+
* and its attachment) must not meet. */
|
|
454
|
+
const lastSnowflake = new Map();
|
|
455
|
+
function mintSnowflake(m, epoch, root, at) {
|
|
456
|
+
const ms = BigInt(Date.parse(at ?? '') || 0);
|
|
457
|
+
const base = ms > epoch ? (ms - epoch) << 22n : 0n;
|
|
458
|
+
let max = -1n;
|
|
459
|
+
for (const [name, decl] of Object.entries(m.resources)) {
|
|
460
|
+
if (!/^\{snowflake:\d+\}$/.test(decl.ids ?? m.ids.template))
|
|
461
|
+
continue;
|
|
462
|
+
for (const r of resourcesOfType(m.service, storedType(m, name), root))
|
|
463
|
+
if (/^\d+$/.test(r.id)) {
|
|
464
|
+
const v = BigInt(r.id);
|
|
465
|
+
if (v > max)
|
|
466
|
+
max = v;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const key = `${m.service}\0${root ?? ''}`;
|
|
470
|
+
const last = lastSnowflake.get(key) ?? -1n;
|
|
471
|
+
const floor = max > last ? max : last;
|
|
472
|
+
const id = base > floor ? base : floor + 1n;
|
|
473
|
+
lastSnowflake.set(key, id);
|
|
474
|
+
return String(id);
|
|
475
|
+
}
|
|
476
|
+
/** The next id of `count` lowercase letters for a resource, derived from the resource and the count before it, past
|
|
477
|
+
* any the World already holds. */
|
|
478
|
+
function mintLetters(m, resource, count, root, prefix = '') {
|
|
479
|
+
const held = new Set(resourcesOfType(m.service, storedType(m, resource), root).map((r) => r.id));
|
|
480
|
+
for (let n = held.size + 1;; n++) {
|
|
481
|
+
const id = `${prefix}${lettersFrom(`${m.service}:${resource}:${n}`, count)}`;
|
|
482
|
+
if (!held.has(id))
|
|
483
|
+
return id;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/** The next hex id for a resource: N hex digits derived from the resource and the count before it, past any the World
|
|
487
|
+
* already holds (a Cloudflare id, 32). */
|
|
488
|
+
function mintHex(m, resource, count, root, prefix = '') {
|
|
489
|
+
const held = new Set(resourcesOfType(m.service, storedType(m, resource), root).map((r) => r.id));
|
|
490
|
+
for (let n = held.size + 1;; n++) {
|
|
491
|
+
const id = `${prefix}${sha256(`${m.service}:${resource}:${n}`).slice(0, count)}`;
|
|
492
|
+
if (!held.has(id))
|
|
493
|
+
return id;
|
|
494
|
+
}
|
|
272
495
|
}
|
|
273
496
|
/** The next UUID for a resource: a version-4-shaped UUID derived from the resource and the count before it, past any
|
|
274
497
|
* the World already holds. */
|
|
@@ -276,8 +499,7 @@ function mintUuid(m, resource, root) {
|
|
|
276
499
|
const type = storedType(m, resource);
|
|
277
500
|
const held = new Set(resourcesOfType(m.service, type, root).map((r) => r.id));
|
|
278
501
|
for (let n = held.size + 1;; n++) {
|
|
279
|
-
const
|
|
280
|
-
const id = `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${'89ab'[parseInt(h[16], 16) % 4]}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
502
|
+
const id = uuidFrom(`${m.service}:${resource}:${n}`);
|
|
281
503
|
if (!held.has(id))
|
|
282
504
|
return id;
|
|
283
505
|
}
|
|
@@ -289,7 +511,15 @@ function expand(m, resource, body, paths, root) {
|
|
|
289
511
|
const [head, ...rest] = path.split('.');
|
|
290
512
|
const target = embeds[head];
|
|
291
513
|
const id = out[head];
|
|
292
|
-
if (!target
|
|
514
|
+
if (!target)
|
|
515
|
+
continue;
|
|
516
|
+
// an object already embedded (an earlier path's) is walked further, not fetched again
|
|
517
|
+
if (id && typeof id === 'object' && !Array.isArray(id)) {
|
|
518
|
+
if (rest.length)
|
|
519
|
+
out[head] = expand(m, target, id, [rest.join('.')], root);
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (typeof id !== 'string')
|
|
293
523
|
continue;
|
|
294
524
|
const hit = stored(m, target, root).find((r) => r.id === id);
|
|
295
525
|
if (hit)
|
|
@@ -304,17 +534,114 @@ function expandPaths(m, params) {
|
|
|
304
534
|
async function write(m, call, resource, id, fields, operation, params, root, occurredAt) {
|
|
305
535
|
return (await writeDetailed(m, call, resource, id, fields, operation, params, root, occurredAt)).body;
|
|
306
536
|
}
|
|
537
|
+
const sentOf = new WeakMap();
|
|
538
|
+
/** The lane a vendor's router sent a request to (pack-fetch's laneRouter sets it). */
|
|
539
|
+
export const LANE_HEADER = 'x-volter-lane';
|
|
540
|
+
function sentRequest(m, call, body) {
|
|
541
|
+
const url = new URL(call.request.url);
|
|
542
|
+
const named = [...(m.headers ?? []), ...(m.version ? [m.version.header] : [])];
|
|
543
|
+
const headers = Object.fromEntries(named.flatMap((h) => { const v = call.request.headers.get(h); return v === null ? [] : [[h.toLowerCase(), v]]; }));
|
|
544
|
+
const lane = call.request.headers.get(LANE_HEADER) ?? undefined;
|
|
545
|
+
const requestId = call.request.headers.get('x-twins-request-id') ?? undefined;
|
|
546
|
+
const declared = new Set((call.operation.query ?? []).map((q) => q.name));
|
|
547
|
+
const query = {};
|
|
548
|
+
for (const name of new Set(url.searchParams.keys())) {
|
|
549
|
+
if (!declared.has(name))
|
|
550
|
+
continue;
|
|
551
|
+
const values = url.searchParams.getAll(name);
|
|
552
|
+
query[name] = values.length > 1 ? values : values[0];
|
|
553
|
+
}
|
|
554
|
+
const fields = new Set((call.operation.body ?? []).map((f) => f.name));
|
|
555
|
+
const asCame = body !== null && typeof body === 'object' && ('$text' in body || '$base64' in body);
|
|
556
|
+
const sentBody = fields.size && !asCame && body !== null && typeof body === 'object' && !Array.isArray(body) ? Object.fromEntries(Object.entries(body).filter(([k]) => fields.has(k))) : body;
|
|
557
|
+
return { path: { ...call.params }, query, body: sentBody, headers, ...(lane ? { lane } : {}), ...(requestId ? { requestId } : {}) };
|
|
558
|
+
}
|
|
559
|
+
/** What a write records of the call that made it: the surface operation and the request as it came, or which of the
|
|
560
|
+
* World's own doors made it (a door or a screen, the gap, the clock), which a perform never sends. */
|
|
561
|
+
/** A body as it came: `{ $text }` when its bytes are UTF-8, else `{ $base64 }`. */
|
|
562
|
+
async function rawBody(request) {
|
|
563
|
+
const bytes = new Uint8Array(await request.clone().arrayBuffer());
|
|
564
|
+
try {
|
|
565
|
+
return { $text: new TextDecoder('utf-8', { fatal: true }).decode(bytes) };
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
return { $base64: Buffer.from(bytes).toString('base64') };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/** A multipart request's file parts, kept in the World's blob store by their bytes' digest (`files/<sha256>`): what a
|
|
572
|
+
* write records of them (`input.files`), so a perform sends them again. */
|
|
573
|
+
async function keptFiles(m, call, root) {
|
|
574
|
+
const files = sentOf.get(call)?.parts?.filter((p) => p.filename !== null);
|
|
575
|
+
if (!files?.length)
|
|
576
|
+
return undefined;
|
|
577
|
+
const out = [];
|
|
578
|
+
for (const f of files) {
|
|
579
|
+
const blob = `files/${hashFieldValue({ bytes: Buffer.from(f.body).toString('base64') })}`;
|
|
580
|
+
await writeResourceBlob(m.service, blob, f.body, root);
|
|
581
|
+
out.push({ field: f.name, name: f.filename, type: f.type, blob });
|
|
582
|
+
}
|
|
583
|
+
return out;
|
|
584
|
+
}
|
|
585
|
+
function recordedInput(m, call, merged) {
|
|
586
|
+
const id = call.operation.id;
|
|
587
|
+
const sent = sentOf.get(call);
|
|
588
|
+
const asSent = sent ? { path: sent.path, query: sent.query, body: sent.body, headers: sent.headers, ...(sent.lane ? { lane: sent.lane } : {}), ...(sent.requestId ? { requestId: sent.requestId } : {}), ...(sent.type ? { contentType: sent.type } : {}) } : { path: call.params, body: merged };
|
|
589
|
+
if (id === 'graphql') {
|
|
590
|
+
const b = (sent?.body ?? {});
|
|
591
|
+
return { graphql: { query: b.query, variables: b.variables ?? {}, ...(b.operationName ? { operationName: b.operationName } : {}) }, ...(sent?.lane ? { lane: sent.lane } : {}), headers: sent?.headers ?? {} };
|
|
592
|
+
}
|
|
593
|
+
if (id === 'clock')
|
|
594
|
+
return { clock: true };
|
|
595
|
+
if (id === 'unmatched')
|
|
596
|
+
return { gap: true, ...asSent };
|
|
597
|
+
if ((m.doors ?? []).some((d) => d.id === id) || (m.screens ?? []).some((sc) => sc.id === id) || id === 'machine-pool' || id.startsWith('socket:'))
|
|
598
|
+
return { door: id, ...asSent };
|
|
599
|
+
return { operationId: id, ...asSent };
|
|
600
|
+
}
|
|
307
601
|
async function writeDetailed(m, call, resource, id, fields, operation, params, root, occurredAt) {
|
|
602
|
+
const withFiles = async (input) => { const files = input.operationId ? await keptFiles(m, call, root) : undefined; return files ? { ...input, files } : input; };
|
|
308
603
|
const { resource: row, result } = await applyTwinWrite(m.service,
|
|
309
604
|
// who made it: the login the pack's identity reads off the request, when it declares one, so a subject's
|
|
310
605
|
// history can say who did each thing
|
|
311
|
-
|
|
606
|
+
// a vendor's own move (ctx.asVendor, the clock) is the World's, as the clock's writes are: no API call a head performs
|
|
607
|
+
inVendorMove()
|
|
608
|
+
? { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: { clock: true }, occurredAt, actor: { kind: 'system' } }
|
|
609
|
+
: { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: await withFiles(recordedInput(m, call, params)), occurredAt, actor: { kind: 'agent', ...(m.identity ? { id: m.identity(call.request.headers.get('authorization'), root) } : {}) } }, root);
|
|
610
|
+
await keepCounts(m, call, resource, row, params, root, occurredAt);
|
|
312
611
|
const body = view(m, resource, row);
|
|
612
|
+
const context = () => contextFor(m, { ...call, request: call.request.clone() }, { ...(root !== undefined ? { root } : {}), clock: () => occurredAt });
|
|
313
613
|
if (m.onWrite)
|
|
314
|
-
await m.onWrite({ operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request, context
|
|
614
|
+
await m.onWrite({ operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request, context });
|
|
615
|
+
if (m.events) {
|
|
616
|
+
const events = m.events;
|
|
617
|
+
const write = { operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request };
|
|
618
|
+
await deliverEvents(m.service, events, write, async (type) => (events.render ? events.render(await context(), write, type) : body), undefined, events.values ? async (type) => events.values(await context(), write, type) : undefined, (answer) => deliveryOutcome(m, answer, root));
|
|
619
|
+
}
|
|
620
|
+
if (m.sockets?.length)
|
|
621
|
+
socketWrite(m.service, root, { operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request });
|
|
315
622
|
// the subject's id as it stands after the write: the vendor's, when a live head minted one
|
|
316
623
|
return { body, id: result.externalId ?? row.id, ...(result.vendorData !== undefined ? { vendorData: result.vendorData } : {}) };
|
|
317
624
|
}
|
|
625
|
+
/** The declared counts a written subject is a child in (a resource's `counts`), each kept on its parent: written when
|
|
626
|
+
* the count moved, through the write path, so the parent's history says when. */
|
|
627
|
+
async function keepCounts(m, call, resource, row, params, root, occurredAt) {
|
|
628
|
+
for (const [parent, decl] of Object.entries(m.resources)) {
|
|
629
|
+
for (const [field, c] of Object.entries(decl.counts ?? {})) {
|
|
630
|
+
if (storedType(m, c.of) !== storedType(m, resource) || row[c.by] === undefined || row[c.by] === null)
|
|
631
|
+
continue;
|
|
632
|
+
const pid = String(row[c.by]);
|
|
633
|
+
const held = stored(m, parent, root).find((r) => r.id === pid);
|
|
634
|
+
if (!held)
|
|
635
|
+
continue;
|
|
636
|
+
const n = stored(m, c.of, root).filter((r) => {
|
|
637
|
+
const x = r;
|
|
638
|
+
return String(x[c.by]) === pid && (c.present === undefined || (x[c.present] !== undefined && x[c.present] !== null));
|
|
639
|
+
}).length + (c.plus ?? 0);
|
|
640
|
+
if (held[field] !== n)
|
|
641
|
+
await writeDetailed(m, call, parent, pid, { [field]: n }, `${storedType(m, parent)}.counted`, params, root, occurredAt);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
318
645
|
/** An answer in the vendor's success envelope, under the operation's key, when the vendor has one. */
|
|
319
646
|
function envelope(m, op, body) {
|
|
320
647
|
return m.success && op.answers?.key ? { ...m.success, [op.answers.key]: body } : body;
|
|
@@ -339,6 +666,45 @@ export function observeTransitions(observer) {
|
|
|
339
666
|
/** What a declared machine says to one move: the transition that allows it, or the refusal it gives (and
|
|
340
667
|
* nothing when it declares neither). The derived core and `legal` ask it; so does an engine that is not
|
|
341
668
|
* HTTP-shaped (a line protocol's session), so one machine rules every wire. */
|
|
669
|
+
/** What a delivery's answer writes on its endpoint (the endpoint kind's `outcome`, architecture "Events and webhooks"):
|
|
670
|
+
* a success resets the failure count and writes its fields; a failure counts, and a run of them makes the declared
|
|
671
|
+
* move, asked of the endpoint's machine as the vendor's (a move the machine does not allow is not made). Read fresh
|
|
672
|
+
* and written in one move, since the answer arrives after the write's own and answers may arrive together. */
|
|
673
|
+
async function deliveryOutcome(m, answer, root) {
|
|
674
|
+
const o = answer.kind.outcome;
|
|
675
|
+
const type = answer.kind.storedAs;
|
|
676
|
+
const ok = answer.status >= 200 && answer.status < 300;
|
|
677
|
+
// the read and the count are one move under the action lock, so two answers at once count twice
|
|
678
|
+
await runAsVendorMove(() => applyTwinWriteAtomic(m.service, (resources) => {
|
|
679
|
+
const row = resources.find((r) => r.type === type && String(r.id) === String(answer.row.id));
|
|
680
|
+
if (!row || row.deleted === true)
|
|
681
|
+
return { kind: 'skip', value: undefined };
|
|
682
|
+
const fields = {};
|
|
683
|
+
let operation = `${type}.delivered`;
|
|
684
|
+
if (ok) {
|
|
685
|
+
fields[o.failures] = 0;
|
|
686
|
+
const seconds = Math.floor((Date.parse(answer.occurredAt) || 0) / 1000);
|
|
687
|
+
for (const [k, v] of Object.entries(o.success ?? {}))
|
|
688
|
+
fields[k] = v === '$time.iso' ? answer.occurredAt : v === '$time.s' ? seconds : v;
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
const failures = (Number(row[o.failures]) || 0) + 1;
|
|
692
|
+
fields[o.failures] = failures;
|
|
693
|
+
operation = `${type}.delivery-failed`;
|
|
694
|
+
const exempt = o.unless !== undefined && row[o.unless.field] === o.unless.equals;
|
|
695
|
+
if (o.move && o.after !== undefined && failures >= o.after && !exempt) {
|
|
696
|
+
const decl = Object.values(m.resources).find((r) => (r.storedAs ?? '') === type) ?? m.resources[type];
|
|
697
|
+
const machine = decl?.state?.[o.move.field];
|
|
698
|
+
const { move } = machine ? transitionFor(o.move.field, machine, o.move.operation, row[o.move.field], o.move.to, String(row.id), 'vendor') : {};
|
|
699
|
+
if (move) {
|
|
700
|
+
fields[o.move.field] = o.move.to;
|
|
701
|
+
operation = o.move.operation;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return { kind: 'write', value: undefined, write: { operation, subjectType: type, subjectId: String(row.id), fields, occurredAt: answer.occurredAt, actor: { kind: 'system' } } };
|
|
706
|
+
}, root));
|
|
707
|
+
}
|
|
342
708
|
export function transitionFor(field, decl, operationId, current, requested, id, actor = 'api') {
|
|
343
709
|
const now = stateOf(decl, current);
|
|
344
710
|
const candidates = decl.transitions.filter((t) => (t.actor ?? 'api') === actor && (t.operation ? t.operation === operationId : true) && (requested === undefined || (t.to ?? now) === String(requested)));
|
|
@@ -418,7 +784,153 @@ function adoptedInCall(service, fields, root) {
|
|
|
418
784
|
async function boundaryParams(m, call, root) {
|
|
419
785
|
return adoptedInCall(m.service, await readParams(m, call.request, call.operation), root);
|
|
420
786
|
}
|
|
787
|
+
/** The refusal a subject's fields would draw from its resource's `unique` rules: another live subject (not `except`)
|
|
788
|
+
* already holds every field of a rule, each field given. */
|
|
789
|
+
function conflictOf(m, resource, fields, except, root) {
|
|
790
|
+
for (const rule of m.resources[resource]?.unique ?? []) {
|
|
791
|
+
if (rule.fields.some((f) => fields[f] === undefined || fields[f] === null))
|
|
792
|
+
continue;
|
|
793
|
+
const held = stored(m, resource, root).some((r) => r.id !== except && rule.fields.every((f) => r[f] === fields[f]));
|
|
794
|
+
if (held)
|
|
795
|
+
return rule.refusal;
|
|
796
|
+
}
|
|
797
|
+
return undefined;
|
|
798
|
+
}
|
|
799
|
+
/** A subject deleted through the write path, after every subject its resource's `cascade` takes with it (depth first). */
|
|
800
|
+
async function removeSubject(m, call, resource, id, params, root, at, operation) {
|
|
801
|
+
for (const child of m.resources[resource]?.cascade ?? []) {
|
|
802
|
+
for (const row of stored(m, child.resource, root).filter((r) => r[child.field] === id)) {
|
|
803
|
+
await removeSubject(m, call, child.resource, String(row.id), params, root, at);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
await write(m, call, resource, id, { deleted: true }, operation ?? `${storedType(m, resource)}.delete`, params, root, at);
|
|
807
|
+
}
|
|
808
|
+
/** Whether a key names a stored subject: its id, or one of the resource's alternate keys. */
|
|
809
|
+
const namedBy = (decl, row, key) => row.id === key || (decl?.alternateKeys ?? []).some((f) => key !== undefined && key !== null && row[f] === key);
|
|
810
|
+
const isBag = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
811
|
+
/** One field of an update, as the resource's `update` rule merges it into what is stored. */
|
|
812
|
+
/** A bag with every `null` key removed, at every level (a deep merge's new branch: null removes a key "at any level"). */
|
|
813
|
+
const withoutNulls = (bag) => Object.fromEntries(Object.entries(bag).filter(([, v]) => v !== null).map(([k, v]) => [k, isBag(v) ? withoutNulls(v) : v]));
|
|
814
|
+
export function mergeField(rule, prior, next) {
|
|
815
|
+
if (rule === 'deep' && isBag(next) && !isBag(prior))
|
|
816
|
+
return withoutNulls(next);
|
|
817
|
+
if (rule === 'replace' || !isBag(next) || !isBag(prior))
|
|
818
|
+
return next;
|
|
819
|
+
if (rule === 'deep') {
|
|
820
|
+
const out = { ...prior };
|
|
821
|
+
for (const [k, v] of Object.entries(next)) {
|
|
822
|
+
if (v === null)
|
|
823
|
+
delete out[k];
|
|
824
|
+
else
|
|
825
|
+
out[k] = isBag(v) && isBag(out[k]) ? mergeField('deep', out[k], v) : v;
|
|
826
|
+
}
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
return Object.fromEntries(Object.entries({ ...prior, ...next }).filter(([, value]) => value !== ''));
|
|
830
|
+
}
|
|
831
|
+
/** A list's rows as the resource declares them arranged for this call: its field filters, its search, its order (the
|
|
832
|
+
* `orderBy` parameter's, else `order`, else newest first); a refusal when the call orders by a field it does not take. */
|
|
833
|
+
function arrangeRows(m, resource, params, input) {
|
|
834
|
+
const decl = m.resources[resource];
|
|
835
|
+
let rows = input;
|
|
836
|
+
for (const f of decl?.filters ?? [])
|
|
837
|
+
if (params[f] !== undefined)
|
|
838
|
+
rows = rows.filter((r) => String(r[f]) === String(params[f]));
|
|
839
|
+
const needle = decl?.search ? params[decl.search.param] : undefined;
|
|
840
|
+
if (decl?.search && typeof needle === 'string' && needle !== '') {
|
|
841
|
+
const q = needle.toLowerCase();
|
|
842
|
+
rows = rows.filter((r) => (decl.search.exact ?? []).some((f) => String(r[f] ?? '').toLowerCase() === q) || (decl.search.partial ?? []).some((f) => String(r[f] ?? '').toLowerCase().includes(q)));
|
|
843
|
+
}
|
|
844
|
+
let order = decl?.order ?? { field: 'created', direction: 'desc' };
|
|
845
|
+
// a declared ordering parameter orders text by its text; without one, the order is numeric, as it always was
|
|
846
|
+
let byText = false;
|
|
847
|
+
if (decl?.orderBy) {
|
|
848
|
+
const asked = params[decl.orderBy.param];
|
|
849
|
+
const spec = typeof asked === 'string' && asked !== '' ? asked : decl.orderBy.default;
|
|
850
|
+
const field = spec.replace(/^[+-]/, '');
|
|
851
|
+
if (!decl.orderBy.fields.includes(field))
|
|
852
|
+
return { ...decl.orderBy.invalid, message: decl.orderBy.invalid.message.replace('{fields}', decl.orderBy.fields.join(', ')) };
|
|
853
|
+
order = { field, direction: spec.startsWith('-') ? 'desc' : 'asc' };
|
|
854
|
+
byText = true;
|
|
855
|
+
}
|
|
856
|
+
const indexed = rows.map((row, i) => ({ row, i }));
|
|
857
|
+
indexed.sort((a, b) => {
|
|
858
|
+
const x = a.row[order.field];
|
|
859
|
+
const y = b.row[order.field];
|
|
860
|
+
const byValue = !byText || typeof x === 'number' || typeof y === 'number' ? Number(x ?? 0) - Number(y ?? 0) : String(x ?? '').localeCompare(String(y ?? ''));
|
|
861
|
+
// ties by mint order (`file-twin-10` after `file-twin-9`), not by the ids' lexical order
|
|
862
|
+
const d = byValue || String(a.row.id).localeCompare(String(b.row.id), undefined, { numeric: true });
|
|
863
|
+
return order.direction === 'desc' ? -d : d;
|
|
864
|
+
});
|
|
865
|
+
return indexed.map((x) => x.row);
|
|
866
|
+
}
|
|
867
|
+
/** A page of arranged rows, answered in the manifest's list envelope with its paging (cursors, pages, offsets). */
|
|
868
|
+
function pageRows(m, op, call, params, rows, resource, root, paths) {
|
|
869
|
+
const size = m.list.limits?.[op.id] ?? m.list.limit;
|
|
870
|
+
const asked = params[m.list.limit.param];
|
|
871
|
+
const n = asked === undefined || asked === '' ? size.default : Number(asked);
|
|
872
|
+
const limit = Number.isFinite(n) ? Math.min(size.max, Math.max(1, Math.trunc(n))) : size.default;
|
|
873
|
+
const after = m.list.after ? params[m.list.after] : undefined;
|
|
874
|
+
const before = m.list.before ? params[m.list.before] : undefined;
|
|
875
|
+
let from = 0;
|
|
876
|
+
let to;
|
|
877
|
+
if (typeof before === 'string') {
|
|
878
|
+
const at = rows.findIndex((r) => r.id === before);
|
|
879
|
+
to = at === -1 ? (m.list.unknownCursor === 'end' ? 0 : rows.length) : at;
|
|
880
|
+
from = Math.max(0, to - limit);
|
|
881
|
+
}
|
|
882
|
+
else {
|
|
883
|
+
if (typeof after === 'string') {
|
|
884
|
+
const at = rows.findIndex((r) => r.id === after);
|
|
885
|
+
from = at === -1 ? (m.list.unknownCursor === 'end' ? rows.length : 0) : at + 1;
|
|
886
|
+
}
|
|
887
|
+
to = from + limit;
|
|
888
|
+
}
|
|
889
|
+
const page = rows.slice(from, to).map((r) => (paths.length ? expand(m, resource, r, paths.filter((p) => p.startsWith('data.')).map((p) => p.slice(5)), root) : r));
|
|
890
|
+
const hasMore = typeof before === 'string' ? from > 0 : to < rows.length;
|
|
891
|
+
const url = op.path.replace(/\{[^}]+\}/g, (p) => call.params[p.slice(1, -1)] ?? p);
|
|
892
|
+
if (m.list.page) {
|
|
893
|
+
const n = Math.max(1, Math.floor(Number(params[m.list.page.param]) || 1));
|
|
894
|
+
const slice = rows.slice((n - 1) * limit, n * limit);
|
|
895
|
+
const last = Math.max(1, Math.ceil(rows.length / limit));
|
|
896
|
+
const headers = new Headers();
|
|
897
|
+
if (m.list.page.link && rows.length > limit) {
|
|
898
|
+
const at = (k) => {
|
|
899
|
+
const u = new URL(call.request.url);
|
|
900
|
+
u.searchParams.set(m.list.page.param, String(k));
|
|
901
|
+
return u.toString();
|
|
902
|
+
};
|
|
903
|
+
const rels = [...(n > 1 ? [`<${at(n - 1)}>; rel="prev"`] : []), ...(n < last ? [`<${at(n + 1)}>; rel="next"`, `<${at(last)}>; rel="last"`] : []), ...(n > 1 ? [`<${at(1)}>; rel="first"`] : [])];
|
|
904
|
+
if (rels.length)
|
|
905
|
+
headers.set('link', rels.join(', '));
|
|
906
|
+
}
|
|
907
|
+
// `{total_count}` the count before paging, `{max_page}` the number of pages (none for an empty list: Polar's pagination)
|
|
908
|
+
return Response.json(fill(m.list.envelope, { data: slice, next_cursor: null, key: op.answers?.key ?? 'data', total_count: rows.length, max_page: Math.ceil(rows.length / limit) }), { headers });
|
|
909
|
+
}
|
|
910
|
+
if (m.list.offset) {
|
|
911
|
+
const skip = Math.max(0, Math.trunc(Number(params[m.list.offset.param]) || 0));
|
|
912
|
+
const slice = rows.slice(skip, skip + limit);
|
|
913
|
+
const key = op.answers?.key ?? 'data';
|
|
914
|
+
return Response.json({ ...(m.success ?? {}), [key]: slice, ...fill(m.list.envelopes?.[op.id] ?? m.list.envelope, { data: slice, total_count: rows.length, key }) });
|
|
915
|
+
}
|
|
916
|
+
if (m.list.cursor) {
|
|
917
|
+
const start = decodeOffset(params[m.list.cursor.param]);
|
|
918
|
+
const slice = rows.slice(start, start + limit);
|
|
919
|
+
const next = start + limit < rows.length ? encodeOffset(start + limit) : '';
|
|
920
|
+
return Response.json({ ...(m.success ?? {}), ...fill(m.list.envelope, { data: slice, next_cursor: next, key: op.answers?.key ?? 'data' }), ...(op.answers?.key ? { [op.answers.key]: slice } : {}) });
|
|
921
|
+
}
|
|
922
|
+
return Response.json(fill(m.list.envelope, { data: page, has_more: hasMore, url, first_id: page[0]?.id ?? null, last_id: page.at(-1)?.id ?? null }));
|
|
923
|
+
}
|
|
421
924
|
/** Serve one operation from the manifest, or say why the core cannot. */
|
|
925
|
+
/** A setting's fields as the vendor answers them: its own, without the kernel's bookkeeping or the parent it is kept
|
|
926
|
+
* under (a setting answers its values, not an identity). */
|
|
927
|
+
function settingFields(own, parentField) {
|
|
928
|
+
return Object.fromEntries(Object.entries(own).filter(([k]) => !k.startsWith('_') && k !== 'deleted' && k !== parentField));
|
|
929
|
+
}
|
|
930
|
+
function settingView(m, resource, hit) {
|
|
931
|
+
// ownFields: the kernel's id, type and updatedAt gone, a vendor's own of those names restored
|
|
932
|
+
return settingFields(ownFields(hit), m.resources[resource]?.parent?.field);
|
|
933
|
+
}
|
|
422
934
|
export async function serveCore(m, call, scope = {}) {
|
|
423
935
|
const root = scope.root;
|
|
424
936
|
const at = (scope.clock ?? worldNow)();
|
|
@@ -436,7 +948,13 @@ export async function serveCore(m, call, scope = {}) {
|
|
|
436
948
|
return { served: orphan };
|
|
437
949
|
const parent = decl.parent;
|
|
438
950
|
const parentId = parent ? parentValue(parent, call.params) : undefined;
|
|
439
|
-
|
|
951
|
+
// the caller's tenant, when the vendor has tenancy and the resource is not shared by every tenant
|
|
952
|
+
const tenantField = m.tenant && !decl.shared ? m.tenant.field : undefined;
|
|
953
|
+
const tenant = tenantField && scope.tenant ? await scope.tenant(await contextFor(m, { ...call, request: call.request.clone() }, scope)) : undefined;
|
|
954
|
+
if (tenantField && tenant === undefined)
|
|
955
|
+
return { unmodeled: `the caller names no tenant (semantics/tenant.ts) for ${resource}` };
|
|
956
|
+
const underParent = (r) => (!parent || r[parent.field] === parentId)
|
|
957
|
+
&& (!tenantField || r[tenantField] === tenant);
|
|
440
958
|
const paths = expandPaths(m, params);
|
|
441
959
|
// the vendor's success status from its spec: 201 for most creates, 204 with no body where it answers nothing
|
|
442
960
|
const status = op.successStatus ?? 200;
|
|
@@ -445,6 +963,13 @@ export async function serveCore(m, call, scope = {}) {
|
|
|
445
963
|
});
|
|
446
964
|
const control = new Set([m.expandParam, m.list.limit.param, m.list.after, m.list.before, m.list.offset?.param].filter(Boolean));
|
|
447
965
|
const data = Object.fromEntries(Object.entries(params).filter(([k]) => !control.has(k) && k !== 'id'));
|
|
966
|
+
// a strict vendor refuses a body it does not declare before the core writes it (body.strict, as ctx.fields)
|
|
967
|
+
if (m.body.strict && (op.class === 'create' || op.class === 'update')) {
|
|
968
|
+
const queried = new Set((op.query ?? []).map((q) => q.name));
|
|
969
|
+
const checked = strictFields(m, op, Object.fromEntries(Object.entries(data).filter(([k]) => !queried.has(k) && !(k in call.params))));
|
|
970
|
+
if ('refused' in checked)
|
|
971
|
+
return { served: checked.refused };
|
|
972
|
+
}
|
|
448
973
|
switch (op.class) {
|
|
449
974
|
case 'create': {
|
|
450
975
|
for (const field of Object.keys(decl.state ?? {}))
|
|
@@ -453,88 +978,51 @@ export async function serveCore(m, call, scope = {}) {
|
|
|
453
978
|
if (decl.key)
|
|
454
979
|
return { unmodeled: 'a create under a composite key is a handler' };
|
|
455
980
|
const provided = m.ids.acceptProvided && typeof params.id === 'string' && params.id ? params.id : undefined;
|
|
456
|
-
const id = provided ?? mintId(m, resource, root);
|
|
981
|
+
const id = provided ?? mintId(m, resource, root, at);
|
|
457
982
|
const assigned = Object.fromEntries(Object.entries(decl.assigned ?? {}).map(([k, rule]) => [k, applyRule(m, rule, id, data, at)]));
|
|
458
983
|
const initial = Object.fromEntries(Object.entries(decl.state ?? {}).map(([k, s]) => [k, s.initial]));
|
|
459
|
-
const under = parent ? { [parent.field]: parentId } : {};
|
|
984
|
+
const under = { ...(parent ? { [parent.field]: parentId } : {}), ...(tenantField ? { [tenantField]: tenant } : {}) };
|
|
460
985
|
if (decl.number) {
|
|
461
|
-
const siblings = stored(m, resource, root, { withDeleted: true }).filter(
|
|
986
|
+
const siblings = stored(m, resource, root, { withDeleted: true }).filter(underParent);
|
|
462
987
|
under[decl.number.field] = 1 + siblings.reduce((n, r) => Math.max(n, Number(r[decl.number.field]) || 0), 0);
|
|
463
988
|
}
|
|
464
|
-
|
|
989
|
+
const made = { ...assigned, ...initial, ...under, ...data };
|
|
990
|
+
const clash = conflictOf(m, resource, made, undefined, root);
|
|
991
|
+
if (clash)
|
|
992
|
+
return { served: vendorError(m, clash) };
|
|
993
|
+
return answer(await write(m, call, resource, id, made, `${storedType(m, resource)}.create`, params, root, at));
|
|
465
994
|
}
|
|
466
995
|
case 'retrieve': {
|
|
467
|
-
const hit = idParam ? stored(m, resource, root).find((r) => r
|
|
996
|
+
const hit = idParam ? stored(m, resource, root).find((r) => namedBy(decl, r, idParam) && underParent(r)) : undefined;
|
|
997
|
+
// a setting never written answers its defaults; one written answers them under what was written
|
|
998
|
+
if (decl.setting)
|
|
999
|
+
return answer({ ...decl.setting.defaults, ...(hit ? settingView(m, resource, hit) : {}) });
|
|
468
1000
|
return hit ? answer(view(m, resource, hit)) : { served: notFound(m, resource, String(idParam), Object.keys(call.params).at(-1)) };
|
|
469
1001
|
}
|
|
470
1002
|
case 'list': {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
rows.sort((a, b) => {
|
|
477
|
-
// ties by mint order (`file-twin-10` after `file-twin-9`), not by the ids' lexical order
|
|
478
|
-
const d = Number(a[order.field] ?? 0) - Number(b[order.field] ?? 0) || String(a.id).localeCompare(String(b.id), undefined, { numeric: true });
|
|
479
|
-
return order.direction === 'desc' ? -d : d;
|
|
480
|
-
});
|
|
481
|
-
const size = m.list.limits?.[op.id] ?? m.list.limit;
|
|
482
|
-
const asked = params[m.list.limit.param];
|
|
483
|
-
const n = asked === undefined || asked === '' ? size.default : Number(asked);
|
|
484
|
-
const limit = Number.isFinite(n) ? Math.min(size.max, Math.max(1, Math.trunc(n))) : size.default;
|
|
485
|
-
const after = m.list.after ? params[m.list.after] : undefined;
|
|
486
|
-
const before = m.list.before ? params[m.list.before] : undefined;
|
|
487
|
-
let from = 0;
|
|
488
|
-
let to;
|
|
489
|
-
if (typeof before === 'string') {
|
|
490
|
-
const at = rows.findIndex((r) => r.id === before);
|
|
491
|
-
to = at === -1 ? (m.list.unknownCursor === 'end' ? 0 : rows.length) : at;
|
|
492
|
-
from = Math.max(0, to - limit);
|
|
493
|
-
}
|
|
494
|
-
else {
|
|
495
|
-
if (typeof after === 'string') {
|
|
496
|
-
const at = rows.findIndex((r) => r.id === after);
|
|
497
|
-
from = at === -1 ? (m.list.unknownCursor === 'end' ? rows.length : 0) : at + 1;
|
|
498
|
-
}
|
|
499
|
-
to = from + limit;
|
|
500
|
-
}
|
|
501
|
-
const page = rows.slice(from, to).map((r) => (paths.length ? expand(m, resource, r, paths.filter((p) => p.startsWith('data.')).map((p) => p.slice(5)), root) : r));
|
|
502
|
-
const hasMore = typeof before === 'string' ? from > 0 : to < rows.length;
|
|
503
|
-
const url = op.path.replace(/\{[^}]+\}/g, (p) => call.params[p.slice(1, -1)] ?? p);
|
|
504
|
-
if (m.list.page) {
|
|
505
|
-
const n = Math.max(1, Math.floor(Number(params[m.list.page.param]) || 1));
|
|
506
|
-
const slice = rows.slice((n - 1) * limit, n * limit);
|
|
507
|
-
const last = Math.max(1, Math.ceil(rows.length / limit));
|
|
508
|
-
const headers = new Headers();
|
|
509
|
-
if (m.list.page.link && rows.length > limit) {
|
|
510
|
-
const at = (k) => {
|
|
511
|
-
const u = new URL(call.request.url);
|
|
512
|
-
u.searchParams.set(m.list.page.param, String(k));
|
|
513
|
-
return u.toString();
|
|
514
|
-
};
|
|
515
|
-
const rels = [...(n > 1 ? [`<${at(n - 1)}>; rel="prev"`] : []), ...(n < last ? [`<${at(n + 1)}>; rel="next"`, `<${at(last)}>; rel="last"`] : []), ...(n > 1 ? [`<${at(1)}>; rel="first"`] : [])];
|
|
516
|
-
if (rels.length)
|
|
517
|
-
headers.set('link', rels.join(', '));
|
|
518
|
-
}
|
|
519
|
-
return { served: Response.json(fill(m.list.envelope, { data: slice, next_cursor: null, key: op.answers?.key ?? 'data' }), { headers }) };
|
|
520
|
-
}
|
|
521
|
-
if (m.list.offset) {
|
|
522
|
-
const skip = Math.max(0, Math.trunc(Number(params[m.list.offset.param]) || 0));
|
|
523
|
-
const slice = rows.slice(skip, skip + limit);
|
|
524
|
-
const key = op.answers?.key ?? 'data';
|
|
525
|
-
return { served: Response.json({ ...(m.success ?? {}), [key]: slice, ...fill(m.list.envelopes?.[op.id] ?? m.list.envelope, { data: slice, total_count: rows.length, key }) }) };
|
|
526
|
-
}
|
|
527
|
-
if (m.list.cursor) {
|
|
528
|
-
const start = decodeOffset(params[m.list.cursor.param]);
|
|
529
|
-
const slice = rows.slice(start, start + limit);
|
|
530
|
-
const next = start + limit < rows.length ? encodeOffset(start + limit) : '';
|
|
531
|
-
return { served: Response.json({ ...(m.success ?? {}), ...fill(m.list.envelope, { data: slice, next_cursor: next, key: op.answers?.key ?? 'data' }), ...(op.answers?.key ? { [op.answers.key]: slice } : {}) }) };
|
|
532
|
-
}
|
|
533
|
-
return { served: Response.json(fill(m.list.envelope, { data: page, has_more: hasMore, url, first_id: page[0]?.id ?? null, last_id: page.at(-1)?.id ?? null })) };
|
|
1003
|
+
const rows = stored(m, resource, root).filter(underParent).map((r) => view(m, resource, r));
|
|
1004
|
+
const arranged = arrangeRows(m, resource, params, rows);
|
|
1005
|
+
if (!Array.isArray(arranged))
|
|
1006
|
+
return { served: vendorError(m, arranged) };
|
|
1007
|
+
return { served: pageRows(m, op, call, params, arranged, resource, root, paths) };
|
|
534
1008
|
}
|
|
535
1009
|
case 'update':
|
|
536
1010
|
case 'action': {
|
|
537
|
-
const hit = idParam ? stored(m, resource, root).find((r) => r
|
|
1011
|
+
const hit = idParam ? stored(m, resource, root).find((r) => namedBy(decl, r, idParam) && underParent(r)) : undefined;
|
|
1012
|
+
// a setting's update merges what is given over what it holds (its defaults until first written), making it
|
|
1013
|
+
if (decl.setting && op.class === 'update' && idParam) {
|
|
1014
|
+
const current = { ...decl.setting.defaults, ...(hit ? settingView(m, resource, hit) : {}) };
|
|
1015
|
+
const merged = {};
|
|
1016
|
+
for (const [k, v] of Object.entries(data))
|
|
1017
|
+
merged[k] = mergeField(decl.update, current[k], v);
|
|
1018
|
+
const under = { ...(parent ? { [parent.field]: parentId } : {}), ...(tenantField ? { [tenantField]: tenant } : {}) };
|
|
1019
|
+
await write(m, call, resource, hit?.id ?? String(idParam), { ...under, ...merged }, `${storedType(m, resource)}.update`, params, root, at);
|
|
1020
|
+
// a setting whose update the spec documents with no content answers none (a specification that omits a body
|
|
1021
|
+
// elsewhere is not trusted this far: only a setting's update reads it)
|
|
1022
|
+
if (op.emptySuccess)
|
|
1023
|
+
return { served: new Response(null, { status }) };
|
|
1024
|
+
return answer({ ...current, ...merged });
|
|
1025
|
+
}
|
|
538
1026
|
if (!hit)
|
|
539
1027
|
return { served: notFound(m, resource, String(idParam), Object.keys(call.params).at(-1)) };
|
|
540
1028
|
const current = view(m, resource, hit);
|
|
@@ -562,17 +1050,18 @@ export async function serveCore(m, call, scope = {}) {
|
|
|
562
1050
|
if (op.class === 'action' && !moved)
|
|
563
1051
|
return { unmodeled: `action ${op.id} declares no transition` };
|
|
564
1052
|
const merged = {};
|
|
565
|
-
for (const [k, v] of Object.entries(changes))
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1053
|
+
for (const [k, v] of Object.entries(changes))
|
|
1054
|
+
merged[k] = mergeField(decl.update, current[k], v);
|
|
1055
|
+
const clash = conflictOf(m, resource, { ...current, ...merged }, hit.id, root);
|
|
1056
|
+
if (clash)
|
|
1057
|
+
return { served: vendorError(m, clash) };
|
|
569
1058
|
return answer(await write(m, call, resource, hit.id, merged, `${storedType(m, resource)}.${op.class === 'update' ? 'update' : op.id}`, params, root, at));
|
|
570
1059
|
}
|
|
571
1060
|
case 'delete': {
|
|
572
|
-
const hit = idParam ? stored(m, resource, root).find((r) => r
|
|
1061
|
+
const hit = idParam ? stored(m, resource, root).find((r) => namedBy(decl, r, idParam) && underParent(r)) : undefined;
|
|
573
1062
|
if (!hit)
|
|
574
1063
|
return { served: notFound(m, resource, String(idParam), Object.keys(call.params).at(-1)) };
|
|
575
|
-
await
|
|
1064
|
+
await removeSubject(m, call, resource, hit.id, params, root, at);
|
|
576
1065
|
const shape = decl.deleted !== undefined ? decl.deleted : m.deleted;
|
|
577
1066
|
return { served: status === 204 || shape === null ? new Response(null, { status: 204 }) : Response.json(fill(shape, { id: hit.id, object: current(hit) }), { status }) };
|
|
578
1067
|
}
|
|
@@ -600,19 +1089,32 @@ export function sse(m, events, operationId) {
|
|
|
600
1089
|
});
|
|
601
1090
|
return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache' } });
|
|
602
1091
|
}
|
|
603
|
-
|
|
1092
|
+
/** The context the kernel opens for a call (a handler's, a door's, a screen's). Kernel-internal: packs are given it. */
|
|
1093
|
+
export async function contextFor(m, call, scope) {
|
|
604
1094
|
const root = scope.root;
|
|
605
1095
|
const asAsked = call;
|
|
606
1096
|
call = adoptedPath(m, call, root);
|
|
607
1097
|
const at = (scope.clock ?? worldNow)();
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
1098
|
+
// the guard's parse when it made one (taken once), else this context's own
|
|
1099
|
+
const parsed = takeParsedBody(call.request);
|
|
1100
|
+
const text = parsed ? '' : await bodyTextOf(call.request.clone()).catch(() => '');
|
|
1101
|
+
let body = parsed?.value;
|
|
1102
|
+
if (!parsed) {
|
|
1103
|
+
try {
|
|
1104
|
+
body = text ? JSON.parse(text) : undefined;
|
|
1105
|
+
}
|
|
1106
|
+
catch {
|
|
1107
|
+
body = text;
|
|
1108
|
+
}
|
|
615
1109
|
}
|
|
1110
|
+
// the request as it came, before the core's parameter merge and alias adoption: what a write records, so a perform
|
|
1111
|
+
// can send it again (the real-system adapters, "What the derived core records with a write")
|
|
1112
|
+
const boundary = multipartBoundary(call.request.headers.get('content-type'));
|
|
1113
|
+
const parts = boundary === null ? undefined : multipartParts(new Uint8Array(await call.request.clone().arrayBuffer()), boundary);
|
|
1114
|
+
// a body that is not JSON is recorded as it came, its type with it: text as text, bytes (a gzip the vendor's client
|
|
1115
|
+
// compressed itself, PostHog's `compression=gzip-js`) as base64
|
|
1116
|
+
const raw = !parts && typeof body === 'string' && body !== '' ? await rawBody(call.request) : undefined;
|
|
1117
|
+
sentOf.set(call, { ...sentRequest(m, asAsked, parts ? Object.fromEntries(parts.filter((p) => p.filename === null).map((p) => [p.name, new TextDecoder().decode(p.body)])) : raw ?? body), ...(parts ? { parts } : {}), ...(raw ? { type: call.request.headers.get('content-type') ?? undefined } : {}) });
|
|
616
1118
|
// a handler reading the body itself sees the adopted id too (the boundary's one resolution, below)
|
|
617
1119
|
if (body && typeof body === 'object' && !Array.isArray(body))
|
|
618
1120
|
body = adoptedInCall(m.service, body, root);
|
|
@@ -629,17 +1131,71 @@ async function contextFor(m, call, scope) {
|
|
|
629
1131
|
text,
|
|
630
1132
|
root,
|
|
631
1133
|
occurredAt: at,
|
|
1134
|
+
publicBase: twinPublicBase(call.request),
|
|
1135
|
+
scenario: scenarioDecisionOf(call.request),
|
|
1136
|
+
deliver: (url, init) => deliverToApplication(url, init),
|
|
1137
|
+
ask: (url, init, within) => askApplication(url, init, within),
|
|
1138
|
+
worldEnv: (name) => worldEnvValue(name),
|
|
1139
|
+
asVendor: (fn) => runAsVendorMove(fn),
|
|
1140
|
+
git: (name) => {
|
|
1141
|
+
if (!/^[A-Za-z0-9_.-]+(\/[A-Za-z0-9_.-]+)*$/.test(name) || name.split('/').some((seg) => seg === '.' || seg === '..'))
|
|
1142
|
+
throw new Error(`semantics: ${name} is not a git repository name`);
|
|
1143
|
+
const plane = `${worldPaths(m.service, root).dir}/git`;
|
|
1144
|
+
return { store: new GitObjectStore(getActiveBlobStore(), `${plane}/objects`), refs: new GitRefs(getActiveWorldStore(), `${plane}/repos/${name}`) };
|
|
1145
|
+
},
|
|
1146
|
+
mail: (route, mail, headers) => sendMail(route, mail, headers),
|
|
632
1147
|
actor: m.identity ? m.identity(call.request.headers.get('authorization'), root) : undefined,
|
|
633
1148
|
now: () => now(m, at),
|
|
634
1149
|
get: (resource, id) => {
|
|
635
1150
|
const hit = stored(m, resource, root).find((r) => r.id === id);
|
|
636
1151
|
return hit ? view(m, resource, hit) : undefined;
|
|
637
1152
|
},
|
|
1153
|
+
ownerRow: (owner, resource, id) => {
|
|
1154
|
+
if (!m.ownerReads?.some((r) => r.owner === owner && r.resource === resource))
|
|
1155
|
+
throw new Error(`semantics: undeclared owner read ${owner}/${resource}`);
|
|
1156
|
+
const row = projectOwnerResources(owner, root, { type: resource, id }).find((r) => r.type === resource && r.id === id);
|
|
1157
|
+
return row ? { ...ownFields(row), ...(row.deleted === true ? { deleted: true } : {}) } : undefined;
|
|
1158
|
+
},
|
|
638
1159
|
rows: (resource) => stored(m, resource, root).map((r) => view(m, resource, r)),
|
|
639
|
-
mint: (resource) => mintId(m, resource, root),
|
|
1160
|
+
mint: (resource) => mintId(m, resource, root, at),
|
|
1161
|
+
sockets: (id) => openSessions(m.service, root, id),
|
|
1162
|
+
issue: async (resource) => {
|
|
1163
|
+
const id = mintId(m, resource, root, at);
|
|
1164
|
+
if (!resource.startsWith('_'))
|
|
1165
|
+
throw new Error(`semantics: ${resource} is not a bookkeeping type (an issued id's type starts with _)`);
|
|
1166
|
+
if (scope.readOnly !== true && !isReadOnlyRequest(call.request)) {
|
|
1167
|
+
await applyTwinWrite(m.service, { operation: `${resource.slice(1)}.issue`, subjectType: storedType(m, resource), subjectId: id, fields: { issuedAt: at }, occurredAt: at, actor: { kind: 'system' } }, root);
|
|
1168
|
+
}
|
|
1169
|
+
return id;
|
|
1170
|
+
},
|
|
1171
|
+
secret: async (label) => hmac(await worldSeed(m.service, root, at), label, 'base64url'),
|
|
1172
|
+
get machines() {
|
|
1173
|
+
const enrolled = resourcesOfType(m.service, MACHINE_POOL, root).find((r) => r.id === 'pool');
|
|
1174
|
+
return poolFor(m.service, root, enrolled?.kind ?? 'none');
|
|
1175
|
+
},
|
|
1176
|
+
signingKey: (label, alg) => worldSigningKey(m.service, root, at, label, alg ?? 'RS256'),
|
|
1177
|
+
heldSigningKey: (label, alg) => heldWorldSigningKey(m.service, root, label, alg ?? 'RS256'),
|
|
640
1178
|
write: (resource, id, fields, operation) => write(m, call, resource, id, fields, operation, params, root, at),
|
|
1179
|
+
accumulate: async (resource, id, deltas, operation) => {
|
|
1180
|
+
if (!resource.startsWith('_') || !m.resources[resource])
|
|
1181
|
+
throw new Error('accumulate requires a declared bookkeeping resource');
|
|
1182
|
+
await applyTwinWriteAtomic(m.service, (resources) => {
|
|
1183
|
+
const prior = resources.find((row) => row.type === storedType(m, resource) && row.id === id && row.deleted !== true);
|
|
1184
|
+
if (!prior)
|
|
1185
|
+
throw new Error('accumulate requires an existing bookkeeping row');
|
|
1186
|
+
const fields = {};
|
|
1187
|
+
for (const [field, delta] of Object.entries(deltas)) {
|
|
1188
|
+
const value = prior[field];
|
|
1189
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isFinite(delta) || !Number.isFinite(value + delta))
|
|
1190
|
+
throw new Error('accumulate requires finite numeric fields and deltas');
|
|
1191
|
+
fields[field] = value + delta;
|
|
1192
|
+
}
|
|
1193
|
+
return { kind: 'write', value: undefined, write: { operation, subjectType: storedType(m, resource), subjectId: id, fields, occurredAt: at, actor: { kind: 'agent' } } };
|
|
1194
|
+
}, root);
|
|
1195
|
+
},
|
|
641
1196
|
writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at),
|
|
642
1197
|
resolve: (resource, id) => resolveSubjectId(m.service, storedType(m, resource), id, root),
|
|
1198
|
+
storedType: (resource) => storedType(m, resource),
|
|
643
1199
|
ok: (fields) => Response.json({ ...(m.success ?? {}), ...fields }),
|
|
644
1200
|
row: (resource, id, opts) => stored(m, resource, root, opts).find((r) => r.id === id),
|
|
645
1201
|
rowsRaw: (resource, opts) => stored(m, resource, root, opts),
|
|
@@ -648,7 +1204,18 @@ async function contextFor(m, call, scope) {
|
|
|
648
1204
|
record: async (type, fields, id) => {
|
|
649
1205
|
if (!type.startsWith('_'))
|
|
650
1206
|
throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
|
|
651
|
-
|
|
1207
|
+
// an id the caller does not give is the next past every one of this type the tree holds (as `{n}` ids are minted),
|
|
1208
|
+
// never a count of the rows, which a removed row would make repeat
|
|
1209
|
+
const subject = id ?? (() => {
|
|
1210
|
+
const prefix = `${type.slice(1)}_`;
|
|
1211
|
+
let max = 0;
|
|
1212
|
+
for (const r of resourcesOfType(m.service, type, root)) {
|
|
1213
|
+
const n = r.id.startsWith(prefix) ? Number(r.id.slice(prefix.length)) : NaN;
|
|
1214
|
+
if (Number.isInteger(n) && n > max)
|
|
1215
|
+
max = n;
|
|
1216
|
+
}
|
|
1217
|
+
return `${prefix}${max + 1}`;
|
|
1218
|
+
})();
|
|
652
1219
|
await applyTwinWrite(m.service, { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } }, root);
|
|
653
1220
|
return subject;
|
|
654
1221
|
},
|
|
@@ -677,11 +1244,45 @@ async function contextFor(m, call, scope) {
|
|
|
677
1244
|
},
|
|
678
1245
|
refuse: (e) => vendorError(m, e),
|
|
679
1246
|
notFound: (resource, id, param) => notFound(m, resource, id, param),
|
|
1247
|
+
validate: () => validateBody(m, call.operation, body),
|
|
680
1248
|
reply: (body, status = 200) => Response.json(body, { status }),
|
|
681
1249
|
wrap: (body, extra = {}) => Response.json({ ...envelope(m, call.operation, body), ...extra }),
|
|
682
1250
|
sse: (events) => sse(m, events, call.operation.id),
|
|
683
|
-
expand: (resource, body) => (paths.length ? expand(m, resource, body, paths, root) : body),
|
|
1251
|
+
expand: (resource, body, given) => ((given ?? paths).length ? expand(m, resource, body, given ?? paths, root) : body),
|
|
684
1252
|
at: (when) => contextFor(m, { ...call, request: new Request(call.request.url) }, { ...scope, clock: () => when }),
|
|
1253
|
+
crypto: handlerCrypto,
|
|
1254
|
+
flag: (value) => flagOf(m, value),
|
|
1255
|
+
fields: (body) => strictFields(m, call.operation, body),
|
|
1256
|
+
vendorFetch: (url, init) => vendorFetch(url, init),
|
|
1257
|
+
parts: async () => {
|
|
1258
|
+
const boundary = multipartBoundary(call.request.headers.get('content-type'));
|
|
1259
|
+
return boundary === null ? [] : multipartParts(new Uint8Array(await call.request.clone().arrayBuffer()), boundary);
|
|
1260
|
+
},
|
|
1261
|
+
engine: scope.database === undefined ? unboundDatabase(m.service) : managedDatabase(scope.database, { readOnly: scope.readOnly === true || isReadOnlyRequest(call.request), clock: at }),
|
|
1262
|
+
redis: (commands, options) => execRedisRun(commands, {
|
|
1263
|
+
root, occurredAt: at, service: m.service, readOnly: scope.readOnly === true || isReadOnlyRequest(call.request), dialect: options.dialect,
|
|
1264
|
+
...(options.database === undefined ? {} : { database: options.database }),
|
|
1265
|
+
}),
|
|
1266
|
+
blobs: {
|
|
1267
|
+
put: (key, bytes) => writeResourceBlob(m.service, key, bytes, root),
|
|
1268
|
+
get: (key) => readResourceBlob(m.service, key, root),
|
|
1269
|
+
remove: (key) => removeResourceBlob(m.service, key, root),
|
|
1270
|
+
},
|
|
1271
|
+
merge: (resource, prior, next) => mergeField(m.resources[resource]?.update, prior, next),
|
|
1272
|
+
conflict: (resource, fields, except) => {
|
|
1273
|
+
const clash = conflictOf(m, resource, fields, except, root);
|
|
1274
|
+
return clash ? vendorError(m, clash) : undefined;
|
|
1275
|
+
},
|
|
1276
|
+
remove: (resource, id, operation) => removeSubject(m, call, resource, id, params, root, at, operation),
|
|
1277
|
+
find: (resource, key) => {
|
|
1278
|
+
const id = resolveSubjectId(m.service, storedType(m, resource), key, root);
|
|
1279
|
+
const hit = stored(m, resource, root).find((r) => r.id === id || namedBy(m.resources[resource], r, key));
|
|
1280
|
+
return hit ? view(m, resource, hit) : undefined;
|
|
1281
|
+
},
|
|
1282
|
+
list: (resource, rows) => {
|
|
1283
|
+
const arranged = arrangeRows(m, resource, params, rows);
|
|
1284
|
+
return Array.isArray(arranged) ? pageRows(m, call.operation, call, params, arranged, resource, root, paths) : vendorError(m, arranged);
|
|
1285
|
+
},
|
|
685
1286
|
over: (other) => contextFor(other, { ...asAsked, request: asAsked.request.clone() }, { ...scope, clock: () => at }),
|
|
686
1287
|
core: async () => { const out = await serveCore(m, { ...call, request: pristine.clone() }, { ...scope, clock: () => at }); return 'served' in out ? out.served : undefined; },
|
|
687
1288
|
own: (row) => ownFields(row),
|
|
@@ -708,6 +1309,59 @@ export function coreFor(m, scope = {}) {
|
|
|
708
1309
|
const hex = (s) => Array.from(new TextEncoder().encode(s), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
709
1310
|
/** Read-only refusal, API-version validation and idempotent replay, applied once around every
|
|
710
1311
|
* operation a handler or the core serves. */
|
|
1312
|
+
/** The vendor's refusal of a request's credential under the manifest's `auth`, or undefined when it passes: what the
|
|
1313
|
+
* cross-cutting gate answers for an operation, and what a pack whose vendor checks the credential before routing (an
|
|
1314
|
+
* unknown path answers 401, as Supabase's recording shows) calls in front of its dispatch. */
|
|
1315
|
+
/** The header the kernel names a scenario's decision in, set by createPackFetch alone (it strips it from every request it
|
|
1316
|
+
* is sent). */
|
|
1317
|
+
export const SCENARIO_DECISION_HEADER = 'x-volter-scenario-decision';
|
|
1318
|
+
function scenarioDecisionOf(request) {
|
|
1319
|
+
const raw = request.headers.get(SCENARIO_DECISION_HEADER);
|
|
1320
|
+
if (!raw)
|
|
1321
|
+
return undefined;
|
|
1322
|
+
try {
|
|
1323
|
+
return JSON.parse(raw);
|
|
1324
|
+
}
|
|
1325
|
+
catch {
|
|
1326
|
+
return undefined;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
export function authRefusal(m, request, root) {
|
|
1330
|
+
if (!m.auth)
|
|
1331
|
+
return undefined;
|
|
1332
|
+
const url = new URL(request.url);
|
|
1333
|
+
if (m.auth.paths !== undefined && !new RegExp(m.auth.paths).test(url.pathname))
|
|
1334
|
+
return undefined;
|
|
1335
|
+
const sources = [{ header: m.auth.header, scheme: m.auth.scheme }, ...(m.auth.also ?? [])];
|
|
1336
|
+
const given = sources.map((s) => (s.query !== undefined ? url.searchParams.get(s.query) : s.header ? request.headers.get(s.header) : null)).some((v) => v !== null);
|
|
1337
|
+
if (!given && !m.auth.gateWhenAbsent)
|
|
1338
|
+
return undefined;
|
|
1339
|
+
const keyOf = (s) => {
|
|
1340
|
+
if (s.query !== undefined)
|
|
1341
|
+
return (url.searchParams.get(s.query) ?? '').trim();
|
|
1342
|
+
const raw = s.header ? request.headers.get(s.header) : null;
|
|
1343
|
+
if (raw === null)
|
|
1344
|
+
return '';
|
|
1345
|
+
if (!s.scheme)
|
|
1346
|
+
return raw.trim();
|
|
1347
|
+
const scheme = `${s.scheme.toLowerCase()} `;
|
|
1348
|
+
return raw.toLowerCase().startsWith(scheme) ? raw.slice(scheme.length).trim() : '';
|
|
1349
|
+
};
|
|
1350
|
+
const key = sources.map(keyOf).find((k) => k !== '') ?? '';
|
|
1351
|
+
// no credential sent is `missing`; one sent in a form the vendor does not read is a key it does not know
|
|
1352
|
+
if (!key)
|
|
1353
|
+
return vendorError(m, given ? m.auth.invalid : m.auth.missing);
|
|
1354
|
+
if (m.auth.invalidKeys.includes(key) || (m.auth.keyFormat && !new RegExp(m.auth.keyFormat).test(key)))
|
|
1355
|
+
return vendorError(m, m.auth.invalid);
|
|
1356
|
+
const held = m.auth.held;
|
|
1357
|
+
if (held && !held.standing?.includes(key)) {
|
|
1358
|
+
const hash = sha256(key);
|
|
1359
|
+
const holds = resourcesOfType(m.service, held.storedAs, root).some((r) => r.deleted !== true && r[held.hashField] === hash && (held.unless === undefined || r[held.unless] !== true));
|
|
1360
|
+
if (!holds)
|
|
1361
|
+
return vendorError(m, m.auth.invalid);
|
|
1362
|
+
}
|
|
1363
|
+
return undefined;
|
|
1364
|
+
}
|
|
711
1365
|
export function crossCutting(m, opts = {}) {
|
|
712
1366
|
const finish = async (r, request) => {
|
|
713
1367
|
if (!m.origin || !(r.headers.get('content-type') ?? '').includes('json'))
|
|
@@ -727,17 +1381,9 @@ export function crossCutting(m, opts = {}) {
|
|
|
727
1381
|
return async (call, next) => finish(await guarded(call, next), call.request);
|
|
728
1382
|
async function guarded(call, next) {
|
|
729
1383
|
const { request } = call;
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
const scheme = `${m.auth.scheme.toLowerCase()} `;
|
|
734
|
-
const key = raw && raw.toLowerCase().startsWith(scheme) ? raw.slice(scheme.length).trim() : '';
|
|
735
|
-
if (!key)
|
|
736
|
-
return vendorError(m, m.auth.missing);
|
|
737
|
-
if (m.auth.invalidKeys.includes(key) || (m.auth.keyFormat && !new RegExp(m.auth.keyFormat).test(key)))
|
|
738
|
-
return vendorError(m, m.auth.invalid);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
1384
|
+
const refused = m.auth?.exempt?.includes(call.operation.id) ? undefined : authRefusal(m, request, opts.root);
|
|
1385
|
+
if (refused)
|
|
1386
|
+
return refused;
|
|
741
1387
|
// a read-only twin refuses writes: what the operation does, not the HTTP verb it came by (an RPC
|
|
742
1388
|
// wire POSTs its reads). A read-only REQUEST (x-volter-read-only, request-scope.ts) is refused the
|
|
743
1389
|
// same way, up front, on a writable twin.
|
|
@@ -745,16 +1391,14 @@ export function crossCutting(m, opts = {}) {
|
|
|
745
1391
|
if (readOnly && !['retrieve', 'list', 'computed'].includes(call.operation.class) && !m.reads?.includes(call.operation.id))
|
|
746
1392
|
return vendorError(m, m.readOnly);
|
|
747
1393
|
// a body labelled JSON that does not parse is the vendor's refusal, never a crash of the twin
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
}
|
|
757
|
-
}
|
|
1394
|
+
// (`json: 'always'` reads the vendor's JSON bodies whatever their label, never an operation its spec declares
|
|
1395
|
+
// multipart or form: Cloudflare's asset and script uploads)
|
|
1396
|
+
const declared = call.operation.bodyEncoding;
|
|
1397
|
+
const labelled = request.headers.get('content-type') ?? '';
|
|
1398
|
+
if ((labelled.includes('json') && !labelled.includes('ndjson')) || (m.body.json === 'always' && declared !== 'multipart' && declared !== 'form')) {
|
|
1399
|
+
const ok = request.method === 'GET' || request.method === 'HEAD' ? true : await checkJsonBody(request.clone()).catch(() => true);
|
|
1400
|
+
if (!ok)
|
|
1401
|
+
return vendorError(m, m.malformedBody ?? { status: 400, message: 'The request body could not be parsed as JSON.' });
|
|
758
1402
|
}
|
|
759
1403
|
if (m.version) {
|
|
760
1404
|
const value = request.headers.get(m.version.header);
|
|
@@ -765,7 +1409,7 @@ export function crossCutting(m, opts = {}) {
|
|
|
765
1409
|
if (!m.idempotency || !key || !(m.idempotency.methods ?? ['POST']).includes(request.method.toUpperCase()))
|
|
766
1410
|
return next();
|
|
767
1411
|
const url = new URL(request.url);
|
|
768
|
-
const signature = hashFieldValue({ path: url.pathname + url.search, body: await request.clone()
|
|
1412
|
+
const signature = hashFieldValue({ path: url.pathname + url.search, body: await bodyTextOf(request.clone()) });
|
|
769
1413
|
const id = `idem_${hex(key)}`;
|
|
770
1414
|
const prior = twinResources(m.service, opts.root).find((r) => r.type === m.idempotency.storedAs && r.id === id);
|
|
771
1415
|
if (prior) {
|