@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.
@@ -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.js";
9
- import { TWIN_PREFIX_HEADER } from "./twin-fetch.js";
9
+ import { placedCookies, TWIN_PREFIX_HEADER } from "./twin-fetch.js";
10
10
  import { PREFIX_PARAMS_HEADER } from "./derived.js";
11
11
  import { withHeaders } from "./with-headers.js";
12
12
  import { authRefusal, bindSemantics, contextFor, LANE_HEADER, SCENARIO_DECISION_HEADER, coreFor, crossCutting, derivedRequestScopes, vendorError } from "./derived-core.js";
@@ -17,6 +17,9 @@ import { getActiveWorldStore } from "./world-store.js";
17
17
  import { runWithCorrelationId } from "./actions.js";
18
18
  import { deferPerform, performRequestGroup, RefusedWriteError, VendorWriteError } from "./head.js";
19
19
  import { withCors } from "./cors.js";
20
+ import { contentAddressOf } from "./content-route.js";
21
+ import { getPack } from "./packRegistry.js";
22
+ import { hostRules } from '../vendor-hosts.cjs';
20
23
  import { socketUpgrade } from "./sockets.js";
21
24
  import { MACHINE_POOL, POOL_KINDS } from "./machines.js";
22
25
  import { applyTwinWrite } from "./serve.js";
@@ -112,6 +115,7 @@ export function createPackFetch(parts, options = {}) {
112
115
  ...(options.clock ? { clock: options.clock } : {}),
113
116
  ...(options.database !== undefined ? { database: options.database, readOnly: options.readOnly ?? false } : {}),
114
117
  ...(parts.tenant ? { tenant: parts.tenant } : {}),
118
+ ...(options.application ? { application: options.application } : {}),
115
119
  };
116
120
  // a command table's surface (Redis's, a vendor that carries Redis over HTTP) names no HTTP operation: its wire is the
117
121
  // pack's front (its `around`), and the table is the denominator the grade counts
@@ -267,6 +271,78 @@ export function createPackFetch(parts, options = {}) {
267
271
  };
268
272
  const rootServes = (method, path) => Boolean(matchOperation(routes, method, path));
269
273
  const lane = m.lanes ? laneRouter(m.vendor, m.lanes, parts.lanes ?? {}, rootServes) : undefined;
