@volter/world-core 3.0.18 → 3.0.20

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.
@@ -16,7 +16,7 @@ import { worldNow } from './world-clock.ts';
16
16
  import { worldEnvValue } from './world-env.ts';
17
17
  import { openSessions, socketWrite, type SocketDecl, type SocketSession } from './sockets.ts';
18
18
  import { MACHINE_POOL, poolFor, type MachinePool } from './machines.ts';
19
- import { askApplication, deliverEvents, deliverToApplication, type ApplicationAnswer, type DeliveryAnswer, type EventRender, type EventScheme, type EventsDecl, type EventValues } from './events.ts';
19
+ import { askApplication, deliverEvents, deliverToApplication, type ApplicationAnswer, type ApplicationStandIn, type DeliveryAnswer, type EventRender, type EventScheme, type EventsDecl, type EventValues } from './events.ts';
20
20
  import type { RateBudgetDeclaration } from './rateBudget.ts';
21
21
  import { handlerCrypto, hmac, lettersFrom, sha256, uuidFrom, type HandlerCrypto } from './signing.ts';
22
22
  import { hashFieldValue } from './hash.ts';
@@ -214,7 +214,9 @@ export type LaneRoute = {
214
214
  export type LanesDecl = { cors?: CorsDecl; routes: ReadonlyArray<LaneRoute>; default?: string };
215
215
  /** The manifest of a vendor whose every API is a lane (Cloudflare's API v4 and R2, AWS's services): no API of its own,
216
216
  * only what the vendor's front says — its discovery facts, its lanes and its descriptor. */
217
- export type VendorManifest = { vendor: string; discovery?: DerivedManifest['discovery']; lanes: LanesDecl; descriptor: Omit<PackDescriptor, 'vendor'>; ingest?: IngestDecl; rateBudget?: RateBudgetDeclaration };
217
+ export type VendorManifest = { vendor: string; discovery?: DerivedManifest['discovery']; lanes: LanesDecl; descriptor: Omit<PackDescriptor, 'vendor'>; ingest?: IngestDecl; rateBudget?: RateBudgetDeclaration;
218
+ /** the vendor has no vendor-backed half (architecture, "The descriptor"): packOf copies it, and derives no state system */
219
+ vendorBacked?: DerivedManifest['vendorBacked'] };
218
220
 
219
221
  /** `code` as the vendor writes it: a string, or a number (Discord's JSON error codes are integers) */
220
222
  export type ErrorSpec = { status: number; message: string; code?: string | number; param?: string; kind?: string };
@@ -235,6 +237,15 @@ export type ScreenDecl = {
235
237
  demand: string;
236
238
  status: 'done' | 'todo';
237
239
  controls?: string[];
240
+ /** wherever this screen would answer (its host, a World's place, its path alone), a request the pack's API names for
241
+ * its method is the operation's, not this screen's: a site whose pages draw while its forms and links act on
242
+ * operations the spec writes at the same paths (Hacker News's /login page posts to POST /login; its front page at `/`
243
+ * would otherwise take GET /vote) (architecture, "Screens") */
244
+ yieldsToApi?: true;
245
+ /** a content screen that takes a form at a World's place (`<base>/@<host>/…`), beside GET and HEAD: the methods it
246
+ * takes, with its demand (an upload bucket a page posts a lease's form to: Reddit's S3 hosts) (architecture, "A page
247
+ * at a World's place") */
248
+ takes?: ReadonlyArray<'POST'>;
238
249
  /** the vendor's documentation of the round trip or the screen */
239
250
  source: string;
240
251
  };
@@ -524,6 +535,14 @@ export function flagOf(m: Pick<DerivedManifest, 'booleans'>, value: unknown): bo
524
535
  * text: a field the operation's spec types as a number or a boolean (`scalars`, by bracket path, written by
525
536
  * world-tooling's formScalarsOf) is read as one when its text is that literal; every other field stays the text sent
526
537
  * (`metadata[order]=007`, `name=2024`). */
538
+ /** A field set as the object's own, whatever its name (`__proto__`, `constructor` and `prototype` are data a caller
539
+ * may name: Stripe's metadata keys are the caller's), never through what the object inherits. */
540
+ export function ownField(node: Record<string, unknown> | unknown[], key: string, value: unknown): void {
541
+ Object.defineProperty(node, key, { value, writable: true, enumerable: true, configurable: true });
542
+ }
543
+ /** A field the object holds as its own, never one it inherits (`constructor` is every object's). */
544
+ const ownValue = (node: Record<string, unknown> | unknown[], key: string): unknown => (Object.hasOwn(node, key) ? (node as Record<string, unknown>)[key] : undefined);
545
+
527
546
  export function parseBracketForm(text: string, scalars?: Readonly<Record<string, string | undefined>>): Record<string, unknown> {
528
547
  const out: Record<string, unknown> = {};
529
548
  for (const [rawKey, raw] of new URLSearchParams(text)) {
@@ -533,18 +552,26 @@ export function parseBracketForm(text: string, scalars?: Readonly<Record<string,
533
552
  : kind === 'integer' ? (/^-?\d+$/.test(raw) ? Number(raw) : raw)
534
553
  : kind === 'number' ? (/^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw)
535
554
  : raw;
536
- let node: any = out;
537
- parts.forEach((key, i) => {
555
+ // every field is the node's own, read and written as such: a key naming an object's machinery (`__proto__[x]`,
556
+ // `constructor[prototype][x]`) is a field of that name, never a walk into what every object inherits
557
+ let node: Record<string, unknown> | unknown[] = out;
558
+ for (const [i, key] of parts.entries()) {
559
+ // an array holds its items by index (`a[]`, `a[0]`): a named key under one (`a[]=1&a[length]=-1`) has nowhere to
560
+ // go, as a key under text has none (`a=1&a[b]=2`): the first shape stands
561
+ if (Array.isArray(node) && key !== '' && !/^\d+$/.test(key)) break;
538
562
  if (i === parts.length - 1) {
539
563
  if (key === '') {
540
564
  if (Array.isArray(node)) node.push(value);
541
- } else node[key] = value;
542
- return;
565
+ } else ownField(node, key, value);
566
+ break;
543
567
  }
544
568
  const next = parts[i + 1]!;
545
- if (node[key] === undefined) node[key] = next === '' || /^\d+$/.test(next) ? [] : {};
546
- node = node[key];
547
- });
569
+ if (key === '') break;
570
+ let child = ownValue(node, key);
571
+ if (child === undefined) { child = next === '' || /^\d+$/.test(next) ? [] : {}; ownField(node, key, child); }
572
+ if (child === null || typeof child !== 'object') break;
573
+ node = child as Record<string, unknown> | unknown[];
574
+ }
548
575
  }
549
576
  return out;
550
577
  }
@@ -580,26 +607,38 @@ export async function readParams(manifest: DerivedManifest, request: Request, op
580
607
  const parts = boundary === null ? [] : multipartParts(new Uint8Array(await request.arrayBuffer()), boundary);
581
608
  const out: Record<string, unknown> = { ...query };
582
609
  for (const part of parts) {
610
+ // each part a field of its own name, whatever the name (`__proto__` included: ownField)
583
611
  if (part.filename === null) {
584
- out[part.name] = new TextDecoder().decode(part.body);
612
+ ownField(out, part.name, new TextDecoder().decode(part.body));
585
613
  continue;
586
614
  }
587
615
  const bytes = part.body;
588
616
  const parsed = { name: part.filename, type: part.type ?? '', size: bytes.byteLength, content: new TextDecoder().decode(bytes) };
589
- out[part.name] = Object.defineProperty(parsed, 'bytes', { value: bytes, enumerable: false });
617
+ ownField(out, part.name, Object.defineProperty(parsed, 'bytes', { value: bytes, enumerable: false }));
590
618
  }
591
619
  return out;
592
620
  }
593
- const text = await bodyTextOf(request);
594
- if (!text) return query;
595
621
  // NDJSON (`application/x-ndjson`) is lines of JSON, not a JSON document: the handler reads its text
596
- const isJson = type.includes('json') && !type.includes('ndjson');
622
+ const media = (type.split(';')[0] ?? '').trim().toLowerCase();
623
+ const isJson = media.includes('json') && !media.includes('ndjson');
624
+ // a form is a body labelled one (application/x-www-form-urlencoded); a body of any other type (an image, a video, an
625
+ // octet-stream PUT to an upload URL, a text/plain SQL statement, an NDJSON batch) is no form: its parameters are the
626
+ // query's, and the handler reads its text or bytes itself
627
+ const isForm = media === 'application/x-www-form-urlencoded';
597
628
  const asJson = isJson || manifest.body.json === 'always';
629
+ if (!isForm && !asJson) return query;
630
+ const text = await bodyTextOf(request);
631
+ if (!text) return query;
598
632
  // `json: 'always'` reads a body labelled otherwise as JSON (GitHub reads curl -d's); a body that is not JSON is read
599
- // as the form it is labelled, never a crash (a sign-in page's own form, posted to the vendor's page)
633
+ // as a form only when it is labelled one (a sign-in page's own form, posted to the vendor's page), never a crash
600
634
  let body: unknown;
601
- if (asJson) { try { body = JSON.parse(text); } catch { if (isJson) throw new SyntaxError('malformed JSON body'); body = parseBracketForm(text, scalars); } }
602
- else body = parseBracketForm(text, scalars);
635
+ if (asJson) {
636
+ try { body = JSON.parse(text); } catch {
637
+ if (isJson) throw new SyntaxError('malformed JSON body');
638
+ if (!isForm) return query;
639
+ body = parseBracketForm(text, scalars);
640
+ }
641
+ } else body = parseBracketForm(text, scalars);
603
642
  // a body that is not an object (GitHub's set-labels takes a bare array) is the body, not fields
604
643
  return body && typeof body === 'object' && !Array.isArray(body) ? { ...query, ...(body as Record<string, unknown>) } : { ...query };
605
644
  }
