@volter/world-core 3.0.156 → 3.0.157

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 (74) hide show
  1. package/attach.cjs +2 -0
  2. package/capability-decision.cjs +6 -4
  3. package/dist/attach.cjs +2 -0
  4. package/dist/capability-decision.cjs +6 -4
  5. package/dist/generated/pack-facts.json +77 -8
  6. package/dist/network-policy.cjs +11 -1
  7. package/dist/network-policy.d.cts +1 -0
  8. package/dist/pack-facts.d.cts +3 -0
  9. package/dist/required-credential-transport.cjs +29 -0
  10. package/dist/required-credential-transport.d.cts +3 -0
  11. package/dist/src/blob-store.d.ts +16 -0
  12. package/dist/src/blob-store.js +76 -1
  13. package/dist/src/derived-core.d.ts +72 -6
  14. package/dist/src/derived-core.js +192 -51
  15. package/dist/src/derived-real.js +97 -15
  16. package/dist/src/derived.d.ts +5 -0
  17. package/dist/src/executor.d.ts +7 -0
  18. package/dist/src/executor.js +15 -1
  19. package/dist/src/git/lfs.d.ts +13 -3
  20. package/dist/src/git/lfs.js +41 -9
  21. package/dist/src/head.d.ts +2 -0
  22. package/dist/src/head.js +6 -0
  23. package/dist/src/index.d.ts +1 -0
  24. package/dist/src/index.js +1 -0
  25. package/dist/src/live/ingest.js +9 -13
  26. package/dist/src/live/rtmp.js +7 -5
  27. package/dist/src/live/segmenter.js +7 -1
  28. package/dist/src/machines.js +5 -1
  29. package/dist/src/managed-go-programs.d.ts +55 -0
  30. package/dist/src/managed-go-programs.js +133 -0
  31. package/dist/src/mongodb/stream.d.ts +23 -0
  32. package/dist/src/pack-fetch.d.ts +6 -1
  33. package/dist/src/pack-fetch.js +49 -6
  34. package/dist/src/pack-paths.d.ts +2 -1
  35. package/dist/src/pack-paths.js +6 -1
  36. package/dist/src/resource-blob.d.ts +4 -0
  37. package/dist/src/resource-blob.js +8 -1
  38. package/dist/src/runtime.d.ts +3 -3
  39. package/dist/src/runtime.js +2 -2
  40. package/dist/src/serve-http.js +53 -3
  41. package/dist/src/sockets.d.ts +4 -1
  42. package/dist/src/sockets.js +59 -6
  43. package/dist/src/vendor-call.js +58 -0
  44. package/dist/src/world-store.d.ts +15 -1
  45. package/dist/src/world-store.js +78 -2
  46. package/generated/pack-facts.json +77 -8
  47. package/network-policy.cjs +11 -1
  48. package/network-policy.d.cts +1 -0
  49. package/pack-facts.d.cts +3 -0
  50. package/package.json +8 -1
  51. package/required-credential-transport.cjs +29 -0
  52. package/required-credential-transport.d.cts +3 -0
  53. package/src/blob-store.ts +59 -1
  54. package/src/derived-core.ts +215 -51
  55. package/src/derived-real.ts +80 -14
  56. package/src/derived.ts +6 -1
  57. package/src/executor.ts +17 -3
  58. package/src/git/lfs.ts +40 -9
  59. package/src/head.ts +6 -0
  60. package/src/index.ts +1 -0
  61. package/src/live/ingest.ts +7 -3
  62. package/src/live/rtmp.ts +7 -5
  63. package/src/live/segmenter.ts +7 -1
  64. package/src/machines.ts +5 -1
  65. package/src/managed-go-programs.ts +146 -0
  66. package/src/mongodb/stream.ts +10 -0
  67. package/src/pack-fetch.ts +46 -8
  68. package/src/pack-paths.ts +5 -1
  69. package/src/resource-blob.ts +9 -1
  70. package/src/runtime.ts +4 -2
  71. package/src/serve-http.ts +43 -3
  72. package/src/sockets.ts +39 -8
  73. package/src/vendor-call.ts +54 -0
  74. package/src/world-store.ts +66 -2
@@ -4,7 +4,7 @@ import { parseExactJson, exactInteger, compareExactNumbers } from './exact-json.
4
4
  // They run over the kernel executor (`RemoteExecute`), which applies the sealed credential; the credential never
5
5
  // reaches here, only its keyed fingerprint (`PerformContext.credential`, for the idempotency key).
6
6
  import type { TwinAction } from './actions.ts';
7
- import { aliasesFrom, parentEntries, readParentTreeMap, readTree } from './log.ts';
7
+ import { aliasesFrom, parentEntries, readParentTreeMap, readTree, wholeLog } from './log.ts';
8
8
  import { storedType, type DerivedManifest, type IngestDecl, type ResourceDecl, type VendorManifest } from './derived-core.ts';
9
9
  import type { DerivedOperation, DerivedSurface } from './derived.ts';
10
10
  import { verifyEvent, type EventScheme } from './events.ts';
@@ -181,10 +181,20 @@ const fixedHeaders = (op: DerivedOperation): Record<string, string> => Object.fr
181
181
 
182
182
  /** The body as the operation's encoding sends it. */