274
+ // at a World's place, a page's asset or upload on another of the pack's content hosts is `<base>/@<host>/<path>`
275
+ // (architecture, "A page at a World's place"): answered by the built content screen that names that host exactly,
276
+ // routed from it, and by nothing else; a request naming no such host is general routing's
277
+ const contentScreens = screens.filter((s) => s.decl.kind === 'content');
278
+ for (const s of contentScreens)
279
+ if (s.decl.yieldsToApi)
280
+ throw new Error(`${m.vendor}: the content screen ${s.decl.id} declares yieldsToApi; a content screen answers its host's requests alone (architecture, "Screens")`);
281
+ const contentHosts = [...new Set(contentScreens.flatMap((s) => [s.decl.host, ...(s.decl.hosts ?? [])]).filter((h) => !/[{<]/.test(h)).map((h) => h.toLowerCase()))];
282
+ const contentNotFound = (host, path) => {
283
+ const nf = m.notFound;
284
+ 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 } : {}) });
285
+ };
286
+ /** A request at a World's place for `/@<host>/<path>`, `<host>` one a built content screen of this unit names exactly:
287
+ * 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
288
+ * the descriptor's host rules (`rules`: this unit's, or its vendor's for a lane), read as the injector reads them.
289
+ * Undefined for any other request. */
290
+ // the descriptor's host rules: this unit's, or, for a lane (which declares none), its vendor's as registered
291
+ const descriptorHosts = () => m.descriptor?.hosts ?? getPack(m.vendor)?.hosts;
292
+ const atContentHost = async (request, rules = descriptorHosts()) => {
293
+ if (!request.headers.has(TWIN_PREFIX_HEADER))
294
+ return undefined;
295
+ const url = new URL(request.url);
296
+ const address = contentAddressOf(url.pathname);
297
+ if (!address || !contentHosts.includes(address.host))
298
+ return undefined;
299
+ const { host, path: inner } = address;
300
+ if (address.refused)
301
+ return contentNotFound(host, inner);
302
+ const declared = hostRules(rules);
303
+ if (declared.names(host) && !declared.takes(host, inner))
304
+ return contentNotFound(host, inner);
305
+ const onHost = contentScreens.filter((s) => [s.decl.host, ...(s.decl.hosts ?? [])].some((h) => h.toLowerCase() === host));
306
+ const method = request.method.toUpperCase();
307
+ const allowed = (s) => method === 'GET' || method === 'HEAD' || (method === 'POST' && (s.decl.takes ?? []).includes('POST'));
308
+ // the request as it reaches the content host, through the pack's own routing of a host (its path prefix and labels)
309
+ url.pathname = inner;
310
+ const headers = new Headers(request.headers);
311
+ headers.set('x-volter-twin-original-host', host);
312
+ const sent = method === 'POST' && request.body ? { body: request.body, duplex: 'half' } : {};
313
+ const there = routed(new Request(url, { method, headers, signal: request.signal, ...sent }));
314
+ const path = new URL(there.url).pathname.replace(/\/+$/, '') || '/';
315
+ const hit = onHost.map((s) => ({ s, hit: s.path(path) })).find((x) => x.hit && allowed(x.s));
316
+ if (!hit)
317
+ return contentNotFound(host, inner);
318
+ const asked = options.readOnly && !isReadOnlyRequest(there) ? withHeaders(there, new Headers([...there.headers, [READ_ONLY_REQUEST_HEADER, '1']])) : there;
319
+ if (method === 'POST' && ((options.readOnly ?? false) || isReadOnlyRequest(asked)))
320
+ return refusedReadOnly();
321
+ if (parts.clock)
322
+ await runAsVendorMove(async () => parts.clock(await open(new Request(asked.url, m.tenant ? { headers: asked.headers } : {}), 'clock', 'POST', '/_twin/clock', {})));
323
+ return hit.s.answer(await open(asked, hit.s.decl.id, method, hit.s.decl.path, { ...(hit.s.host(host)?.params ?? {}), ...hit.hit.params }));
324
+ };
325
+ /** A request as the kernel reads it: the headers only the kernel names taken off, its body decoded (undefined when it
326
+ * does not decode as its Content-Encoding says). */
327
+ const prepared = async (arrived) => {
328
+ if (arrived.headers.has(SCENARIO_DECISION_HEADER) || arrived.headers.has(PREFIX_PARAMS_HEADER)) {
329
+ const headers = new Headers(arrived.headers);
330
+ headers.delete(SCENARIO_DECISION_HEADER);
331
+ headers.delete(PREFIX_PARAMS_HEADER);
332
+ arrived = new Request(arrived, { headers });
333
+ }
334
+ return decoded(m, arrived);
335
+ };
336
+ const undecodable = (arrived) => vendorError(m, m.encodings?.undecodable ?? { status: 400, message: `the body does not decode as its Content-Encoding (${arrived.headers.get('content-encoding')}) says` });
337
+ /** The content route at the request's arrival: the answer, or undefined when this unit's content screens name no such
338
+ * host. */
339
+ const answerContent = async (arrived) => {
340
+ const address = arrived.headers.has(TWIN_PREFIX_HEADER) ? contentAddressOf(new URL(arrived.url).pathname) : undefined;
341
+ if (!address || !contentHosts.includes(address.host))
342
+ return undefined;
343
+ const readable = await prepared(arrived);
344
+ return readable ? atContentHost(readable) : undecodable(arrived);
345
+ };
270
346
  const fetch = async (arrived) => {
271
347
  // the discovery door is the pack's own, whichever lane would take its path
272
348
  const asked = new URL(arrived.url).pathname.replace(/\/+$/, '') || '/';
@@ -280,19 +356,18 @@ export function createPackFetch(parts, options = {}) {
280
356
  const frames = await parts.board(await open(request, 'board', 'GET', '/twin/board', {}));
281
357
  return Response.json({ frames: frames.map((f) => ({ ...f, drawn: drawsUrl(f.url) })) });
282
358
  }
359
+ // `<base>/@<host>/<path>` at a World's place, for a host a built content screen of this pack names, is that screen's
360
+ // alone (only the kernel names a scenario's decision or a path prefix's parameters: `prepared` takes them off); for
361
+ // one a lane's names, the lane router sends it to that lane's own fetch, wrapped as any request to it is
362
+ const own = await answerContent(arrived);
363
+ if (own)
364
+ return own;
283
365
  const toLane = discovery ? undefined : lane?.(arrived);
284
366
  if (toLane)
285
367
  return toLane(arrived);
286
- // only the kernel names a scenario's decision, or a path prefix's parameters
287
- if (arrived.headers.has(SCENARIO_DECISION_HEADER) || arrived.headers.has(PREFIX_PARAMS_HEADER)) {
288
- const headers = new Headers(arrived.headers);
289
- headers.delete(SCENARIO_DECISION_HEADER);
290
- headers.delete(PREFIX_PARAMS_HEADER);
291
- arrived = new Request(arrived, { headers });
292
- }
293
- const readable = await decoded(m, arrived);
368
+ const readable = await prepared(arrived);
294
369
  if (!readable)
295
- return vendorError(m, m.encodings?.undecodable ?? { status: 400, message: `the body does not decode as its Content-Encoding (${arrived.headers.get('content-encoding')}) says` });
370
+ return undecodable(arrived);
296
371
  const incoming = routed(readable);
297
372
  // a twin started read-only marks every request so, so every context it opens knows (isReadOnlyRequest)
298
373
  const request = options.readOnly && !isReadOnlyRequest(incoming) ? withHeaders(incoming, new Headers([...incoming.headers, [READ_ONLY_REQUEST_HEADER, '1']])) : incoming;
@@ -355,8 +430,10 @@ export function createPackFetch(parts, options = {}) {
355
430
  // whichever host the twin is reached (its links carry the twin's own base), never a root screen off its own host
356
431
  const host = hostOf(request);
357
432
  const exact = (s) => [s.decl.host, ...(s.decl.hosts ?? [])].some((h) => !/[{<]/.test(h) && h.toLowerCase() === host);
358
- const hits = screens.map((s) => ({ s, hit: s.path(path), onHost: s.host(host) })).filter((x) => x.hit);
359
433
  const apiPath = Boolean(matchOperation(routes, request.method, url.pathname, url.searchParams, request.headers));
434
+ // a screen that yields to the API draws only what no operation names, wherever it would answer (architecture,
435
+ // "Screens": Hacker News's /login page, whose form posts to POST /login)
436
+ const hits = screens.map((s) => ({ s, hit: s.path(path), onHost: s.host(host) })).filter((x) => x.hit && !(apiPath && x.s.decl.yieldsToApi));
360
437
  // reached at a World's place for the twin (`x-forwarded-prefix`, served-world's wire), the host is the World's: a
361
438
  // workspace is shown there by its path alone, its root too (the console frames it so)
362
439
  const placed = request.headers.has(TWIN_PREFIX_HEADER);
@@ -421,7 +498,9 @@ export function createPackFetch(parts, options = {}) {
421
498
  headers.set('content-type', m.jsonContentType);
422
499
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
423
500
  };
424
- const served = m.cors ? withCors(m.cors, labelled) : labelled;
501
+ const cors = m.cors ? withCors(m.cors, labelled) : labelled;
502
+ // at a World's place every cookie the twin sets is kept to the twin's base path (twin-fetch.ts, placedCookies)
503
+ const served = async (request) => placedCookies(request, await cors(request));
425
504
  // which paths this pack's API serves, for a vendor's lane router that asks (unlessOnlyRoot)
426
505
  const serves = (method, path) => Boolean(matchOperation(routes, method, path));
427
506
  // the vendor's sockets: a request to a declared socket's path opens a session of its engine, over a context of the
@@ -434,7 +513,7 @@ export function createPackFetch(parts, options = {}) {
434
513
  async (asked) => engineOf()?.serve(asked)) : undefined;
435
514
  // what serves each operation: the pack's own, and each lane's
436
515
  const owners = () => ({ ...api.owners(), ...lanesOwners(parts.lanes ?? {}) });
437
- return derivedRequestScopes(m, Object.assign(served, { owners, serves, workspaces, boards: Boolean(parts.board), draws: drawsUrl, ...(upgrade ? { upgrade } : {}) }));
516
+ return derivedRequestScopes(m, Object.assign(served, { owners, serves, workspaces, boards: Boolean(parts.board), draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}) }));
438
517
  }
