@volter/world-core 2.0.37 → 3.0.1

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.
Files changed (205) hide show
  1. package/README.md +4 -5
  2. package/app-route.cjs +12 -6
  3. package/app-route.d.cts +1 -1
  4. package/dist/app-route.cjs +12 -6
  5. package/dist/app-route.d.cts +1 -1
  6. package/dist/generated/pack-facts.json +1410 -3069
  7. package/dist/inject.cjs +64 -9
  8. package/dist/pack-facts.cjs +44 -0
  9. package/dist/src/actions.d.ts +3 -3
  10. package/dist/src/actions.js +22 -16
  11. package/dist/src/ancestry.d.ts +14 -2
  12. package/dist/src/ancestry.js +92 -2
  13. package/dist/src/anthropic-wire.d.ts +39 -0
  14. package/dist/src/anthropic-wire.js +136 -0
  15. package/dist/src/bytes.d.ts +7 -0
  16. package/dist/src/bytes.js +35 -0
  17. package/dist/src/changeset.d.ts +1 -1
  18. package/dist/src/changeset.js +0 -0
  19. package/dist/src/clickhouse/index.d.ts +3 -0
  20. package/dist/src/clickhouse/index.js +6 -0
  21. package/dist/src/clickhouse/sql.d.ts +233 -0
  22. package/dist/src/clickhouse/sql.js +4329 -0
  23. package/dist/src/clickhouse/types.d.ts +18 -0
  24. package/dist/src/clickhouse/types.js +47 -0
  25. package/dist/src/clickhouse/values.d.ts +146 -0
  26. package/dist/src/clickhouse/values.js +858 -0
  27. package/dist/src/client-bundle.js +2 -3
  28. package/dist/src/cors.d.ts +15 -0
  29. package/dist/src/cors.js +31 -0
  30. package/dist/src/derived-core.d.ts +487 -24
  31. package/dist/src/derived-core.js +788 -144
  32. package/dist/src/derived-real.d.ts +13 -0
  33. package/dist/src/derived-real.js +518 -0
  34. package/dist/src/derived.d.ts +35 -1
  35. package/dist/src/derived.js +61 -9
  36. package/dist/src/emit.js +1 -2
  37. package/dist/src/events.d.ts +206 -0
  38. package/dist/src/events.js +341 -0
  39. package/dist/src/executor.d.ts +3 -0
  40. package/dist/src/executor.js +19 -2
  41. package/dist/src/file-response.d.ts +6 -0
  42. package/dist/src/file-response.js +30 -0
  43. package/dist/src/fork.js +3 -2
  44. package/dist/src/git/history.d.ts +7 -0
  45. package/dist/src/git/history.js +24 -0
  46. package/dist/src/git/index.d.ts +1 -0
  47. package/dist/src/git/index.js +1 -0
  48. package/dist/src/git/lfs.d.ts +28 -0
  49. package/dist/src/git/lfs.js +66 -0
  50. package/dist/src/git/objects.js +3 -8
  51. package/dist/src/git/smart-http.d.ts +3 -1
  52. package/dist/src/git/smart-http.js +67 -6
  53. package/dist/src/graphql-wire.d.ts +29 -0
  54. package/dist/src/graphql-wire.js +101 -0
  55. package/dist/src/grpc-wire.d.ts +67 -0
  56. package/dist/src/grpc-wire.js +170 -0
  57. package/dist/src/h2.d.ts +40 -0
  58. package/dist/src/h2.js +656 -0
  59. package/dist/src/head.d.ts +32 -3
  60. package/dist/src/head.js +161 -40
  61. package/dist/src/history.d.ts +1 -1
  62. package/dist/src/history.js +6 -6
  63. package/dist/src/hpack.json +1 -0
  64. package/dist/src/index.d.ts +64 -75
  65. package/dist/src/index.js +58 -101
  66. package/dist/src/log.js +28 -19
  67. package/dist/src/machines.d.ts +50 -0
  68. package/dist/src/machines.js +151 -0
  69. package/dist/src/managed-database.d.ts +86 -0
  70. package/dist/src/managed-database.js +283 -0
  71. package/dist/src/multipart.d.ts +11 -0
  72. package/dist/src/multipart.js +51 -0
  73. package/dist/src/observe.d.ts +15 -5
  74. package/dist/src/observe.js +23 -9
  75. package/dist/src/openai-wire.d.ts +108 -0
  76. package/dist/src/openai-wire.js +337 -0
  77. package/dist/src/pack-assets.d.ts +3 -4
  78. package/dist/src/pack-assets.js +15 -10
  79. package/dist/src/pack-fetch.d.ts +77 -0
  80. package/dist/src/pack-fetch.js +449 -0
  81. package/dist/src/pack-paths.d.ts +12 -0
  82. package/dist/src/pack-paths.js +86 -0
  83. package/dist/src/packRegistry.d.ts +69 -162
  84. package/dist/src/packRegistry.js +55 -20
  85. package/dist/src/people.d.ts +13 -0
  86. package/dist/src/people.js +18 -0
  87. package/dist/src/placeholder-image.d.ts +5 -0
  88. package/dist/src/placeholder-image.js +114 -0
  89. package/dist/src/protobuf.d.ts +28 -0
  90. package/dist/src/protobuf.js +332 -0
  91. package/dist/src/redis/engine.js +1 -1
  92. package/dist/src/request-scope.d.ts +1 -1
  93. package/dist/src/request-scope.js +6 -4
  94. package/dist/src/resource-blob.d.ts +5 -0
  95. package/dist/src/resource-blob.js +11 -0
  96. package/dist/src/runtime.d.ts +85 -0
  97. package/dist/src/runtime.js +104 -0
  98. package/dist/src/s3/wire.d.ts +60 -0
  99. package/dist/src/s3/wire.js +157 -0
  100. package/dist/src/scenario.d.ts +3 -0
  101. package/dist/src/scenario.js +2 -0
  102. package/dist/src/schema-sample.d.ts +1 -0
  103. package/dist/src/schema-sample.js +21 -0
  104. package/dist/src/sealed-box.d.ts +14 -0
  105. package/dist/src/sealed-box.js +225 -0
  106. package/dist/src/serve-http.d.ts +14 -0
  107. package/dist/src/serve-http.js +27 -3
  108. package/dist/src/serve.d.ts +6 -0
  109. package/dist/src/serve.js +69 -14
  110. package/dist/src/signing.d.ts +135 -0
  111. package/dist/src/signing.js +222 -0
  112. package/dist/src/sigv4.d.ts +48 -0
  113. package/dist/src/sigv4.js +167 -0
  114. package/dist/src/smtp.d.ts +16 -0
  115. package/dist/src/smtp.js +72 -0
  116. package/dist/src/sockets.d.ts +51 -0
  117. package/dist/src/sockets.js +90 -0
  118. package/dist/src/state-system.d.ts +1 -0
  119. package/dist/src/state-system.js +1 -1
  120. package/dist/src/storage.d.ts +1 -1
  121. package/dist/src/storage.js +3 -3
  122. package/dist/src/trace-context.js +1 -1
  123. package/dist/src/twin-fetch.d.ts +0 -7
  124. package/dist/src/twin-fetch.js +0 -14
  125. package/dist/src/vendor-call.d.ts +6 -0
  126. package/dist/src/vendor-call.js +41 -0
  127. package/dist/src/world-store.js +1 -1
  128. package/dist/vendor-hosts.cjs +36 -125
  129. package/dist/vendor-hosts.d.cts +8 -0
  130. package/generated/pack-facts.json +1410 -3069
  131. package/inject.cjs +64 -9
  132. package/pack-facts.cjs +44 -0
  133. package/package.json +17 -3
  134. package/src/actions.ts +23 -16
  135. package/src/ancestry.ts +74 -2
  136. package/src/anthropic-wire.ts +137 -0
  137. package/src/bytes.ts +42 -0
  138. package/src/changeset.ts +5 -5
  139. package/src/clickhouse/index.ts +6 -0
  140. package/src/clickhouse/sql.ts +3059 -0
  141. package/src/clickhouse/types.ts +44 -0
  142. package/src/clickhouse/values.ts +697 -0
  143. package/src/client-bundle.ts +2 -3
  144. package/src/cors.ts +34 -0
  145. package/src/derived-core.ts +1013 -146
  146. package/src/derived-real.ts +434 -0
  147. package/src/derived.ts +73 -3
  148. package/src/emit.ts +1 -2
  149. package/src/events.ts +449 -0
  150. package/src/executor.ts +24 -2
  151. package/src/file-response.ts +27 -0
  152. package/src/fork.ts +3 -2
  153. package/src/git/history.ts +19 -0
  154. package/src/git/index.ts +1 -0
  155. package/src/git/lfs.ts +67 -0
  156. package/src/git/objects.ts +3 -5
  157. package/src/git/smart-http.ts +56 -6
  158. package/src/graphql-wire.ts +106 -0
  159. package/src/grpc-wire.ts +159 -0
  160. package/src/h2.ts +627 -0
  161. package/src/head.ts +132 -41
  162. package/src/history.ts +6 -6
  163. package/src/hpack.json +1 -0
  164. package/src/index.ts +82 -329
  165. package/src/log.ts +27 -18
  166. package/src/machines.ts +151 -0
  167. package/src/managed-database.ts +299 -0
  168. package/src/multipart.ts +51 -0
  169. package/src/observe.ts +31 -15
  170. package/src/openai-wire.ts +371 -0
  171. package/src/pack-assets.ts +15 -11
  172. package/src/pack-fetch.ts +458 -0
  173. package/src/pack-paths.ts +72 -0
  174. package/src/packRegistry.ts +79 -167
  175. package/src/people.ts +31 -0
  176. package/src/placeholder-image.ts +88 -0
  177. package/src/protobuf.ts +251 -0
  178. package/src/redis/engine.ts +1 -1
  179. package/src/request-scope.ts +8 -4
  180. package/src/resource-blob.ts +13 -0
  181. package/src/runtime.ts +344 -0
  182. package/src/s3/wire.ts +172 -0
  183. package/src/scenario.ts +4 -0
  184. package/src/schema-sample.ts +24 -0
  185. package/src/sealed-box.ts +182 -0
  186. package/src/serve-http.ts +31 -3
  187. package/src/serve.ts +58 -14
  188. package/src/signing.ts +231 -0
  189. package/src/sigv4.ts +158 -0
  190. package/src/smtp.ts +76 -0
  191. package/src/sockets.ts +140 -0
  192. package/src/state-system.ts +2 -2
  193. package/src/storage.ts +3 -3
  194. package/src/trace-context.ts +1 -1
  195. package/src/twin-fetch.ts +0 -20
  196. package/src/vendor-call.ts +41 -0
  197. package/src/world-store.ts +1 -1
  198. package/vendor-hosts.cjs +36 -125
  199. package/vendor-hosts.d.cts +8 -0
  200. package/dist/src/mirror-shell.d.ts +0 -2
  201. package/dist/src/mirror-shell.js +0 -13
  202. package/dist/src/v1-removed.d.ts +0 -159
  203. package/dist/src/v1-removed.js +0 -124
  204. package/src/mirror-shell.ts +0 -15
  205. package/src/v1-removed.ts +0 -172
