@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,7 +1,13 @@
1
1
  import type { HandlerContext } from './derived-core.js';
2
+ import { type Person } from './people.js';
2
3
  type Writer = Pick<HandlerContext, 'record' | 'secret' | 'occurredAt'>;
3
- /** The named cookie, without interpreting any other vendor's cookie. */
4
+ /** The value of the cookie `name` the request carries, as a browser sends it back. */
5
+ export declare function cookieOf(request: Request, name: string): string | undefined;
6
+ /** The named session cookie; malformed encodings do not name a session. */
4
7
  export declare function browserSessionToken(request: Request, name: string): string | undefined;
8
+ /** The person the request's session cookie names, or undefined when nobody is signed in; with `scope`, only a session
9
+ * begun in it (a vendor's sandbox dashboard and its production one sign a person in apart). */
10
+ export declare function signedIn(ctx: HandlerContext, cookie: string, scope?: string): Person | undefined;
5
11
  /** A new, independent sign-in, returning the cookie the vendor places on its response. */
6
12
  export declare function startBrowserSession(ctx: Writer, email: string, cookie: string, scope?: string): Promise<string>;
7
13
  /** End the session the request holds, then clear its cookie. Unknown cookies create no rows. */
@@ -1,18 +1,29 @@
1
- /** The named cookie, without interpreting any other vendor's cookie. */
2
- export function browserSessionToken(request, name) {
1
+ import { personOf } from "./people.js";
2
+ /** The value of the cookie `name` the request carries, as a browser sends it back. */
3
+ export function cookieOf(request, name) {
3
4
  for (const part of (request.headers.get('cookie') ?? '').split(';')) {
4
5
  const at = part.indexOf('=');
5
- if (at > 0 && part.slice(0, at).trim() === name) {
6
- try {
7
- return decodeURIComponent(part.slice(at + 1).trim());
8
- }
9
- catch {
10
- return undefined;
11
- }
12
- }
6
+ if (at > 0 && part.slice(0, at).trim() === name)
7
+ return decodeURIComponent(part.slice(at + 1).trim());
13
8
  }
14
9
  return undefined;
15
10
  }
11
+ /** The named session cookie; malformed encodings do not name a session. */
12
+ export function browserSessionToken(request, name) {
13
+ try {
14
+ return cookieOf(request, name);
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ /** The person the request's session cookie names, or undefined when nobody is signed in; with `scope`, only a session
21
+ * begun in it (a vendor's sandbox dashboard and its production one sign a person in apart). */
22
+ export function signedIn(ctx, cookie, scope) {
23
+ const token = cookieOf(ctx.call.request, cookie);
24
+ const session = token ? ctx.rowsRaw('_web_session').find((s) => s.token === token && (scope === undefined || s.scope === scope)) : undefined;
25
+ return session ? personOf(ctx, String(session.email)) : undefined;
26
+ }
16
27
  /** A new, independent sign-in, returning the cookie the vendor places on its response. */
17
28
  export async function startBrowserSession(ctx, email, cookie, scope) {
18
29
  const issued = await ctx.record('_web_session_issue', { email, ...(scope !== undefined ? { scope } : {}) });
@@ -15,7 +15,7 @@ import { type MultipartPart } from './multipart.js';
15
15
  import type { CorsDecl } from './cors.js';
16
16
  import { type TwinAction } from './actions.js';
17
17
  import type { DerivedCall, DerivedCoreOutcome, DerivedHandler, DerivedOperation } from './derived.js';
18
- import type { PackDescriptor } from './packRegistry.js';
18
+ import { type PackDescriptor } from './packRegistry.js';
19
19
  import type { ScenarioDecision, ScenarioHandler } from './scenario.js';
20
20
  /** How a stored field gets its value when the server assigns it. `now` is the world clock in the
21
21
  * manifest's time format; `id` the subject's id; a `value` is stored as given. */
@@ -914,13 +914,25 @@ export type CoreScope = {
914
914
  readOnly?: boolean;
915
915
  /** `semantics/tenant.ts`: the tenant the caller acts in (the manifest's `tenant`), or undefined for none */
916
916
  tenant?: (ctx: HandlerContext) => string | undefined | Promise<string | undefined>;
917
- /** the application a walk stands in for: what the vendor asks it (`ctx.ask`) is answered by it (events.ts) */
917
+ /** the application a walk stands in for: vendor requests and declared events are answered by it (events.ts) */
918
918
  application?: ApplicationStandIn;
919
919
  };
920
920
  /** World-owned generated values use the same private seed and log as pack secrets. */
921
921
  export declare function worldSecret(service: string, root: string, label: string, at?: string): Promise<string>;
922
922
  /** The same retained account primitive for pack doors and private native twin fronts. */
923
923
  export declare function worldAccount(service: string, root: string | undefined, id?: unknown, at?: string, provisioning?: boolean): Promise<string>;
924
+ type WorldOrganization = {
925
+ id: string;
926
+ label: string;
927
+ members: string[];
928
+ };
929
+ type VendorNameRule = {
930
+ max: number;
931
+ lower?: boolean;
932
+ allowed: RegExp;
933
+ separator?: string;
934
+ first?: RegExp;
935
+ };
924
936
  /** The lane a vendor's router sent a request to (pack-fetch's laneRouter sets it). */
925
937
  export declare const LANE_HEADER = "x-volter-lane";
926
938
  /** What a coverage measurement sees of a pack's declared state logic: each move a request made and each refusal a
@@ -1018,6 +1030,27 @@ export type SemanticsContext = {
1018
1030
  secret(label: string): Promise<string>;
1019
1031
  /** Select an account at a pack's credential door. Packs retain their own vendor account/key semantics. */
1020
1032
  account(field?: string): Promise<string>;
1033
+ /** The selected organization's label and people from the World's organization registry. */
1034
+ organization(): Promise<WorldOrganization>;
1035
+ /** The selected deployment, or an empty string for the organization's own level. */
1036
+ deployment(): string;
1037
+ /** A vendor-safe label; retained links keep identity independent of names. */
1038
+ vendorName(label: string, rule: VendorNameRule, options?: {
1039
+ suffixed?: boolean;
1040
+ identity?: string;
1041
+ }): string;
1042
+ /** Retain the native customer tenant for the selected organization. */
1043
+ tenant(resource: string, make: (organization: WorldOrganization) => Promise<string>): Promise<Record<string, unknown>>;
1044
+ /** Retain one native resource of this kind for the selected organization and deployment. */
1045
+ deploymentObject(kind: string, resource: string, make: (deployment: string) => Promise<string>): Promise<Record<string, unknown>>;
1046
+ /** Reconstruct a live native key, replacing its link when the vendor's reader refuses it. */
1047
+ accountKey(purpose: string, resource: string, key: {
1048
+ make: () => Promise<string>;
1049
+ read: (row: Record<string, unknown>) => Promise<string | undefined>;
1050
+ }): Promise<{
1051
+ id: string;
1052
+ secret: string;
1053
+ }>;
1021
1054
  /** The World's machine pool (machines.ts), as the pack's `machine-pool` door enrolled it; `none` when none was. */
1022
1055
  machines: MachinePool;
1023
1056
  /** 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
@@ -1219,7 +1252,7 @@ export type HandlerContext = Omit<SemanticsContext, 'now' | 'core' | 'atomically
1219
1252
  };
1220
1253
  /** What a write hook reads: the writing call's context without its ways to write, so a hook that renders an event
1221
1254
  * cannot write again (and run itself again). */
1222
- export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'at' | 'over'>;
1255
+ export type WriteHookContext = Omit<HandlerContext, 'write' | 'batch' | 'create' | 'createUnique' | 'change' | 'issue' | 'record' | 'accumulate' | 'tenant' | 'deploymentObject' | 'accountKey' | 'at' | 'over'>;
1223
1256
  /** A derived pack's handler: one operation, by its operationId, over the contract's context. */
1224
1257
  export type Handler = (ctx: HandlerContext) => Promise<Response>;
1225
1258
  export declare function contextFor(m: DerivedManifest, call: DerivedCall, scope: CoreScope): Promise<SemanticsContext>;
@@ -37,6 +37,7 @@ import { vendorFetch } from "./vendor-call.js";
37
37
  import { packReferences } from "./references.js";
38
38
  import { twinPublicBase, withRequestScopes } from "./twin-fetch.js";
39
39
  import { inVendorMove, isReadOnlyRequest, runAsVendorMove, writesRefused } from "./request-scope.js";
40
+ import { getPack } from "./packRegistry.js";
40
41
  import { bodyTextOf, checkJsonBody, takeParsedBody } from "./derived.js";
41
42
  /** What a move to `to` stores in the field: a boolean field's value is a boolean, and a derived field's
42
43
  * value is written by the move's effects (a state name is not a timestamp), so it stores nothing itself. */
@@ -473,6 +474,57 @@ export async function worldAccount(service, root, id = 'default', at = worldNow(
473
474
  }, root);
474
475
  return id;
475
476
  }
477
+ function nativeAccountLink(row, field) {
478
+ if (!row || row.deleted === true)
479
+ return undefined;
480
+ const fields = ownFields(row);
481
+ const value = field === undefined ? fields : fields[field];
482
+ if (!value || typeof value !== 'object' || Array.isArray(value))
483
+ return undefined;
484
+ const link = value;
485
+ return typeof link.resource === 'string' && typeof link.id === 'string' ? { resource: link.resource, id: link.id } : undefined;
486
+ }
487
+ const sameAccountLink = (a, b) => a?.resource === b?.resource && a?.id === b?.id;
488
+ /** Native creation and reconstruction run outside the action lock; only the retained link is compared and written. */
489
+ async function retainNativeAccountLink(service, root, at, call, type, id, field, resource, make, read) {
490
+ for (;;) {
491
+ const before = nativeAccountLink(resourcesOfType(service, type, root).find((row) => row.id === id), field);
492
+ const held = before && await read(before);
493
+ if (held !== undefined)
494
+ return held;
495
+ const made = { resource, id: await make() };
496
+ if (await read(made) === undefined)
497
+ throw new Error(`Credential ${type}/${id}: native ${resource}/${made.id} is unavailable after creation`);
498
+ const { value } = await applyTwinWriteAtomic(service, (snapshot) => {
499
+ const current = nativeAccountLink(snapshot.find((row) => row.type === type && row.id === id), field);
500
+ if (!sameAccountLink(current, before))
501
+ return { kind: 'skip', value: current };
502
+ return {
503
+ kind: 'write', value: made,
504
+ write: {
505
+ operation: `${type.slice(1)}.link`, subjectType: type, subjectId: id,
506
+ fields: field === undefined ? made : { name: id, [field]: made },
507
+ ...provisioningInput(call), occurredAt: at, actor: { kind: 'system' },
508
+ },
509
+ };
510
+ }, root);
511
+ const linked = value && await read(value);
512
+ if (linked !== undefined)
513
+ return linked;
514
+ }
515
+ }
516
+ function vendorName(label, rule, { suffixed = false, identity = label } = {}) {
517
+ const allowed = new RegExp(rule.allowed.source, rule.allowed.flags.replace(/[gy]/g, ''));
518
+ const separator = rule.separator ?? '-';
519
+ if ([...separator].length !== 1 || !allowed.test(separator))
520
+ throw new Error('Credential name separator must be one allowed character');
521
+ const first = rule.first && new RegExp(rule.first.source, rule.first.flags.replace(/[gy]/g, ''));
522
+ let name = [...(rule.lower ? label.toLowerCase() : label)].map((letter) => allowed.test(letter) ? letter : separator).join('');
523
+ name = name.replace(new RegExp(`${escapeRe(separator)}+`, 'g'), separator);
524
+ if (first && !first.test(name[0] ?? ''))
525
+ name = `w${separator}${name}`;
526
+ return name !== label || name.length > rule.max || suffixed ? `${name.slice(0, Math.max(0, rule.max - 9))}${separator}${sha256(identity).slice(0, 8)}` : name;
527
+ }
476
528
  /** A World's signing key for a label (ctx.signingKey): made once from the platform's random source, then read; the
477
529
  * making is one per service store and label in the process, as the seed's is. */
478
530
  const SIGNING_KEY = '_world_signing_key';
@@ -571,9 +623,9 @@ function mintId(m, resource, root, at, snapshot) {
571
623
  * increment. One space over every snowflake resource of the vendor, as its one generator's: the instant's first, or one
572
624
  * past the greatest the World holds when that is later (a frozen clock, or several ids in one millisecond), so ids
573
625
  * rise with time and never repeat. */
574
- /** The last snowflake minted per vendor and World in this process: two mints before the first is written (a message
626
+ /** The last snowflake minted per store, vendor and World: two mints before the first is written (a message
575
627
  * and its attachment) must not meet. */
576
- const lastSnowflake = new Map();
628
+ const lastSnowflake = new WeakMap();
577
629
  function mintSnowflake(m, epoch, root, at, snapshot) {
578
630
  const ms = BigInt(Date.parse(at) || 0);
579
631
  const base = ms > epoch ? (ms - epoch) << 22n : 0n;
@@ -588,11 +640,13 @@ function mintSnowflake(m, epoch, root, at, snapshot) {
588
640
  max = v;
589
641
  }
590
642
  }
643
+ const store = getActiveWorldStore();
644
+ const reservations = lastSnowflake.get(store) ?? lastSnowflake.set(store, new Map()).get(store);
591
645
  const key = `${m.service}\0${root ?? ''}`;
592
- const last = lastSnowflake.get(key) ?? -1n;
646
+ const last = reservations.get(key) ?? -1n;
593
647
  const floor = max > last ? max : last;
594
648
  const id = base > floor ? base : floor + 1n;
595
- lastSnowflake.set(key, id);
649
+ reservations.set(key, id);
596
650
  return String(id);
597
651
  }
598
652
  /** The next KSUID for a resource (Clerk's and Svix's ids, `ksuidFrom`): the World's instant, then the type's mint
@@ -895,7 +949,7 @@ async function finishWrite(m, call, resource, row, result, operation, params, ro
895
949
  const endpoint = (kind) => ({ ...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) }])) } : {}) });
896
950
  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) };
897
951
  const write = { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() };
898
- 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);
952
+ 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);
899
953
  }
900
954
  if (m.sockets?.length)
901
955
  socketWrite(m.service, root, { operation, storedType: m.resources[resource]?.storedAs ?? resource, body, ...(root !== undefined ? { root } : {}), occurredAt, request: request.clone() });
@@ -1489,6 +1543,8 @@ export async function contextFor(m, call, scope) {
1489
1543
  });
1490
1544
  const paths = expandPaths(m, params);
1491
1545
  const stateChecks = new Map();
1546
+ const credentialDoor = m.descriptor?.credentialDoor ?? getPack(m.vendor)?.credentialDoor;
1547
+ const credentialFields = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
1492
1548
  const rawResources = new WeakMap();
1493
1549
  const rawRow = (resource, row) => {
1494
1550
  const name = m.resources[resource] ? resource : Object.keys(m.resources).find((name) => storedType(m, name) === row.type);
@@ -1502,6 +1558,10 @@ export async function contextFor(m, call, scope) {
1502
1558
  rawResources.set(row, name ?? resource);
1503
1559
  return row;
1504
1560
  };
1561
+ const liveNativeRow = (link) => {
1562
+ const row = context.row(link.resource, link.id);
1563
+ return row && row.deleted !== true ? row : undefined;
1564
+ };
1505
1565
  const context = {
1506
1566
  call,
1507
1567
  params,
@@ -1558,9 +1618,38 @@ export async function contextFor(m, call, scope) {
1558
1618
  },
1559
1619
  occurredAt: at,
1560
1620
  secret: async (label) => hmac(await worldSeed(m.service, root, at), scopeLabel(m, label), 'base64url'),
1561
- account: async (field = 'account') => {
1562
- const fields = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
1563
- return worldAccount(m.service, root, fields[field] ?? 'default', at, call.request.headers.get('x-volter-world-activity') === 'provisioning');
1621
+ account: async (field = credentialDoor?.account?.field ?? 'account') => {
1622
+ return worldAccount(m.service, root, credentialFields[field] ?? 'default', at, call.request.headers.get('x-volter-world-activity') === 'provisioning');
1623
+ },
1624
+ organization: async () => {
1625
+ const id = await context.account();
1626
+ const row = projectOwnerResources('_world', root, { type: '_world_organization', id }).find((row) => row.type === '_world_organization' && row.id === id && row.deleted !== true);
1627
+ return row ? { id, label: row.label, members: [...row.members] } : { id, label: id, members: ['owner@world.test'] };
1628
+ },
1629
+ deployment: () => {
1630
+ const id = credentialFields[credentialDoor?.deployment?.field ?? 'deployment'] ?? '';
1631
+ if (id !== '' && !isWorldAccountName(id))
1632
+ throw new Error('Credential deployment must be a name of at most 128 letters, digits, dots, underscores or hyphens');
1633
+ return id;
1634
+ },
1635
+ vendorName,
1636
+ tenant: async (resource, make) => {
1637
+ const id = await context.account();
1638
+ return retainNativeAccountLink(m.service, root, at, call, '_world_account', id, 'tenant', resource, async () => make(await context.organization()), async (link) => liveNativeRow(link));
1639
+ },
1640
+ deploymentObject: async (kind, resource, make) => {
1641
+ const organization = await context.account(), deployment = context.deployment();
1642
+ return retainNativeAccountLink(m.service, root, at, call, '_world_deployment_object', `${organization}/${deployment}/${kind}`, undefined, resource, () => make(deployment), async (link) => liveNativeRow(link));
1643
+ },
1644
+ accountKey: async (purpose, resource, key) => {
1645
+ const organization = await context.account(), deployment = context.deployment();
1646
+ return retainNativeAccountLink(m.service, root, at, call, '_world_account_key', `${organization}/${deployment}/${purpose}`, undefined, resource, () => key.make(), async (link) => {
1647
+ const row = liveNativeRow(link);
1648
+ if (!row)
1649
+ return undefined;
1650
+ const secret = await key.read(row);
1651
+ return secret === undefined ? undefined : { id: link.id, secret };
1652
+ });
1564
1653
  },
1565
1654
  record: async (type, fields, id, operation) => {
1566
1655
  if (!type.startsWith('_'))
@@ -1621,7 +1710,12 @@ export async function contextFor(m, call, scope) {
1621
1710
  const row = await atomicWrite(m, call, resource, (snapshot) => {
1622
1711
  const id = options?.id ?? mintId(m, resource, root, at, snapshot);
1623
1712
  const selected = typeof fields === 'function' ? fields(id) : fields;
1624
- 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])));
1713
+ const hit = snapshot.find((r) => {
1714
+ if (r.type !== storedType(m, resource) || r.deleted === true)
1715
+ return false;
1716
+ const fields = { [m.resources[resource]?.idAs ?? 'id']: r.id, ...ownFields(r) };
1717
+ return rules.some((rule) => rule.fields.every((field) => selected[field] !== undefined && fields[field] === selected[field]));
1718
+ });
1625
1719
  if (hit) {
1626
1720
  existing = view(m, resource, hit);
1627
1721
  return undefined;
@@ -1661,7 +1755,7 @@ export async function contextFor(m, call, scope) {
1661
1755
  },
1662
1756
  writeDetailed: (resource, id, fields, operation) => writeDetailed(m, call, resource, id, fields, operation, params, root, at, stateChecks),
1663
1757
  resolve: (resource, id) => resolveSubjectId(m.service, storedType(m, resource), id, root),
1664
- storedType: (resource) => storedType(m, resource),
1758
+ storedType: (resource) => m.resources[resource]?.storedAs ?? resource,
1665
1759
  ok: (fields) => Response.json({ ...(m.success ?? {}), ...fields }),
1666
1760
  row: (resource, id, opts) => { const row = stored(m, resource, root, opts).find((r) => r.id === id); return row ? rawRow(resource, row) : undefined; },
1667
1761
  rowsRaw: (resource, opts) => stored(m, resource, root, opts).map((row) => rawRow(resource, row)),
@@ -1782,10 +1876,12 @@ const BATCH_REFUSED_READS = ['get', 'rows', 'tree', 'history', 'find', 'list', '
1782
1876
  /** The members that write, or wait on writes, or answer as another context: each lands the staged writes first. */
1783
1877
  const BATCH_LANDING_MEMBERS = ['create', 'createUnique', 'change', 'accumulate', 'writeDetailed', 'record', 'atomically', 'remove', 'issue', 'at', 'over', 'core', 'awaitWrite', 'awaitCondition',
1784
1878
  // a World's seed and signing keys are written when first asked (worldSeed, worldSigningKey, as a vendor's move)
1785
- 'secret', 'signingKey'];
1879
+ 'secret', 'signingKey', 'account', 'organization', 'tenant', 'deploymentObject', 'accountKey'];
1786
1880
  /** `ctx.batch` over a context whose writes may be grouped (see `batchable`). */
1787
1881
  function bindBatch(m, call, context, params, root, at, rawRow) {
1788
1882
  let staged;
1883
+ // A callback's native object must land before the helper writes its retained link.
1884
+ let nativeAccountWrites = 0;
1789
1885
  // each staged subject as it will stand, by `type:id`, in the order first written
1790
1886
  const subjects = new Map();
1791
1887
  const land = async () => {
@@ -1801,7 +1897,19 @@ function bindBatch(m, call, context, params, root, at, rawRow) {
1801
1897
  const plain = ctx[name];
1802
1898
  if (typeof plain !== 'function')
1803
1899
  continue;
1804
- ctx[name] = async (...args) => { await land(); return plain(...args); };
1900
+ ctx[name] = async (...args) => {
1901
+ await land();
1902
+ const native = name === 'tenant' || name === 'deploymentObject' || name === 'accountKey';
1903
+ if (native)
1904
+ nativeAccountWrites++;
1905
+ try {
1906
+ return await plain(...args);
1907
+ }
1908
+ finally {
1909
+ if (native)
1910
+ nativeAccountWrites--;
1911
+ }
1912
+ };
1805
1913
  }
1806
1914
  for (const name of BATCH_REFUSED_READS) {
1807
1915
  const plain = ctx[name];
@@ -1816,7 +1924,7 @@ function bindBatch(m, call, context, params, root, at, rawRow) {
1816
1924
  const plainWrite = context.write, plainRow = context.row, plainRowsRaw = context.rowsRaw;
1817
1925
  context.write = async (resource, id, fields, operation) => {
1818
1926
  // a vendor's own move (ctx.asVendor) is the clock's write, recorded as the system's: it lands alone, in its place
1819
- if (!staged || inVendorMove()) {
1927
+ if (!staged || nativeAccountWrites > 0 || inVendorMove()) {
1820
1928
  await land();
1821
1929
  return plainWrite(resource, id, fields, operation);
1822
1930
  }
@@ -682,10 +682,10 @@ function refreshOf(units, service) {
682
682
  const sent = asked.length ? `${target}${target.includes('?') ? '&' : '?'}${new URLSearchParams(asked)}` : target;
683
683
  const answer = await execute({ method: op.method.toUpperCase(), path: sentTo(m, sent, subject), ...onLane, headers: fixedHeaders(op) });
684
684
  // a door's subject the vendor does not show the root (another bot's private chat) stays as the World holds it
685
- if (!rooted.has(key) && answer.status >= 400 && answer.status !== 429)
686
- continue;
687
685
  if (answer.status === 401)
688
686
  throw new Error(`401: the vendor refused the root's credential (${op.method.toUpperCase()} ${path}); nothing folded`);
687
+ if (!rooted.has(key) && answer.status >= 400 && answer.status !== 429)
688
+ continue;
689
689
  if (answer.status === 403) {
690
690
  unread.push({ type, path, status: 403 });
691
691
  refused = true;
@@ -700,7 +700,19 @@ function refreshOf(units, service) {
700
700
  continue;
701
701
  if (answer.status >= 400 || refusedIn(m, parse(answer)))
702
702
  throw new Error(`${m.vendor}: ${resource} refresh ${op.id} (${op.method.toUpperCase()} ${op.path}): ${answer.status}: ${refusalWords(m, parse(answer))}`);
703
- const full = object(op.answers?.key ? at(parse(answer), op.answers.key) : parse(answer)) ?? {};
703
+ const parsed = parse(answer);
704
+ let full;
705
+ if (op.answers?.list) {
706
+ const items = itemsOf(op, parsed);
707
+ const matching = items.find((item) => (scopeIdOf(scope, item) ?? storedIdOf(decl, item)) === vendorId);
708
+ if (!matching && items.length)
709
+ throw new Error(`${m.vendor}: ${resource} refresh ${op.id} omitted the requested subject; nothing folded`);
710
+ if (!matching)
711
+ continue;
712
+ full = matching;
713
+ }
714
+ else
715
+ full = object(op.answers?.key ? at(parsed, op.answers.key) : parsed) ?? {};
704
716
  const local = m.worldFields?.[resource] ?? {};
705
717
  const fields = Object.fromEntries(Object.entries(full).filter(([k]) => !(k in local)));
706
718
  if (decl.parent) {
@@ -164,6 +164,8 @@ export declare function eventTypeOf(decl: Pick<EventsDecl, 'types' | 'known'>, o
164
164
  /** Whether an endpoint's list of event types takes a type: every type when the list is empty, `*` or `<family>.*`
165
165
  * its patterns, else the type itself. */
166
166
  export declare function takesEvent(list: unknown, type: string): boolean;
167
+ /** Await already-scheduled first dispatches and consume their retained persistence failures. */
168
+ export declare function settleEventDeliveries(root: string): Promise<void>;
167
169
  /** Who watches signed packets before dispatch (a walk reading each as its receiver would). This is not evidence of
168
170
  * transport or acceptance. Set and restored by the walk, as its World store is; unset, nothing watches. */
169
171
  export type DeliveryObserver = (delivery: EventDelivery & {
@@ -179,13 +181,14 @@ export type ApplicationAnswer = {
179
181
  body: string;
180
182
  missed?: 'timeout' | 'unreachable';
181
183
  };
182
- /** Who answers what the vendor asks the application (`ctx.ask`) before the World's application route does, given to a
184
+ /** Who answers a vendor's requests to the application before the World's application route does, given to a
183
185
  * pack's fetch (`PackFetchOptions.application`) and belonging to that fetch alone: an answer it gives is the
184
186
  * application's; `'unreachable'` is a question nobody answers (a walk's life, a sealed World, takes no other); undefined
185
187
  * is a question it does not take, which goes on as any other does: the application route, the World's egress rule, the
186
188
  * network. A walk's stand-in answers every question (its life's answers, else unreachable); a hosted World's router
187
189
  * answers only what it can answer truthfully (WorldDoors.askInWorld) and leaves the rest. `bodyError` models a
188
- * disconnect after the response headers; a caller needing the complete body receives no complete answer. */
190
+ * disconnect after the response headers; a caller needing the complete body receives no complete answer, while an
191
+ * event dispatch acknowledges the response status without reading its body. */
189
192
  export type ApplicationStandIn = (url: string, init: RequestInit) => Promise<{
190
193
  status: number;
191
194
  headers?: Record<string, string>;
@@ -198,4 +201,4 @@ export type ApplicationStandIn = (url: string, init: RequestInit) => Promise<{
198
201
  export declare function askApplication(url: string, init: RequestInit, within: number, standIn?: ApplicationStandIn): Promise<ApplicationAnswer>;
199
202
  /** Every event a write sends, rendered, stored when the vendor keeps its events, signed, delivered to each endpoint
200
203
  * that takes it, and recorded. */
201
- export declare function deliverEvents(service: string, decl: EventsDecl, write: EventWrite, data: (type: string) => Promise<Record<string, unknown>>, transport?: EventTransport, values?: (type: string) => Promise<Record<string, unknown>>): Promise<EventDelivery[]>;
204
+ export declare function deliverEvents(service: string, decl: EventsDecl, write: EventWrite, data: (type: string) => Promise<Record<string, unknown>>, transport?: EventTransport, values?: (type: string) => Promise<Record<string, unknown>>, application?: ApplicationStandIn): Promise<EventDelivery[]>;
@@ -7,6 +7,7 @@
7
7
  // Imports stay free of side effects: the delivery's transport (Node's http, the World's egress policy) is loaded when a
8
8
  // delivery is made, never when a browser client imports the kernel.
9
9
  import { createHash, createHmac } from 'node:crypto';
10
+ import { resolve } from 'node:path';
10
11
  import { applyTwinWrite, applyTwinWriteAtomic, twinResources } from "./serve.js";
11
12
  import { equalSecrets as same, ksuidFrom } from "./signing.js";
12
13
  import { deliveryTraceHeaders } from "./trace-context.js";
@@ -223,6 +224,28 @@ const worldTransport = async (url, body, headers) => {
223
224
  return 0;
224
225
  }
225
226
  };
227
+ const pendingDeliveries = new WeakMap();
228
+ function forgetDeliveries(store, root, deliveries) {
229
+ const roots = pendingDeliveries.get(store), pending = roots?.get(root);
230
+ if (!pending)
231
+ return;
232
+ for (const delivery of deliveries)
233
+ pending.delete(delivery);
234
+ if (!pending.size)
235
+ roots.delete(root);
236
+ if (!roots.size)
237
+ pendingDeliveries.delete(store);
238
+ }
239
+ /** Await already-scheduled first dispatches and consume their retained persistence failures. */
240
+ export async function settleEventDeliveries(root) {
241
+ const store = getActiveWorldStore(), path = resolve(root);
242
+ const deliveries = [...(pendingDeliveries.get(store)?.get(path) ?? [])];
243
+ const outcomes = await Promise.allSettled(deliveries);
244
+ forgetDeliveries(store, path, deliveries);
245
+ const failures = outcomes.flatMap((outcome) => outcome.status === 'rejected' ? [outcome.reason] : []);
246
+ if (failures.length)
247
+ throw new AggregateError(failures, 'Declared-event dispatch failed');
248
+ }
226
249
  let deliveryObserver;
227
250
  export function setDeliveryObserver(observer) {
228
251
  const previous = deliveryObserver;
@@ -266,12 +289,16 @@ export async function askApplication(url, init, within, standIn) {
266
289
  }
267
290
  /** Every event a write sends, rendered, stored when the vendor keeps its events, signed, delivered to each endpoint
268
291
  * that takes it, and recorded. */
269
- export async function deliverEvents(service, decl, write, data, transport = worldTransport, values) {
292
+ export async function deliverEvents(service, decl, write, data, transport = worldTransport, values, application) {
270
293
  if (write.storedType === decl.store)
271
294
  return [];
295
+ const dispatch = application ? async (url, body, headers) => {
296
+ const answer = await application(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body });
297
+ return answer === undefined ? transport(url, body, headers) : answer === 'unreachable' ? 0 : answer.status;
298
+ } : transport;
272
299
  const sent = [];
273
300
  for (const type of eventTypesOf(decl, write.operation))
274
- sent.push(...(await deliverEventType(service, decl, write, data, transport, values, type)));
301
+ sent.push(...(await deliverEventType(service, decl, write, data, dispatch, values, type)));
275
302
  return sent;
276
303
  }
277
304
  /** One event type a write sends: to the live endpoints that take it, stored when the vendor keeps its events. */
@@ -364,11 +391,29 @@ async function deliverEventType(service, decl, write, data, transport, values, t
364
391
  const store = getActiveWorldStore(), deliveryRoot = worldPaths(service, write.root).root;
365
392
  for (const d of sent)
366
393
  deliveryObserver?.({ ...d, service });
367
- for (const packet of recorded)
368
- setTimeout(() => {
369
- // Persistence failure leaves a queued/sending claim for explicit recovery;
370
- // it must not fail the already-committed vendor write or cause a blind resend.
371
- void withWorldStore(store, () => sendRecordedEvent(service, packet.type, packet.id, deliveryRoot, transport, trace)).catch(() => { });
394
+ const roots = pendingDeliveries.get(store) ?? new Map();
395
+ pendingDeliveries.set(store, roots);
396
+ const pending = roots.get(deliveryRoot) ?? new Set();
397
+ roots.set(deliveryRoot, pending);
398
+ for (const packet of recorded) {
399
+ let finish;
400
+ let fail;
401
+ const dispatched = new Promise((done, reject) => { finish = done; fail = reject; });
402
+ // Live writes return before dispatch; an explicit settlement reports persistence failures.
403
+ void dispatched.catch(() => { });
404
+ pending.add(dispatched);
405
+ setTimeout(async () => {
406
+ try {
407
+ await withWorldStore(store, () => sendRecordedEvent(service, packet.type, packet.id, deliveryRoot, transport, trace));
408
+ finish();
409
+ forgetDeliveries(store, deliveryRoot, [dispatched]);
410
+ }
411
+ catch (error) {
412
+ // Keep the rejection until explicit settlement consumes it. The queued/sending claim remains for
413
+ // native recovery; this must not fail the already-committed vendor write or cause a blind resend.
414
+ fail(error);
415
+ }
372
416
  }, 0);
417
+ }
373
418
  return sent;
374
419
  }
@@ -5,6 +5,10 @@ export declare function fileResponse(path: string, init?: ResponseInit): Respons
5
5
  * Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: every satisfiable range is a
6
6
  * 206 with its Content-Range, `bytes=0-` (the whole body) included; a range that does not parse, a first-byte-pos past
7
7
  * its last-byte-pos (`bytes=5-2`) included, is ignored and the whole body is a 200; a well-formed range that starts past
8
- * the end (any range of an empty body) is a 416. `nosniff` stops a browser second-guessing the content type the vendor gave.
8
+ * the end (any range of an empty body) is a 416. Lazy sources supply inclusive ranges and stream in bounded reads.
9
+ * `nosniff` stops a browser second-guessing the content type the vendor gave.
9
10
  */
10
- export declare function bytesResponse(request: Request, bytes: Uint8Array, contentType: string, cacheControl?: string): Response;
11
+ export declare function bytesResponse(request: Request, bytes: Uint8Array | {
12
+ size: number;
13
+ range(start: number, end: number): Promise<Uint8Array | null>;
14
+ }, contentType: string, cacheControl?: string): Response;
@@ -24,10 +24,11 @@ export function fileResponse(path, init) {
24
24
  * Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: every satisfiable range is a
25
25
  * 206 with its Content-Range, `bytes=0-` (the whole body) included; a range that does not parse, a first-byte-pos past
26
26
  * its last-byte-pos (`bytes=5-2`) included, is ignored and the whole body is a 200; a well-formed range that starts past
27
- * the end (any range of an empty body) is a 416. `nosniff` stops a browser second-guessing the content type the vendor gave.
27
+ * the end (any range of an empty body) is a 416. Lazy sources supply inclusive ranges and stream in bounded reads.
28
+ * `nosniff` stops a browser second-guessing the content type the vendor gave.
28
29
  */
29
30
  export function bytesResponse(request, bytes, contentType, cacheControl = 'public, max-age=604800, immutable') {
30
- const total = bytes.length;
31
+ const total = bytes instanceof Uint8Array ? bytes.length : bytes.size;
31
32
  const base = { 'content-type': contentType, 'x-content-type-options': 'nosniff', 'accept-ranges': 'bytes', 'cache-control': cacheControl };
32
33
  const range = /^bytes=(\d*)-(\d*)$/.exec((request.headers.get('range') ?? '').trim());
33
34
  let start = 0;
@@ -47,9 +48,32 @@ export function bytesResponse(request, bytes, contentType, cacheControl = 'publi
47
48
  // every satisfiable range is answered as one, the whole body's `bytes=0-` included: a media player (Safari's) reads a
48
49
  // 206 with its Content-Range as the server taking ranges, and a 200 as one it cannot seek in
49
50
  const partial = valid;
50
- const body = bytes.slice(start, end + 1);
51
- return new Response(request.method === 'HEAD' ? null : new Blob([body]), {
51
+ let body = null;
52
+ if (request.method !== 'HEAD') {
53
+ if (bytes instanceof Uint8Array)
54
+ body = new Blob([bytes.slice(start, end + 1)]);
55
+ else {
56
+ let at = start;
57
+ body = new ReadableStream({
58
+ async pull(controller) {
59
+ if (at > end) {
60
+ controller.close();
61
+ return;
62
+ }
63
+ const last = Math.min(at + 8 * 1024 * 1024 - 1, end);
64
+ const chunk = await bytes.range(at, last);
65
+ if (!chunk) {
66
+ controller.error(new Error('stored bytes are gone'));
67
+ return;
68
+ }
69
+ controller.enqueue(chunk);
70
+ at = last + 1;
71
+ },
72
+ });
73
+ }
74
+ }
75
+ return new Response(body, {
52
76
  status: partial ? 206 : 200,
53
- headers: { ...base, 'content-length': String(body.length), ...(partial ? { 'content-range': `bytes ${start}-${end}/${total}` } : {}) },
77
+ headers: { ...base, 'content-length': String(end + 1 - start), ...(partial ? { 'content-range': `bytes ${start}-${end}/${total}` } : {}) },
54
78
  });
55
79
  }
@@ -67,6 +67,7 @@ export * as protobuf from './protobuf.js';
67
67
  export * as grpc from './grpc-wire.js';
68
68
  export { acceptH2Connection, type H2Handler, type Http1Handler } from './h2.js';
69
69
  export { placeholderPng } from './placeholder-image.js';
70
+ export { imageDimensions, readImageHeader, readMp4Header } from './media-header.js';
70
71
  export { presignSigV4, signSigV4, verifySigV4, type SigV4Verdict } from './sigv4.js';
71
72
  export * as redis from './redis/index.js';
72
73
  export * as mysql from './mysql/index.js';
@@ -76,7 +77,7 @@ export * as git from './git/index.js';
76
77
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError } from './scenario.js';
77
78
  export type { PackScenarioAdapter, ScenarioDecision, ScenarioDocument, ScenarioExtractorSpec, ScenarioFault, ScenarioFaultResult, ScenarioFeatures, ScenarioHandler, ScenarioMatcher, ScenarioMissRecord, ScenarioStatus } from './scenario.js';
78
79
  export { verifyAttestationBundle } from './attestation.js';
79
- export { browserSessionToken, startBrowserSession, endBrowserSession } from './browser-session.js';
80
+ export { cookieOf, signedIn, browserSessionToken, startBrowserSession, endBrowserSession } from './browser-session.js';
80
81
  export { readHostPort } from './host-port.js';
81
82
  export { assertBrowserName, browserOrigin, validateBrowserState, validateBrowserStorageState, validateBrowserContext, browserAcceptLanguage, cookieMatchesHost, type BrowserCookie, type BrowserState, type BrowserStorageState, type BrowserContext, type BrowserIndexedDB } from './browser-state.js';
82
83
  export * as managedOriginalInputs from './managed-original-inputs.js';
package/dist/src/index.js CHANGED
@@ -59,6 +59,7 @@ export * as protobuf from "./protobuf.js";
59
59
  export * as grpc from "./grpc-wire.js";
60
60
  export { acceptH2Connection } from "./h2.js";
61
61
  export { placeholderPng } from "./placeholder-image.js";
62
+ export { imageDimensions, readImageHeader, readMp4Header } from "./media-header.js";
62
63
  export { presignSigV4, signSigV4, verifySigV4 } from "./sigv4.js";
63
64
  export * as redis from "./redis/index.js";
64
65
  export * as mysql from "./mysql/index.js";
@@ -68,7 +69,7 @@ export * as git from "./git/index.js";
68
69
  // ── the scenario grammar (architecture, "Behaviour") ───────────────────────────────────────────
69
70
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError } from "./scenario.js";
70
71
  export { verifyAttestationBundle } from "./attestation.js";
71
- export { browserSessionToken, startBrowserSession, endBrowserSession } from "./browser-session.js";
72
+ export { cookieOf, signedIn, browserSessionToken, startBrowserSession, endBrowserSession } from "./browser-session.js";
72
73
  export { readHostPort } from "./host-port.js";
73
74
  export { assertBrowserName, browserOrigin, validateBrowserState, validateBrowserStorageState, validateBrowserContext, browserAcceptLanguage, cookieMatchesHost } from "./browser-state.js";
74
75
  export * as managedOriginalInputs from "./managed-original-inputs.js";