439
518
  /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
440
519
  * route whose conditions all hold, then the default), behind the gateway's CORS; undefined when none takes it. */
@@ -470,6 +549,13 @@ function laneRouter(vendor, decl, lanes, rootServes = () => false) {
470
549
  });
471
550
  const fallback = decl.default !== undefined ? served(decl.default) : undefined;
472
551
  return (request) => {
552
+ // `<base>/@<host>/<path>` at a World's place, for a host a lane's built content screen names, goes to that lane, before
553
+ // any lane is picked by the request's host or path: its own fetch answers it by its content route, under the vendor's
554
+ // host rules (architecture, "A page at a World's place")
555
+ const address = request.headers.has(TWIN_PREFIX_HEADER) ? contentAddressOf(new URL(request.url).pathname) : undefined;
556
+ const content = address && Object.keys(lanes).find((l) => lanes[l].fetch.contentHosts?.includes(address.host));
557
+ if (content)
558
+ return served(content);
473
559
  const path = new URL(request.url).pathname.replace(/\/{2,}/g, '/');
474
560
  if (path.startsWith('/_twin/')) {
475
561
  const door = doors.find((d) => d.method === request.method && d.matches(path));
@@ -86,3 +86,11 @@ export interface TwinFetchAdapterConfig {
86
86
  responseHeaders?: Record<string, string>;
87
87
  }
88
88
  export declare function createTwinFetchFromHandler(handler: (request: TwinFetchHandlerRequest) => TwinFetchHandlerResult | Promise<TwinFetchHandlerResult>, config: TwinFetchAdapterConfig): (request: Request) => Promise<Response>;
89
+ /** 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
90
+ * base path there (`Path=/` and no Path become `Path=<prefix>`, which RFC 6265's path-match gives the twin's root
91
+ * `<prefix>` and every path under it and no sibling; a path under the vendor's root, `Path=/x`, becomes `<prefix>/x`),
92
+ * so one twin's sign-in never reaches a sibling twin served from the same pages origin (architecture, "A page at a
93
+ * World's place"). A `__Host-` cookie, which a browser takes only at `Path=/`, is left as set. An answer off a place, at
94
+ * a prefix the World never sends (outside `/[A-Za-z0-9._~/-]*`, which twinPublicBase refuses too), or setting no cookie
95
+ * is the answer as it is. */
96
+ export declare function placedCookies(request: Request, response: Response): Response;
@@ -178,3 +178,33 @@ async function readScoped(run) {
178
178
  return out.value;
179
179
  return { status: 405, body: { error: 'read_only', message: out.refused.message } };
180
180
  }
181
+ /** 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
182
+ * base path there (`Path=/` and no Path become `Path=<prefix>`, which RFC 6265's path-match gives the twin's root
183
+ * `<prefix>` and every path under it and no sibling; a path under the vendor's root, `Path=/x`, becomes `<prefix>/x`),
184
+ * so one twin's sign-in never reaches a sibling twin served from the same pages origin (architecture, "A page at a
185
+ * World's place"). A `__Host-` cookie, which a browser takes only at `Path=/`, is left as set. An answer off a place, at
186
+ * a prefix the World never sends (outside `/[A-Za-z0-9._~/-]*`, which twinPublicBase refuses too), or setting no cookie
187
+ * is the answer as it is. */
188
+ export function placedCookies(request, response) {
189
+ const prefix = request.headers.get(TWIN_PREFIX_HEADER)?.replace(/\/+$/, '');
190
+ if (!prefix || !/^\/[A-Za-z0-9._~\/-]*$/.test(prefix))
191
+ return response;
192
+ const cookies = response.headers.getSetCookie?.() ?? [];
193
+ if (!cookies.length || response.status < 200)
194
+ return response;
195
+ const scoped = cookies.map((cookie) => {
196
+ if (/^\s*__Host-/i.test(cookie))
197
+ return cookie;
198
+ const path = /;\s*path=([^;]*)/i.exec(cookie)?.[1]?.trim();
199
+ // already scoped (a lane's answer its vendor passes on): as it is
200
+ if (path !== undefined && (path === prefix || path.startsWith(`${prefix}/`)))
201
+ return cookie;
202
+ const at = !path || path === '/' ? prefix : `${prefix}${path.startsWith('/') ? path : `/${path}`}`.replace(/\/+$/, '');
203
+ return path === undefined ? `${cookie}; Path=${at}` : cookie.replace(/(;\s*)path=[^;]*/i, `$1Path=${at}`);
204
+ });
205
+ const headers = new Headers(response.headers);
206
+ headers.delete('set-cookie');
207
+ for (const cookie of scoped)
208
+ headers.append('set-cookie', cookie);
209
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
210
+ }
@@ -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 };
@@ -1137,6 +1137,48 @@
1137
1137
  },
