@volter/world-core 3.0.4 → 3.0.6

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.
@@ -296,6 +296,21 @@ export type ScreenDecl = {
296
296
  /** the vendor's documentation of the round trip or the screen */
297
297
  source: string;
298
298
  };
299
+ /** A protocol hook's arguments: the root's executor (it applies the sealed credential; `issued` carries one the vendor
300
+ * issued instead), the group and its first entry's recorded input, the perform's context, the derived perform of an
301
+ * input (`send`: the recorded request, or one the hook changed), the stored id the World's id of a type resolves to,
302
+ * the surface's base path, and the World's blobs of this twin. */
303
+ export type PerformHookArgs = {
304
+ execute: import('./remote-execute.js').RemoteExecute;
305
+ group: import('./actions.js').TwinAction[];
306
+ input: Record<string, unknown>;
307
+ ctx: import('./head.js').PerformContext;
308
+ send: (input: Record<string, unknown>) => Promise<import('./head.js').PushOutcome>;
309
+ resolve: (type: string, localId: string) => string;
310
+ basePath: string;
311
+ blob: (key: string) => Promise<Uint8Array | null>;
312
+ };
313
+ export type PerformHook = (args: PerformHookArgs) => Promise<import('./head.js').PushOutcome>;
299
314
  export type DerivedManifest = {
300
315
  /** The pack's descriptor, as data (docs/contributing/architecture.md, "The descriptor"), every field but `vendor`, which
301
316
  * is the manifest's: the pack registers `packOf(manifest)`, so its index is a fixed file. A lane has none. */
@@ -384,6 +399,14 @@ export type DerivedManifest = {
384
399
  vendorBacked?: {
385
400
  none: string;
386
401
  };
402
+ /** the resource the vendor's paths name the account by (Cloudflare's `accounts/{account_id}`): with a root, the
403
+ * World's account is the one account the root's credential reaches, which refresh observes ("The account a root
404
+ * is", architecture) */
405
+ account?: string;
406
+ /** a vendor protocol one recorded request cannot express, by the operation that ends it: a session whose answer
407
+ * issues the credential its next calls carry (Cloudflare's assets upload), performed by the hook in place of the
408
+ * derived request, which it composes (`send`) ("Protocols a request cannot express", architecture) */
409
+ performs?: Record<string, PerformHook>;
387
410
  /** `withParam`: the error names the path parameter that held the unknown id */
388
411
  notFound: {
389
412
  status: number;
@@ -77,7 +77,9 @@ function resolvedDeep(value, resolve) {
77
77
  const o = object(value);
78
78
  return o ? Object.fromEntries(Object.entries(o).map(([k, v]) => [k, resolvedDeep(v, resolve)])) : value;
79
79
  }
80
- /** A multipart body: the recorded fields, then each recorded file part read back from the World's blob store. */
80
+ /** A multipart body: the recorded fields, then each recorded file part read back from the World's blob store. An
81
+ * operation the vendor takes as multipart is sent so with no file part too (a Workers upload of assets alone carries
82
+ * only its `metadata` field). */
81
83
  async function multipart(body, files, ctx) {
82
84
  const boundary = `----volter${hashFieldValue({ body, files }).slice(0, 24)}`;
83
85
  const chunks = [];
@@ -122,10 +124,37 @@ function unitOf(units, input) {
122
124
  ?? units.find((u) => u.surface.operations.some((o) => o.id === id))
123
125
  ?? (input.graphql ? units[0] : undefined);
124
126
  }
127
+ /** The account a root is, before a perform sends a path naming the World's ("The account a root is"): the World's
128
+ * account its own doors made, unadopted, adopts the one account the root's credential lists. Read once per deploy (an
129
+ * adopted account is not asked again); a list the vendor refuses, or one naming several accounts, adopts none. */
130
+ async function adoptAccount(units, execute, ctx) {
131
+ if (ctx.service === undefined)
132
+ return;
133
+ for (const u of units) {
134
+ const m = u.manifest;
135
+ const decl = m.account ? m.resources[m.account] : undefined;
136
+ if (!m.account || !decl)
137
+ continue;
138
+ const type = storedType(m, m.account);
139
+ const local = readTree(ctx.service, ctx.root).filter((r) => r.type === type && r.deleted !== true && ctx.resolve(type, String(r.id)) === String(r.id));
140
+ const scope = decl.refresh;
141
+ const op = scope && 'list' in scope ? u.surface.operations.find((o) => o.id === scope.list) : undefined;
142
+ if (!local.length || !op)
143
+ continue;
144
+ const answer = await execute({ method: op.method.toUpperCase(), path: `${op.basePath ?? u.surface.basePath ?? ''}${op.path}`, ...(u.lane ? { lane: u.lane } : {}) });
145
+ if (answer.status >= 400)
146
+ continue;
147
+ const items = itemsOf(op, parse(answer));
148
+ const id = items.length === 1 ? storedIdOf(decl, items[0]) : undefined;
149
+ if (id === undefined || local.some((l) => String(l.id) === id))
150
+ continue;
151
+ observeResources(ctx.service, [{ type, id, fields: items[0], adopts: String(local[0].id) }], ctx.root !== undefined ? { root: ctx.root } : {});
152
+ }
153
+ }
125
154
  function performOf(units) {
126
- return async (execute, group, ctx) => {
155
+ // the request a group's first entry recorded (or one a protocol hook changed), sent to the vendor
156
+ const derived = async (execute, group, ctx, input) => {
127
157
  const first = group[0];
128
- const input = (first.input ?? {});
129
158
  const unit = unitOf(units, input);
130
159
  if (!unit)
131
160
  throw new RefusedWriteError('vendor', `the write records no operation of the vendor's surface (${String(input.operationId ?? 'none')})`, first.id);
@@ -183,12 +212,12 @@ function performOf(units) {
183
212
  const asCame = object(input.body);
184
213
  const raw = asCame && typeof asCame.$text === 'string' ? { body: asCame.$text } : asCame && typeof asCame.$base64 === 'string' ? { body: new Uint8Array(Buffer.from(asCame.$base64, 'base64')) } : undefined;
185
214
  const sent = raw ? { ...raw, ...(typeof input.contentType === 'string' ? { type: input.contentType } : {}) }
186
- : files.length ? await multipart(body, files, ctx) : op ? encode(op, body) : { body: JSON.stringify(body), type: 'application/json' };
215
+ : files.length || op?.bodyEncoding === 'multipart' ? await multipart(body, files, ctx) : op ? encode(op, body) : { body: JSON.stringify(body), type: 'application/json' };
187
216
  const headers = { ...(object(input.headers) ?? {}), ...(sent.type ? { 'content-type': sent.type } : {}) };
188
217
  // a retried deploy makes nothing twice, and two Worlds deploying into one account never collide
189
218
  if (m.idempotency)
190
219
  headers[m.idempotency.header] = hashFieldValue({ credential: ctx.credential ?? '', entry: first.id });
191
- const answer = await execute({ method, path, headers, ...(sent.body !== undefined ? { body: sent.body } : {}) });
220
+ const answer = await execute({ method, path, headers, ...(sent.body !== undefined ? { body: sent.body } : {}), ...(unit.lane ? { lane: unit.lane } : {}) });
192
221
  const parsed = parse(answer);
193
222
  const shape = refusalShape(m);
194
223
  const refused = answer.status >= 400 || (shape.length > 0 && shape.every(([p, v]) => at(parsed, p) === v));
@@ -228,6 +257,23 @@ function performOf(units) {
228
257
  }
229
258
  return { externalId, actionId: primary.id, data: obj, ...(also.length ? { also } : {}) };
230
259
  };
260
+ return async (execute, group, ctx) => {
261
+ await adoptAccount(units, execute, ctx);
262
+ const input = (group[0].input ?? {});
263
+ const unit = unitOf(units, input);
264
+ // a protocol one request cannot express is the pack's hook, which composes the derived perform ("Protocols a
265
+ // request cannot express")
266
+ const hook = unit && typeof input.operationId === 'string' ? unit.manifest.performs?.[input.operationId] : undefined;
267
+ if (!hook || !unit)
268
+ return derived(execute, group, ctx, input);
269
+ return hook({
270
+ execute, group, input, ctx,
271
+ send: (changed) => derived(execute, group, ctx, changed),
272
+ resolve: (type, localId) => ctx.resolve(type, localId),
273
+ basePath: unit.surface.basePath ?? '',
274
+ blob: (key) => readResourceBlob(ctx.service ?? '', key, ctx.root),
275
+ });
276
+ };
231
277
  }
232
278
  /** Path parameters that name a subject by a field rather than its id, sent as the vendor's value of that field, read
233
279
  * from the adopted landed copy ("Perform", step 1): a parent found by `parent.where` (a repository addressed as
@@ -279,7 +325,8 @@ function refreshOf(units, service) {
279
325
  return async (execute, opts) => {
280
326
  const observed = [];
281
327
  const complete = [];
282
- for (const { manifest: m, surface } of units) {
328
+ for (const unit of units) {
329
+ const { manifest: m, surface } = unit;
283
330
  // parents before children, however deep
284
331
  const scoped = Object.entries(m.resources).filter(([, d]) => d.refresh && !('none' in d.refresh));
285
332
  const depth = (r, seen = new Set()) => { const p = m.resources[r]?.parent?.resource; return p && !seen.has(p) ? 1 + depth(p, seen.add(r)) : 0; };
@@ -301,7 +348,7 @@ function refreshOf(units, service) {
301
348
  // a parent the operation names in its query, not its path (Slack's conversations.history `channel`)
302
349
  const byQuery = decl.parent?.param && parent !== undefined && !op.path.includes(`{${decl.parent.param}}`) ? { [decl.parent.param]: String(parent) } : {};
303
350
  const q = new URLSearchParams({ ...page, ...byQuery });
304
- const answer = await execute({ method: op.method.toUpperCase(), path: [...q].length ? `${path}?${q}` : path });
351
+ const answer = await execute({ method: op.method.toUpperCase(), path: [...q].length ? `${path}?${q}` : path, ...(unit.lane ? { lane: unit.lane } : {}) });
305
352
  if (answer.status === 401 || answer.status === 403)
306
353
  throw new Error(`${answer.status}: the vendor refused the root's credential; nothing folded`);
307
354
  if (answer.status === 429)
@@ -400,6 +447,21 @@ function adopt(service, root, units, observed) {
400
447
  list.push({ id: e.subject.id, fields: held.fields });
401
448
  pending.set(e.subject.type, list);
402
449
  }
450
+ // the account a root is ("The account a root is"): the root's credential reaches one account of the type the pack's
451
+ // paths name it by; observed alone, it is the World's own account (made by the World's door, so no deploy settled it)
452
+ for (const u of units) {
453
+ if (!u.manifest.account)
454
+ continue;
455
+ const type = storedType(u.manifest, u.manifest.account);
456
+ const seen = observed.filter((o) => o.type === type);
457
+ if (seen.length !== 1 || tree.has(`${type}:${seen[0].id}`))
458
+ continue;
459
+ const local = readTree(service, root).find((r) => r.type === type && r.deleted !== true && !aliases.has(`${type}:${String(r.id)}`) && String(r.id) !== seen[0].id);
460
+ if (local) {
461
+ seen[0].adopts = String(local.id);
462
+ aliases.set(`${type}:${String(local.id)}`, seen[0].id);
463
+ }
464
+ }
403
465
  if (!pending.size)
404
466
  return;
405
467
  const declOf = (type) => {
@@ -12,8 +12,14 @@ import type { HostRule } from './packRegistry.js';
12
12
  * and no sealed credential migrates.
13
13
  */
14
14
  export type TwinAuthStrategy =
15
- /** the key rides in the query string (`?key=…`, `?appid=…`) — no header will do */
15
+ /** A vendor of lanes whose lanes authenticate apart (Cloudflare's API by bearer, its R2 by SigV4): a lane named takes
16
+ * its own strategy, every other call the sealed headers; the request names its lane (`RemoteExecuteRequest.lane`). */
16
17
  {
18
+ in: 'lanes';
19
+ lanes: Record<string, TwinAuthStrategy>;
20
+ }
21
+ /** the key rides in the query string (`?key=…`, `?appid=…`) — no header will do */
22
+ | {
17
23
  in: 'query';
18
24
  name: string;
19
25
  }
@@ -23,7 +29,10 @@ export type TwinAuthStrategy =
23
29
  | {
24
30
  in: 'signature';
25
31
  algorithm: 'aws-sigv4';
26
- scope: (req: {
32
+ scope: {
33
+ region: string;
34
+ service: string;
35
+ } | ((req: {
27
36
  method: string;
28
37
  path: string;
29
38
  host: string;
@@ -32,7 +41,7 @@ export type TwinAuthStrategy =
32
41
  }) => {
33
42
  region: string;
34
43
  service: string;
35
- };
44
+ });
36
45
  }
37
46
  /** a signature computed PER REQUEST over bytes only the pack can canonicalize */
38
47
  | {
@@ -130,6 +130,14 @@ async function awsSigV4Headers(input) {
130
130
  };
131
131
  }
132
132
  export function buildRemoteExecute(origin, credential, auth, vendorHosts, options = {}) {
133
+ if (auth?.in === 'lanes') {
134
+ // each lane's strategy over the same origin and credential, the budget charged once, around them all
135
+ const own = { ...options, vendor: undefined };
136
+ const byLane = new Map(Object.entries(auth.lanes).map(([lane, strategy]) => [lane, buildRemoteExecute(origin, credential, strategy, vendorHosts, own)]));
137
+ const headers = buildRemoteExecute(origin, credential, undefined, vendorHosts, own);
138
+ const execute = (request) => (byLane.get(request.lane ?? '') ?? headers)(request);
139
+ return options.vendor === undefined ? execute : budgeted(options.vendor, execute, options.custody?.key ?? JSON.stringify(credential.headers));
140
+ }
133
141
  const execute = auth?.in === 'exchange' ? exchangeExecute(origin, credential, auth, vendorHosts, options.custody) : direct(origin, credential, auth, vendorHosts);
134
142
  return options.vendor === undefined ? execute : budgeted(options.vendor, execute, options.custody?.key ?? JSON.stringify(credential.headers));
135
143
  }
@@ -148,7 +156,7 @@ function budgeted(vendor, execute, token) {
148
156
  }
149
157
  /** The call made as the root's credential is applied by the strategy (header replacement when none). */
150
158
  function direct(origin, credential, auth, vendorHosts) {
151
- return async ({ method, path, headers, body, responseType, presigned }) => {
159
+ return async ({ method, path, headers, body, responseType, presigned, issued }) => {
152
160
  // A PRESIGNED UPLOAD URL the vendor returned: its own signature is its authorization, so the
153
161
  // sealed credential never goes with it, whatever host it names. A host other than the root's
154
162
  // passes the same validation the root does; the root's own origin (a twin standing in for the
@@ -205,8 +213,12 @@ function direct(origin, credential, auth, vendorHosts) {
205
213
  throw new Error('remote execute: path escaped the validated origin');
206
214
  }
207
215
  const outbound = new Headers(headers ?? {});
208
- for (const [name, value] of Object.entries(credential.headers))
209
- outbound.set(name, value);
216
+ // a credential the vendor issued in this perform is the request's authorization; the sealed one stays home
217
+ if (issued !== undefined)
218
+ outbound.set('authorization', issued);
219
+ else
220
+ for (const [name, value] of Object.entries(credential.headers))
221
+ outbound.set(name, value);
210
222
  // ── THE STRATEGIES BEYOND HEADER REPLACEMENT ──────────────────────────────────────────────
211
223
  // Applied AFTER the anchoring check above, so nothing here can move the request off the
212
224
  // validated origin: `searchParams.set` only ever touches the query, and a header is a header.
@@ -214,7 +226,7 @@ function direct(origin, credential, auth, vendorHosts) {
214
226
  // FAIL CLOSED. A strategy with no secret must never send a request that merely LOOKS
215
227
  // authenticated — that silence is the whole defect this exists to remove. The refusals name the
216
228
  // strategy and the parameter, never the secret.
217
- if (auth !== undefined) {
229
+ if (auth !== undefined && issued === undefined) {
218
230
  const secret = credential.secret;
219
231
  if (typeof secret !== 'string' || secret === '') {
220
232
  throw new Error(`remote execute: the ${auth.in} credential strategy needs \`secret\` on the sealed credential, and it is absent`);
@@ -230,7 +242,7 @@ function direct(origin, credential, auth, vendorHosts) {
230
242
  throw new Error('remote execute: the aws-sigv4 credential strategy needs `keyId` on the sealed credential, and it is absent');
231
243
  }
232
244
  // the HOST goes with it: an AWS endpoint names its own region, and only the pack knows how
233
- const { region, service } = auth.scope({ method, path, host: target.host, headers: Object.fromEntries(outbound.entries()), ...(body === undefined ? {} : { body }) });
245
+ const { region, service } = typeof auth.scope === 'function' ? auth.scope({ method, path, host: target.host, headers: Object.fromEntries(outbound.entries()), ...(body === undefined ? {} : { body }) }) : auth.scope;
234
246
  const signed = await awsSigV4Headers({ method, url: target, ...(body === undefined ? {} : { body }), keyId, secret, region, service, headers: outbound, at: new Date() });
235
247
  for (const [name, value] of Object.entries(signed))
236
248
  outbound.set(name, value);
@@ -379,7 +391,7 @@ function exchangeExecute(origin, sealed, auth, vendorHosts, custody) {
379
391
  return anchored({ ...request, headers });
380
392
  };
381
393
  return async (request) => {
382
- if (request.presigned)
394
+ if (request.presigned || request.issued !== undefined)
383
395
  return anchored(request);
384
396
  const held = current();
385
397
  const first = await call(request, held);
@@ -14,7 +14,7 @@ export declare const packOf: (manifest: {
14
14
  }>) => PackDescriptor;
15
15
  /** The pack registered, so the World finds it. */
16
16
  export declare const registerPack: (pack: PackDescriptor) => PackDescriptor;
17
- export type { Actor, CoreScope, DerivedManifest, ErrorSpec, LaneRoute, LanesDecl, VendorManifest, FieldRule, ResourceDecl, ScreenDecl, SeedCall, StateField, Transition } from './derived-core.js';
17
+ export type { Actor, CoreScope, DerivedManifest, PerformHook, PerformHookArgs, ErrorSpec, LaneRoute, LanesDecl, VendorManifest, FieldRule, ResourceDecl, ScreenDecl, SeedCall, StateField, Transition } from './derived-core.js';
18
18
  export type { ApplicationAnswer, EndpointsDecl, EventRender, EventScheme, EventsDecl, EventTransport, EventValues, EventWrite } from './events.js';
19
19
  export { verifyEvent } from './events.js';
20
20
  export { deriveStateSystem, deriveVendorStateSystem } from './derived-real.js';
@@ -12,6 +12,12 @@ export type RemoteExecuteRequest = {
12
12
  * the API, and the request goes WITHOUT the sealed credential. An upload the vendor authorizes
13
13
  * with the API token (LinkedIn's images) is not presigned and cannot use this. */
14
14
  presigned?: true;
15
+ /** An Authorization the vendor ISSUED earlier in the same perform (Cloudflare's assets upload session answers a JWT
16
+ * that its upload calls carry instead of the API token), on the root's own origin: it is sent as the request's
17
+ * Authorization and the sealed credential does not go with it. */
18
+ issued?: string;
19
+ /** the lane of a vendor of lanes the request is (its strategy, when its lanes authenticate apart) */
20
+ lane?: string;
15
21
  };
16
22
  export type RemoteExecuteResponse = {
17
23
  status: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.4",
3
+ "version": "3.0.6",
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",
@@ -239,6 +239,22 @@ export type ScreenDecl = {
239
239
  source: string;
240
240
  };
241
241
 
242
+ /** A protocol hook's arguments: the root's executor (it applies the sealed credential; `issued` carries one the vendor
243
+ * issued instead), the group and its first entry's recorded input, the perform's context, the derived perform of an
244
+ * input (`send`: the recorded request, or one the hook changed), the stored id the World's id of a type resolves to,
245
+ * the surface's base path, and the World's blobs of this twin. */
246
+ export type PerformHookArgs = {
247
+ execute: import('./remote-execute.ts').RemoteExecute;
248
+ group: import('./actions.ts').TwinAction[];
249
+ input: Record<string, unknown>;
250
+ ctx: import('./head.ts').PerformContext;
251
+ send: (input: Record<string, unknown>) => Promise<import('./head.ts').PushOutcome>;
252
+ resolve: (type: string, localId: string) => string;
253
+ basePath: string;
254
+ blob: (key: string) => Promise<Uint8Array | null>;
255
+ };
256
+ export type PerformHook = (args: PerformHookArgs) => Promise<import('./head.ts').PushOutcome>;
257
+
242
258
  export type DerivedManifest = {
243
259
  /** The pack's descriptor, as data (docs/contributing/architecture.md, "The descriptor"), every field but `vendor`, which
244
260
  * is the manifest's: the pack registers `packOf(manifest)`, so its index is a fixed file. A lane has none. */
@@ -293,6 +309,14 @@ export type DerivedManifest = {
293
309
  /** a pack whose wire has no derived perform (a socket, a line protocol, a managed database): binding a root is refused
294
310
  * with this reason */
295
311
  vendorBacked?: { none: string };
312
+ /** the resource the vendor's paths name the account by (Cloudflare's `accounts/{account_id}`): with a root, the
313
+ * World's account is the one account the root's credential reaches, which refresh observes ("The account a root
314
+ * is", architecture) */
315
+ account?: string;
316
+ /** a vendor protocol one recorded request cannot express, by the operation that ends it: a session whose answer
317
+ * issues the credential its next calls carry (Cloudflare's assets upload), performed by the hook in place of the
318
+ * derived request, which it composes (`send`) ("Protocols a request cannot express", architecture) */
319
+ performs?: Record<string, PerformHook>;
296
320
  /** `withParam`: the error names the path parameter that held the unknown id */
297
321
  notFound: { status: number; message: string; code?: string; kind?: string; withParam?: boolean };
298
322
  /** the list envelope (placeholders `{data}`, `{has_more}`, `{url}`, `{first_id}`, `{last_id}`),
@@ -70,7 +70,9 @@ function resolvedDeep(value: unknown, resolve: (v: string) => string): unknown {
70
70
  return o ? Object.fromEntries(Object.entries(o).map(([k, v]) => [k, resolvedDeep(v, resolve)])) : value;
71
71
  }
72
72
 
73
- /** A multipart body: the recorded fields, then each recorded file part read back from the World's blob store. */
73
+ /** A multipart body: the recorded fields, then each recorded file part read back from the World's blob store. An
74
+ * operation the vendor takes as multipart is sent so with no file part too (a Workers upload of assets alone carries
75
+ * only its `metadata` field). */
74
76
  async function multipart(body: unknown, files: Row[], ctx: PerformContext): Promise<{ body: Uint8Array; type: string }> {
75
77
  const boundary = `----volter${hashFieldValue({ body, files }).slice(0, 24)}`;
76
78
  const chunks: Uint8Array[] = [];
@@ -109,10 +111,33 @@ function unitOf(units: Unit[], input: Row): Unit | undefined {
109
111
  ?? (input.graphql ? units[0] : undefined);
110
112
  }
111
113
 
114
+ /** The account a root is, before a perform sends a path naming the World's ("The account a root is"): the World's
115
+ * account its own doors made, unadopted, adopts the one account the root's credential lists. Read once per deploy (an
116
+ * adopted account is not asked again); a list the vendor refuses, or one naming several accounts, adopts none. */
117
+ async function adoptAccount(units: Unit[], execute: RemoteExecute, ctx: PerformContext): Promise<void> {
118
+ if (ctx.service === undefined) return;
119
+ for (const u of units) {
120
+ const m = u.manifest;
121
+ const decl = m.account ? m.resources[m.account] : undefined;
122
+ if (!m.account || !decl) continue;
123
+ const type = storedType(m, m.account);
124
+ const local = (readTree(ctx.service, ctx.root) as Row[]).filter((r) => r.type === type && r.deleted !== true && ctx.resolve(type, String(r.id)) === String(r.id));
125
+ const scope = decl.refresh;
126
+ const op = scope && 'list' in scope ? u.surface.operations.find((o) => o.id === scope.list) : undefined;
127
+ if (!local.length || !op) continue;
128
+ const answer = await execute({ method: op.method.toUpperCase(), path: `${op.basePath ?? u.surface.basePath ?? ''}${op.path}`, ...(u.lane ? { lane: u.lane } : {}) });
129
+ if (answer.status >= 400) continue;
130
+ const items = itemsOf(op, parse(answer));
131
+ const id = items.length === 1 ? storedIdOf(decl, items[0]!) : undefined;
132
+ if (id === undefined || local.some((l) => String(l.id) === id)) continue;
133
+ observeResources(ctx.service, [{ type, id, fields: items[0] as ObservedResource['fields'], adopts: String(local[0]!.id) }], ctx.root !== undefined ? { root: ctx.root } : {});
134
+ }
135
+ }
136
+
112
137
  function performOf(units: Unit[]): PerformAction {
113
- return async (execute: RemoteExecute, group: TwinAction[], ctx: PerformContext): Promise<PushOutcome> => {
138
+ // the request a group's first entry recorded (or one a protocol hook changed), sent to the vendor
139
+ const derived = async (execute: RemoteExecute, group: TwinAction[], ctx: PerformContext, input: Row): Promise<PushOutcome> => {
114
140
  const first = group[0]!;
115
- const input = (first.input ?? {}) as Row;
116
141
  const unit = unitOf(units, input);
117
142
  if (!unit) throw new RefusedWriteError('vendor', `the write records no operation of the vendor's surface (${String(input.operationId ?? 'none')})`, first.id);
118
143
  const { manifest: m, surface } = unit;
@@ -153,11 +178,11 @@ function performOf(units: Unit[]): PerformAction {
153
178
  const asCame = object(input.body);
154
179
  const raw = asCame && typeof asCame.$text === 'string' ? { body: asCame.$text as string } : asCame && typeof asCame.$base64 === 'string' ? { body: new Uint8Array(Buffer.from(asCame.$base64 as string, 'base64')) } : undefined;
155
180
  const sent: { body?: string | Uint8Array; type?: string } = raw ? { ...raw, ...(typeof input.contentType === 'string' ? { type: input.contentType } : {}) }
156
- : files.length ? await multipart(body, files, ctx) : op ? encode(op, body) : { body: JSON.stringify(body), type: 'application/json' };
181
+ : files.length || op?.bodyEncoding === 'multipart' ? await multipart(body, files, ctx) : op ? encode(op, body) : { body: JSON.stringify(body), type: 'application/json' };
157
182
  const headers: Record<string, string> = { ...((object(input.headers) ?? {}) as Record<string, string>), ...(sent.type ? { 'content-type': sent.type } : {}) };
158
183
  // a retried deploy makes nothing twice, and two Worlds deploying into one account never collide
159
184
  if (m.idempotency) headers[m.idempotency.header] = hashFieldValue({ credential: ctx.credential ?? '', entry: first.id });
160
- const answer = await execute({ method, path, headers, ...(sent.body !== undefined ? { body: sent.body } : {}) });
185
+ const answer = await execute({ method, path, headers, ...(sent.body !== undefined ? { body: sent.body } : {}), ...(unit.lane ? { lane: unit.lane } : {}) });
161
186
  const parsed = parse(answer);
162
187
  const shape = refusalShape(m);
163
188
  const refused = answer.status >= 400 || (shape.length > 0 && shape.every(([p, v]) => at(parsed, p) === v));
@@ -193,6 +218,22 @@ function performOf(units: Unit[]): PerformAction {
193
218
  }
194
219
  return { externalId, actionId: primary.id, data: obj, ...(also.length ? { also } : {}) };
195
220
  };
221
+ return async (execute: RemoteExecute, group: TwinAction[], ctx: PerformContext): Promise<PushOutcome> => {
222
+ await adoptAccount(units, execute, ctx);
223
+ const input = (group[0]!.input ?? {}) as Row;
224
+ const unit = unitOf(units, input);
225
+ // a protocol one request cannot express is the pack's hook, which composes the derived perform ("Protocols a
226
+ // request cannot express")
227
+ const hook = unit && typeof input.operationId === 'string' ? unit.manifest.performs?.[input.operationId] : undefined;
228
+ if (!hook || !unit) return derived(execute, group, ctx, input);
229
+ return hook({
230
+ execute, group, input, ctx,
231
+ send: (changed) => derived(execute, group, ctx, changed),
232
+ resolve: (type, localId) => ctx.resolve(type, localId),
233
+ basePath: unit.surface.basePath ?? '',
234
+ blob: (key) => readResourceBlob(ctx.service ?? '', key, ctx.root),
235
+ });
236
+ };
196
237
  }
197
238
 
198
239
  /** Path parameters that name a subject by a field rather than its id, sent as the vendor's value of that field, read
@@ -239,7 +280,8 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
239
280
  return async (execute, opts) => {
240
281
  const observed: ObservedResource[] = [];
241
282
  const complete: Completed[] = [];
242
- for (const { manifest: m, surface } of units) {
283
+ for (const unit of units) {
284
+ const { manifest: m, surface } = unit;
243
285
  // parents before children, however deep
244
286
  const scoped = Object.entries(m.resources).filter(([, d]) => d.refresh && !('none' in d.refresh));
245
287
  const depth = (r: string, seen = new Set<string>()): number => { const p = m.resources[r]?.parent?.resource; return p && !seen.has(p) ? 1 + depth(p, seen.add(r)) : 0; };
@@ -260,7 +302,7 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
260
302
  // a parent the operation names in its query, not its path (Slack's conversations.history `channel`)
261
303
  const byQuery = decl.parent?.param && parent !== undefined && !op.path.includes(`{${decl.parent.param}}`) ? { [decl.parent.param]: String(parent) } : {};
262
304
  const q = new URLSearchParams({ ...page, ...byQuery });
263
- const answer = await execute({ method: op.method.toUpperCase(), path: [...q].length ? `${path}?${q}` : path });
305
+ const answer = await execute({ method: op.method.toUpperCase(), path: [...q].length ? `${path}?${q}` : path, ...(unit.lane ? { lane: unit.lane } : {}) });
264
306
  if (answer.status === 401 || answer.status === 403) throw new Error(`${answer.status}: the vendor refused the root's credential; nothing folded`);
265
307
  if (answer.status === 429) throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
266
308
  if (answer.status >= 400) throw new Error(`${answer.status}: ${refusalWords(m, parse(answer))}`);
@@ -336,6 +378,16 @@ function adopt(service: string, root: string | undefined, units: Unit[], observe
336
378
  if (!list.some((p) => p.id === e.subject.id)) list.push({ id: e.subject.id, fields: held.fields });
337
379
  pending.set(e.subject.type, list);
338
380
  }
381
+ // the account a root is ("The account a root is"): the root's credential reaches one account of the type the pack's
382
+ // paths name it by; observed alone, it is the World's own account (made by the World's door, so no deploy settled it)
383
+ for (const u of units) {
384
+ if (!u.manifest.account) continue;
385
+ const type = storedType(u.manifest, u.manifest.account);
386
+ const seen = observed.filter((o) => o.type === type);
387
+ if (seen.length !== 1 || tree.has(`${type}:${seen[0]!.id}`)) continue;
388
+ const local = (readTree(service, root) as Row[]).find((r) => r.type === type && r.deleted !== true && !aliases.has(`${type}:${String(r.id)}`) && String(r.id) !== seen[0]!.id);
389
+ if (local) { seen[0]!.adopts = String(local.id); aliases.set(`${type}:${String(local.id)}`, seen[0]!.id); }
390
+ }
339
391
  if (!pending.size) return;
340
392
  const declOf = (type: string): { m: DerivedManifest; decl: ResourceDecl } | undefined => {
341
393
  for (const u of units) { const hit = Object.entries(u.manifest.resources).find(([r]) => storedType(u.manifest, r) === type); if (hit) return { m: u.manifest, decl: hit[1] }; }
package/src/executor.ts CHANGED
@@ -21,12 +21,15 @@ import { RateBudget } from './rateBudget.ts';
21
21
  * and no sealed credential migrates.
22
22
  */
23
23
  export type TwinAuthStrategy =
24
+ /** A vendor of lanes whose lanes authenticate apart (Cloudflare's API by bearer, its R2 by SigV4): a lane named takes
25
+ * its own strategy, every other call the sealed headers; the request names its lane (`RemoteExecuteRequest.lane`). */
26
+ | { in: 'lanes'; lanes: Record<string, TwinAuthStrategy> }
24
27
  /** the key rides in the query string (`?key=…`, `?appid=…`) — no header will do */
25
28
  | { in: 'query'; name: string }
26
29
  /** AWS Signature Version 4 — a signature computed per request over the whole canonical request.
27
30
  * `scope` answers which region and service THIS request is for, because a pack may route several
28
31
  * services over one executor (aws routes six); it is pure and is handed no secret. */
29
- | { in: 'signature'; algorithm: 'aws-sigv4'; scope: (req: { method: string; path: string; host: string; headers: Record<string, string>; body?: string | Uint8Array }) => { region: string; service: string } }
32
+ | { in: 'signature'; algorithm: 'aws-sigv4'; scope: { region: string; service: string } | ((req: { method: string; path: string; host: string; headers: Record<string, string>; body?: string | Uint8Array }) => { region: string; service: string }) }
30
33
  /** a signature computed PER REQUEST over bytes only the pack can canonicalize */
31
34
  | {
32
35
  in: 'signature';
@@ -223,6 +226,14 @@ export type CredentialCustody = { key: string; open: () => Promise<CredentialPay
223
226
  * made, refused when the budget is spent, and settled with the vendor's answer (its back-off honoured). */
224
227
  export type RemoteExecuteOptions = { custody?: CredentialCustody; vendor?: string };
225
228
  export function buildRemoteExecute(origin: URL, credential: CredentialPayload, auth?: TwinAuthStrategy, vendorHosts?: readonly HostRule[], options: RemoteExecuteOptions = {}): RemoteExecute {
229
+ if (auth?.in === 'lanes') {
230
+ // each lane's strategy over the same origin and credential, the budget charged once, around them all
231
+ const own = { ...options, vendor: undefined };
232
+ const byLane = new Map(Object.entries(auth.lanes).map(([lane, strategy]) => [lane, buildRemoteExecute(origin, credential, strategy, vendorHosts, own)]));
233
+ const headers = buildRemoteExecute(origin, credential, undefined, vendorHosts, own);
234
+ const execute: RemoteExecute = (request) => (byLane.get(request.lane ?? '') ?? headers)(request);
235
+ return options.vendor === undefined ? execute : budgeted(options.vendor, execute, options.custody?.key ?? JSON.stringify(credential.headers));
236
+ }
226
237
  const execute = auth?.in === 'exchange' ? exchangeExecute(origin, credential, auth, vendorHosts, options.custody) : direct(origin, credential, auth, vendorHosts);
227
238
  return options.vendor === undefined ? execute : budgeted(options.vendor, execute, options.custody?.key ?? JSON.stringify(credential.headers));
228
239
  }
@@ -242,8 +253,8 @@ function budgeted(vendor: string, execute: RemoteExecute, token: string): Remote
242
253
  }
243
254
 
244
255
  /** The call made as the root's credential is applied by the strategy (header replacement when none). */
245
- function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinAuthStrategy, { in: 'exchange' }>, vendorHosts?: readonly HostRule[]): RemoteExecute {
246
- return async ({ method, path, headers, body, responseType, presigned }) => {
256
+ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinAuthStrategy, { in: 'exchange' | 'lanes' }>, vendorHosts?: readonly HostRule[]): RemoteExecute {
257
+ return async ({ method, path, headers, body, responseType, presigned, issued }) => {
247
258
  // A PRESIGNED UPLOAD URL the vendor returned: its own signature is its authorization, so the
248
259
  // sealed credential never goes with it, whatever host it names. A host other than the root's
249
260
  // passes the same validation the root does; the root's own origin (a twin standing in for the
@@ -288,7 +299,9 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
288
299
  if (target.origin !== origin.origin || !target.pathname.startsWith(origin.pathname.replace(/\/+$/, ''))) throw new Error('remote execute: path escaped the validated origin');
289
300
  }
290
301
  const outbound = new Headers(headers ?? {});
291
- for (const [name, value] of Object.entries(credential.headers)) outbound.set(name, value);
302
+ // a credential the vendor issued in this perform is the request's authorization; the sealed one stays home
303
+ if (issued !== undefined) outbound.set('authorization', issued);
304
+ else for (const [name, value] of Object.entries(credential.headers)) outbound.set(name, value);
292
305
 
293
306
  // ── THE STRATEGIES BEYOND HEADER REPLACEMENT ──────────────────────────────────────────────
294
307
  // Applied AFTER the anchoring check above, so nothing here can move the request off the
@@ -297,7 +310,7 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
297
310
  // FAIL CLOSED. A strategy with no secret must never send a request that merely LOOKS
298
311
  // authenticated — that silence is the whole defect this exists to remove. The refusals name the
299
312
  // strategy and the parameter, never the secret.
300
- if (auth !== undefined) {
313
+ if (auth !== undefined && issued === undefined) {
301
314
  const secret = credential.secret;
302
315
  if (typeof secret !== 'string' || secret === '') {
303
316
  throw new Error(`remote execute: the ${auth.in} credential strategy needs \`secret\` on the sealed credential, and it is absent`);
@@ -312,7 +325,7 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
312
325
  throw new Error('remote execute: the aws-sigv4 credential strategy needs `keyId` on the sealed credential, and it is absent');
313
326
  }
314
327
  // the HOST goes with it: an AWS endpoint names its own region, and only the pack knows how
315
- const { region, service } = auth.scope({ method, path, host: target.host, headers: Object.fromEntries(outbound.entries()), ...(body === undefined ? {} : { body }) });
328
+ const { region, service } = typeof auth.scope === 'function' ? auth.scope({ method, path, host: target.host, headers: Object.fromEntries(outbound.entries()), ...(body === undefined ? {} : { body }) }) : auth.scope;
316
329
  const signed = await awsSigV4Headers({ method, url: target, ...(body === undefined ? {} : { body }), keyId, secret, region, service, headers: outbound, at: new Date() });
317
330
  for (const [name, value] of Object.entries(signed)) outbound.set(name, value);
318
331
  } else {
@@ -437,7 +450,7 @@ function exchangeExecute(origin: URL, sealed: CredentialPayload, auth: Extract<T
437
450
  return anchored({ ...request, headers });
438
451
  };
439
452
  return async (request) => {
440
- if (request.presigned) return anchored(request);
453
+ if (request.presigned || request.issued !== undefined) return anchored(request);
441
454
  const held = current();
442
455
  const first = await call(request, held);
443
456
  if (!retryOn.includes(first.status)) return first;
package/src/index.ts CHANGED
@@ -21,7 +21,7 @@ export const packOf = (
21
21
  export const registerPack = (pack: PackDescriptor): PackDescriptor => registerAny(pack);
22
22
 
23
23
  // ── the manifest ───────────────────────────────────────────────────────────────────────────────
24
- export type { Actor, CoreScope, DerivedManifest, ErrorSpec, LaneRoute, LanesDecl, VendorManifest, FieldRule, ResourceDecl, ScreenDecl, SeedCall, StateField, Transition } from './derived-core.ts';
24
+ export type { Actor, CoreScope, DerivedManifest, PerformHook, PerformHookArgs, ErrorSpec, LaneRoute, LanesDecl, VendorManifest, FieldRule, ResourceDecl, ScreenDecl, SeedCall, StateField, Transition } from './derived-core.ts';
25
25
  export type { ApplicationAnswer, EndpointsDecl, EventRender, EventScheme, EventsDecl, EventTransport, EventValues, EventWrite } from './events.ts';
26
26
  export { verifyEvent } from './events.ts';
27
27
  export { deriveStateSystem, deriveVendorStateSystem } from './derived-real.ts';
@@ -16,6 +16,12 @@ export type RemoteExecuteRequest = {
16
16
  * the API, and the request goes WITHOUT the sealed credential. An upload the vendor authorizes
17
17
  * with the API token (LinkedIn's images) is not presigned and cannot use this. */
18
18
  presigned?: true;
19
+ /** An Authorization the vendor ISSUED earlier in the same perform (Cloudflare's assets upload session answers a JWT
20
+ * that its upload calls carry instead of the API token), on the root's own origin: it is sent as the request's
21
+ * Authorization and the sealed credential does not go with it. */
22
+ issued?: string;
23
+ /** the lane of a vendor of lanes the request is (its strategy, when its lanes authenticate apart) */
24
+ lane?: string;
19
25
  };
20
26
  export type RemoteExecuteResponse = {
21
27
  status: number;