183
183
  function encode(op: DerivedOperation, body: unknown): { body?: string; type?: string } {
184
- if (body === undefined || op.bodyEncoding === 'none') return {};
184
+ // a body the request carried where the spec declares none (Tinybird's Events API, NDJSON its client sends as text):
185
+ // the body as recorded, as the vendor read it
186
+ if (body === undefined) return {};
185
187
  if (op.bodyEncoding === 'form') {
188
+ // a form's nested values in the bracket form a form-encoded API reads (`scope[type]=account`, `items[0][price]=…`),
189
+ // the form the kernel's own parseBracketForm reads back; never a value's JSON text
186
190
  const form = new URLSearchParams();
187
- for (const [k, v] of Object.entries(object(body) ?? {})) if (v !== undefined && v !== null) form.set(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
191
+ const put = (key: string, v: unknown): void => {
192
+ if (v === undefined || v === null) return;
193
+ if (Array.isArray(v)) { v.forEach((item, i) => put(`${key}[${i}]`, item)); return; }
194
+ if (typeof v === 'object') { for (const [k, x] of Object.entries(v as Record<string, unknown>)) put(`${key}[${k}]`, x); return; }
195
+ form.append(key, String(v));
196
+ };
197
+ for (const [k, v] of Object.entries(object(body) ?? {})) put(k, v);
188
198
  return { body: form.toString(), type: 'application/x-www-form-urlencoded' };
189
199
  }
190
200
  return { body: JSON.stringify(body), type: 'application/json' };
@@ -249,8 +259,14 @@ async function graphqlAnswers(m: DerivedManifest, surface: DerivedSurface, input
249
259
  /** The account a root is, before a perform sends a path naming the World's ("The account a root is"): the World's
250
260
  * account its own doors made, unadopted, adopts the one account the root's credential lists. Read once per deploy (an
251
261
  * adopted account is not asked again); a list the vendor refuses, or one naming several accounts, adopts none. */
262
+ /** The accounts a root's list already named as the World's own (the World's id is the vendor's), by service and root:
263
+ * nothing to adopt, so not asked again. */
264
+ const accountsMatched = new Map<string, Set<string>>();
265
+
252
266
  async function adoptAccount(units: Unit[], execute: RemoteExecute, ctx: PerformContext): Promise<void> {
253
267
  if (ctx.service === undefined) return;
268
+ const matched = accountsMatched.get(`${ctx.service}\u0000${ctx.root ?? ''}`) ?? new Set<string>();
269
+ accountsMatched.set(`${ctx.service}\u0000${ctx.root ?? ''}`, matched);
254
270
  for (const u of units) {
255
271
  const m = u.manifest;
256
272
  const decl = m.account ? m.resources[m.account] : undefined;
@@ -259,11 +275,12 @@ async function adoptAccount(units: Unit[], execute: RemoteExecute, ctx: PerformC
259
275
  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));
260
276
  const scope = decl.refresh;
261
277
  const op = scope && 'list' in scope ? u.surface.operations.find((o) => o.id === scope.list) : undefined;
262
- if (!local.length || !op) continue;
278
+ if (!local.length || !op || local.every((l) => matched.has(`${type}:${String(l.id)}`))) continue;
263
279
  const answer = await execute({ method: op.method.toUpperCase(), path: sentTo(m, marked(op, `${op.basePath ?? u.surface.basePath ?? ''}${op.path}`), {}), ...(u.lane ? { lane: u.lane } : {}), headers: fixedHeaders(op) });
264
280
  if (answer.status >= 400) continue;
265
281
  const items = itemsOf(op, parse(answer));
266
282
  const id = items.length === 1 ? storedIdOf(decl, items[0]!) : undefined;
283
+ if (id !== undefined && local.some((l) => String(l.id) === id)) matched.add(`${type}:${id}`);
267
284
  if (id === undefined || local.some((l) => String(l.id) === id)) continue;
268
285
  observeResources(ctx.service, [{ type, id, fields: items[0] as ObservedResource['fields'], adopts: String(local[0]!.id) }], ctx.root !== undefined ? { root: ctx.root } : {});
269
286
  }
@@ -469,8 +486,25 @@ function scopeIdOf(scope: object, fields: Record<string, unknown>): string | und
469
486
  return value === undefined || value === null || value === '' ? undefined : String(value);
470
487
  }
471
488
 
489
+ /** A refresh larger than one window of the vendor's rate budget paces itself: a call the budget refuses before making
490
+ * it (RateBudgetError) waits the time the refusal names, at most two minutes a wait, and is made then. The budget is
491
+ * never exceeded and no vendor answer is retried; without it a refresh needing more than one window never finishes
492
+ * (Slack's, after a deploy spent the window: every rerun refused again from the start). */
493
+ function paced(execute: RemoteExecute): RemoteExecute {
494
+ return async (request) => {
495
+ for (let waits = 0; ; waits++) {
496
+ try { return await execute(request); } catch (error) {
497
+ const wait = (error as { name?: string; retryAfterMs?: number }).name === 'RateBudgetError' ? (error as { retryAfterMs: number }).retryAfterMs : undefined;
498
+ if (wait === undefined || wait > 120_000 || waits >= 60) throw error;
499
+ await new Promise((done) => setTimeout(done, wait + 250));
500
+ }
501
+ }
502
+ };
503
+ }
504
+
472
505
  function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapters['refresh']> {
473
- return async (execute, opts) => {
506
+ return async (unpaced, opts) => {
507
+ const execute = paced(unpaced);
474
508
  const observed: ObservedResource[] = [];
475
509
  const complete: Completed[] = [];
476
510
  // what the root's credential may not read (a token scoped to some of the account): left as the World holds it, said
@@ -478,6 +512,16 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
478
512
  let answered = 0;
479
513
  for (const { manifest: m, surface, lane } of units) {
480
514
  const onLane = lane ? { lane } : {};
515
+ const tenantBinding: Record<string, unknown> = {};
516
+ if (m.refreshIdentity) {
517
+ const identity = surface.operations.find(operation => operation.id === m.refreshIdentity!.operation);
518
+ if (!m.tenant || !identity || identity.method !== 'get' || identity.path.includes('{')) throw new Error(`${m.vendor}: refresh identity needs a parameterless GET and declared tenant field`);
519
+ const answer = await execute({ method: 'GET', path: `${identity.basePath ?? surface.basePath ?? ''}${identity.path}`, ...onLane, headers: fixedHeaders(identity) });
520
+ if (answer.status >= 400 || refusedIn(m, parse(answer))) throw new Error(`${m.vendor}: root account identity refused; nothing folded`);
521
+ const tenant = at(parse(answer), m.refreshIdentity.field);
522
+ if (typeof tenant !== 'string' || !tenant) throw new Error(`${m.vendor}: root account identity is absent; nothing folded`);
523
+ tenantBinding[m.tenant.field] = tenant;
524
+ }
481
525
  // parents before children, however deep
482
526
  const scoped = Object.entries(m.resources).filter(([, d]) => d.refresh && !('none' in d.refresh));
483
527
  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; };
@@ -485,6 +529,7 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
485
529
  for (const [resource, decl] of order) {
486
530
  const scope = decl.refresh!;
487
531
  if ('none' in scope) continue;
532
+ if (m.refreshIdentity && (!('list' in scope) || 'graphql' in scope)) throw new Error(`${m.vendor}: account-bound refresh requires REST list scopes`);
488
533
  if ('graphql' in scope && scope.graphql) {
489
534
  await refreshGraphql(m, resource, decl, scope, execute, opts.scope, observed, complete);
490
535
  answered += 1;
@@ -501,6 +546,16 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
501
546
  if ('get' in scope && scope.known) {
502
547
  const aliases = aliasesFrom(parentEntries(service, opts.root));
503
548
  const held = [...readParentTreeMap(service, opts.root)].filter(([, r]) => r.type === type && r.fields.deleted !== true);
549
+ // and each a door made (a person's act on the vendor's site: a group they add the bot to), which the vendor holds
550
+ // as the World does though no deploy landed it
551
+ const rooted = new Set(held.map(([k]) => k));
552
+ const doorMade = new Set(wholeLog(service, opts.root).filter((e) => e.subject.type === type && (e.input as Row | undefined)?.door !== undefined).map((e) => String(e.subject.id)));
553
+ for (const r of readTree(service, opts.root) as Row[]) {
554
+ const key = `${type}:${String(r.id)}`;
555
+ if (r.type !== type || r.deleted === true || !doorMade.has(String(r.id)) || held.some(([k]) => k === key)) continue;
556
+ const { type: _t, id, ...fields } = r;
557
+ held.push([key, { type, id: String(id), fields } as (typeof held)[number][1]]);
558
+ }
504
559
  const read: string[] = [];
505
560
  // a 403 leaves the type as the World holds it, never complete
506
561
  let refused = false;
@@ -523,12 +578,15 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
523
578
  const target = marked(op, path);
524
579
  const sent = asked.length ? `${target}${target.includes('?') ? '&' : '?'}${new URLSearchParams(asked)}` : target;
525
580
  const answer = await execute({ method: op.method.toUpperCase(), path: sentTo(m, sent, subject), ...onLane, headers: fixedHeaders(op) });
581
+ // a door's subject the vendor does not show the root (another bot's private chat) stays as the World holds it
582
+ if (!rooted.has(key) && answer.status >= 400 && answer.status !== 429) continue;
526
583
  if (answer.status === 401) throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
527
584
  if (answer.status === 403) { unread.push({ type, path, status: 403 }); refused = true; break; }
528
585
  if (answer.status === 429) throw new Error(`429: rate limited${answer.headers['retry-after'] ? `, retry after ${answer.headers['retry-after']}s` : ''}`);
529
586
  read.push(String(subject.id));
530
587
  answered += 1;
531
- if (answer.status === 404) continue;
588
+ // the vendor's answer for a subject it no longer has: a 404, or the scope's `gone` (Telegram's 400 `chat not found`)
589
+ if (answer.status === 404 || (scope.gone && answer.status === scope.gone.status && refusalWords(m, parse(answer)).includes(scope.gone.says))) continue;
532
590
  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))}`);
533
591
  const full = object(op.answers?.key ? at(parse(answer), op.answers.key) : parse(answer)) ?? {};
534
592
  const local = (m as { worldFields?: Record<string, Record<string, unknown>> }).worldFields?.[resource] ?? {};
@@ -577,9 +635,12 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
577
635
  : decl.parent?.params?.length && under ? Object.fromEntries(Object.entries(under.params).filter(([n]) => !op.path.includes(`{${n}}`) && (op.query ?? []).some((x) => x.name === n))) : {};
578
636
  // a list asked by POST carries its parent in its body too, unless the operation reads it from its query
579
637
  const parentInQuery = !rpc || (op.query ?? []).some((x) => x.name === decl.parent?.param);
580
- const q = new URLSearchParams(rpc ? (parentInQuery ? byQuery : {}) : { ...page, ...byQuery });
638
+ // a parameter the operation declares in its query, and not its body, goes in the query of a list asked by POST
639
+ // too (TikTok's video list: `fields` in the query, its cursor in the body)
640
+ const inQuery = (n: string): boolean => (op.query ?? []).some((x) => x.name === n) && !(op.body ?? []).some((b) => b.name === n);
641
+ const q = new URLSearchParams(rpc ? { ...Object.fromEntries(Object.entries(page).filter(([k]) => inQuery(k))), ...(parentInQuery ? byQuery : {}) } : { ...page, ...byQuery });
581
642
  // Fixed parameters and cursors take the operation's body types, not their manifest string encoding.
582
- const typedPage = Object.fromEntries(Object.entries(page).map(([k, v]) => {
643
+ const typedPage = Object.fromEntries(Object.entries(page).filter(([k]) => !rpc || !inQuery(k)).map(([k, v]) => {
583
644
  const type = (op.body ?? []).find((b) => b.name === k)?.type;
584
645
  const value = type === 'integer' ? exactInteger(v) : type === 'number' ? Number(v)
585
646
  : type === 'boolean' && (v === 'true' || v === 'false') ? v === 'true' : v;
@@ -631,9 +692,12 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
631
692
  if ('list' in scope && scope.detail) {
632
693
  const detail = surface.operations.find((o) => o.id === scope.detail);
633
694
  if (!detail) throw new Error(`${m.vendor}: ${resource}'s refresh detail names ${scope.detail}, no operation of the surface`);
634
- const id = mapId ?? storedIdOf(decl, item);
695
+ const id = mapId ?? storedIdOf(decl, { ...under?.params, ...item });
696
+ // the item's own id names it in the detail's path where its stored id is a composite key (a Cloudflare
697
+ // script stored `<account>/<name>` is read at `/scripts/{script_name}` by its name)
698
+ const own = item[decl.idAs ?? 'id'];
635
699
  const path = `${detail.basePath ?? surface.basePath ?? ''}${detail.path}`.replace(/\{([^}]+)\}/g, (_, n: string) => {
636
- const value = under?.params[n] ?? item[n] ?? id;
700
+ const value = under?.params[n] ?? item[n] ?? (decl.key && (typeof own === 'string' || typeof own === 'number') ? own : id);
637
701
  if (value === undefined) throw new Error(`${m.vendor}: ${resource}'s refresh detail has no value for ${n}`);
638
702
  return encodeURIComponent(String(value));
639
703
  });
@@ -655,8 +719,10 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
655
719
  // twin_local_ports) is the answering World's, never the vendor's, and is not folded in
656
720
  const local = (m as { worldFields?: Record<string, Record<string, unknown>> }).worldFields?.[resource] ?? {};
657
721
  const vendorShape = Object.fromEntries(Object.entries(full).filter(([k]) => !(k in local)));
658
- const fields = { ...vendorShape, ...kept, ...(decl.parent && parent !== undefined ? { [decl.parent.field]: parent } : {}) };
659
- const id = mapId ?? scopeIdOf(scope, fields) ?? storedIdOf(decl, fields);
722
+ const fields = { ...vendorShape, ...(!decl.shared ? tenantBinding : {}), ...kept, ...(decl.parent && parent !== undefined ? { [decl.parent.field]: parent } : {}) };
723
+ // a key part the item does not carry is the path parameter its parent was listed under (Cloudflare's secret,
724
+ // `{account_id}/{script_name}::{name}`, listed as its name and type alone)
725
+ const id = mapId ?? scopeIdOf(scope, fields) ?? storedIdOf(decl, { ...under?.params, ...fields });
660
726
  if (id === undefined) continue;
661
727
  observed.push({ type, id, fields: fields as ObservedResource['fields'] });
662
728
  }
@@ -717,7 +783,7 @@ function refreshOf(units: Unit[], service: string): NonNullable<StateSystemAdapt
717
783
  // a type under a parent is whole only for the parents observed: what it held under another parent stands. A root
718
784
  // scoped to one resource (`RootConfig.scope`, GitHub's `repos/o/r`) lists only what is under it, so a type with no
719
785
  // parent is never the account's whole list there
720
- if (whole && !decl.parent && !opts.scope) complete.push(type);
786
+ if (whole && !decl.parent && !opts.scope) complete.push(m.refreshIdentity && m.tenant && !decl.shared ? { type, field: m.tenant.field, within: [String(tenantBinding[m.tenant.field])] } : type);
721
787
  else if (whole && parents.length && decl.parent) complete.push({ type, field: decl.parent.field, within: parents.map((p) => p!.value) });
722
788
  }
723
789
  }
@@ -814,7 +880,7 @@ async function refreshGraphql(m: DerivedManifest, resource: string, decl: Resour
814
880
  const items = listed(value).map(object).filter((x): x is Row => x !== undefined);
815
881
  for (const item of items) {
816
882
  const fields = { ...item, ...(decl.parent && under ? { [decl.parent.field]: under.value } : {}) };
817
- const id = storedIdOf(decl, fields);
883
+ const id = storedIdOf(decl, { ...under?.params, ...fields });
818
884
  if (id !== undefined) observed.push({ type, id, fields: fields as ObservedResource['fields'] });
819
885
  }
820
886
  taken += items.length;
package/src/derived.ts CHANGED
@@ -30,6 +30,8 @@ export type DerivedOperation = {
30
30
  emptySuccess?: boolean;
31
31
  /** a form-encoded operation's number and boolean fields, by bracket path (`items.[].quantity`, `metadata.*`) */
32
32
  scalars?: Readonly<Record<string, string | undefined>>;
33
+ /** a form-encoded operation's string fields that take only listed values, by the same bracket path */
34
+ enums?: Readonly<Record<string, ReadonlyArray<string>>>;
33
35
  /** what the success answer is: a resource, a list of them, and the envelope key holding it */
34
36
  answers?: { resource: string; list: boolean; key?: string };
35
37
  /** the path its own server puts before it, where the operation names a server other than the surface's */
@@ -48,7 +50,10 @@ export type DerivedOperation = {
48
50
  export type DerivedSurface = { graphql?: { sdl: string }; format?: string; commands?: ReadonlyArray<{ id: string }>; version: string; basePath?: string; spanning?: ReadonlyArray<string>; operations: ReadonlyArray<DerivedOperation> };
49
51
 
50
52
  /** What a semantics handler receives: the request, the operation it matched, and its path parameters. */
51
- export type DerivedCall = { request: Request; operation: DerivedOperation; params: Record<string, string> };
53
+ export type DerivedCall = { request: Request; operation: DerivedOperation; params: Record<string, string>;
54
+ /** a World screen's call, which no perform sends again: a body in a media type the core does not read (anything
55
+ * but JSON, a form, multipart or text) is the handler's alone, left unread and unrecorded, so an upload streams */
56
+ streamed?: true };
52
57
 
53
58
  /**
54
59
  * A request's body as text, read once per request: the dispatch reads it and tags the request with it, and every clone
package/src/executor.ts CHANGED
@@ -78,6 +78,10 @@ export type TwinAuthStrategy =
78
78
  * it replaces. The kernel keeps the new value for its next exchange and hands the updated credential to the
79
79
  * root's custody to seal (`onRotate`), so the root outlives the first refresh token. */
80
80
  rotate?: { answer: string; credential: string };
81
+ /** The answer's field giving the token's lifetime in seconds (a dotted path; OAuth's `expires_in`): a token held past
82
+ * it is exchanged again before the next call. For a vendor that answers an expired token with no `retryOn` status
83
+ * (TikTok's API for Business answers HTTP 200 with a code), and so a long-lived process never sends a dead token. */
84
+ expiresIn?: string;
81
85
  };
82
86
 
83
87
  async function oauth1SignedHeader(credential: CredentialPayload, method: string, target: URL, headers: Headers, body?: string | Uint8Array): Promise<string> {
@@ -248,7 +252,9 @@ async function awsSigV4Headers(input: {
248
252
  * refused, as before. */
249
253
  /** Where a credential is kept (the head's and refresh's root: head.ts rootCustody): `key` names the credential stably
250
254
  * across rotations and anew when another is sealed in its place (the token cache is keyed by it, not by the secret's value), `open` reads it as sealed now, `seal` keeps a rotated one. */
251
- export type CredentialCustody = { key: string; open: () => Promise<CredentialPayload>; seal: (credential: CredentialPayload) => Promise<void> | void };
255
+ /** `unsealed`: where a rotation the seal failed to keep is recorded durably (the field and the cause, never a value), since
256
+ * the root cannot renew once the process holding the new secret ends; a later successful seal clears it. */
257
+ export type CredentialCustody = { key: string; open: () => Promise<CredentialPayload>; seal: (credential: CredentialPayload) => Promise<void> | void; unsealed?: (field: string, cause: string) => void };
252
258
  /** `vendor`: the vendor whose rate budget every call is charged to (D8): the call is priced and reserved before it is
253
259
  * made, refused when the budget is spent, and settled with the vendor's answer (its back-off honoured). */
254
260
  /** `now`: the instant a signature is made at (the wall clock unless given: an acceptance whose vendor is a fixture World
@@ -418,7 +424,7 @@ function direct(origin: URL, credential: CredentialPayload, auth?: Exclude<TwinA
418
424
  /** Tokens the exchange strategy holds, per root and credential, for the life of the process: an executor
419
425
  * is built per deploy and per refresh, so a token kept only inside one would cost a fresh exchange on every
420
426
  * write (Bluesky allows 30 sessions in five minutes). In memory only; never written anywhere. */
421
- const exchangedTokens = new Map<string, { token: Promise<string> }>();
427
+ const exchangedTokens = new Map<string, { token: Promise<string>; expiresAt?: number }>();
422
428
  /** A rotated credential a seal failed to keep, per custody key: the process spends it (never the spent one the file
423
429
  * still holds) until a seal succeeds or the file moves past the value it replaced. In memory only. */
424
430
  const unsealedRotations = new Map<string, { replaced: string; credential: CredentialPayload }>();
@@ -442,6 +448,7 @@ function exchangeExecute(origin: URL, sealed: CredentialPayload, auth: Extract<T
442
448
  return held;
443
449
  };
444
450
  const key = custody ? `${origin.href}\n${custody.key}` : `${origin.href}\n${JSON.stringify(sealed)}`;
451
+ let expiresAt: number | undefined;
445
452
  const exchange = async (): Promise<string> => {
446
453
  // a rotating secret is read as kept NOW (another executor or process may have rotated it since this one was
447
454
  // built), or as this process holds it when its seal failed and the file still holds the spent value. Two
@@ -473,6 +480,11 @@ function exchangeExecute(origin: URL, sealed: CredentialPayload, auth: Extract<T
473
480
  const code = parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>).error : undefined;
474
481
  throw new Error(`remote execute: the token exchange answered no \`${auth.token}\`${typeof code === 'string' && code !== '' ? ` (${code.slice(0, 120)})` : ''}`);
475
482
  }
483
+ if (auth.expiresIn) {
484
+ const seconds = Number(auth.expiresIn.split('.').reduce<unknown>((at, k) => (at && typeof at === 'object' ? (at as Record<string, unknown>)[k] : undefined), parsed));
485
+ // a minute short of the stated lifetime, so a call in flight never carries a token that dies on the way
486
+ expiresAt = Number.isFinite(seconds) && seconds > 0 ? Date.now() + Math.max(0, seconds - 60) * 1000 : undefined;
487
+ }
476
488
  if (auth.rotate) {
477
489
  const next = auth.rotate.answer.split('.').reduce<unknown>((at, k) => (at && typeof at === 'object' ? (at as Record<string, unknown>)[k] : undefined), parsed);
478
490
  if (typeof next === 'string' && next !== '' && next !== credential[auth.rotate.credential]) {
@@ -483,6 +495,7 @@ function exchangeExecute(origin: URL, sealed: CredentialPayload, auth: Extract<T
483
495
  if (custody) {
484
496
  try { await custody.seal(credential); unsealedRotations.delete(key); } catch (error) {
485
497
  unsealedRotations.set(key, { replaced, credential });
498
+ try { custody.unsealed?.(auth.rotate.credential, error instanceof Error ? error.message : String(error)); } catch { /* the stderr line below still says it */ }
486
499
  console.error(`remote execute: the vendor rotated \`${auth.rotate.credential}\` and sealing the new one failed (${error instanceof Error ? error.message : String(error)}); this process holds it until a seal succeeds`);
487
500
  }
488
501
  }
@@ -492,12 +505,13 @@ function exchangeExecute(origin: URL, sealed: CredentialPayload, auth: Extract<T
492
505
  };
493
506
  const current = (): { token: Promise<string> } => {
494
507
  let held = exchangedTokens.get(key);
508
+ if (held?.expiresAt !== undefined && Date.now() >= held.expiresAt) { exchangedTokens.delete(key); held = undefined; }
495
509
  if (!held) {
496
510
  const token = exchange();
497
511
  held = { token };
498
512
  exchangedTokens.set(key, held);
499
513
  const entry = held;
500
- token.catch(() => { if (exchangedTokens.get(key) === entry) exchangedTokens.delete(key); });
514
+ token.then(() => { entry.expiresAt = expiresAt; }, () => { if (exchangedTokens.get(key) === entry) exchangedTokens.delete(key); });
501
515
  }
502
516
  return held;
503
517
  };
package/src/git/lfs.ts CHANGED
@@ -9,7 +9,14 @@ import { exactInteger } from '../exact-json.ts';
9
9
  export const LFS_TYPE = 'application/vnd.git-lfs+json';
10
10
  export type LfsObject = { oid: string; size: number | string };
11
11
  /** Where a pack keeps its objects: by oid. */
12
- export type LfsStore = { get(oid: string): Promise<Uint8Array | null>; put(oid: string, bytes: Uint8Array): Promise<void> };
12
+ /** Where a server keeps its LFS objects: an object's size when it holds one (the batch API answers from it, never
13
+ * loading the object), and the store an upload's verified bytes go to. */
14
+ export type LfsStore = {
15
+ size(oid: string): Promise<number | null>; put(oid: string, bytes: Uint8Array): Promise<void>;
16
+ /** an upload's body written as it arrives and kept only when `accept` holds for its size and SHA-256 (`ctx.blobs.putStream`):
17
+ * a store with it never holds an object whole */
18
+ putStream?(oid: string, chunks: AsyncIterable<Uint8Array>, accept: (written: { size: number; sha256: string }) => boolean): Promise<boolean>;
19
+ };
13
20
 
14
21
  const OID = /^[0-9a-f]{64}$/;
15
22
  const lfsJson = (status: number, body: unknown): Response => Response.json(body, { status, headers: { 'content-type': LFS_TYPE } });
@@ -48,21 +55,45 @@ export async function lfsBatch(body: unknown, opts: { store: LfsStore; href: (op
48
55
  const o = { oid: String(raw?.oid ?? ''), size: /^-?\d+$/.test(String(raw?.size)) ? exactInteger(String(raw?.size)) : Number(raw?.size) };
49
56
  // source: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md "The object is invalid (for example, an invalid oid or size)."
50
57
  if (!OID.test(o.oid) || !Number.isSafeInteger(o.size) || Number(o.size) < 0) { objects.push({ ...o, error: { code: 422, message: 'Invalid object' } }); continue; }
51
- const held = await opts.store.get(o.oid);
58
+ const held = await opts.store.size(o.oid);
52
59
  if (operation === 'upload') {
53
- objects.push(held ? { ...o, authenticated: true } : { ...o, authenticated: true, actions: { upload: { href: opts.href('upload', o), ...(opts.header ? { header: opts.header } : {}), expires_in: 3600 } } });
60
+ objects.push(held !== null ? { ...o, authenticated: true } : { ...o, authenticated: true, actions: { upload: { href: opts.href('upload', o), ...(opts.header ? { header: opts.header } : {}), expires_in: 3600 } } });
54
61
  } else {
55
62
  // source: https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md "The object does not exist on the server."
56
- objects.push(held ? { oid: o.oid, size: held.length, authenticated: true, actions: { download: { href: opts.href('download', o), ...(opts.header ? { header: opts.header } : {}), expires_in: 3600 } } } : { ...o, error: { code: 404, message: 'Object does not exist' } });
63
+ objects.push(held !== null ? { oid: o.oid, size: held, authenticated: true, actions: { download: { href: opts.href('download', o), ...(opts.header ? { header: opts.header } : {}), expires_in: 3600 } } } : { ...o, error: { code: 404, message: 'Object does not exist' } });
57
64
  }
58
65
  }
59
66
  return lfsJson(200, { transfer: 'basic', objects, hash_algo: 'sha256' });
60
67
  }
61
68
 
62
69
  /** A basic transfer's upload (`PUT` of the object's bytes): kept when they hash to the oid and have the declared size
63
- * (the size is the request's Content-Length when the caller has no other), else refused and nothing kept. */
64
- export async function lfsUpload(bytes: Uint8Array, o: LfsObject, store: LfsStore): Promise<Response> {
65
- if (bytes.length !== o.size || (await lfsOid(bytes)) !== o.oid) return lfsJson(422, { message: `the object's bytes do not match ${o.oid} (${o.size} bytes)` });
66
- await store.put(o.oid, bytes);
67
- return new Response(null, { status: 200 });
70
+ * (the size is the request's Content-Length when the caller has no other), else refused and nothing kept. A body
71
+ * stream goes to the store's `putStream` as it arrives, read no further than the declared size; bytes, or a store
72
+ * without one, are checked whole. */
73
+ export async function lfsUpload(body: Uint8Array | ReadableStream<Uint8Array> | null, o: LfsObject, store: LfsStore): Promise<Response> {
74
+ const refused = (): Response => lfsJson(422, { message: `the object's bytes do not match ${o.oid} (${o.size} bytes)` });
75
+ if (body instanceof Uint8Array || body === null || !store.putStream) {
76
+ const bytes = body instanceof Uint8Array ? body : new Uint8Array(body === null ? new ArrayBuffer(0) : await new Response(body).arrayBuffer());
77
+ if (bytes.length !== o.size || (await lfsOid(bytes)) !== o.oid) return refused();
78
+ await store.put(o.oid, bytes);
79
+ return new Response(null, { status: 200 });
80
+ }
81
+ const declared = Number(o.size);
82
+ let over = false;
83
+ async function* upTo(stream: ReadableStream<Uint8Array>): AsyncGenerator<Uint8Array> {
84
+ const reader = stream.getReader();
85
+ let read = 0;
86
+ try {
87
+ for (;;) {
88
+ const { done, value } = await reader.read();
89
+ if (done) return;
90
+ read += value.byteLength;
91
+ // a body longer than declared is refused without reading the rest of it
92
+ if (read > declared) { over = true; await reader.cancel(); return; }
93
+ yield value;
94
+ }
95
+ } finally { reader.releaseLock(); }
96
+ }
97
+ const kept = await store.putStream(o.oid, upTo(body), (w) => !over && w.size === declared && w.sha256 === o.oid);
98
+ return kept ? new Response(null, { status: 200 }) : refused();
68
99
  }
package/src/head.ts CHANGED
@@ -156,7 +156,10 @@ export async function resealRootCredential(root: BoundRoot, payload: CredentialP
156
156
  // read and write are synchronous: nothing comes between this check and the write
157
157
  if (store.read(root.credential) !== held) throw new Error(`the ${vendor} credential was set again while it rotated: the rotation is dropped`);
158
158
  store.write(root.credential, `${JSON.stringify({ ...sealed, ...(before ? { fingerprint: before.fingerprint } : {}), rotatedAt: now }, null, 2)}\n`, { secret: true });
159
+ store.remove(unsealedMarkerOf(root));
159
160
  }
161
+ /** Beside a root's credential, the record of a rotation its seal failed to keep (`rootCustody`'s `unsealed`). */
162
+ export function unsealedMarkerOf(root: BoundRoot): string { return root.credential.replace(/\.json$/, '.unsealed.json'); }
160
163
  /** The root's custody for an exchange that rotates: the credential as sealed now, and the seal of a rotated one, which
161
164
  * lands only over the record that was opened. */
162
165
  export function rootCustody(root: BoundRoot, vendor: string = vendorOf(root)): CredentialCustody {
@@ -175,6 +178,9 @@ export function rootCustody(root: BoundRoot, vendor: string = vendorOf(root)): C
175
178
  return opened.payload;
176
179
  },
177
180
  seal: (credential) => resealRootCredential(root, credential, vendor, source),
181
+ // the vendor already spent the sealed secret: until a seal lands, the root renews only inside the process holding the
182
+ // new one, so the World keeps a record a person or `doctor` can read after that process ends
183
+ unsealed: (field, cause) => store.write(unsealedMarkerOf(root), `${JSON.stringify({ vendor, field, at: new Date().toISOString(), cause: cause.slice(0, 300) }, null, 2)}\n`),
178
184
  };
179
185
  }
180
186
  /** The vendor a root belongs to: its credential is sealed under the vendor's name. */
package/src/index.ts CHANGED
@@ -108,3 +108,4 @@ export { readHostPort } from './host-port.ts';
108
108
  export { assertBrowserName, browserOrigin, validateBrowserState, validateBrowserStorageState, validateBrowserContext, browserAcceptLanguage, cookieMatchesHost, type BrowserCookie, type BrowserState, type BrowserStorageState, type BrowserContext, type BrowserIndexedDB } from './browser-state.ts';
109
109
 
110
110
  export * as managedOriginalInputs from './managed-original-inputs.ts';
111
+ export * as managedGoPrograms from './managed-go-programs.ts';
@@ -57,7 +57,6 @@ export function liveIngest<C>(options: LiveIngestOptions<C>): TwinStream {
57
57
  let priorInit: number | undefined;
58
58
  let started = false;
59
59
  let metadata: Record<string, unknown> | undefined;
60
- let refusedCodec: string | undefined;
61
60
 
62
61
  const recording = (): LiveRecording => {
63
62
  const row = store.rows(LIVE).find((r) => r.id === stream)!;
@@ -135,7 +134,12 @@ export function liveIngest<C>(options: LiveIngestOptions<C>): TwinStream {
135
134
  app: ask!.app, key: ask!.key, state: 'publishing', startedAt: typeof prior?.startedAt === 'string' ? prior.startedAt : now, endedAt: null,
136
135
  publishes: Number(prior?.publishes ?? 0) + 1, window: ruling.window ?? 6, segmentSeconds: ruling.segmentSeconds ?? 2, ...(ruling.retain !== undefined ? { retain: ruling.retain } : {}),
137
136
  }, 'live.publish'));
137
+ // Every admitted publish has its own encoder and hook state. Continuation comes from the recording above,
138
+ // including on a connection reused after FCUnpublish, never from the prior publish's in-memory state.
139
+ initIndex = -1;
138
140
  priorInit = last?.init;
141
+ started = false;
142
+ metadata = undefined;
139
143
  segmenter = new Segmenter({ targetMs: Math.max(0.5, ruling.segmentSeconds ?? 2) * 1000, offsetMs, firstSequence: last ? last.seq + 1 : 0 });
140
144
  stream = named;
141
145
  admitted = ruling;
@@ -154,7 +158,7 @@ export function liveIngest<C>(options: LiveIngestOptions<C>): TwinStream {
154
158
  async video(timestamp, payload) {
155
159
  if (!segmenter) return;
156
160
  const tag = readVideoTag(payload);
157
- if (tag.kind === 'unsupported') { if (refusedCodec !== tag.codec) log(`${stream}: ${tag.codec} video is not recorded (H.264 is)`); refusedCodec = tag.codec; return; }
161
+ if (tag.kind === 'unsupported') throw new RtmpRefusal('NetStream.Failed', `${tag.codec} video is not supported; publish H.264.`);
158
162
  const s = segmenter;
159
163
  if (tag.kind === 'config') await keepAll(s.videoConfig(tag.config));
160
164
  else if (tag.kind === 'frame') await keepAll(overrun(() => s.videoFrame(timestamp, tag.key, tag.cts, tag.data)));
@@ -162,7 +166,7 @@ export function liveIngest<C>(options: LiveIngestOptions<C>): TwinStream {
162
166
  async audio(timestamp, payload) {
163
167
  if (!segmenter) return;
164
168
  const tag = readAudioTag(payload);
165
- if (tag.kind === 'unsupported') { if (refusedCodec !== tag.codec) log(`${stream}: ${tag.codec} audio is not recorded (AAC is)`); refusedCodec = tag.codec; return; }
169
+ if (tag.kind === 'unsupported') throw new RtmpRefusal('NetStream.Failed', `${tag.codec} audio is not supported; publish AAC.`);
166
170
  const s = segmenter;
167
171
  if (tag.kind === 'config') await keepAll(s.audioConfig(tag.config));
168
172
  else await keepAll(overrun(() => s.audioFrame(timestamp, tag.data)));
package/src/live/rtmp.ts CHANGED
@@ -124,6 +124,9 @@ export function rtmpSession(sink: TwinStreamSink, publisher: RtmpPublisher, log:
124
124
  chunks.clear();
125
125
  current = undefined;
126
126
  held = 0;
127
+ // A refused/ended transport owns the publish's cleanup too. An admission already awaiting the dialect settles
128
+ // first; messages queued behind the refusal cannot admit a new publish on the closed connection.
129
+ chain = chain.then(() => stop()).catch((error) => log(`the publish's end failed: ${String((error as Error)?.message ?? error)}`));
127
130
  try { sink.end(); } catch { /* the peer is gone */ }
128
131
  };
129
132
  /** A connection past a ceiling: said once, and closed. */
@@ -175,6 +178,7 @@ export function rtmpSession(sink: TwinStreamSink, publisher: RtmpPublisher, log:
175
178
  try { refusal = await publisher.publish({ app, name: named, ...(type !== undefined ? { type } : {}), ...(tcUrl !== undefined ? { tcUrl } : {}), ...(flashVer !== undefined ? { flashVer } : {}) }); }
176
179
  catch (error) { log(`publish failed: ${String((error as Error)?.message ?? error)}`); refusal = { code: 'NetStream.Publish.Denied', description: 'The server could not take the stream.' }; }
177
180
  if (refusal) { status(stream, 'error', refusal.code, refusal.description, named); close(); return; }
181
+ ended = false;
178
182
  publishing = true;
179
183
  publishStream = stream;
180
184
  // user control StreamBegin (event 0) for the publish's stream, then its start
@@ -257,7 +261,7 @@ export function rtmpSession(sink: TwinStreamSink, publisher: RtmpPublisher, log:
257
261
  const message: Message = { type: st.type, streamId: st.streamId, timestamp: st.timestamp, payload };
258
262
  queued += payload.length;
259
263
  if (queued > MAX_QUEUED) { overrun(`more than ${MAX_QUEUED} bytes of messages wait to be recorded`); return; }
260
- chain = chain.then(() => (phase === 'closed' && message.type !== 20 ? undefined : onMessage(message))).catch((error) => {
264
+ chain = chain.then(() => (phase === 'closed' ? undefined : onMessage(message))).catch((error) => {
261
265
  log(`a message failed: ${String((error as Error)?.message ?? error)}`);
262
266
  close();
263
267
  }).finally(() => { queued -= payload.length; });
@@ -366,10 +370,8 @@ export function rtmpSession(sink: TwinStreamSink, publisher: RtmpPublisher, log:
366
370
  // message's bytes are not in it: only more bytes finish it, and what it may hold is bounded (MAX_HELD)
367
371
  backlog: () => queued + pending.length,
368
372
  close() {
369
- clearTimeout(deadline);
370
- // the connection ended: a publish still open ends with it, after every message already read
371
- chain = chain.then(() => stop()).catch((error) => log(`the publish's end failed: ${String((error as Error)?.message ?? error)}`));
372
- phase = 'closed';
373
+ // Peer close and server refusal share one end, after any admission or write already under way settles.
374
+ close();
373
375
  },
374
376
  };
375
377
  }
@@ -64,14 +64,20 @@ export class Segmenter {
64
64
  this.avc = config;
65
65
  this.freshInit = true;
66
66
  this.keyed = false;
67
+ this.unkeyedSince = undefined;
67
68
  return out;
68
69
  }
69
70
 
70
71
  audioConfig(config: AacConfig): Cut[] {
71
72
  if (this.aac && same(this.aac.asc, config.asc)) return [];
72
- const out = this.aac ? this.flush() : [];
73
+ const out = this.flush();
73
74
  this.aac = config;
74
75
  this.freshInit = true;
76
+ // A configuration boundary has no running video segment. Its first frame must be a new keyframe, and the
77
+ // audio sample clock belongs to the new configuration (which may have a different sample rate).
78
+ this.keyed = false;
79
+ this.unkeyedSince = undefined;
80
+ this.audioNext = undefined;
75
81
  return out;
76
82
  }
77
83
 
package/src/machines.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  // is reached only when a compute pool is asked to act. The light pool delegates to the host's Substrate adapter.
7
7
  import { refuseReadOnlyWrite } from './request-scope.ts';
8
8
  import { nodeBuiltin } from './serve-http.ts';
9
+ import { refuseRequiredMachineTransport } from '../required-credential-transport.cjs';
9
10
 
10
11
  export type MachineSpec = { name: string; image: string; env?: Record<string, string>; ports?: number[]; command?: string[]; checks?: Array<{ port: number; type: 'http' | 'tcp'; path?: string }>; resetOnStop?: boolean; mounts?: Array<{ name: string; path: string }>; signal?: AbortSignal; startTimeoutMs?: number };
11
12
  export type GuestImage = { context: string; dockerfile?: string; runtime?: 'node' | 'bun' | 'python' | 'deno'; command?: string[]; cwd?: string; env?: Record<string, string>; prepared?: boolean };
@@ -94,6 +95,7 @@ function dockerPool(world: string): MachinePool {
94
95
  });
95
96
  return {
96
97
  kind: 'docker',
98
+ async admitRun() { refuseRequiredMachineTransport(process.env, 'Docker machine application transport'); },
97
99
  async status(name) {
98
100
  const state = JSON.parse(await docker(['inspect', '--format', '{{json .State}}', containerName(world, name)]));
99
101
  return state.Running ? { state: 'started' } : { state: 'exited', exitCode: state.ExitCode, ...(state.Error ? { reason: state.Error } : {}) };
@@ -101,6 +103,7 @@ function dockerPool(world: string): MachinePool {
101
103
  async ports() { throw new Error('port discovery requires the light guest backing'); },
102
104
  async files() { throw new Error('file access requires the light guest backing'); },
103
105
  async run(spec) {
106
+ refuseRequiredMachineTransport(process.env, 'Docker machine application transport');
104
107
  const name = containerName(world, spec.name);
105
108
  await docker(['rm', '-f', name]).catch(() => undefined);
106
109
  const env = Object.entries(spec.env ?? {}).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
@@ -114,6 +117,7 @@ function dockerPool(world: string): MachinePool {
114
117
  return { ports: published };
115
118
  },
116
119
  async exec(name, spec) {
120
+ refuseRequiredMachineTransport(process.env, 'Docker machine exec transport');
117
121
  const { spawn } = nodeBuiltin<typeof import('node:child_process')>('node:child_process');
118
122
  const env = Object.entries(spec.env ?? {}).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
119
123
  // the shell names its own pid first, then becomes the process: its pid is the process's
@@ -176,7 +180,7 @@ function dockerPool(world: string): MachinePool {
176
180
  return fetch(`http://127.0.0.1:${host}${source.pathname}${source.search}`, { method: request.method, headers, body, redirect: 'manual', signal: request.signal });
177
181
  },
178
182
  async pause(name) { await docker(['pause', containerName(world, name)]); },
179
- async resume(name) { await docker(['unpause', containerName(world, name)]); },
183
+ async resume(name) { refuseRequiredMachineTransport(process.env, 'Docker machine resume transport'); await docker(['unpause', containerName(world, name)]); },
180
184
  async stop(name) { await docker(['stop', containerName(world, name)]).catch(() => undefined); },
181
185
  async remove(name) { await docker(['rm', '-f', containerName(world, name)]).catch(() => undefined); },
182
186
  };