@volter/world-core 3.0.29 → 3.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/host-routing.cjs +106 -0
  2. package/dist/host-routing.d.cts +7 -0
  3. package/dist/inject.cjs +5 -8
  4. package/dist/src/actions.d.ts +3 -1
  5. package/dist/src/actions.js +4 -2
  6. package/dist/src/board-keys.d.ts +6 -0
  7. package/dist/src/board-keys.js +4 -0
  8. package/dist/src/browser-cookie-jar.d.ts +9 -0
  9. package/dist/src/browser-cookie-jar.js +104 -0
  10. package/dist/src/browser-session.d.ts +9 -0
  11. package/dist/src/browser-session.js +30 -0
  12. package/dist/src/browser-state.d.ts +52 -0
  13. package/dist/src/browser-state.js +105 -0
  14. package/dist/src/browser.d.ts +12 -0
  15. package/dist/src/browser.js +135 -0
  16. package/dist/src/derived-core.d.ts +3 -1
  17. package/dist/src/derived-core.js +26 -19
  18. package/dist/src/derived.d.ts +1 -0
  19. package/dist/src/index.d.ts +2 -0
  20. package/dist/src/index.js +2 -0
  21. package/dist/src/pack-fetch.js +2 -2
  22. package/dist/src/public-suffixes.json +1 -0
  23. package/dist/src/runtime.d.ts +2 -1
  24. package/dist/src/runtime.js +2 -1
  25. package/dist/src/serve.d.ts +3 -1
  26. package/dist/src/serve.js +4 -2
  27. package/dist/src/twin-fetch.d.ts +1 -1
  28. package/dist/src/twin-fetch.js +23 -4
  29. package/dist/vendor-hosts.cjs +13 -105
  30. package/dist/vendor-hosts.d.cts +3 -0
  31. package/host-routing.cjs +106 -0
  32. package/host-routing.d.cts +7 -0
  33. package/inject.cjs +5 -8
  34. package/package.json +11 -1
  35. package/src/actions.ts +3 -1
  36. package/src/board-keys.ts +4 -0
  37. package/src/browser-cookie-jar.ts +77 -0
  38. package/src/browser-session.ts +31 -0
  39. package/src/browser-state.ts +89 -0
  40. package/src/browser.ts +115 -0
  41. package/src/derived-core.ts +26 -16
  42. package/src/derived.ts +1 -1
  43. package/src/index.ts +4 -0
  44. package/src/pack-fetch.ts +2 -2
  45. package/src/public-suffixes.json +1 -0
  46. package/src/runtime.ts +4 -1
  47. package/src/serve.ts +3 -1
  48. package/src/twin-fetch.ts +9 -4
  49. package/vendor-hosts.cjs +13 -105
  50. package/vendor-hosts.d.cts +3 -0
@@ -4,7 +4,7 @@ export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioErr
4
4
  export { WORLD_CLOCK_ENV, worldNow } from './world-clock.js';
5
5
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.js';
6
6
  export { ORIGINAL_PATH_HEADER } from './sigv4.js';
7
- export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, siteLabel, siteUrlOf, twinPublicBase, twinSiteUrl, withRequestScopes } from './twin-fetch.js';
7
+ export { createTwinFetchFromHandler, placedCookies, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, siteLabel, siteUrlOf, twinPublicBase, twinSiteUrl, withRequestScopes } from './twin-fetch.js';
8
8
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.js';
9
9
  export { compileSurface, createDerivedFetch, matchOperation } from './derived.js';
10
10
  export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.js';
@@ -84,3 +84,4 @@ export { captureHistory, captureParentHistory, historyChanges, historyAtInstant,
84
84
  export type { HistoryView, HistoryLayout, HistoryReference, HistoryOrigin } from './history.js';
85
85
  export { foldHistory } from './log.js';
86
86
  export { volterHome } from './volter-home.js';
87
+ export { boardFrameKey } from './board-keys.js';
@@ -12,7 +12,7 @@ export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioErr
12
12
  export { WORLD_CLOCK_ENV, worldNow } from "./world-clock.js";
13
13
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from "./world-env.js";
14
14
  export { ORIGINAL_PATH_HEADER } from "./sigv4.js";
15
- export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, siteLabel, siteUrlOf, twinPublicBase, twinSiteUrl, withRequestScopes } from "./twin-fetch.js";
15
+ export { createTwinFetchFromHandler, placedCookies, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, siteLabel, siteUrlOf, twinPublicBase, twinSiteUrl, withRequestScopes } from "./twin-fetch.js";
16
16
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from "./request-scope.js";
17
17
  export { compileSurface, createDerivedFetch, matchOperation } from "./derived.js";
18
18
  export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from "./trace-context.js";
