@volter/world-core 3.0.162 → 3.0.164

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.
@@ -1,13 +1,14 @@
1
1
  // The HTTP server seam of the kernel (docs/contributing/architecture.md#the-serve-seam): a fetch handler on
2
2
  // a port, in the shape every `Bun.serve` call already has, so the serve path meets the runtime in
3
- // ONE place. Bun-backed where Bun is the runtime; `node:http`-backed otherwise, the request rebuilt
4
- // as a standard Request and the handler's Response written back. Async, because binding a port is
3
+ // ONE place. Ordinary HTTP uses `node:http`; requested WebSockets retain Bun's native backend.
4
+ // The request is rebuilt as a standard Request and the handler's Response written back. Async, because binding a port is
5
5
  // asynchronous on Node — Bun's synchronous bind is the special case.
6
6
  //
7
7
  // Nothing here runs at module scope (the served-handler registry is made on first use), and `node:http` is reached through `process.getBuiltinModule`
8
8
  // rather than a static import: this module rides into browser client bundles through the kernel's
9
9
  // entrypoint, where a static `node:` import is the 2026-09-06 class of break.
10
10
  import { worldBootAnswer } from "./world-boot.js";
11
+ import { DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE, RequestBodyTooLargeError } from "./request-body.js";
11
12
  /** One socket library at the host seam, shared by inbound upgrades and authenticated outbound relays. */
12
13
  const socketLibrary = () => import('ws');