@@ -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 { createHash } from 'node:crypto';
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 { resolveSubjectId, subjectAliases } from "./actions.js";
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
- const form = await request.formData();
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 [key, value] of form.entries()) {
104
- if (typeof value === 'string') {
105
- out[key] = value;
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 file = value;
109
- const bytes = new Uint8Array(await file.arrayBuffer());
110
- const parsed = { name: file.name, type: file.type, size: file.size, content: new TextDecoder().decode(bytes) };
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.text();
199
+ const text = await bodyTextOf(request);
116
200
  if (!text)
117
201
  return query;
118
- const asJson = type.includes('json') || manifest.body.json === 'always';
119
- const body = asJson ? JSON.parse(text) : parseBracketForm(text, scalars);
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
- return Object.fromEntries(Object.entries(template).map(([k, v]) => [k, fill(v, values, omitAbsent)]).filter(([, v]) => v !== ABSENT));
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
146
252
  const body = manifest.error.replace(/\{(\w+)\}/g, (_, k) => escape(values[k]));
147
- return new Response(body, { status: e.status, headers: { 'content-type': body.trimStart().startsWith('<') ? 'application/xml' : 'text/plain; charset=utf-8' } });
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: e.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 own = m.resources[resource]?.view;
237
- if (own)
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
- // a pack's view sees the stored row whole, and bookkeeping (`_`) never reaches the wire whatever it returns
242
- return Object.fromEntries(Object.entries(m.view(storedType(m, resource), r)).filter(([k]) => !k.startsWith('_')));
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
- return fill(rule.template, { ...values, id });
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
- function mintId(m, resource, root) {
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
- if (m.ids.template === '{uuid}')
424
+ const template = m.resources[resource].ids ?? m.ids.template;
425
+ if (template === '{uuid}')
261
426
  return mintUuid(m, resource, root);
262
- const [head, tail = ''] = m.ids.template.split('{n}');
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(m.ids.template, { prefix, n: max + 1 }));
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 h = createHash('sha256').update(`${m.service}:${resource}:${n}`).digest('hex');
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 || typeof id !== 'string')
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
- { operation, subjectType: storedType(m, resource), subjectId: id, fields, input: { operationId: call.operation.id, params: call.params, body: params }, occurredAt, actor: { kind: 'agent', ...(m.identity ? { id: m.identity(call.request.headers.get('authorization'), root) } : {}) } }, root);
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: () => contextFor(m, { ...call, request: call.request.clone() }, { ...(root !== undefined ? { root } : {}), clock: () => occurredAt }) });
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
- const underParent = (r) => !parent || r[parent.field] === parentId;
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((r) => !parent || r[parent.field] === parentId);
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
- return answer(await write(m, call, resource, id, { ...assigned, ...initial, ...under, ...data }, `${storedType(m, resource)}.create`, params, root, at));
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.id === idParam && underParent(r)) : undefined;
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
- let rows = stored(m, resource, root).filter(underParent).map((r) => view(m, resource, r));
472
- for (const f of decl.filters ?? [])
473
- if (params[f] !== undefined)
474
- rows = rows.filter((r) => String(r[f]) === String(params[f]));
475
- const order = decl.order ?? { field: 'created', direction: 'desc' };
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.id === idParam && underParent(r)) : undefined;
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
- const prior = current[k];
567
- merged[k] = decl.update !== 'replace' && v && typeof v === 'object' && !Array.isArray(v) && prior && typeof prior === 'object' && !Array.isArray(prior) ? Object.fromEntries(Object.entries({ ...prior, ...v }).filter(([, value]) => value !== '')) : v;
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.id === idParam && underParent(r)) : undefined;
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 write(m, call, resource, hit.id, { deleted: true }, `${storedType(m, resource)}.delete`, params, root, at);
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
- async function contextFor(m, call, scope) {
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
- const text = await call.request.clone().text().catch(() => '');
609
- let body = undefined;
610
- try {
611
- body = text ? JSON.parse(text) : undefined;
612
- }
613
- catch {
614
- body = text;
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
- const subject = id ?? `${type.slice(1)}_${twinResources(m.service, root).filter((r) => r.type === type).length + 1}`;
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
- if (m.auth) {
731
- const raw = request.headers.get(m.auth.header);
732
- if (raw !== null || m.auth.gateWhenAbsent) {
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
- if ((request.headers.get('content-type') ?? '').includes('json') || m.body.json === 'always') {
749
- const text = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.clone().text().catch(() => '');
750
- if (text) {
751
- try {
752
- JSON.parse(text);
753
- }
754
- catch {
755
- return vendorError(m, m.malformedBody ?? { status: 400, message: 'The request body could not be parsed as JSON.' });
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().text() });
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) {