1138
1138
  "serveExport": "createGroqTwinServer"
1139
1139
  },
1140
+ "hackernews": {
1141
+ "adoption": {
1142
+ "sdks": [],
1143
+ "envStems": [
1144
+ "HN",
1145
+ "HACKERNEWS"
1146
+ ]
1147
+ },
1148
+ "hosts": [
1149
+ {
1150
+ "host": "hacker-news.firebaseio.com"
1151
+ },
1152
+ {
1153
+ "host": "news.ycombinator.com"
1154
+ }
1155
+ ],
1156
+ "archetype": "crud",
1157
+ "protocol": {
1158
+ "declared": "3",
1159
+ "major": 3,
1160
+ "standing": "current"
1161
+ },
1162
+ "transport": "rest",
1163
+ "resources": [
1164
+ "item",
1165
+ "user",
1166
+ "_vote",
1167
+ "_person",
1168
+ "_web_session",
1169
+ "_web_session_issue"
1170
+ ],
1171
+ "endpointEnvNone": "Hacker News publishes no SDK: clients fetch https://hacker-news.firebaseio.com/v0 directly and people use news.ycombinator.com in a browser, both reached by host",
1172
+ "credentialDoor": {
1173
+ "path": "/_twin/app-credentials",
1174
+ "body": {},
1175
+ "fill": {
1176
+ "HN_USERNAME": "acct",
1177
+ "HN_PASSWORD": "pw"
1178
+ }
1179
+ },
1180
+ "serveExport": "createHackernewsTwinServer"
1181
+ },
1140
1182
  "hubspot": {
1141
1183
  "adoption": {
1142
1184
  "sdks": [
@@ -1844,6 +1886,88 @@
1844
1886
  },
1845
1887
  "serveExport": "createPosthogTwinServer"
1846
1888
  },
1889
+ "reddit": {
1890
+ "adoption": {
1891
+ "sdks": [
1892
+ "snoowrap"
1893
+ ],
1894
+ "pypi": [
1895
+ "praw",
1896
+ "asyncpraw"
1897
+ ],
1898
+ "envStems": [
1899
+ "REDDIT"
1900
+ ]
1901
+ },
1902
+ "hosts": [
1903
+ {
1904
+ "host": "oauth.reddit.com"
1905
+ },
1906
+ {
1907
+ "host": "www.reddit.com"
1908
+ },
1909
+ {
1910
+ "host": "reddit.com"
1911
+ },
1912
+ {
1913
+ "host": "reddit-uploaded-media.s3-accelerate.amazonaws.com"
1914
+ },
1915
+ {
1916
+ "host": "reddit-uploaded-video.s3-accelerate.amazonaws.com"
1917
+ },
1918
+ {
1919
+ "host": "v.redd.it"
1920
+ },
1921
+ {
1922
+ "host": "i.redd.it"
1923
+ },
1924
+ {
1925
+ "host": "preview.redd.it"
1926
+ },
1927
+ {
1928
+ "host": "external-preview.redd.it"
1929
+ },
1930
+ {
1931
+ "host": "b.thumbs.redditmedia.com"
1932
+ }
1933
+ ],
1934
+ "archetype": "crud",
1935
+ "protocol": {
1936
+ "declared": "3",
1937
+ "major": 3,
1938
+ "standing": "current"
1939
+ },
1940
+ "transport": "rest",
1941
+ "resourcesUnreachable": {},
1942
+ "resources": [
1943
+ "account",
1944
+ "subreddit",
1945
+ "link",
1946
+ "comment",
1947
+ "asset",
1948
+ "app",
1949
+ "_access_token",
1950
+ "_token_issue",
1951
+ "_client_secret",
1952
+ "_rate_window",
1953
+ "_vote",
1954
+ "_membership",
1955
+ "_person",
1956
+ "_web_session",
1957
+ "_web_session_issue"
1958
+ ],
1959
+ "endpointEnvNone": "Reddit's clients name oauth.reddit.com and www.reddit.com themselves (the campaign's sender writes them as constants); the World's injector routes those hosts, and no client of the demand reads a base-URL variable",
1960
+ "credentialDoor": {
1961
+ "path": "/_twin/app-credentials",
1962
+ "body": {
1963
+ "username": "volter"
1964
+ },
1965
+ "fill": {
1966
+ "REDDIT_ACCESS_TOKEN": "access_token"
1967
+ }
1968
+ },
1969
+ "serveExport": "createRedditTwinServer"
1970
+ },
1847
1971
  "resend": {
1848
1972
  "adoption": {
1849
1973
  "pypi": [
@@ -2817,27 +2941,27 @@
2817
2941
  },
2818
2942
  {
2819
2943
  "host": "x.com",
2820
- "pathPattern": "^/i/oauth2/authorize/?$|^/_twin/|^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$"
2944
+ "pathPattern": "^/i/oauth2/authorize/?$|^/_twin/|^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$|^/home/?$|^/i/flow/login/?$|^/$"
2821
2945
  },
2822
2946
  {
2823
2947
  "host": "twitter.com",
2824
- "pathPattern": "^/i/oauth2/authorize/?$|^/_twin/|^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$"
2948
+ "pathPattern": "^/i/oauth2/authorize/?$|^/_twin/|^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$|^/home/?$|^/i/flow/login/?$|^/$"
2825
2949
  },
2826
2950
  {
2827
2951
  "host": "www.x.com",
2828
- "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$"
2952
+ "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$|^/home/?$|^/i/flow/login/?$|^/$"
2829
2953
  },
2830
2954
  {
2831
2955
  "host": "www.twitter.com",
2832
- "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$"
2956
+ "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$|^/home/?$|^/i/flow/login/?$|^/$"
2833
2957
  },
2834
2958
  {
2835
2959
  "host": "mobile.twitter.com",
2836
- "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$"
2960
+ "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$|^/home/?$|^/i/flow/login/?$|^/$"
2837
2961
  },
2838
2962
  {
2839
2963
  "host": "mobile.x.com",
2840
- "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$"
2964
+ "pathPattern": "^/(?!_twin/)[A-Za-z0-9_]{1,15}/status/[0-9]{1,19}/?$|^/(?!(?:i|en|home|explore|notifications|messages|settings|search|compose|login|logout|signup|tos|privacy|about|account|intent|share|hashtag|lists|bookmarks|jobs|download|grok|premium|communities|creators|twin|_twin)/?$)[A-Za-z0-9_]{1,15}/?$|^/home/?$|^/i/flow/login/?$|^/$"
2841
2965
  },
2842
2966
  {
2843
2967
  "host": "pbs.twimg.com",
@@ -2863,7 +2987,6 @@
2863
2987
  "oauth_client",
2864
2988
  "oauth1_app",
2865
2989
  "account",
2866
- "session",
2867
2990
  "auth_request",
2868
2991
  "post",
2869
2992
  "rate_limit",
@@ -2873,6 +2996,7 @@
2873
2996
  "repost",
2874
2997
  "bookmark",
2875
2998
  "_impressions",
2999
+ "_card",
2876
3000
  "_grant",
2877
3001
  "_oauth1_grant",
2878
3002
  "_rate_window",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.18",
3
+ "version": "3.0.20",
4
4
  "description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
5
5
  "keywords": [
6
6
  "twin",
@@ -0,0 +1,24 @@
1
+ // THE CONTENT ROUTE'S ADDRESS (architecture, "Screens", "A page at a World's place"): at a World's place, what a page
2
+ // reaches on another of the pack's hosts that a content screen serves is `<base>/@<host>/<path>`. This reads that
3
+ // address as a browser would have written it: a doubled slash collapsed, `%40` for `@`, the host's case, a trailing dot
4
+ // and a default port (80, 443) normalised away. What it reads is a candidate only: the route is taken when the host is
5
+ // one a built content screen names (pack-fetch.ts), and a request naming no such host is general routing's, as before.
6
+
7
+ /** A `/@<host>/<path>` address: the host normalised, the path under it (slashes collapsed), and why the spelling cannot
8
+ * be the host's content (`refused`: a port other than the default), when it cannot. */
9
+ export type ContentAddress = { host: string; path: string; refused?: string };
10
+
11
+ export function contentAddressOf(pathname: string): ContentAddress | undefined {
12
+ const collapsed = pathname.replace(/\/{2,}/g, '/');
13
+ const named = /^\/(?:@|%40)([^/]+)(\/.*)?$/i.exec(collapsed);
14
+ if (!named) return undefined;
15
+ let token: string;
16
+ try { token = decodeURIComponent(named[1]!).toLowerCase(); } catch { return undefined; }
17
+ const at = /^([^:]*)(?::(\d*))?$/.exec(token);
18
+ if (!at) return undefined;
19
+ const host = at[1]!.replace(/\.$/, '');
20
+ if (!/^[a-z0-9_-]+(?:\.[a-z0-9_-]+)+$/.test(host)) return undefined;
21
+ const port = at[2];
22
+ const refused = port && port !== '80' && port !== '443' ? `port ${port} is not the host's` : undefined;
23
+ return { host, path: named[2] ?? '/', ...(refused ? { refused } : {}) };
24
+ }