@@ -103,3 +103,4 @@ export { assertStateRemovable, checkParent, stateGeneration, withAncestryLock, w
103
103
  export { captureHistory, captureParentHistory, historyChanges, historyAtInstant, historyDigest, historyEntries, historyLength, historyPrefix, inheritedHistory, originHead, publishOriginHistory, readHistoryView } from "./history.js";
104
104
  export { foldHistory } from "./log.js";
105
105
  export { volterHome } from "./volter-home.js";
106
+ export { boardFrameKey } from "./board-keys.js";
@@ -168,7 +168,9 @@ export declare function recordedInput(input: unknown): Promise<unknown>;
168
168
  * Use this when acceptance or the new fields depend on current projected state; a caller-side
169
169
  * read followed by `applyTwinWrite` is not atomic across processes.
170
170
  */
171
- export declare function applyTwinWriteAtomic<T>(service: string, prepare: (resources: readonly TwinResource[]) => AtomicTwinWriteDecision<T>, root?: string): Promise<{
171
+ export declare function applyTwinWriteAtomic<T>(service: string, prepare: (resources: readonly TwinResource[]) => AtomicTwinWriteDecision<T>, root?: string,
172
+ /** A type index when the decision and its preconditions need only that type, read inside the state lock. */
173
+ read?: () => TwinResource[]): Promise<{
172
174
  value: T;
173
175
  result?: TwinWriteResult;
174
176
  }>;
package/dist/src/serve.js CHANGED
@@ -498,7 +498,9 @@ function actionForTwinWrite(service, write) {
498
498
  * Use this when acceptance or the new fields depend on current projected state; a caller-side
499
499
  * read followed by `applyTwinWrite` is not atomic across processes.
500
500
  */
501
- export async function applyTwinWriteAtomic(service, prepare, root) {
501
+ export async function applyTwinWriteAtomic(service, prepare, root,
502
+ /** A type index when the decision and its preconditions need only that type, read inside the state lock. */
503
+ read) {
502
504
  // Same ruling as applyTwinWrite: a local write is an OCCURRENCE unless the caller supplied
503
505
  // identity. The mode rides on the decision because only the callback knows which write it chose.
504
506
  const committed = decideAndAppendAction(service, (resources) => {
@@ -511,7 +513,7 @@ export async function applyTwinWriteAtomic(service, prepare, root) {
511
513
  action: actionForTwinWrite(service, decision.write),
512
514
  identity: decision.write.idempotencyKey || decision.write.actionId ? 'caller' : 'occurrence',
513
515
  };
514
- }, root);
516
+ }, root, read);
515
517
  // as applyTwinWrite: a seed's write is the placeholder's default data, never performed
516
518
  const head = committed.action && committed.appended && !committed.placeholder ? await performAtHead(service, committed.action, root) : { performed: false };
517
519
  return {
@@ -17,7 +17,7 @@ export declare const TWIN_SITES_HEADER = "x-volter-world-sites";
17
17
  export declare const siteLabel: (hostname: string) => string;
18
18
  /** The address a sites template gives `hostname`, or null where the template is not one or the name makes no address
19
19
  * (a label over 63 characters). */
20
- export declare function siteUrlOf(template: string, hostname: string): string | null;
20
+ export declare function siteUrlOf(template: string, hostname: string, browser?: string): string | null;
21
21
  /** Where a page links a hostname the twin answers: the World's address for it when the World shows sites, else the
22
22
  * hostname itself (reached through the World's proxy, or the vendor). A pack's screen links a site from this, as it
23
23
  * mints its own URLs from twinPublicBase. */
@@ -1,3 +1,4 @@
1
+ import { assertBrowserName } from "./browser-state.js";
1
2
  // THE ONE HTTP ADAPTATION (runtime contract R12b): most packs' serve path is the same
2
3
  // ~20 lines of glue — the keyless GET /twin manifest door, the body read, a header map,
3
4
  // the dispatch into `handle<Vendor>TwinRequest` with the WORLD instant, and a JSON
@@ -50,13 +51,22 @@ export const TWIN_SITES_HEADER = 'x-volter-world-sites';
50
51
  export const siteLabel = (hostname) => hostname.toLowerCase().replace(/\./g, '-');
51
52
  /** The address a sites template gives `hostname`, or null where the template is not one or the name makes no address
52
53
  * (a label over 63 characters). */
53
- export function siteUrlOf(template, hostname) {
54
+ export function siteUrlOf(template, hostname, browser) {
54
55
  const host = hostname.toLowerCase();
56
+ if (browser) {
57
+ try {
58
+ assertBrowserName(browser);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ const label = `${browser ? `${browser}--` : ''}${siteLabel(host)}`;
55
65
  if (!/^[a-z0-9.-]+$/.test(host))
56
66
  return null;
57
67
  // a one-label site is never an IDN's (`xn--…` would be read as punycode, and is no address)
58
- const filled = /^https?:\/\/\{host\}\.[A-Za-z0-9.-]+(:\d+)?$/.test(template) ? template.replace('{host}', host)
59
- : /^https?:\/\/\{site\}--[A-Za-z0-9.-]+(:\d+)?$/.test(template) && !host.startsWith('xn--') ? template.replace('{site}', siteLabel(host)) : null;
68
+ const filled = /^https?:\/\/\{host\}\.[A-Za-z0-9.-]+(:\d+)?$/.test(template) ? template.replace('{host}', browser ? label : host)
69
+ : /^https?:\/\/\{site\}--[A-Za-z0-9.-]+(:\d+)?$/.test(template) && !host.startsWith('xn--') ? template.replace('{site}', label) : null;
60
70
  try {
61
71
  return filled && new URL(filled).hostname.split('.').every((l) => l.length > 0 && l.length <= 63) ? filled : null;
62
72
  }
@@ -69,7 +79,16 @@ export function siteUrlOf(template, hostname) {
69
79
  * mints its own URLs from twinPublicBase. */
70
80
  export function twinSiteUrl(request, hostname) {
71
81
  const at = request.headers.get(TWIN_SITES_HEADER);
72
- return (at ? siteUrlOf(at, hostname) : null) ?? `https://${hostname.toLowerCase()}`;
82
+ let browser = request.headers.get('x-volter-world-browser') ?? undefined;
83
+ if (browser) {
84
+ try {
85
+ assertBrowserName(browser);
86
+ }
87
+ catch {
88
+ browser = undefined;
89
+ }
90
+ }
91
+ return (at ? siteUrlOf(at, hostname, browser) : null) ?? `https://${hostname.toLowerCase()}`;
73
92
  }
74
93
  /** The request scopes a pack wrapped in `withRequestScopes` enforces, advertised on its `GET /twin` as
75
94
  * `requestScopes`: `read` — a request carrying `x-volter-read-only: 1` (request-scope.ts) has every
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
  // THE VENDOR HOST TABLE — which vendor's twin serves a hostname (and, where two vendors share a host, which path), the
3
- // one home of that fact in a process, dependency-free and side-effect free. The injector (inject.cjs) routes by it; the
3
+ // Node process's loaded facts. The pure compiler and resolver are host-routing.cjs; this loader reads Node facts.
4
+ // The injector (inject.cjs) routes by the table; the
4
5
  // application route (app-route.cjs) never answers a name it gives a vendor, in the injector or in a twin's delivery.
5
- // Moved out of inject.cjs unchanged (2026-09-27).
6
6
  // ── PATH-AWARE DISAMBIGUATION (the shared-host case) ─────────────────────────────────────────
7
7
  // A predicate is called as `(hostname, pathname)`. `pathname` is OPTIONAL and is `undefined`
8
8
  // wherever the caller genuinely does not have one yet — most importantly the proxy's CONNECT
@@ -25,108 +25,12 @@
25
25
  // `browserassets` and `googlefavicon`; kernel packages carry no pack descriptor). Every vendor
26
26
  // pack's hosts live on its descriptor as DATA — host | suffix | hostPattern, pathPattern,
27
27
  // exclude, key — compiled from pack-facts.json.
28
- const VENDOR_HOSTS = {
29
- googlefavicon: (h) => h === 'www.google.com' || h === 'google.com' || h === 't2.gstatic.com',
30
- browserassets: (h) =>
31
- h === 'cdnjs.cloudflare.com' ||
32
- h === 'www.googletagmanager.com' ||
33
- h === 'www.google-analytics.com' ||
34
- h === 'connect.facebook.net' ||
35
- h === 'snap.licdn.com' ||
36
- h === 'px.ads.linkedin.com' ||
37
- h === 'static.hotjar.com' ||
38
- h === 'fonts.googleapis.com' ||
39
- h === 'fonts.gstatic.com' ||
40
- h === 'api.fontshare.com' ||
41
- h === 'images.subscribe.dev' ||
42
- h === 'www.youtube.com' ||
43
- h === 'www.youtube-nocookie.com' ||
44
- h === 'i.ytimg.com',
45
- };
46
-
47
- // descriptor-first migration (architecture.md, "The descriptor"): packs now declare their hosts as DATA on their descriptor
48
- // (`hosts` on TwinPack), compiled into the committed pack-facts artifact — a plain JSON this
49
- // preloaded, dependency-free module can `require` natively. The hand table above shrinks toward
50
- // empty as entries move; a vendor present in both homes throws (dual declaration is the drift
51
- // this migration ends, and a broken checkout should refuse to inject rather than half-route).
52
- /** The keys each pack's compiled rules answer under, by vendor: a pack's own facts learned later (addPackHosts) replace
53
- * that vendor's rules, never another's. */
54
- const COMPILED = new Map();
55
- /** The hand table's keys, before any pack's rules: a pack installed later replaces its hand entry in place (keeping its
56
- * position, which decides who answers a shared host first), where the built-in facts may not declare one twice. */
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
-
91
- function compilePackHosts(packs, overlay) {
92
- for (const vendor of Object.keys(packs)) {
93
- const rules = packs[vendor].hosts;
94
- if (!rules || rules.length === 0) continue;
95
- for (const key of COMPILED.get(vendor) || []) delete VENDOR_HOSTS[key];
96
- COMPILED.delete(vendor);
97
- if (VENDOR_HOSTS[vendor] && !(overlay && HAND.has(vendor))) {
98
- throw new Error(`inject: vendor "${vendor}" declares hosts on its pack descriptor AND in the hand VENDOR_HOSTS table — one home per fact; delete the hand entry.`);
99
- }
100
- // Rules group by `key` (default: the vendor id) — aws's descriptor declares the s3 and
101
- // secretsmanager routing identities its twin answers under. A key matches when ANY include
102
- // rule matches AND NO exclude rule matches.
103
- const byKey = new Map();
104
- for (const rule of rules) {
105
- const key = rule.key === undefined ? vendor : rule.key;
106
- if (!byKey.has(key)) byKey.set(key, []);
107
- byKey.get(key).push(rule);
108
- }
109
- for (const [key, keyRules] of byKey) {
110
- if (VENDOR_HOSTS[key] && !(overlay && HAND.has(key))) {
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.`);
112
- }
113
- const compile = (rule) => compileHostRule(rule, `key "${key}" (pack ${vendor})`);
114
- const includes = keyRules.filter((r) => r.exclude !== true).map(compile);
115
- const excludes = keyRules.filter((r) => r.exclude === true).map(compile);
116
- VENDOR_HOSTS[key] = (h, p) => includes.some((m) => m(h, p)) && !excludes.some((m) => m(h, p));
117
- COMPILED.set(vendor, [...(COMPILED.get(vendor) || []), key]);
118
- }
119
- }
120
- }
121
- {
122
- const facts = require('./pack-facts.cjs').packFacts();
123
- const installed = new Set(facts.installed || []);
124
- compilePackHosts(Object.fromEntries(Object.entries(facts.packs).filter(([vendor]) => !installed.has(vendor))), false);
125
- compilePackHosts(Object.fromEntries(Object.entries(facts.packs).filter(([vendor]) => installed.has(vendor))), true);
126
- }
127
- /** Rules from packs' facts learned after this module loaded (a runtime told the facts of the World it boots): each
128
- * vendor's replace its earlier rules. */
129
- function addPackHosts(packs) { compilePackHosts(packs || {}, true); }
28
+ const routing = require('./host-routing.cjs');
29
+ const facts = require('./pack-facts.cjs').packFacts();
30
+ const table = routing.createHostTable(facts.packs, facts.installed);
31
+ const VENDOR_HOSTS = table.hosts;
32
+ const hostRules = routing.hostRules;
33
+ function addPackHosts(packs) { table.addPackHosts(packs); }
130
34
 
131
35
  /** Whether any vendor's host rule names `host` (at the host level, whatever the path), whether or not its twin runs. */
132
36
  function isVendorHost(host) {
@@ -166,4 +70,8 @@ function twinOrigins(env) {
166
70
  return map;
167
71
  }
168
72
 
169
- module.exports = { VENDOR_HOSTS, hostRules, addPackHosts, isVendorHost, vendorsOfHost, twinEnvStem, twinOrigins };
73
+ /** Resolve using this Node process's loaded facts. */
74
+ function resolveTwinHost(hostname, map, pathname, claimed) {
75
+ return routing.resolveTwinHost(VENDOR_HOSTS, hostname, map, pathname, claimed);
76
+ }
77
+ module.exports = { resolveTwinHost, VENDOR_HOSTS, hostRules, addPackHosts, isVendorHost, vendorsOfHost, twinEnvStem, twinOrigins };
@@ -9,3 +9,6 @@ export function twinOrigins(env: Readonly<Record<string, string | undefined>>):
9
9
  /** A pack's host rules (its descriptor's `hosts`, as data) read as the injector reads them: whether any include names a
10
10
  * host whatever the path, and whether a request to a host and path is the pack's. */
11
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 };
12
+
13
+ /** Resolve an active twin exactly as the proxy, first declared host/path rules, then claimed hosts. */
14
+ export function resolveTwinHost(hostname: string, map: Readonly<Record<string, string>>, pathname?: string, claimed?: Readonly<Record<string, ReadonlySet<string> | (() => ReadonlySet<string>)>>): { vendor: string; origin: string } | null;
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+ // Host routing is data in, answer out. No imports, environment reads or module-load table construction.
3
+ function compileHostRule(rule, where) {
4
+ const selectors = ['host', 'suffix', 'hostPattern'].filter((k) => rule[k] !== undefined).length;
5
+ 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)`);
6
+ const hostRe = rule.hostPattern === undefined ? null : new RegExp(rule.hostPattern);
7
+ const pathRe = rule.pathPattern === undefined ? null : new RegExp(rule.pathPattern);
8
+ return (h, p) =>
9
+ (rule.host !== undefined ? h === rule.host : rule.suffix !== undefined ? h.endsWith(rule.suffix) : hostRe.test(h))
10
+ && (pathRe === null || p === undefined || pathRe.test(p));
11
+ }
12
+
13
+ /** A pack's host rules (its descriptor's `hosts`) read as the injector reads them: `names(host)`, whether any include
14
+ * rule's selector names the host, whatever the path; `takes(host, path)`, whether the request is the pack's (under
15
+ * one of its keys, an include matches and no exclude does). The content route at a World's place holds a
16
+ * `<base>/@<host>/<path>` to it (pack-fetch.ts). */
17
+ function hostRules(rules) {
18
+ const byKey = new Map();
19
+ for (const rule of rules || []) {
20
+ const key = rule.key === undefined ? '' : rule.key;
21
+ if (!byKey.has(key)) byKey.set(key, []);
22
+ byKey.get(key).push(rule);
23
+ }
24
+ const keys = [...byKey.values()].map((keyRules) => ({
25
+ includes: keyRules.filter((r) => r.exclude !== true).map((r) => compileHostRule(r, 'a pack')),
26
+ excludes: keyRules.filter((r) => r.exclude === true).map((r) => compileHostRule(r, 'a pack')),
27
+ }));
28
+ return {
29
+ names: (h) => keys.some((k) => k.includes.some((m) => m(h))),
30
+ takes: (h, p) => keys.some((k) => k.includes.some((m) => m(h, p)) && !k.excludes.some((m) => m(h, p))),
31
+ };
32
+ }
33
+
34
+ /** Build one independent host table, in the proxy's key order, from supplied pack facts and overlays. */
35
+ function createHostTable(packs, installed = []) {
36
+ const VENDOR_HOSTS = {
37
+ googlefavicon: (h) => h === 'www.google.com' || h === 'google.com' || h === 't2.gstatic.com',
38
+ browserassets: (h) =>
39
+ h === 'cdnjs.cloudflare.com' ||
40
+ h === 'www.googletagmanager.com' ||
41
+ h === 'www.google-analytics.com' ||
42
+ h === 'connect.facebook.net' ||
43
+ h === 'snap.licdn.com' ||
44
+ h === 'px.ads.linkedin.com' ||
45
+ h === 'static.hotjar.com' ||
46
+ h === 'fonts.googleapis.com' ||
47
+ h === 'fonts.gstatic.com' ||
48
+ h === 'api.fontshare.com' ||
49
+ h === 'images.subscribe.dev' ||
50
+ h === 'www.youtube.com' ||
51
+ h === 'www.youtube-nocookie.com' ||
52
+ h === 'i.ytimg.com',
53
+ };
54
+ const COMPILED = new Map();
55
+ const HAND = new Set(Object.keys(VENDOR_HOSTS));
56
+ function compilePackHosts(packs, overlay) {
57
+ for (const vendor of Object.keys(packs)) {
58
+ const rules = packs[vendor].hosts;
59
+ if (!rules || rules.length === 0) continue;
60
+ for (const key of COMPILED.get(vendor) || []) delete VENDOR_HOSTS[key];
61
+ COMPILED.delete(vendor);
62
+ if (VENDOR_HOSTS[vendor] && !(overlay && HAND.has(vendor))) {
63
+ throw new Error(`inject: vendor "${vendor}" declares hosts on its pack descriptor AND in the hand VENDOR_HOSTS table — one home per fact; delete the hand entry.`);
64
+ }
65
+ // Rules group by `key` (default: the vendor id) — aws's descriptor declares the s3 and
66
+ // secretsmanager routing identities its twin answers under. A key matches when ANY include
67
+ // rule matches AND NO exclude rule matches.
68
+ const byKey = new Map();
69
+ for (const rule of rules) {
70
+ const key = rule.key === undefined ? vendor : rule.key;
71
+ if (!byKey.has(key)) byKey.set(key, []);
72
+ byKey.get(key).push(rule);
73
+ }
74
+ for (const [key, keyRules] of byKey) {
75
+ if (VENDOR_HOSTS[key] && !(overlay && HAND.has(key))) {
76
+ 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.`);
77
+ }
78
+ const compile = (rule) => compileHostRule(rule, `key "${key}" (pack ${vendor})`);
79
+ const includes = keyRules.filter((r) => r.exclude !== true).map(compile);
80
+ const excludes = keyRules.filter((r) => r.exclude === true).map(compile);
81
+ VENDOR_HOSTS[key] = (h, p) => includes.some((m) => m(h, p)) && !excludes.some((m) => m(h, p));
82
+ COMPILED.set(vendor, [...(COMPILED.get(vendor) || []), key]);
83
+ }
84
+ }
85
+ }
86
+ const overlays = new Set(installed);
87
+ compilePackHosts(Object.fromEntries(Object.entries(packs).filter(([vendor]) => !overlays.has(vendor))), false);
88
+ compilePackHosts(Object.fromEntries(Object.entries(packs).filter(([vendor]) => overlays.has(vendor))), true);
89
+ return { hosts: VENDOR_HOSTS, addPackHosts: packs => compilePackHosts(packs || {}, true) };
90
+ }
91
+
92
+ /** Static host/path rules first, then supplied claimed hosts. */
93
+ function resolveTwinHost(rules, hostname, map, pathname, claimed = {}) {
94
+ for (const vendor of Object.keys(rules)) {
95
+ if (map[vendor] && rules[vendor](hostname, pathname)) return { vendor, origin: map[vendor] };
96
+ }
97
+ const host = String(hostname || '').toLowerCase();
98
+ for (const vendor of Object.keys(claimed)) {
99
+ if (!map[vendor]) continue;
100
+ const hosts = typeof claimed[vendor] === 'function' ? claimed[vendor]() : claimed[vendor];
101
+ if (hosts.has(host)) return { vendor, origin: map[vendor] };
102
+ }
103
+ return null;
104
+ }
105
+
106
+ module.exports = { createHostTable, hostRules, resolveTwinHost };
@@ -0,0 +1,7 @@
1
+ /** Pure host rules and resolution: supplied data only, safe in an isolate. */
2
+ export type HostRuleData = { host?: string; suffix?: string; hostPattern?: string; pathPattern?: string; key?: string; exclude?: true };
3
+ export type HostPredicate = (hostname: string, pathname?: string) => boolean;
4
+ export type HostFacts = Readonly<Record<string, { hosts?: ReadonlyArray<HostRuleData> }>>;
5
+ export function hostRules(rules: ReadonlyArray<HostRuleData> | undefined): { names(hostname: string): boolean; takes(hostname: string, pathname: string): boolean };
6
+ export function createHostTable(packs: HostFacts, installed?: Iterable<string>): { hosts: Record<string, HostPredicate>; addPackHosts(packs: HostFacts): void };
7
+ export function resolveTwinHost(rules: Readonly<Record<string, HostPredicate>>, hostname: string, map: Readonly<Record<string, string>>, pathname?: string, claimed?: Readonly<Record<string, ReadonlySet<string> | (() => ReadonlySet<string>)>>): { vendor: string; origin: string } | null;
package/inject.cjs CHANGED
@@ -47,6 +47,7 @@ const { createPublicKey, createVerify } = require('crypto');
47
47
 
48
48
  // the vendor host table (VENDOR_HOSTS: a hostname, and a path where two vendors share a host, to the vendor whose twin
49
49
  // serves it) has its own home, shared with the application route
50
+ const { resolveTwinHost } = require('./host-routing.cjs');
50
51
  const { VENDOR_HOSTS, twinEnvStem, twinOrigins } = require('./vendor-hosts.cjs');
51
52
  const dns = require('dns');
52
53
  const http2 = require('http2');
@@ -216,15 +217,11 @@ function readMap(env) {
216
217
  * sees only the SNI name) omit it and get host-level candidacy.
217
218
  */
218
219
  function resolveTwin(hostname, map, pathname) {
219
- for (const vendor of Object.keys(VENDOR_HOSTS)) {
220
- if (map[vendor] && VENDOR_HOSTS[vendor](hostname, pathname)) return { vendor, origin: map[vendor] };
221
- }
222
- // then a host a running twin claims (a custom domain a person connected), as DNS would route it; never one a
223
- // descriptor's hosts name, which the loop above has already answered
220
+ const target = resolveTwinHost(VENDOR_HOSTS, hostname, map, pathname, Object.fromEntries(
221
+ Object.keys(CLAIMERS).map(vendor => [vendor, () => claimsAt(map[vendor], CLAIMERS[vendor]).hosts])
222
+ ));
223
+ if (target) return target;
224
224
  const host = String(hostname || '').toLowerCase();
225
- for (const vendor of Object.keys(CLAIMERS)) {
226
- if (map[vendor] && claimsAt(map[vendor], CLAIMERS[vendor]).hosts.has(host)) return { vendor, origin: map[vendor] };
227
- }
228
225
  // last, the application's own production hostnames, routed to where it listens in the World; never the World's own
229
226
  // origin or a twin's, whose requests carry the World key
230
227
  const bare = host.replace(/\.$/, '');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.29",
3
+ "version": "3.0.31",
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",
@@ -29,6 +29,8 @@
29
29
  "network-policy.d.cts",
30
30
  "app-route.cjs",
31
31
  "vendor-hosts.cjs",
32
+ "host-routing.cjs",
33
+ "host-routing.d.cts",
32
34
  "pack-facts.cjs",
33
35
  "vendor-hosts.d.cts",
34
36
  "world-clock.cjs",
@@ -61,6 +63,10 @@
61
63
  "types": "./dist/src/runtime.d.ts",
62
64
  "default": "./dist/src/runtime.js"
63
65
  },
66
+ "./browser": {
67
+ "types": "./dist/src/browser.d.ts",
68
+ "default": "./dist/src/browser.js"
69
+ },
64
70
  "./args": {
65
71
  "types": "./dist/src/args.d.ts",
66
72
  "default": "./dist/src/args.js"
@@ -86,6 +92,10 @@
86
92
  "default": "./app-route.cjs"
87
93
  },
88
94
  "./vendor-hosts": "./vendor-hosts.cjs",
95
+ "./host-routing": {
96
+ "types": "./host-routing.d.cts",
97
+ "default": "./host-routing.cjs"
98
+ },
89
99
  "./pack-facts": "./pack-facts.cjs",
90
100
  "./world-clock": {
91
101
  "types": "./world-clock.d.cts",
package/src/actions.ts CHANGED
@@ -361,9 +361,11 @@ export function decideAndAppendAction<T>(
361
361
  service: string,
362
362
  decide: (resources: TwinResource[]) => AtomicActionDecision<T>,
363
363
  root?: string,
364
+ /** A narrower indexed snapshot, read under the same lock; it must cover every decision and precondition. */
365
+ read: () => TwinResource[] = () => projectResources(service, root),
364
366
  ): { value: T; action?: TwinAction; appended: boolean; placeholder?: true } {
365
367
  return withProjection(service, root, () => withFileLock(actionsLock(service, root), () => {
366
- const resources = projectResources(service, root);
368
+ const resources = read();
367
369
  const decision = decide(resources);
368
370
  if (decision.kind === 'skip') return { value: decision.value, appended: false };
369
371
  refuseReadOnlyWrite(service); // a decision to write is the write a read-only request may not make
@@ -0,0 +1,4 @@
1
+ /** A page's key, shared by its World and console. Browser keys cannot equal vendor-prefixed plain keys. */
2
+ export function boardFrameKey(frame: { vendor: string; id: string; browser?: string }): string {
3
+ return frame.browser ? `~browser:${encodeURIComponent(frame.browser)}:${frame.vendor}:${frame.id}` : `${frame.vendor}:${frame.id}`;
4
+ }
@@ -0,0 +1,77 @@
1
+ import suffixes from './public-suffixes.json' with { type: 'json' };
2
+ import { cookieMatchesHost, type BrowserCookie } from './browser-state.ts';
3
+
4
+ /** Cookie domain acceptance uses the public suffix list, including its wildcard and exception rules. */
5
+ let rules: Set<string> | undefined;
6
+ function publicSuffix(domain: string): boolean {
7
+ rules ??= new Set(suffixes.rules.map(rule => {
8
+ const prefix = rule.startsWith('!') ? '!' : rule.startsWith('*.') ? '*.' : '';
9
+ return `${prefix}${new URL(`https://${rule.slice(prefix.length)}`).hostname}`;
10
+ }));
11
+ const labels = domain.split('.');
12
+ let size = 1;
13
+ for (let i = 0; i < labels.length; i++) {
14
+ const suffix = labels.slice(i).join('.');
15
+ if (rules.has(`!${suffix}`)) return labels.length === labels.length - i - 1;
16
+ if (rules.has(suffix)) size = Math.max(size, labels.length - i);
17
+ if (i > 0 && rules.has(`*.${suffix}`)) size = Math.max(size, labels.length - i + 1);
18
+ }
19
+ return labels.length === size;
20
+ }
21
+ const domainMatches = (host: string, domain: string): boolean => host === domain || host.endsWith(`.${domain}`);
22
+ const pathMatches = (path: string, scope: string): boolean => path === scope || (path.startsWith(scope) && (scope.endsWith('/') || path[scope.length] === '/'));
23
+
24
+ /** A seed's HTTP cookie jar; cookies remain at the real URL even when transport goes to a twin. */
25
+ export class BrowserCookieJar {
26
+ private cookies: BrowserCookie[];
27
+ constructor(cookies: BrowserCookie[] = []) { this.cookies = structuredClone(cookies); }
28
+ snapshot(now: number): BrowserCookie[] {
29
+ this.cookies = this.cookies.filter(c => c.expires === -1 || c.expires > now / 1000);
30
+ return structuredClone(this.cookies);
31
+ }
32
+ header(url: URL, now: number): string {
33
+ return this.snapshot(now).filter(c => (!c.secure || url.protocol === 'https:') &&
34
+ cookieMatchesHost(c, url.hostname) && pathMatches(url.pathname, c.path))
35
+ .sort((a, b) => b.path.length - a.path.length).map(c => `${c.name}=${c.value}`).join('; ');
36
+ }
37
+ set(line: string, url: URL, now: number): void {
38
+ const [pair = '', ...attributes] = line.split(';');
39
+ const eq = pair.indexOf('=');
40
+ if (eq < 1) return;
41
+ const name = pair.slice(0, eq).trim(); const value = pair.slice(eq + 1).trim();
42
+ if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || /[\x00-\x1f\x7f;\u0100-\uffff]/.test(value)) return;
43
+ const attrs = new Map<string, string>();
44
+ for (const part of attributes) { const at = part.indexOf('='); attrs.set((at < 0 ? part : part.slice(0, at)).trim().toLowerCase(), at < 0 ? '' : part.slice(at + 1).trim()); }
45
+ let domain = url.hostname;
46
+ const asked = attrs.get('domain');
47
+ if (asked) {
48
+ if (/[\s:/\\?#@%]/.test(asked)) return;
49
+ let normalized: string;
50
+ try { normalized = new URL(`https://${asked.replace(/^\./, '')}`).hostname; } catch { return; }
51
+ if (!domainMatches(url.hostname, normalized) || publicSuffix(normalized)) return;
52
+ domain = `.${normalized}`;
53
+ }
54
+ const last = url.pathname.lastIndexOf('/');
55
+ const fallback = last > 0 ? url.pathname.slice(0, last) : '/';
56
+ const path = attrs.get('path')?.startsWith('/') ? attrs.get('path')! : fallback;
57
+ if (/[\x00-\x20\x7f;\u0100-\uffff]/.test(path)) return;
58
+ const secure = attrs.has('secure');
59
+ if (secure && url.protocol !== 'https:') return;
60
+ const same = attrs.get('samesite')?.toLowerCase();
61
+ const sameSite = same === 'strict' ? 'Strict' : same === 'none' ? 'None' : 'Lax';
62
+ if (sameSite === 'None' && !secure) return;
63
+ if (name.startsWith('__Secure-') && !secure) return;
64
+ if (name.startsWith('__Host-') && (!secure || asked || attrs.get('path') !== '/')) return;
65
+ let expires = -1;
66
+ const date = attrs.get('expires');
67
+ if (date && Number.isFinite(Date.parse(date))) expires = Date.parse(date) / 1000;
68
+ const age = attrs.get('max-age');
69
+ if (age && /^-?\d+$/.test(age)) { const seconds = Number(age); expires = seconds <= 0 ? 0 : Math.min(8640000000000, now / 1000 + seconds); }
70
+ const existing = this.cookies.findIndex(c => c.name === name && c.domain.replace(/^\./, '') === domain.replace(/^\./, '') && c.path === path);
71
+ // An insecure response cannot replace a secure cookie with an overlapping scope.
72
+ if (url.protocol !== 'https:' && this.cookies.some(c => c.name === name && c.secure && domainMatches(domain.replace(/^\./, ''), c.domain.replace(/^\./, '')) && pathMatches(path, c.path))) return;
73
+ const cookie: BrowserCookie = { name, value, domain, path, expires, secure, httpOnly: attrs.has('httponly'), sameSite };
74
+ if (existing < 0) this.cookies.push(cookie); else this.cookies[existing] = cookie;
75
+ this.snapshot(now);
76
+ }
77
+ }
@@ -0,0 +1,31 @@
1
+ // A vendor's browser session: the bookkeeping used by its own sign-in and sign-out pages.
2
+ import type { HandlerContext } from './derived-core.ts';
3
+
4
+ type Writer = Pick<HandlerContext, 'record' | 'secret' | 'occurredAt'>;
5
+
6
+ /** The named cookie, without interpreting any other vendor's cookie. */
7
+ export function browserSessionToken(request: Request, name: string): string | undefined {
8
+ for (const part of (request.headers.get('cookie') ?? '').split(';')) {
9
+ const at = part.indexOf('=');
10
+ if (at > 0 && part.slice(0, at).trim() === name) {
11
+ try { return decodeURIComponent(part.slice(at + 1).trim()); } catch { return undefined; }
12
+ }
13
+ }
14
+ return undefined;
15
+ }
16
+
17
+ /** A new, independent sign-in, returning the cookie the vendor places on its response. */
18
+ export async function startBrowserSession(ctx: Writer, email: string, cookie: string, scope?: string): Promise<string> {
19
+ const issued = await ctx.record('_web_session_issue', { email, ...(scope !== undefined ? { scope } : {}) });
20
+ const token = await ctx.secret(`web-session:${issued}`);
21
+ await ctx.record('_web_session', { token, email, ...(scope !== undefined ? { scope } : {}), created_at: ctx.occurredAt }, `websession:${token}`);
22
+ return `${cookie}=${token}; path=/; HttpOnly; Secure; SameSite=Lax`;
23
+ }
24
+
25
+ /** End the session the request holds, then clear its cookie. Unknown cookies create no rows. */
26
+ export async function endBrowserSession(ctx: Pick<HandlerContext, 'call' | 'record' | 'rowsRaw' | 'occurredAt'>, cookie: string): Promise<string> {
27
+ const token = browserSessionToken(ctx.call.request, cookie);
28
+ const session = token ? ctx.rowsRaw('_web_session').find((s) => s.token === token) : undefined;
29
+ if (session) await ctx.record('_web_session', { token: null, ended_at: ctx.occurredAt }, String(session.id));
30
+ return `${cookie}=; path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
31
+ }