13
14
  export async function connectWebSocket(url, headers, protocols = []) {
@@ -56,7 +57,7 @@ export async function serveHttp(options) {
56
57
  const hostname = options.hostname ?? '127.0.0.1';
57
58
  const urlHost = options.hostname ?? '127.0.0.1';
58
59
  const bun = bunRuntime();
59
- if (bun) {
60
+ if (bun && (options.upgrade !== undefined || 'websocket' in options)) {
60
61
  // the global `Bun.serve` — the request journal's wrap of it (serve.ts) applies to packs and to
61
62
  // this seam alike, so the seam never decorates twice on Bun
62
63
  const upgrade = options.upgrade;
@@ -191,7 +192,8 @@ function builtin(name) {
191
192
  }
192
193
  async function serveOnNode(options) {
193
194
  const http = builtin('node:http');
194
- const { Readable } = builtin('node:stream');
195
+ const { Readable, Transform } = builtin('node:stream');
196
+ const requestLimit = options.maxRequestBodySize ?? DEFAULT_MAX_DECODED_REQUEST_BODY_SIZE;
195
197
  if ('websocket' in options)
196
198
  throw new Error('serveHttp: use the portable upgrade callbacks for WebSocket support on Node');
197
199
  const protocol = options.tls ? 'https' : 'http';
@@ -230,20 +232,35 @@ async function serveOnNode(options) {
230
232
  const activeRequests = new Set();
231
233
  const server = factory((req, res) => {
232
234
  const controller = new AbortController();
233
- active.add(controller);
234
- const abort = () => { if (!controller.signal.aborted)
235
- controller.abort(); };
235
+ const socket = req.socket;
236
+ const hasInputBody = req.headers['transfer-encoding'] !== undefined || Number(req.headers['content-length']) > 0;
237
+ let incoming;
238
+ let flushingBodyRefusal = false;
239
+ const abort = () => {
240
+ if (!controller.signal.aborted)
241
+ controller.abort();
242
+ incoming?.destroy(controller.signal.reason);
243
+ if (!flushingBodyRefusal) {
244
+ req.destroy();
245
+ if (!res.writableFinished)
246
+ res.destroy();
247
+ socket.destroy();
248
+ }
249
+ };
250
+ active.add(abort);
236
251
  const onRequestClose = () => { if (!req.complete)
237
252
  abort(); };
238
- const onResponseClose = () => { if (!res.writableFinished)
239
- abort(); };
253
+ const onResponseClose = () => { if (!res.writableFinished) {
254
+ flushingBodyRefusal = false;
255
+ abort();
256
+ } };
240
257
  const onSocketClose = () => { if (!res.writableFinished)
241
258
  abort(); };
242
259
  req.on('aborted', abort);
243
260
  req.on('close', onRequestClose);
244
261
  req.on('error', abort);
245
- req.socket.on('close', onSocketClose);
246
- req.socket.on('error', abort);
262
+ socket.on('close', onSocketClose);
263
+ socket.on('error', abort);
247
264
  res.on('close', onResponseClose);
248
265
  res.on('error', abort);
249
266
  const disconnected = () => controller.signal.aborted || res.destroyed;
@@ -279,8 +296,38 @@ async function serveOnNode(options) {
279
296
  closed();
280
297
  });
281
298
  let request;
299
+ let bodyTooLarge = false;
300
+ const rejectBody = () => {
301
+ if (bodyTooLarge)
302
+ return;
303
+ bodyTooLarge = true;
304
+ const error = new RequestBodyTooLargeError();
305
+ if (incoming)
306
+ req.unpipe(incoming);
307
+ req.pause();
308
+ if (!res.headersSent && !res.destroyed) {
309
+ for (const name of res.getHeaderNames())
310
+ res.removeHeader(name);
311
+ flushingBodyRefusal = true;
312
+ res.writeHead(413, { 'connection': 'close', 'content-type': 'text/plain', 'content-length': Buffer.byteLength(error.message) });
313
+ res.once('finish', () => { flushingBodyRefusal = false; req.destroy(); });
314
+ res.end(error.message);
315
+ }
316
+ else if (!res.destroyed)
317
+ res.destroy(error);
318
+ abort();
319
+ };
320
+ const inputSettled = new Promise((resolve) => {
321
+ const settled = () => { req.off('end', settled); req.off('close', settled); resolve(); };
322
+ req.once('end', settled);
323
+ req.once('close', settled);
324
+ });
282
325
  const handle = async () => {
283
326
  const method = req.method ?? 'GET';
327
+ if (Number(req.headers['content-length']) > requestLimit) {
328
+ rejectBody();
329
+ return;
330
+ }
284
331
  let response;
285
332
  try {
286
333
  const headers = new Headers();
@@ -295,9 +342,26 @@ async function serveOnNode(options) {
295
342
  }
296
343
  const url = `${protocol}://${req.headers.host ?? `${options.hostname}:${server.address()?.port ?? 0}`}${req.url ?? '/'}`;
297
344
  const withBody = method !== 'GET' && method !== 'HEAD';
298
- const init = { method, headers, signal: controller.signal, ...(withBody ? { body: Readable.toWeb(req), duplex: 'half' } : {}) };
345
+ let received = 0;
346
+ incoming = new Transform({ transform(chunk, _encoding, callback) {
347
+ received += chunk.byteLength;
348
+ if (received > requestLimit) {
349
+ rejectBody();
350
+ callback(new RequestBodyTooLargeError());
351
+ }
352
+ else
353
+ callback(null, chunk);
354
+ } });
355
+ const inputClosed = () => { if (!req.readableEnded)
356
+ abort(); };
357
+ incoming.on('error', inputClosed);
358
+ incoming.once('close', inputClosed);
359
+ const init = { method, headers, signal: controller.signal, ...(withBody ? { body: Readable.toWeb(incoming), duplex: 'half' } : {}) };
299
360
  request = new Request(url, init);
300
361
  activeRequests.add(request);
362
+ req.pipe(incoming);
363
+ if (!withBody)
364
+ incoming.resume();
301
365
  response = await options.fetch(request);
302
366
  }
303
367
  catch (error) {
@@ -365,6 +429,7 @@ async function serveOnNode(options) {
365
429
  if (k !== 'set-cookie')
366
430
  res.setHeader(k, v);
367
431
  }
432
+ res.strictContentLength = response.headers.has('content-length');
368
433
  const cookies = response.headers.getSetCookie?.() ?? [];
369
434
  if (cookies.length)
370
435
  res.setHeader('set-cookie', cookies);
@@ -424,12 +489,23 @@ async function serveOnNode(options) {
424
489
  res.destroy(error instanceof Error ? error : new Error(String(error)));
425
490
  }).then(async () => {
426
491
  await responseSettled;
427
- active.delete(controller);
492
+ // A runtime may retire its native body forwarding when the response ends.
493
+ // Close unfinished input after the response flushes; a consumed upload keeps its connection.
494
+ if (hasInputBody && !incoming?.readableEnded)
495
+ abort();
496
+ else
497
+ incoming?.resume();
498
+ await inputSettled;
499
+ if (incoming) {
500
+ req.unpipe(incoming);
501
+ incoming.destroy();
502
+ }
503
+ active.delete(abort);
428
504
  req.off('aborted', abort);
429
505
  req.off('close', onRequestClose);
430
506
  req.off('error', abort);
431
- req.socket.off('close', onSocketClose);
432
- req.socket.off('error', abort);
507
+ socket.off('close', onSocketClose);
508
+ socket.off('error', abort);
433
509
  res.off('close', onResponseClose);
434
510
  res.off('error', abort);
435
511
  });
@@ -462,8 +538,8 @@ async function serveOnNode(options) {
462
538
  return {
463
539
  hostname: options.hostname, port, url: new URL(`${protocol}://${options.urlHost}:${port}/`),
464
540
  stop: (closeActive = true) => new Promise((resolve) => { if (closeActive) {
465
- for (const controller of active)
466
- controller.abort();
541
+ for (const abort of active)
542
+ abort();
467
543
  for (const peer of socketServer?.clients ?? [])
468
544
  peer.terminate();
469
545
  } socketServer?.close(); server.close(() => { if (closeActive && active.size)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.162",
3
+ "version": "3.0.164",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
@@ -1,19 +1,31 @@
1
1
  // A vendor's browser session: the bookkeeping used by its own sign-in and sign-out pages.
2
2
  import type { HandlerContext } from './derived-core.ts';
3
+ import { personOf, type Person } from './people.ts';
3
4
 
4
5
  type Writer = Pick<HandlerContext, 'record' | 'secret' | 'occurredAt'>;
5
6
 
6
- /** The named cookie, without interpreting any other vendor's cookie. */
7
- export function browserSessionToken(request: Request, name: string): string | undefined {
7
+ /** The value of the cookie `name` the request carries, as a browser sends it back. */
8
+ export function cookieOf(request: Request, name: string): string | undefined {
8
9
  for (const part of (request.headers.get('cookie') ?? '').split(';')) {
9
10
  const at = part.indexOf('=');
10
- if (at > 0 && part.slice(0, at).trim() === name) {
11
- try { return decodeURIComponent(part.slice(at + 1).trim()); } catch { return undefined; }
12
- }
11
+ if (at > 0 && part.slice(0, at).trim() === name) return decodeURIComponent(part.slice(at + 1).trim());
13
12
  }
14
13
  return undefined;
15
14
  }
16
15
 
16
+ /** The named session cookie; malformed encodings do not name a session. */
17
+ export function browserSessionToken(request: Request, name: string): string | undefined {
18
+ try { return cookieOf(request, name); } catch { return undefined; }
19
+ }
20
+
21
+ /** The person the request's session cookie names, or undefined when nobody is signed in; with `scope`, only a session
22
+ * begun in it (a vendor's sandbox dashboard and its production one sign a person in apart). */
23
+ export function signedIn(ctx: HandlerContext, cookie: string, scope?: string): Person | undefined {
24
+ const token = cookieOf(ctx.call.request, cookie);
25
+ const session = token ? ctx.rowsRaw('_web_session').find((s) => s.token === token && (scope === undefined || s.scope === scope)) : undefined;
26
+ return session ? personOf(ctx, String(session.email)) : undefined;
27
+ }
28
+
17
29
  /** A new, independent sign-in, returning the cookie the vendor places on its response. */
18
30
  export async function startBrowserSession(ctx: Writer, email: string, cookie: string, scope?: string): Promise<string> {
19
31
  const issued = await ctx.record('_web_session_issue', { email, ...(scope !== undefined ? { scope } : {}) });
@@ -11,7 +11,7 @@ import { parseExactJson, compareExactNumbers, exactInteger, isExactIntegerValue
11
11
  import { applyTwinWrite, applyTwinWriteAtomic, applyTwinWrites, twinResources, type TwinResource } from './serve.ts';
12
12
  import { copyResource, foldLocalWrite, heldSubject, ownFields, parentStamp, subjectHistory, treeChangesSince, treeResources, treeStamp, type Subject } from './log.ts';
13
13
  import { performsAtHeadNow } from './head.ts';
14
- import { getActiveWorldStore } from './world-store.ts';
14
+ import { getActiveWorldStore, type WorldStore } from './world-store.ts';
15
15
  import { getActiveBlobStore } from './blob-store.ts';
16
16
  import { worldPaths } from './storage.ts';
17
17
  import { GitObjectStore } from './git/objects.ts';
@@ -43,7 +43,7 @@ import type { SubjectFields } from './hash.ts';
43
43
  import { twinPublicBase, withRequestScopes } from './twin-fetch.ts';
44
44
  import { inVendorMove, isReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
45
45
  import type { DerivedCall, DerivedCoreOutcome, DerivedHandler, DerivedOperation } from './derived.ts';
46
- import type { PackDescriptor } from './packRegistry.ts';
46
+ import { getPack, type PackDescriptor } from './packRegistry.ts';
47
47
  import type { ScenarioDecision, ScenarioHandler } from './scenario.ts';
48
48
  import { bodyTextOf, checkJsonBody, takeParsedBody } from './derived.ts';
49
49
 
@@ -937,7 +937,7 @@ export type CoreScope = {
937
937
  root?: string; clock?: () => string; database?: string; readOnly?: boolean;
938
938
  /** `semantics/tenant.ts`: the tenant the caller acts in (the manifest's `tenant`), or undefined for none */
939
939
  tenant?: (ctx: HandlerContext) => string | undefined | Promise<string | undefined>;
940
- /** the application a walk stands in for: what the vendor asks it (`ctx.ask`) is answered by it (events.ts) */
940
+ /** the application a walk stands in for: vendor requests and declared events are answered by it (events.ts) */
941
941
  application?: ApplicationStandIn;
942
942
  };
943
943
 
@@ -996,6 +996,62 @@ export async function worldAccount(service: string, root: string | undefined, id
996
996
  return id;
997
997
  }
998
998
 
999
+ type WorldOrganization = { id: string; label: string; members: string[] };
1000
+ type VendorNameRule = { max: number; lower?: boolean; allowed: RegExp; separator?: string; first?: RegExp };
1001
+ type NativeAccountLink = { resource: string; id: string };
1002
+
1003
+ function nativeAccountLink(row: TwinResource | undefined, field?: string): NativeAccountLink | undefined {
1004
+ if (!row || row.deleted === true) return undefined;
1005
+ const fields = ownFields(row);
1006
+ const value = field === undefined ? fields : fields[field];
1007
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
1008
+ const link = value as Record<string, unknown>;
1009
+ return typeof link.resource === 'string' && typeof link.id === 'string' ? { resource: link.resource, id: link.id } : undefined;
1010
+ }
1011
+
1012
+ const sameAccountLink = (a: NativeAccountLink | undefined, b: NativeAccountLink | undefined): boolean =>
1013
+ a?.resource === b?.resource && a?.id === b?.id;
1014
+
1015
+ /** Native creation and reconstruction run outside the action lock; only the retained link is compared and written. */
1016
+ async function retainNativeAccountLink<T>(
1017
+ service: string, root: string | undefined, at: string, call: DerivedCall,
1018
+ type: string, id: string, field: string | undefined, resource: string,
1019
+ make: () => Promise<string>, read: (link: NativeAccountLink) => Promise<T | undefined>,
1020
+ ): Promise<T> {
1021
+ for (;;) {
1022
+ const before = nativeAccountLink(resourcesOfType(service, type, root).find((row) => row.id === id), field);
1023
+ const held = before && await read(before);
1024
+ if (held !== undefined) return held;
1025
+ const made = { resource, id: await make() };
1026
+ if (await read(made) === undefined) throw new Error(`Credential ${type}/${id}: native ${resource}/${made.id} is unavailable after creation`);
1027
+ const { value } = await applyTwinWriteAtomic(service, (snapshot) => {
1028
+ const current = nativeAccountLink(snapshot.find((row) => row.type === type && row.id === id), field);
1029
+ if (!sameAccountLink(current, before)) return { kind: 'skip', value: current };
1030
+ return {
1031
+ kind: 'write', value: made,
1032
+ write: {
1033
+ operation: `${type.slice(1)}.link`, subjectType: type, subjectId: id,
1034
+ fields: field === undefined ? made : { name: id, [field]: made },
1035
+ ...provisioningInput(call), occurredAt: at, actor: { kind: 'system' },
1036
+ },
1037
+ };
1038
+ }, root);
1039
+ const linked = value && await read(value);
1040
+ if (linked !== undefined) return linked;
1041
+ }
1042
+ }
1043
+
1044
+ function vendorName(label: string, rule: VendorNameRule, { suffixed = false, identity = label }: { suffixed?: boolean; identity?: string } = {}): string {
1045
+ const allowed = new RegExp(rule.allowed.source, rule.allowed.flags.replace(/[gy]/g, ''));
1046
+ const separator = rule.separator ?? '-';
1047
+ if ([...separator].length !== 1 || !allowed.test(separator)) throw new Error('Credential name separator must be one allowed character');
1048
+ const first = rule.first && new RegExp(rule.first.source, rule.first.flags.replace(/[gy]/g, ''));
1049
+ let name = [...(rule.lower ? label.toLowerCase() : label)].map((letter) => allowed.test(letter) ? letter : separator).join('');
1050
+ name = name.replace(new RegExp(`${escapeRe(separator)}+`, 'g'), separator);
1051
+ if (first && !first.test(name[0] ?? '')) name = `w${separator}${name}`;
1052
+ return name !== label || name.length > rule.max || suffixed ? `${name.slice(0, Math.max(0, rule.max - 9))}${separator}${sha256(identity).slice(0, 8)}` : name;
1053
+ }
1054
+
999
1055
  /** A World's signing key for a label (ctx.signingKey): made once from the platform's random source, then read; the
1000
1056
  * making is one per service store and label in the process, as the seed's is. */
1001
1057
  const SIGNING_KEY = '_world_signing_key';
@@ -1082,9 +1138,9 @@ function mintId(m: DerivedManifest, resource: string, root: string | undefined,
1082
1138
  * increment. One space over every snowflake resource of the vendor, as its one generator's: the instant's first, or one
1083
1139
  * past the greatest the World holds when that is later (a frozen clock, or several ids in one millisecond), so ids
1084
1140
  * rise with time and never repeat. */
1085
- /** The last snowflake minted per vendor and World in this process: two mints before the first is written (a message
1141
+ /** The last snowflake minted per store, vendor and World: two mints before the first is written (a message
1086
1142
  * and its attachment) must not meet. */
1087
- const lastSnowflake = new Map<string, bigint>();
1143
+ const lastSnowflake = new WeakMap<WorldStore, Map<string, bigint>>();
1088
1144
  function mintSnowflake(m: DerivedManifest, epoch: bigint, root: string | undefined, at: string, snapshot?: readonly TwinResource[]): string {
1089
1145
  const ms = BigInt(Date.parse(at) || 0);
1090
1146
  const base = ms > epoch ? (ms - epoch) << 22n : 0n;
@@ -1093,11 +1149,13 @@ function mintSnowflake(m: DerivedManifest, epoch: bigint, root: string | undefin
1093
1149
  if (!/^\{snowflake:\d+\}$/.test(decl.ids ?? m.ids.template)) continue;
1094
1150
  for (const r of mintRows(m, name, root, snapshot)) if (/^\d+$/.test(r.id)) { const v = BigInt(r.id); if (v > max) max = v; }
1095
1151
  }
1152
+ const store = getActiveWorldStore();
1153
+ const reservations = lastSnowflake.get(store) ?? lastSnowflake.set(store, new Map()).get(store)!;
1096
1154
  const key = `${m.service}\0${root ?? ''}`;
1097
- const last = lastSnowflake.get(key) ?? -1n;
1155
+ const last = reservations.get(key) ?? -1n;
1098
1156
  const floor = max > last ? max : last;
1099
1157
  const id = base > floor ? base : floor + 1n;
1100
- lastSnowflake.set(key, id);
1158
+ reservations.set(key, id);
1101
1159
  return String(id);
1102
1160
  }
1103
1161
 
@@ -1396,7 +1454,7 @@ async function finishWrite(m: DerivedManifest, call: DerivedCall, resource: stri
1396
1454
  const endpoint = (kind: import('./events.ts').EndpointsDecl): import('./events.ts').EndpointsDecl => ({ ...kind, storedAs: storedType(m, kind.storedAs), ...(kind.joins ? { joins: Object.fromEntries(Object.entries(kind.joins).map(([name, join]) => [name, { ...join, storedAs: storedType(m, join.storedAs) }])) } : {}) });
1397
1455
  const events = { ...m.events, ...(m.events.store ? { store: storedType(m, m.events.store) } : {}), endpoints: 'storedAs' in m.events.endpoints ? endpoint(m.events.endpoints) : m.events.endpoints.map(endpoint) };
1398
1456
  const write = { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() };
1399
- 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);
1457
+ 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, scopeOfCall.get(call)?.application);
1400
1458
  }
1401
1459
  if (m.sockets?.length) socketWrite(m.service, root, { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() });
1402
1460
  // a read held until the World writes (held-reads.ts) hears it
@@ -1959,6 +2017,18 @@ export type SemanticsContext = {
1959
2017
  secret(label: string): Promise<string>;
1960
2018
  /** Select an account at a pack's credential door. Packs retain their own vendor account/key semantics. */
1961
2019
  account(field?: string): Promise<string>;
2020
+ /** The selected organization's label and people from the World's organization registry. */
2021
+ organization(): Promise<WorldOrganization>;
2022
+ /** The selected deployment, or an empty string for the organization's own level. */
2023
+ deployment(): string;
2024
+ /** A vendor-safe label; retained links keep identity independent of names. */
2025
+ vendorName(label: string, rule: VendorNameRule, options?: { suffixed?: boolean; identity?: string }): string;
2026
+ /** Retain the native customer tenant for the selected organization. */
2027
+ tenant(resource: string, make: (organization: WorldOrganization) => Promise<string>): Promise<Record<string, unknown>>;
2028
+ /** Retain one native resource of this kind for the selected organization and deployment. */
2029
+ deploymentObject(kind: string, resource: string, make: (deployment: string) => Promise<string>): Promise<Record<string, unknown>>;
2030
+ /** Reconstruct a live native key, replacing its link when the vendor's reader refuses it. */
2031
+ accountKey(purpose: string, resource: string, key: { make: () => Promise<string>; read: (row: Record<string, unknown>) => Promise<string | undefined> }): Promise<{ id: string; secret: string }>;
1962
2032
  /** The World's machine pool (machines.ts), as the pack's `machine-pool` door enrolled it; `none` when none was. */
1963
2033
  machines: MachinePool;
1964
2034
  /** A signing key pair for `label` (an issuer's ID tokens, a provider's JWTs, a CA's or a log's key), PEM: RSA 2048
@@ -2105,7 +2175,7 @@ export type HandlerContext = Omit<SemanticsContext, 'now' | 'core' | 'atomically
2105
2175
  };
2106
2176
  /** What a write hook reads: the writing call's context without its ways to write, so a hook that renders an event
2107
2177
  * cannot write again (and run itself again). */
2108
- export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'at' | 'over'>;
2178
+ export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'tenant' | 'deploymentObject' | 'accountKey' | 'at' | 'over'>;
2109
2179
  /** A derived pack's handler: one operation, by its operationId, over the contract's context. */
2110
2180
  export type Handler = (ctx: HandlerContext) => Promise<Response>;
2111
2181
 
@@ -2153,6 +2223,8 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2153
2223
  });
2154
2224
  const paths = expandPaths(m, params);
2155
2225
  const stateChecks: StateChecks = new Map();
2226
+ const credentialDoor = m.descriptor?.credentialDoor ?? getPack(m.vendor)?.credentialDoor;
2227
+ const credentialFields = body && typeof body === 'object' && !Array.isArray(body) ? body as Record<string, unknown> : {};
2156
2228
  const rawResources = new WeakMap<Record<string, unknown>, string>();
2157
2229
  const rawRow = (resource: string, row: TwinResource): Record<string, unknown> => {
2158
2230
  const name = m.resources[resource] ? resource : Object.keys(m.resources).find((name) => storedType(m, name) === row.type);
@@ -2166,6 +2238,10 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2166
2238
  rawResources.set(row as Record<string, unknown>, name ?? resource);
2167
2239
  return row as Record<string, unknown>;
2168
2240
  };
2241
+ const liveNativeRow = (link: NativeAccountLink): Record<string, unknown> | undefined => {
2242
+ const row = context.row(link.resource, link.id);
2243
+ return row && row.deleted !== true ? row : undefined;
2244
+ };
2169
2245
  const context: SemanticsContext = {
2170
2246
  call,
2171
2247
  params,
@@ -2218,9 +2294,39 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2218
2294
  },
2219
2295
  occurredAt: at,
2220
2296
  secret: async (label) => hmac(await worldSeed(m.service, root, at), scopeLabel(m, label), 'base64url'),
2221
- account: async (field = 'account') => {
2222
- const fields = body && typeof body === 'object' && !Array.isArray(body) ? body as Record<string, unknown> : {};
2223
- return worldAccount(m.service, root, fields[field] ?? 'default', at, call.request.headers.get('x-volter-world-activity') === 'provisioning');
2297
+ account: async (field = credentialDoor?.account?.field ?? 'account') => {
2298
+ return worldAccount(m.service, root, credentialFields[field] ?? 'default', at, call.request.headers.get('x-volter-world-activity') === 'provisioning');
2299
+ },
2300
+ organization: async () => {
2301
+ const id = await context.account();
2302
+ const row = projectOwnerResources('_world', root, { type: '_world_organization', id }).find((row) => row.type === '_world_organization' && row.id === id && row.deleted !== true);
2303
+ return row ? { id, label: row.label as string, members: [...(row.members as string[])] } : { id, label: id, members: ['owner@world.test'] };
2304
+ },
2305
+ deployment: () => {
2306
+ const id = credentialFields[credentialDoor?.deployment?.field ?? 'deployment'] ?? '';
2307
+ if (id !== '' && !isWorldAccountName(id)) throw new Error('Credential deployment must be a name of at most 128 letters, digits, dots, underscores or hyphens');
2308
+ return id;
2309
+ },
2310
+ vendorName,
2311
+ tenant: async (resource, make) => {
2312
+ const id = await context.account();
2313
+ return retainNativeAccountLink(m.service, root, at, call, '_world_account', id, 'tenant', resource,
2314
+ async () => make(await context.organization()), async (link) => liveNativeRow(link));
2315
+ },
2316
+ deploymentObject: async (kind, resource, make) => {
2317
+ const organization = await context.account(), deployment = context.deployment();
2318
+ return retainNativeAccountLink(m.service, root, at, call, '_world_deployment_object', `${organization}/${deployment}/${kind}`, undefined, resource,
2319
+ () => make(deployment), async (link) => liveNativeRow(link));
2320
+ },
2321
+ accountKey: async (purpose, resource, key) => {
2322
+ const organization = await context.account(), deployment = context.deployment();
2323
+ return retainNativeAccountLink(m.service, root, at, call, '_world_account_key', `${organization}/${deployment}/${purpose}`, undefined, resource,
2324
+ () => key.make(), async (link) => {
2325
+ const row = liveNativeRow(link);
2326
+ if (!row) return undefined;
2327
+ const secret = await key.read(row);
2328
+ return secret === undefined ? undefined : { id: link.id, secret };
2329
+ });
2224
2330
  },
2225
2331
  record: async (type, fields, id, operation) => {
2226
2332
  if (!type.startsWith('_')) throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
@@ -2272,7 +2378,11 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2272
2378
  const row = await atomicWrite(m, call, resource, (snapshot) => {
2273
2379
  const id = options?.id ?? mintId(m, resource, root, at, snapshot);
2274
2380
  const selected = typeof fields === 'function' ? fields(id) : fields;
2275
- const hit = snapshot.find((r) => r.type === storedType(m, resource) && r.deleted !== true && rules.some((rule) => rule.fields.every((f) => selected[f] !== undefined && view(m, resource, r)[f] === selected[f])));
2381
+ const hit = snapshot.find((r) => {
2382
+ if (r.type !== storedType(m, resource) || r.deleted === true) return false;
2383
+ const fields = { [m.resources[resource]?.idAs ?? 'id']: r.id, ...ownFields(r) };
2384
+ return rules.some((rule) => rule.fields.every((field) => selected[field] !== undefined && fields[field] === selected[field]));
2385
+ });
2276
2386
  if (hit) { existing = view(m, resource, hit); return undefined; }
2277
2387
  return { id, fields: selected };
2278
2388
  }, operation, params, root, at, undefined, stateChecks, options?.input === 'omit');
@@ -2304,7 +2414,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
2304
2414
  },
2305
2415
  writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at, stateChecks),
2306
2416
  resolve: (resource, id) => resolveSubjectId(m.service, storedType(m, resource), id, root),
2307
- storedType: (resource) => storedType(m, resource),
2417
+ storedType: (resource) => m.resources[resource]?.storedAs ?? resource,
2308
2418
  ok: (fields) => Response.json({ ...(m.success ?? {}), ...fields }),
2309
2419
  row: (resource, id, opts) => { const row = stored(m, resource, root, opts).find((r) => r.id === id); return row ? rawRow(resource, row) : undefined; },
2310
2420
  rowsRaw: (resource, opts) => stored(m, resource, root, opts).map((row) => rawRow(resource, row)),
@@ -2427,12 +2537,14 @@ const BATCH_REFUSED_READS = ['get', 'rows', 'tree', 'history', 'find', 'list', '
2427
2537
  /** The members that write, or wait on writes, or answer as another context: each lands the staged writes first. */
2428
2538
  const BATCH_LANDING_MEMBERS = ['create', 'createUnique', 'change', 'accumulate', 'writeDetailed', 'record', 'atomically', 'remove', 'issue', 'at', 'over', 'core', 'awaitWrite', 'awaitCondition',
2429
2539
  // a World's seed and signing keys are written when first asked (worldSeed, worldSigningKey, as a vendor's move)
2430
- 'secret', 'signingKey'] as const;
2540
+ 'secret', 'signingKey', 'account', 'organization', 'tenant', 'deploymentObject', 'accountKey'] as const;
2431
2541
 
2432
2542
  /** `ctx.batch` over a context whose writes may be grouped (see `batchable`). */
2433
2543
  function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsContext, params: Record<string, unknown>, root: string | undefined, at: string,
2434
2544
  rawRow: (resource: string, row: TwinResource) => Record<string, unknown>): void {
2435
2545
  let staged: StagedWrite[] | undefined;
2546
+ // A callback's native object must land before the helper writes its retained link.
2547
+ let nativeAccountWrites = 0;
2436
2548
  // each staged subject as it will stand, by `type:id`, in the order first written
2437
2549
  const subjects = new Map<string, Subject>();
2438
2550
  const land = async (): Promise<void> => {
@@ -2446,7 +2558,13 @@ function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsCont
2446
2558
  for (const name of BATCH_LANDING_MEMBERS) {
2447
2559
  const plain = ctx[name];
2448
2560
  if (typeof plain !== 'function') continue;
2449
- ctx[name] = async (...args: unknown[]) => { await land(); return (plain as (...a: unknown[]) => unknown)(...args); };
2561
+ ctx[name] = async (...args: unknown[]) => {
2562
+ await land();
2563
+ const native = name === 'tenant' || name === 'deploymentObject' || name === 'accountKey';
2564
+ if (native) nativeAccountWrites++;
2565
+ try { return await (plain as (...a: unknown[]) => unknown)(...args); }
2566
+ finally { if (native) nativeAccountWrites--; }
2567
+ };
2450
2568
  }
2451
2569
  for (const name of BATCH_REFUSED_READS) {
2452
2570
  const plain = ctx[name];
@@ -2459,7 +2577,7 @@ function bindBatch(m: DerivedManifest, call: DerivedCall, context: SemanticsCont
2459
2577
  const plainWrite = context.write, plainRow = context.row, plainRowsRaw = context.rowsRaw;
2460
2578
  context.write = async (resource, id, fields, operation) => {
2461
2579
  // a vendor's own move (ctx.asVendor) is the clock's write, recorded as the system's: it lands alone, in its place
2462
- if (!staged || inVendorMove()) { await land(); return plainWrite(resource, id, fields, operation); }
2580
+ if (!staged || nativeAccountWrites > 0 || inVendorMove()) { await land(); return plainWrite(resource, id, fields, operation); }
2463
2581
  const type = storedType(m, resource), key = `${type}:${id}`;
2464
2582
  // the one subject, as the memoized tree holds it (readTreeMap would copy the whole tree on every staged write)
2465
2583
  const subject = foldLocalWrite(subjects.get(key) ?? heldSubject(m.service, type, id, root), type, id, fields as SubjectFields, at);
@@ -583,8 +583,8 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
583
583
  const sent = asked.length ? `${target}${target.includes('?') ? '&' : '?'}${new URLSearchParams(asked)}` : target;
584
584
  const answer = await execute({ method: op.method.toUpperCase(), path: sentTo(m, sent, subject), ...onLane, headers: fixedHeaders(op) });
585
585
  // a door's subject the vendor does not show the root (another bot's private chat) stays as the World holds it
586
- if (!rooted.has(key) && answer.status >= 400 && answer.status !== 429) continue;
587
586
  if (answer.status === 401) throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
587
+ if (!rooted.has(key) && answer.status >= 400 && answer.status !== 429) continue;
588
588
  if (answer.status === 403) { unread.push({ type, path, status: 403 }); refused = true; break; }
589
589
  if (answer.status === 429) throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
590
590
  read.push(String(subject.id));
@@ -592,7 +592,15 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
592
592
  // the vendor's answer for a subject it no longer has: a 404, or the scope's `gone` (Telegram's 400 `chat not found`)
593
593
  if (answer.status === 404 || (scope.gone && answer.status === scope.gone.status && refusalWords(m, parse(answer)).includes(scope.gone.says))) continue;
594
594
  if (answer.status >= 400 || refusedIn(m, parse(answer))) throw new Error(`${m.vendor}: ${resource} refresh ${op.id} (${op.method.toUpperCase()} ${op.path}): ${answer.status}: ${refusalWords(m, parse(answer))}`);
595
- const full = object(op.answers?.key ? at(parse(answer), op.answers.key) : parse(answer)) ?? {};
595
+ const parsed = parse(answer);
596
+ let full: Row;
597
+ if (op.answers?.list) {
598
+ const items = itemsOf(op, parsed);
599
+ const matching = items.find((item) => (scopeIdOf(scope, item) ?? storedIdOf(decl, item)) === vendorId);
600
+ if (!matching && items.length) throw new Error(`${m.vendor}: ${resource} refresh ${op.id} omitted the requested subject; nothing folded`);
601
+ if (!matching) continue;
602
+ full = matching;
603
+ } else full = object(op.answers?.key ? at(parsed, op.answers.key) : parsed) ?? {};
596
604
  const local = (m as { worldFields?: Record<string, Record<string, unknown>> }).worldFields?.[resource] ?? {};
597
605
  const fields = Object.fromEntries(Object.entries(full).filter(([k]) => !(k in local)));
598
606
  if (decl.parent) {