@@ -743,6 +782,8 @@ export type CoreScope = {
743
782
  root?: string; clock?: () => string; database?: string; readOnly?: boolean;
744
783
  /** `semantics/tenant.ts`: the tenant the caller acts in (the manifest's `tenant`), or undefined for none */
745
784
  tenant?: (ctx: HandlerContext) => string | undefined | Promise<string | undefined>;
785
+ /** the application a walk stands in for: what the vendor asks it (`ctx.ask`) is answered by it (events.ts) */
786
+ application?: ApplicationStandIn;
746
787
  };
747
788
 
748
789
  function now(m: DerivedManifest, at: string): unknown {
@@ -979,6 +1020,9 @@ function recordedInput(m: DerivedManifest, call: DerivedCall, merged: Record<str
979
1020
  return { operationId: id, ...asSent };
980
1021
  }
981
1022
 
1023
+ /** The scope each call is served in (serveCore, contextFor), for the contexts a write opens for its hooks. */
1024
+ const scopeOfCall = new WeakMap<DerivedCall, CoreScope>();
1025
+
982
1026
  async function writeDetailed(m: DerivedManifest, call: DerivedCall, resource: string, id: string, fields: Record<string, unknown>, operation: string, params: Record<string, unknown>, root: string | undefined, occurredAt: string): Promise<{ body: Record<string, unknown>; id: string; vendorData?: unknown }> {
983
1027
  const withFiles = async (input: Record<string, unknown>): Promise<Record<string, unknown>> => { const files = input.operationId ? await keptFiles(m, call, root) : undefined; return files ? { ...input, files } : input; };
984
1028
  const { resource: row, result } = await applyTwinWrite(
@@ -993,7 +1037,9 @@ async function writeDetailed(m: DerivedManifest, call: DerivedCall, resource: st
993
1037
  );
994
1038
  await keepCounts(m, call, resource, row as unknown as Record<string, unknown>, params, root, occurredAt);
995
1039
  const body = view(m, resource, row);
996
- const context = (): Promise<SemanticsContext> => contextFor(m, { ...call, request: call.request.clone() }, { ...(root !== undefined ? { root } : {}), clock: () => occurredAt });
1040
+ // the write's own context for its hooks, in the scope the call was served in (a walk's application answers, a tenant,
1041
+ // the managed database), at the write's moment
1042
+ const context = (): Promise<SemanticsContext> => contextFor(m, { ...call, request: call.request.clone() }, { ...(scopeOfCall.get(call) ?? {}), ...(root !== undefined ? { root } : {}), clock: () => occurredAt });
997
1043
  if (m.onWrite) await m.onWrite({ operation, storedType: storedType(m, resource), body, ...(root !== undefined ? { root } : {}), occurredAt, request: call.request, context });
998
1044
  if (m.events) {
999
1045
  const events = m.events;
@@ -1314,6 +1360,7 @@ export async function serveCore(m: DerivedManifest, call: DerivedCall, scope: Co
1314
1360
  const decl = resource ? m.resources[resource] : undefined;
1315
1361
  if (!resource || !decl) return { unmodeled: `no manifest entry for resource ${resource ?? '(none)'}` };
1316
1362
  call = adoptedPath(m, call, root);
1363
+ scopeOfCall.set(call, scope);
1317
1364
  // the body is read from a copy, so the call's own request is still unread for the write hook's context
1318
1365
  const params = await boundaryParams(m, { ...call, request: call.request.clone() }, root);
1319
1366
  const idParam = subjectOf(m, resource, call);
@@ -1626,6 +1673,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
1626
1673
  const root = scope.root;
1627
1674
  const asAsked = call;
1628
1675
  call = adoptedPath(m, call, root);
1676
+ scopeOfCall.set(call, scope);
1629
1677
  const at = (scope.clock ?? worldNow)();
1630
1678
  // the guard's parse when it made one (taken once), else this context's own
1631
1679
  const parsed = takeParsedBody(call.request);
@@ -1658,7 +1706,7 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
1658
1706
  publicBase: twinPublicBase(call.request),
1659
1707
  scenario: scenarioDecisionOf(call.request),
1660
1708
  deliver: (url, init) => deliverToApplication(url, init),
1661
- ask: (url, init, within) => askApplication(url, init, within),
1709
+ ask: (url, init, within) => askApplication(url, init, within, scope.application),
1662
1710
  worldEnv: (name) => worldEnvValue(name),
1663
1711
  asVendor: (fn) => runAsVendorMove(fn),
1664
1712
  git: (name) => {
package/src/derived.ts CHANGED
@@ -202,6 +202,9 @@ export type DerivedFetch = ((request: Request) => Promise<Response>) & {
202
202
  boards?: boolean;
203
203
  /** Whether a built screen of it takes a URL: a board frame it draws. */
204
204
  draws?(url: string): boolean;
205
+ /** The hosts its built content screens name: a vendor's lane router sends `<base>/@<host>/<path>` for one of them to
206
+ * this fetch (pack-fetch.ts). */
207
+ contentHosts?: ReadonlyArray<string>;
205
208
  };
206
209
 
207
210
  /** A workspace screen a pack draws, as `GET /twin` lists it: its id and path (Viewing a World). */
package/src/events.ts CHANGED
@@ -323,9 +323,26 @@ export async function deliverToApplication(url: string, init: RequestInit): Prom
323
323
  * World let no request out to it or it could not be reached (`unreachable`), or it did not answer in time (`timeout`). */
324
324
  export type ApplicationAnswer = { status: number; body: string; missed?: 'timeout' | 'unreachable' };
325
325
 
326
+ /** Who answers what the vendor asks the application (`ctx.ask`) before the World's application route does, given to a
327
+ * pack's fetch (`PackFetchOptions.application`) and belonging to that fetch alone: an answer it gives is the
328
+ * application's; `'unreachable'` is a question nobody answers (a walk's life, a sealed World, takes no other); undefined
329
+ * is a question it does not take, which goes on as any other does: the application route, the World's egress rule, the
330
+ * network. A walk's stand-in answers every question (its life's answers, else unreachable); a hosted World's router
331
+ * answers only what it can answer truthfully (WorldDoors.askInWorld) and leaves the rest. */
332
+ export type ApplicationStandIn = (url: string, init: RequestInit) => Promise<{ status: number; body: string } | 'unreachable' | undefined>;
333
+
326
334
  /** A message the vendor sends the application's server and decides by its answer (Stripe's real-time authorization
327
- * request, answered within its window): over the same transport, waited on for at most `within` milliseconds. */
328
- export async function askApplication(url: string, init: RequestInit, within: number): Promise<ApplicationAnswer> {
335
+ * request, answered within its window): over the same transport, waited on for at most `within` milliseconds, the
336
+ * fetch's stand-in first when it was given one (its window the same). */
337
+ export async function askApplication(url: string, init: RequestInit, within: number, standIn?: ApplicationStandIn): Promise<ApplicationAnswer> {
338
+ if (standIn) {
339
+ let timer: ReturnType<typeof setTimeout> | undefined;
340
+ const late = new Promise<'late'>((done) => { timer = setTimeout(() => done('late'), within); });
341
+ const stood = await Promise.race([standIn(url, init), late]).finally(() => clearTimeout(timer));
342
+ if (stood === 'late') return { status: 0, body: '', missed: 'timeout' };
343
+ if (stood === 'unreachable') return { status: 0, body: '', missed: 'unreachable' };
344
+ if (stood !== undefined) return stood;
345
+ }
329
346
  const [{ appDestination, appFetch }, { worldEgressRefusal }] = await Promise.all([
330
347
  import('../app-route.cjs') as Promise<{ appDestination: (value: string) => unknown; appFetch: (url: string, init: RequestInit) => Promise<{ status: number; text(): Promise<string> }> }>,
331
348
  import('../network-policy.cjs') as Promise<{ worldEgressRefusal: (value: string) => unknown }>,
@@ -22,9 +22,10 @@ export function fileResponse(path: string, init?: ResponseInit): Response {
22
22
  }
23
23
 
24
24
  /**
25
- * Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: a range that does not parse, a
26
- * first-byte-pos past its last-byte-pos (`bytes=5-2`) included, is ignored and the whole body is a 200; a well-formed
27
- * range that starts past the end is a 416. `nosniff` stops a browser second-guessing the content type the vendor gave.
25
+ * Stored bytes as a response, honouring a single `Range: bytes=` as RFC 9110 §14 has it: every satisfiable range is a
26
+ * 206 with its Content-Range, `bytes=0-` (the whole body) included; a range that does not parse, a first-byte-pos past
27
+ * its last-byte-pos (`bytes=5-2`) included, is ignored and the whole body is a 200; a well-formed range that starts past
28
+ * the end (any range of an empty body) is a 416. `nosniff` stops a browser second-guessing the content type the vendor gave.
28
29
  */
29
30
  export function bytesResponse(request: Request, bytes: Uint8Array, contentType: string, cacheControl = 'public, max-age=604800, immutable'): Response {
30
31
  const total = bytes.length;
@@ -40,7 +41,9 @@ export function bytesResponse(request: Request, bytes: Uint8Array, contentType:
40
41
  }
41
42
  if (start >= total || (range[1] === '' && Number(range[2]) === 0)) return new Response(null, { status: 416, headers: { ...base, 'content-range': `bytes */${total}` } });
42
43
  }
43
- const partial = start !== 0 || end !== total - 1;
44
+ // every satisfiable range is answered as one, the whole body's `bytes=0-` included: a media player (Safari's) reads a
45
+ // 206 with its Content-Range as the server taking ranges, and a 200 as one it cannot seek in
46
+ const partial = valid;
44
47
  const body = bytes.slice(start, end + 1);
45
48
  return new Response(request.method === 'HEAD' ? null : new Blob([body]), {
46
49
  status: partial ? 206 : 200,
package/src/pack-fetch.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // one of them is answered over the contract's context, which the kernel opens: a pack opens none, reads the tree and
7
7
  // writes it only through one.
8
8
  import { ORIGINAL_PATH_HEADER } from './sigv4.ts';
9
- import { TWIN_PREFIX_HEADER } from './twin-fetch.ts';
9
+ import { placedCookies, TWIN_PREFIX_HEADER } from './twin-fetch.ts';
10
10
  import { PREFIX_PARAMS_HEADER } from './derived.ts';
11
11
  import { withHeaders } from './with-headers.ts';
12
12
  import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, coreFor, crossCutting, derivedRequestScopes, vendorError, type CoreScope, type DerivedManifest, type ErrorSpec, type Handler, type HandlerContext, type LanesDecl, type VendorManifest } from './derived-core.ts';
@@ -17,6 +17,13 @@ import { getActiveWorldStore } from './world-store.ts';
17
17
  import { runWithCorrelationId } from './actions.ts';
18
18
  import { deferPerform, performRequestGroup, RefusedWriteError, VendorWriteError } from './head.ts';
19
19
  import { withCors } from './cors.ts';
20
+ import type { ApplicationStandIn } from './events.ts';
21
+ import { contentAddressOf } from './content-route.ts';
22
+ import { getPack, type HostRule } from './packRegistry.ts';
23
+ import { hostRules } from '../vendor-hosts.cjs';
24
+
25
+ /** A descriptor's host rules, as data. */
26
+ type PackDescriptorHosts = ReadonlyArray<HostRule> | undefined;
20
27
  import { socketUpgrade, type SocketEngine } from './sockets.ts';
21
28
  import { MACHINE_POOL, POOL_KINDS } from './machines.ts';
22
29
  import { applyTwinWrite } from './serve.ts';
@@ -68,12 +75,23 @@ export type PackParts = {
68
75
  export type PackScenario<Req = any> = { adapter: PackScenarioAdapter<Req>; operations: ReadonlyArray<string>; request: (ctx: HandlerContext) => Req | undefined };
69
76
 
70
77
  /** A lane as its vendor's fetch is given it: its own pack fetch, and its manifest (the doors it declares). */
71
- export type LanePart = { fetch: ((request: Request) => Promise<Response>) & { serves?: (method: string, path: string) => boolean; boards?: boolean; draws?: (url: string) => boolean }; manifest: { doors?: ReadonlyArray<{ method: string; path: string }> } };
78
+ export type LanePart = {
79
+ fetch: ((request: Request) => Promise<Response>) & {
80
+ serves?: (method: string, path: string) => boolean; boards?: boolean; draws?: (url: string) => boolean;
81
+ /** the hosts the lane's built content screens name: its vendor's router sends `<base>/@<host>/` there */
82
+ contentHosts?: ReadonlyArray<string>;
83
+ };
84
+ manifest: { doors?: ReadonlyArray<{ method: string; path: string }> };
85
+ };
72
86
 
73
87
  /** Where a pack serves: its World's root, whether it was started read-only, the World instant when a caller pins it, the
74
88
  * World's managed Postgres the runtime binds a pack declaring `managedDatabase` to (a handler's `ctx.engine`), and the
75
89
  * World's scenario file for a model vendor's turns (`scenarioPath`, the runtime's `--scenario`). */
76
- export type PackFetchOptions = { root?: string; readOnly?: boolean; clock?: () => string; database?: string; scenarioPath?: string };
90
+ export type PackFetchOptions = {
91
+ root?: string; readOnly?: boolean; clock?: () => string; database?: string; scenarioPath?: string;
92
+ /** the application a walk stands in for (a life's `application` answers): this fetch's `ctx.ask` is answered by it */
93
+ application?: ApplicationStandIn;
94
+ };
77
95
 
78
96
  type Match = { params: Record<string, string> };
79
97
  /** A declared path (`/{account}/r2/api-tokens`, `/<room>`, a door's `/_twin/users/{email}`) as a matcher of a request's
@@ -160,6 +178,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
160
178
  ...(options.clock ? { clock: options.clock } : {}),
161
179
  ...(options.database !== undefined ? { database: options.database, readOnly: options.readOnly ?? false } : {}),
162
180
  ...(parts.tenant ? { tenant: parts.tenant } : {}),
181
+ ...(options.application ? { application: options.application } : {}),
163
182
  };
164
183
  // a command table's surface (Redis's, a vendor that carries Redis over HTTP) names no HTTP operation: its wire is the
165
184
  // pack's front (its `around`), and the table is the denominator the grade counts
@@ -295,6 +314,68 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
295
314
  };
296
315
  const rootServes = (method: string, path: string): boolean => Boolean(matchOperation(routes, method, path));
297
316
  const lane = m.lanes ? laneRouter(m.vendor, m.lanes, parts.lanes ?? {}, rootServes) : undefined;
317
+ // at a World's place, a page's asset or upload on another of the pack's content hosts is `<base>/@<host>/<path>`
318
+ // (architecture, "A page at a World's place"): answered by the built content screen that names that host exactly,
319
+ // routed from it, and by nothing else; a request naming no such host is general routing's
320
+ const contentScreens = screens.filter((s) => s.decl.kind === 'content');
321
+ for (const s of contentScreens) if (s.decl.yieldsToApi) throw new Error(`${m.vendor}: the content screen ${s.decl.id} declares yieldsToApi; a content screen answers its host's requests alone (architecture, "Screens")`);
322
+ const contentHosts = [...new Set(contentScreens.flatMap((s) => [s.decl.host, ...(s.decl.hosts ?? [])]).filter((h) => !/[{<]/.test(h)).map((h) => h.toLowerCase()))];
323
+ const contentNotFound = (host: string, path: string): Response => {
324
+ const nf = m.notFound;
325
+ return vendorError(m, { status: nf.status, message: nf.message.replace(/\{object\}/g, host).replace(/\{id\}/g, path), ...(nf.code !== undefined ? { code: nf.code } : {}), ...(nf.kind !== undefined ? { kind: nf.kind } : {}) });
326
+ };
327
+ /** A request at a World's place for `/@<host>/<path>`, `<host>` one a built content screen of this unit names exactly:
328
+ * that screen's answer, or the pack's not-found for what the host or the screen does not take. The path is held to
329
+ * the descriptor's host rules (`rules`: this unit's, or its vendor's for a lane), read as the injector reads them.
330
+ * Undefined for any other request. */
331
+ // the descriptor's host rules: this unit's, or, for a lane (which declares none), its vendor's as registered
332
+ const descriptorHosts = (): PackDescriptorHosts => m.descriptor?.hosts ?? getPack(m.vendor)?.hosts;
333
+ const atContentHost = async (request: Request, rules: PackDescriptorHosts = descriptorHosts()): Promise<Response | undefined> => {
334
+ if (!request.headers.has(TWIN_PREFIX_HEADER)) return undefined;
335
+ const url = new URL(request.url);
336
+ const address = contentAddressOf(url.pathname);
337
+ if (!address || !contentHosts.includes(address.host)) return undefined;
338
+ const { host, path: inner } = address;
339
+ if (address.refused) return contentNotFound(host, inner);
340
+ const declared = hostRules(rules);
341
+ if (declared.names(host) && !declared.takes(host, inner)) return contentNotFound(host, inner);
342
+ const onHost = contentScreens.filter((s) => [s.decl.host, ...(s.decl.hosts ?? [])].some((h) => h.toLowerCase() === host));
343
+ const method = request.method.toUpperCase();
344
+ const allowed = (s: (typeof screens)[number]): boolean => method === 'GET' || method === 'HEAD' || (method === 'POST' && (s.decl.takes ?? []).includes('POST'));
345
+ // the request as it reaches the content host, through the pack's own routing of a host (its path prefix and labels)
346
+ url.pathname = inner;
347
+ const headers = new Headers(request.headers);
348
+ headers.set('x-volter-twin-original-host', host);
349
+ const sent = method === 'POST' && request.body ? { body: request.body, duplex: 'half' } : {};
350
+ const there = routed(new Request(url, { method, headers, signal: request.signal, ...sent } as RequestInit));
351
+ const path = new URL(there.url).pathname.replace(/\/+$/, '') || '/';
352
+ const hit = onHost.map((s) => ({ s, hit: s.path(path) })).find((x) => x.hit && allowed(x.s));
353
+ if (!hit) return contentNotFound(host, inner);
354
+ const asked = options.readOnly && !isReadOnlyRequest(there) ? withHeaders(there, new Headers([...there.headers, [READ_ONLY_REQUEST_HEADER, '1']])) : there;
355
+ if (method === 'POST' && ((options.readOnly ?? false) || isReadOnlyRequest(asked))) return refusedReadOnly();
356
+ if (parts.clock) await runAsVendorMove(async () => parts.clock!(await open(new Request(asked.url, m.tenant ? { headers: asked.headers } : {}), 'clock', 'POST', '/_twin/clock', {})));
357
+ return hit.s.answer(await open(asked, hit.s.decl.id, method, hit.s.decl.path, { ...(hit.s.host(host)?.params ?? {}), ...hit.hit!.params }));
358
+ };
359
+ /** A request as the kernel reads it: the headers only the kernel names taken off, its body decoded (undefined when it
360
+ * does not decode as its Content-Encoding says). */
361
+ const prepared = async (arrived: Request): Promise<Request | undefined> => {
362
+ if (arrived.headers.has(SCENARIO_DECISION_HEADER) || arrived.headers.has(PREFIX_PARAMS_HEADER)) {
363
+ const headers = new Headers(arrived.headers);
364
+ headers.delete(SCENARIO_DECISION_HEADER);
365
+ headers.delete(PREFIX_PARAMS_HEADER);
366
+ arrived = new Request(arrived, { headers });
367
+ }
368
+ return decoded(m, arrived);
369
+ };
370
+ const undecodable = (arrived: Request): Response => vendorError(m, m.encodings?.undecodable ?? { status: 400, message: `the body does not decode as its Content-Encoding (${arrived.headers.get('content-encoding')}) says` });
371
+ /** The content route at the request's arrival: the answer, or undefined when this unit's content screens name no such
372
+ * host. */
373
+ const answerContent = async (arrived: Request): Promise<Response | undefined> => {
374
+ const address = arrived.headers.has(TWIN_PREFIX_HEADER) ? contentAddressOf(new URL(arrived.url).pathname) : undefined;
375
+ if (!address || !contentHosts.includes(address.host)) return undefined;
376
+ const readable = await prepared(arrived);
377
+ return readable ? atContentHost(readable) : undecodable(arrived);
378
+ };
298
379
  const fetch = async (arrived: Request): Promise<Response> => {
299
380
  // the discovery door is the pack's own, whichever lane would take its path
300
381
  const asked = new URL(arrived.url).pathname.replace(/\/+$/, '') || '/';
@@ -307,17 +388,15 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
307
388
  const frames = await parts.board(await open(request, 'board', 'GET', '/twin/board', {}));
308
389
  return Response.json({ frames: frames.map((f) => ({ ...f, drawn: drawsUrl(f.url) })) });
309
390
  }
391
+ // `<base>/@<host>/<path>` at a World's place, for a host a built content screen of this pack names, is that screen's
392
+ // alone (only the kernel names a scenario's decision or a path prefix's parameters: `prepared` takes them off); for
393
+ // one a lane's names, the lane router sends it to that lane's own fetch, wrapped as any request to it is
394
+ const own = await answerContent(arrived);
395
+ if (own) return own;
310
396
  const toLane = discovery ? undefined : lane?.(arrived);
311
397
  if (toLane) return toLane(arrived);
312
- // only the kernel names a scenario's decision, or a path prefix's parameters
313
- if (arrived.headers.has(SCENARIO_DECISION_HEADER) || arrived.headers.has(PREFIX_PARAMS_HEADER)) {
314
- const headers = new Headers(arrived.headers);
315
- headers.delete(SCENARIO_DECISION_HEADER);
316
- headers.delete(PREFIX_PARAMS_HEADER);
317
- arrived = new Request(arrived, { headers });
318
- }
319
- const readable = await decoded(m, arrived);
320
- if (!readable) return vendorError(m, m.encodings?.undecodable ?? { status: 400, message: `the body does not decode as its Content-Encoding (${arrived.headers.get('content-encoding')}) says` });
398
+ const readable = await prepared(arrived);
399
+ if (!readable) return undecodable(arrived);
321
400
  const incoming = routed(readable);
322
401
  // a twin started read-only marks every request so, so every context it opens knows (isReadOnlyRequest)
323
402
  const request = options.readOnly && !isReadOnlyRequest(incoming) ? withHeaders(incoming, new Headers([...incoming.headers, [READ_ONLY_REQUEST_HEADER, '1']])) : incoming;
@@ -368,8 +447,10 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
368
447
  // whichever host the twin is reached (its links carry the twin's own base), never a root screen off its own host
369
448
  const host = hostOf(request);
370
449
  const exact = (s: (typeof screens)[number]): boolean => [s.decl.host, ...(s.decl.hosts ?? [])].some((h) => !/[{<]/.test(h) && h.toLowerCase() === host);
371
- const hits = screens.map((s) => ({ s, hit: s.path(path), onHost: s.host(host) })).filter((x) => x.hit);
372
450
  const apiPath = Boolean(matchOperation(routes, request.method, url.pathname, url.searchParams, request.headers));
451
+ // a screen that yields to the API draws only what no operation names, wherever it would answer (architecture,
452
+ // "Screens": Hacker News's /login page, whose form posts to POST /login)
453
+ const hits = screens.map((s) => ({ s, hit: s.path(path), onHost: s.host(host) })).filter((x) => x.hit && !(apiPath && x.s.decl.yieldsToApi));
373
454
  // reached at a World's place for the twin (`x-forwarded-prefix`, served-world's wire), the host is the World's: a
374
455
  // workspace is shown there by its path alone, its root too (the console frames it so)
375
456
  const placed = request.headers.has(TWIN_PREFIX_HEADER);
@@ -424,7 +505,9 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
424
505
  headers.set('content-type', m.jsonContentType!);
425
506
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
426
507
  };
427
- const served = m.cors ? withCors(m.cors, labelled) : labelled;
508
+ const cors = m.cors ? withCors(m.cors, labelled) : labelled;
509
+ // at a World's place every cookie the twin sets is kept to the twin's base path (twin-fetch.ts, placedCookies)
510
+ const served = async (request: Request): Promise<Response> => placedCookies(request, await cors(request));
428
511
  // which paths this pack's API serves, for a vendor's lane router that asks (unlessOnlyRoot)
429
512
  const serves = (method: string, path: string): boolean => Boolean(matchOperation(routes, method, path));
430
513
  // the vendor's sockets: a request to a declared socket's path opens a session of its engine, over a context of the
@@ -436,7 +519,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
436
519
  async (asked) => engineOf()?.serve(asked)) : undefined;
437
520
  // what serves each operation: the pack's own, and each lane's
438
521
  const owners = (): Record<string, DerivedOwner> => ({ ...api.owners(), ...lanesOwners(parts.lanes ?? {}) });
439
- return derivedRequestScopes(m, Object.assign(served, { owners, serves, workspaces, boards: Boolean(parts.board), draws: drawsUrl, ...(upgrade ? { upgrade } : {}) }));
522
+ return derivedRequestScopes(m, Object.assign(served, { owners, serves, workspaces, boards: Boolean(parts.board), draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}) }));
440
523
  }
441
524
 
442
525
  /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
@@ -473,6 +556,12 @@ function laneRouter(vendor: string, decl: LanesDecl, lanes: Record<string, LaneP
473
556
  });
474
557
  const fallback = decl.default !== undefined ? served(decl.default) : undefined;
475
558
  return (request) => {
559
+ // `<base>/@<host>/<path>` at a World's place, for a host a lane's built content screen names, goes to that lane, before
560
+ // any lane is picked by the request's host or path: its own fetch answers it by its content route, under the vendor's
561
+ // host rules (architecture, "A page at a World's place")
562
+ const address = request.headers.has(TWIN_PREFIX_HEADER) ? contentAddressOf(new URL(request.url).pathname) : undefined;
563
+ const content = address && Object.keys(lanes).find((l) => lanes[l]!.fetch.contentHosts?.includes(address.host));
564
+ if (content) return served(content);
476
565
  const path = new URL(request.url).pathname.replace(/\/{2,}/g, '/');
477
566
  if (path.startsWith('/_twin/')) {
478
567
  const door = doors.find((d) => d.method === request.method && d.matches(path));
package/src/twin-fetch.ts CHANGED
@@ -230,3 +230,29 @@ async function readScoped(run: (asReadOnly?: boolean) => TwinFetchHandlerResult
230
230
  if (!out.refused) return out.value;
231
231
  return { status: 405, body: { error: 'read_only', message: out.refused.message } };
232
232
  }
233
+
234
+ /** A twin's answer at a World's place with its cookies kept to the twin: every cookie it sets is scoped to the twin's
235
+ * base path there (`Path=/` and no Path become `Path=<prefix>`, which RFC 6265's path-match gives the twin's root
236
+ * `<prefix>` and every path under it and no sibling; a path under the vendor's root, `Path=/x`, becomes `<prefix>/x`),
237
+ * so one twin's sign-in never reaches a sibling twin served from the same pages origin (architecture, "A page at a
238
+ * World's place"). A `__Host-` cookie, which a browser takes only at `Path=/`, is left as set. An answer off a place, at
239
+ * a prefix the World never sends (outside `/[A-Za-z0-9._~/-]*`, which twinPublicBase refuses too), or setting no cookie
240
+ * is the answer as it is. */
241
+ export function placedCookies(request: Request, response: Response): Response {
242
+ const prefix = request.headers.get(TWIN_PREFIX_HEADER)?.replace(/\/+$/, '');
243
+ if (!prefix || !/^\/[A-Za-z0-9._~\/-]*$/.test(prefix)) return response;
244
+ const cookies = response.headers.getSetCookie?.() ?? [];
245
+ if (!cookies.length || response.status < 200) return response;
246
+ const scoped = cookies.map((cookie) => {
247
+ if (/^\s*__Host-/i.test(cookie)) return cookie;
248
+ const path = /;\s*path=([^;]*)/i.exec(cookie)?.[1]?.trim();
249
+ // already scoped (a lane's answer its vendor passes on): as it is
250
+ if (path !== undefined && (path === prefix || path.startsWith(`${prefix}/`))) return cookie;
251
+ const at = !path || path === '/' ? prefix : `${prefix}${path.startsWith('/') ? path : `/${path}`}`.replace(/\/+$/, '');
252
+ return path === undefined ? `${cookie}; Path=${at}` : cookie.replace(/(;\s*)path=[^;]*/i, `$1Path=${at}`);
253
+ });
254
+ const headers = new Headers(response.headers);
255
+ headers.delete('set-cookie');
256
+ for (const cookie of scoped) headers.append('set-cookie', cookie);
257
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
258
+ }
package/vendor-hosts.cjs CHANGED
@@ -55,6 +55,39 @@ const COMPILED = new Map();
55
55
  /** The hand table's keys, before any pack's rules: a pack installed later replaces its hand entry in place (keeping its
56
56
  * position, which decides who answers a shared host first), where the built-in facts may not declare one twice. */
57
57
  const HAND = new Set(Object.keys(VENDOR_HOSTS));
58
+ /** One host rule as a predicate over (hostname, pathname?): its one selector (host | suffix | hostPattern) and its
59
+ * pathPattern, a path left out matching any. */
60
+ function compileHostRule(rule, where) {
61
+ const selectors = ['host', 'suffix', 'hostPattern'].filter((k) => rule[k] !== undefined).length;
62
+ if (selectors !== 1) throw new Error(`inject: ${where} rule ${JSON.stringify(rule)} — exactly one of host | suffix | hostPattern (pack-facts.json is hand-edited or stale)`);
63
+ const hostRe = rule.hostPattern === undefined ? null : new RegExp(rule.hostPattern);
64
+ const pathRe = rule.pathPattern === undefined ? null : new RegExp(rule.pathPattern);
65
+ return (h, p) =>
66
+ (rule.host !== undefined ? h === rule.host : rule.suffix !== undefined ? h.endsWith(rule.suffix) : hostRe.test(h))
67
+ && (pathRe === null || p === undefined || pathRe.test(p));
68
+ }
69
+
70
+ /** A pack's host rules (its descriptor's `hosts`) read as the injector reads them: `names(host)`, whether any include
71
+ * rule's selector names the host, whatever the path; `takes(host, path)`, whether the request is the pack's (under
72
+ * one of its keys, an include matches and no exclude does). The content route at a World's place holds a
73
+ * `<base>/@<host>/<path>` to it (pack-fetch.ts). */
74
+ function hostRules(rules) {
75
+ const byKey = new Map();
76
+ for (const rule of rules || []) {
77
+ const key = rule.key === undefined ? '' : rule.key;
78
+ if (!byKey.has(key)) byKey.set(key, []);
79
+ byKey.get(key).push(rule);
80
+ }
81
+ const keys = [...byKey.values()].map((keyRules) => ({
82
+ includes: keyRules.filter((r) => r.exclude !== true).map((r) => compileHostRule(r, 'a pack')),
83
+ excludes: keyRules.filter((r) => r.exclude === true).map((r) => compileHostRule(r, 'a pack')),
84
+ }));
85
+ return {
86
+ names: (h) => keys.some((k) => k.includes.some((m) => m(h))),
87
+ takes: (h, p) => keys.some((k) => k.includes.some((m) => m(h, p)) && !k.excludes.some((m) => m(h, p))),
88
+ };
89
+ }
90
+
58
91
  function compilePackHosts(packs, overlay) {
59
92
  for (const vendor of Object.keys(packs)) {
60
93
  const rules = packs[vendor].hosts;
@@ -77,15 +110,7 @@ function compilePackHosts(packs, overlay) {
77
110
  if (VENDOR_HOSTS[key] && !(overlay && HAND.has(key))) {
78
111
  throw new Error(`inject: key "${key}" (pack ${vendor}) is declared twice — on this descriptor and in the hand VENDOR_HOSTS table or another pack's descriptor; a key has one home.`);
79
112
  }
80
- const compile = (rule) => {
81
- const selectors = ['host', 'suffix', 'hostPattern'].filter((k) => rule[k] !== undefined).length;
82
- if (selectors !== 1) throw new Error(`inject: key "${key}" (pack ${vendor}) rule ${JSON.stringify(rule)} — exactly one of host | suffix | hostPattern (pack-facts.json is hand-edited or stale)`);
83
- const hostRe = rule.hostPattern === undefined ? null : new RegExp(rule.hostPattern);
84
- const pathRe = rule.pathPattern === undefined ? null : new RegExp(rule.pathPattern);
85
- return (h, p) =>
86
- (rule.host !== undefined ? h === rule.host : rule.suffix !== undefined ? h.endsWith(rule.suffix) : hostRe.test(h))
87
- && (pathRe === null || p === undefined || pathRe.test(p));
88
- };
113
+ const compile = (rule) => compileHostRule(rule, `key "${key}" (pack ${vendor})`);
89
114
  const includes = keyRules.filter((r) => r.exclude !== true).map(compile);
90
115
  const excludes = keyRules.filter((r) => r.exclude === true).map(compile);
91
116
  VENDOR_HOSTS[key] = (h, p) => includes.some((m) => m(h, p)) && !excludes.some((m) => m(h, p));
@@ -141,4 +166,4 @@ function twinOrigins(env) {
141
166
  return map;
142
167
  }
143
168
 
144
- module.exports = { VENDOR_HOSTS, addPackHosts, isVendorHost, vendorsOfHost, twinEnvStem, twinOrigins };
169
+ module.exports = { VENDOR_HOSTS, hostRules, addPackHosts, isVendorHost, vendorsOfHost, twinEnvStem, twinOrigins };
@@ -6,3 +6,6 @@ export function vendorsOfHost(hostname: string): string[];
6
6
  export function twinEnvStem(vendor: string): string;
7
7
  /** The vendors whose twin `env` points at, each with its origin. */
8
8
  export function twinOrigins(env: Readonly<Record<string, string | undefined>>): Record<string, string>;
9
+ /** A pack's host rules (its descriptor's `hosts`, as data) read as the injector reads them: whether any include names a
10
+ * host whatever the path, and whether a request to a host and path is the pack's. */
11
+ export function hostRules(rules: ReadonlyArray<{ host?: string; suffix?: string; hostPattern?: string; pathPattern?: string; key?: string; exclude?: true }> | undefined): { names(hostname: string): boolean; takes(hostname: string, pathname: string): boolean };