@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
@@ -0,0 +1,89 @@
1
+ /** The frontend state of a scenario, in Playwright's storageState form. */
2
+ export type BrowserCookie = { name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean; sameSite: 'Strict' | 'Lax' | 'None'; partitionKey?: string; _crHasCrossSiteAncestor?: boolean };
3
+ export type BrowserIndexedDB = { name: string; version: number; stores: {
4
+ name: string; autoIncrement: boolean; keyPath?: string; keyPathArray?: string[];
5
+ records: { key?: unknown; keyEncoded?: unknown; value?: unknown; valueEncoded?: unknown }[];
6
+ indexes: { name: string; keyPath?: string; keyPathArray?: string[]; multiEntry: boolean; unique: boolean }[];
7
+ }[] };
8
+ export type BrowserState = { cookies: BrowserCookie[]; origins: { origin: string; localStorage: { name: string; value: string }[]; indexedDB?: BrowserIndexedDB[] }[] };
9
+
10
+ export function assertBrowserName(name: string): void {
11
+ if (!/^[a-z0-9](?:[a-z0-9]|-(?!-)){0,23}$/.test(name) || name.endsWith('-')) throw new Error('browser name: 1–24 lowercase letters, digits or single hyphens, starting and ending with a letter or digit');
12
+ }
13
+
14
+ function object(value: unknown, keys: string[]): Record<string, unknown> {
15
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !keys.includes(k))) throw new Error(`browser state: an object with only ${keys.join(', ')}`);
16
+ return value as Record<string, unknown>;
17
+ }
18
+ const host = (value: string): boolean => value !== 'localhost' && !value.endsWith('.localhost') && !/^\d+(?:\.\d+){3}$/.test(value) && /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*$/.test(value) && value.length <= 253 && value.split('.').every(l => l.length <= 63);
19
+
20
+ export function browserOrigin(value: string): string {
21
+ let url: URL;
22
+ try { url = new URL(value); } catch { throw new Error('browser origin: an absolute http or https vendor origin'); }
23
+ if (!['http:', 'https:'].includes(url.protocol) || url.origin !== value || !host(url.hostname)) throw new Error('browser origin: a canonical http or https vendor origin, never a local twin URL');
24
+ return value;
25
+ }
26
+
27
+ /** Refuse unknown keys at every level; the accepted object is never reshaped. */
28
+ export function validateBrowserState(value: unknown): BrowserState {
29
+ const state = object(value, ['cookies', 'origins']);
30
+ if (!Array.isArray(state.cookies) || !Array.isArray(state.origins)) throw new Error('browser state: cookies and origins are arrays');
31
+ const cookies = new Set<string>();
32
+ for (const raw of state.cookies) {
33
+ const c = object(raw, ['name', 'value', 'domain', 'path', 'expires', 'httpOnly', 'secure', 'sameSite', 'partitionKey', '_crHasCrossSiteAncestor']);
34
+ if (typeof c.name !== 'string' || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(c.name) || typeof c.value !== 'string' || /[\x00-\x1f\x7f;]/.test(c.value)) throw new Error('browser cookie: a cookie name and wire-safe string value');
35
+ if (/[^\u0000-\u00ff]/.test(c.value)) throw new Error('browser cookie: value characters must be U+00FF or below for Set-Cookie');
36
+ if (typeof c.domain !== 'string' || !host(c.domain.replace(/^\./, ''))) throw new Error('browser cookie: a real vendor domain, optionally prefixed by a dot');
37
+ if (typeof c.path !== 'string' || !c.path.startsWith('/') || /[\x00-\x20\x7f;\u0100-\uffff]/.test(c.path)) throw new Error('browser cookie: an absolute wire-safe cookie path');
38
+ if (typeof c.expires !== 'number' || !Number.isFinite(c.expires) || (c.expires < 0 && c.expires !== -1)) throw new Error('browser cookie: expires is Unix seconds, or -1 for a session');
39
+ if (typeof c.httpOnly !== 'boolean' || typeof c.secure !== 'boolean' || (typeof c.sameSite !== 'string' || !['Strict', 'Lax', 'None'].includes(c.sameSite))) throw new Error('browser cookie: httpOnly and secure are booleans; sameSite is Strict, Lax or None');
40
+ if (c.partitionKey !== undefined && (typeof c.partitionKey !== 'string' || !c.partitionKey || /[\x00-\x20\x7f]/.test(c.partitionKey))) throw new Error('browser cookie: partitionKey is a nonempty string');
41
+ if (c._crHasCrossSiteAncestor !== undefined && typeof c._crHasCrossSiteAncestor !== 'boolean') throw new Error('browser cookie: _crHasCrossSiteAncestor is a boolean');
42
+ const key = JSON.stringify([c.name, c.domain, c.path, c.partitionKey]);
43
+ if (cookies.has(key)) throw new Error('browser state: duplicate cookie');
44
+ cookies.add(key);
45
+ }
46
+ const origins = new Set<string>();
47
+ for (const raw of state.origins) {
48
+ const o = object(raw, ['origin', 'localStorage', 'indexedDB']);
49
+ if (typeof o.origin !== 'string') throw new Error('browser state: origin is a string');
50
+ browserOrigin(o.origin);
51
+ if (origins.has(o.origin)) throw new Error('browser state: duplicate origin');
52
+ origins.add(o.origin);
53
+ if (!Array.isArray(o.localStorage)) throw new Error('browser state: localStorage is an array');
54
+ if (o.indexedDB !== undefined) {
55
+ if (!Array.isArray(o.indexedDB)) throw new Error('browser storage: indexedDB is an array');
56
+ const keyPath = (item: Record<string, unknown>): void => {
57
+ if (item.keyPath !== undefined && typeof item.keyPath !== 'string') throw new Error('browser IndexedDB: keyPath is a string');
58
+ if (item.keyPathArray !== undefined && (!Array.isArray(item.keyPathArray) || item.keyPathArray.some(k => typeof k !== 'string'))) throw new Error('browser IndexedDB: keyPathArray contains strings');
59
+ };
60
+ for (const rawDB of o.indexedDB) {
61
+ const db = object(rawDB, ['name', 'version', 'stores']);
62
+ if (typeof db.name !== 'string' || typeof db.version !== 'number' || !Number.isSafeInteger(db.version) || db.version < 1 || !Array.isArray(db.stores)) throw new Error('browser IndexedDB: a name, positive integer version and stores array');
63
+ for (const rawStore of db.stores) {
64
+ const store = object(rawStore, ['name', 'autoIncrement', 'keyPath', 'keyPathArray', 'records', 'indexes']);
65
+ if (typeof store.name !== 'string' || typeof store.autoIncrement !== 'boolean' || !Array.isArray(store.records) || !Array.isArray(store.indexes)) throw new Error('browser IndexedDB: a store name, autoIncrement boolean, records and indexes arrays');
66
+ keyPath(store);
67
+ for (const record of store.records) object(record, ['key', 'keyEncoded', 'value', 'valueEncoded']);
68
+ for (const rawIndex of store.indexes) {
69
+ const index = object(rawIndex, ['name', 'keyPath', 'keyPathArray', 'multiEntry', 'unique']);
70
+ if (typeof index.name !== 'string' || typeof index.multiEntry !== 'boolean' || typeof index.unique !== 'boolean') throw new Error('browser IndexedDB: an index name, multiEntry and unique booleans');
71
+ keyPath(index);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ const names = new Set<string>();
77
+ for (const rawEntry of o.localStorage) {
78
+ const entry = object(rawEntry, ['name', 'value']);
79
+ if (typeof entry.name !== 'string' || typeof entry.value !== 'string' || names.has(entry.name)) throw new Error('browser storage: unique string names and string values');
80
+ names.add(entry.name);
81
+ }
82
+ }
83
+ return value as BrowserState;
84
+ }
85
+
86
+ export function cookieMatchesHost(cookie: BrowserCookie, hostname: string): boolean {
87
+ const domain = cookie.domain.replace(/^\./, '');
88
+ return hostname === domain || (cookie.domain.startsWith('.') && hostname.endsWith(`.${domain}`));
89
+ }
package/src/browser.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { BrowserCookieJar } from './browser-cookie-jar.ts';
2
+ import { assertBrowserName, browserOrigin, validateBrowserState, type BrowserState } from './browser-state.ts';
3
+ import { nodeBuiltin } from './serve-http.ts';
4
+ import { worldNow } from './world-clock.ts';
5
+
6
+ export { cookieMatchesHost } from './browser-state.ts';
7
+
8
+ export type SeedBrowser = {
9
+ fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
10
+ storage(origin: string): { set(key: string, value: string): Promise<void>; remove(key: string): Promise<void> };
11
+ state(): Promise<BrowserState>;
12
+ };
13
+
14
+ /** A seed's named browser. Every state write goes through the World's single HTTP loader. */
15
+ export async function browser(name: string): Promise<SeedBrowser> {
16
+ assertBrowserName(name);
17
+ const door = process.env.VOLTER_WORLD_BROWSER_DOOR;
18
+ const token = process.env.VOLTER_WORLD_BROWSER_TOKEN;
19
+ if (!process.env.VOLTER_WORLD || !door || !token) throw new Error('browser(name) runs in a World seed, with its browsers door and credential');
20
+ const require = nodeBuiltin<typeof import('node:module')>('module').createRequire(import.meta.url);
21
+ const routing = require('../vendor-hosts.cjs') as typeof import('../vendor-hosts.cjs');
22
+ const { packs } = require('../pack-facts.cjs').packFacts() as { packs: Record<string, { hostsClaimed?: { door: string } }> };
23
+ const resolve = async (url: URL): Promise<{ vendor: string; origin: string } | null> => {
24
+ const map = routing.twinOrigins(process.env);
25
+ const declared = routing.resolveTwinHost(url.hostname, map, url.pathname);
26
+ if (declared) return declared;
27
+ const claimed: Record<string, Set<string>> = {};
28
+ for (const [vendor, facts] of Object.entries(packs)) {
29
+ const origin = map[vendor] ?? process.env[`${routing.twinEnvStem(vendor)}_TWIN_URL`];
30
+ if (!origin || !facts.hostsClaimed) continue;
31
+ map[vendor] = origin;
32
+ const headers = new Headers({ 'x-volter-read-only': '1' });
33
+ if (process.env.VOLTER_TWINS_KEY) headers.set('x-twins-key', process.env.VOLTER_TWINS_KEY);
34
+ const listed = await fetch(`${origin}${facts.hostsClaimed.door}`, { headers })
35
+ .then(r => r.ok ? r.json() as Promise<{ hosts?: unknown }> : null).catch(() => null);
36
+ claimed[vendor] = new Set(Array.isArray(listed?.hosts) ? listed.hosts.map(h => String(h).toLowerCase()) : []);
37
+ }
38
+ return routing.resolveTwinHost(url.hostname, map, url.pathname, claimed);
39
+ };
40
+ const initial = await fetch(`${door}/${name}`, { headers: { 'x-volter-token': token } });
41
+ if (!initial.ok && initial.status !== 404) throw new Error(`browser ${name}: ${initial.status} ${await initial.text()}`);
42
+ const stored = initial.ok ? validateBrowserState(await initial.json()) : { cookies: [], origins: [] };
43
+ const jar = new BrowserCookieJar(stored.cookies);
44
+ const origins = structuredClone(stored.origins);
45
+ let queue: Promise<unknown> = Promise.resolve();
46
+ const serial = <T>(fn: () => Promise<T>): Promise<T> => { const next = queue.then(fn); queue = next.then(() => undefined, () => undefined); return next; };
47
+ const snapshot = (): BrowserState => {
48
+ const now = new Date(worldNow()).getTime();
49
+ const cookies = jar.snapshot(now);
50
+ return validateBrowserState({ cookies, origins: structuredClone(origins) });
51
+ };
52
+ const save = async (): Promise<void> => {
53
+ const response = await fetch(`${door}/${name}`, { method: 'PUT', headers: { 'content-type': 'application/json', 'x-volter-token': token }, body: JSON.stringify(snapshot()) });
54
+ if (!response.ok) throw new Error(`browser ${name}: ${response.status} ${await response.text()}`);
55
+ };
56
+ if (initial.status === 404) await save();
57
+ return {
58
+ state: () => serial(async () => snapshot()),
59
+ storage(origin) {
60
+ browserOrigin(origin);
61
+ const change = (key: string, value?: string): Promise<void> => serial(async () => {
62
+ if (typeof key !== 'string' || (value !== undefined && typeof value !== 'string')) throw new Error('browser storage: string keys and values');
63
+ const before = JSON.stringify(origins);
64
+ let entry = origins.find(o => o.origin === origin);
65
+ if (!entry && value !== undefined) { entry = { origin, localStorage: [] }; origins.push(entry); }
66
+ if (entry) {
67
+ entry.localStorage = entry.localStorage.filter(v => v.name !== key);
68
+ if (value !== undefined) entry.localStorage.push({ name: key, value });
69
+ }
70
+ if (JSON.stringify(origins) !== before) await save();
71
+ });
72
+ return { set: (key, value) => change(key, value), remove: key => change(key) };
73
+ },
74
+ fetch: (input, init) => serial(async () => {
75
+ const request = new Request(input, init);
76
+ let url = new URL(request.url);
77
+ let method = request.method;
78
+ let body: ArrayBuffer | undefined = request.body ? await request.arrayBuffer() : undefined;
79
+ const headers = new Headers(request.headers);
80
+ if (headers.has('cookie')) throw new Error('browser.fetch carries its own cookie jar');
81
+ for (let redirects = 0; ; redirects++) {
82
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('browser.fetch takes an http or https vendor URL');
83
+ const target = await resolve(url);
84
+ if (!target || target.vendor === 'app') throw new Error(`browser ${name}: no twin in this World serves ${url.hostname}${url.pathname}`);
85
+ const at = new URL(target.origin);
86
+ at.pathname = `${at.pathname.replace(/\/$/, '')}${url.pathname}`;
87
+ at.search = url.search;
88
+ const sent = new Headers(headers);
89
+ const cookies = jar.header(url, new Date(worldNow()).getTime());
90
+ if (cookies) sent.set('cookie', cookies);
91
+ sent.set('host', new URL(target.origin).pathname.replace(/\/$/, '') ? at.host : url.host);
92
+ sent.set('x-volter-twin-original-host', url.host);
93
+ sent.set('x-forwarded-host', url.host);
94
+ sent.set('x-forwarded-proto', url.protocol.slice(0, -1));
95
+ if (process.env.VOLTER_TWINS_KEY) sent.set('x-twins-key', process.env.VOLTER_TWINS_KEY);
96
+ const before = JSON.stringify(snapshot());
97
+ const response = await fetch(at, { method, headers: sent, body, redirect: 'manual', signal: request.signal });
98
+ for (const cookie of response.headers.getSetCookie()) jar.set(cookie, url, new Date(worldNow()).getTime());
99
+ if (JSON.stringify(snapshot()) !== before) await save();
100
+ const location = response.headers.get('location');
101
+ if (!location || ![301, 302, 303, 307, 308].includes(response.status) || request.redirect === 'manual') return response;
102
+ if (request.redirect === 'error') throw new Error('browser.fetch refused a redirect');
103
+ if (redirects >= 20) throw new Error('browser.fetch exceeded the fetch redirect limit');
104
+ await response.body?.cancel();
105
+ const next = new URL(location, url);
106
+ if (next.origin !== url.origin) { headers.delete('authorization'); headers.delete('proxy-authorization'); }
107
+ if ((response.status === 303 && method !== 'HEAD') || ([301, 302].includes(response.status) && method === 'POST')) {
108
+ method = 'GET'; body = undefined;
109
+ for (const key of ['content-type', 'content-length', 'content-encoding', 'content-language', 'content-location']) headers.delete(key);
110
+ }
111
+ url = next;
112
+ }
113
+ }),
114
+ };
115
+ }
@@ -772,8 +772,9 @@ function view(m: DerivedManifest, resource: string, r: TwinResource): Record<str
772
772
  * a caller that pins an instant passes its own). */
773
773
  /** One call of a protocol-3 pack's default data (`src/semantics/seed.ts` exports `seed: SeedCall[]`): a request to the
774
774
  * vendor's own operation (or the pack's door), made in order against the twin by the runner `volter-world init` writes
775
- * beside the copied data. */
776
- export type SeedCall = { method: string; path: string; body?: unknown; headers?: Record<string, string> };
775
+ * beside the copied data. An ordinary path is relative to the twin base. A call naming a browser uses the
776
+ * vendor's absolute URL and is exported separately as `browserSeed`, after the ordinary `seed` calls. */
777
+ export type SeedCall = { browser?: string; method: string; path: string; body?: unknown; headers?: Record<string, string> };
777
778
 
778
779
  /** Where a call is answered: the World's root, its clock, and the managed Postgres the runtime binds to the pack
779
780
  * (`database`, the descriptor's `managedDatabase`), which a handler reaches as `ctx.engine` — READ ONLY in every
@@ -1702,7 +1703,6 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
1702
1703
  body,
1703
1704
  text,
1704
1705
  root,
1705
- occurredAt: at,
1706
1706
  publicBase: twinPublicBase(call.request),
1707
1707
  scenario: scenarioDecisionOf(call.request),
1708
1708
  deliver: (url, init) => deliverToApplication(url, init),
@@ -1737,7 +1737,30 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
1737
1737
  }
1738
1738
  return id;
1739
1739
  },
1740
+ occurredAt: at,
1740
1741
  secret: async (label) => hmac(await worldSeed(m.service, root, at), label, 'base64url'),
1742
+ record: async (type, fields, id) => {
1743
+ if (!type.startsWith('_')) throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
1744
+ if (id !== undefined) {
1745
+ await applyTwinWrite(m.service, { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: id, fields, occurredAt: at, actor: { kind: 'system' } }, root);
1746
+ return id;
1747
+ }
1748
+ const { value } = await applyTwinWriteAtomic(m.service, (resources) => {
1749
+ // Allocate and append under the same state lock: concurrent sign-ins must never share an issue id.
1750
+ const subject = (() => {
1751
+ const prefix = `${type.slice(1)}_`;
1752
+ let max = 0;
1753
+ for (const r of resources) {
1754
+ if (r.type !== type) continue;
1755
+ const n = r.id.startsWith(prefix) ? Number(r.id.slice(prefix.length)) : NaN;
1756
+ if (Number.isInteger(n) && n > max) max = n;
1757
+ }
1758
+ return `${prefix}${max + 1}`;
1759
+ })();
1760
+ return { kind: 'write', value: subject, write: { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } } };
1761
+ }, root, () => resourcesOfType(m.service, type, root));
1762
+ return value;
1763
+ },
1741
1764
  get machines() {
1742
1765
  const enrolled = resourcesOfType(m.service, MACHINE_POOL, root).find((r) => r.id === 'pool') as unknown as { kind?: MachinePool['kind'] } | undefined;
1743
1766
  return poolFor(m.service, root, enrolled?.kind ?? 'none');
@@ -1767,19 +1790,6 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
1767
1790
  rowsRaw: (resource, opts) => stored(m, resource, root, opts) as Array<Record<string, unknown>>,
1768
1791
  tree: () => twinResources(m.service, root) as Array<Record<string, unknown>>,
1769
1792
  history: (resource, id) => subjectHistory(m.service, { type: storedType(m, resource), id }, root).map((e) => ({ ...(e.operation ? { operation: e.operation } : {}), ...(e.fields ? { fields: e.fields as Record<string, unknown> } : {}), ...(e.occurredAt ? { occurredAt: e.occurredAt } : {}) })),
1770
- record: async (type, fields, id) => {
1771
- if (!type.startsWith('_')) throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
1772
- // an id the caller does not give is the next past every one of this type the tree holds (as `{n}` ids are minted),
1773
- // never a count of the rows, which a removed row would make repeat
1774
- const subject = id ?? (() => {
1775
- const prefix = `${type.slice(1)}_`;
1776
- let max = 0;
1777
- for (const r of resourcesOfType(m.service, type, root)) { const n = r.id.startsWith(prefix) ? Number(r.id.slice(prefix.length)) : NaN; if (Number.isInteger(n) && n > max) max = n; }
1778
- return `${prefix}${max + 1}`;
1779
- })();
1780
- await applyTwinWrite(m.service, { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } }, root);
1781
- return subject;
1782
- },
1783
1793
  raw: (body, init = {}) => new Response(body, { status: init.status ?? 200, headers: init.headers ?? {} }),
1784
1794
  atomically: async (decide) => {
1785
1795
  const { value } = await applyTwinWriteAtomic(
package/src/derived.ts CHANGED
@@ -208,7 +208,7 @@ export type DerivedFetch = ((request: Request) => Promise<Response>) & {
208
208
  };
209
209
 
210
210
  /** A workspace screen a pack draws, as `GET /twin` lists it: its id and path (Viewing a World). */
211
- export type Workspace = { id: string; path: string };
211
+ export type Workspace = { id: string; path: string; host?: string };
212
212
 
213
213
  /** A page of the World's board (architecture, "The board"): a page there is to see at the vendor, a record's own (an
214
214
  * account's profile, each of its posts, a site and each page it serves), as the vendor's pack lists them from its state
package/src/index.ts CHANGED
@@ -89,3 +89,7 @@ export * as git from './git/index.ts';
89
89
  // ── the scenario grammar (architecture, "Behaviour") ───────────────────────────────────────────
90
90
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError } from './scenario.ts';
91
91
  export type { PackScenarioAdapter, ScenarioDecision, ScenarioDocument, ScenarioExtractorSpec, ScenarioFault, ScenarioFaultResult, ScenarioFeatures, ScenarioHandler, ScenarioMatcher, ScenarioMissRecord, ScenarioStatus } from './scenario.ts';
92
+
93
+ export { browserSessionToken, startBrowserSession, endBrowserSession } from './browser-session.ts';
94
+
95
+ export { assertBrowserName, browserOrigin, validateBrowserState, cookieMatchesHost, type BrowserCookie, type BrowserState, type BrowserIndexedDB } from './browser-state.ts';
package/src/pack-fetch.ts CHANGED
@@ -20,7 +20,7 @@ import { withCors } from './cors.ts';
20
20
  import type { ApplicationStandIn } from './events.ts';
21
21
  import { contentAddressOf } from './content-route.ts';
22
22
  import { getPack, type HostRule } from './packRegistry.ts';
23
- import { hostRules } from '../vendor-hosts.cjs';
23
+ import { hostRules } from '../host-routing.cjs';
24
24
 
25
25
  /** A descriptor's host rules, as data. */
26
26
  type PackDescriptorHosts = ReadonlyArray<HostRule> | undefined;
@@ -270,7 +270,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
270
270
  });
271
271
  // the workspaces a person can be shown (Viewing a World): each built workspace screen's id and path, the pack's own
272
272
  // and its lanes'
273
- const workspaces = (): Workspace[] => [...(m.screens ?? []).filter((s) => s.kind === 'workspace' && s.status === 'done' && parts.screens?.[s.id]).map((s) => ({ id: s.id, path: s.path })), ...lanesWorkspaces(parts.lanes ?? {})];
273
+ const workspaces = (): Workspace[] => [...(m.screens ?? []).filter((s) => s.kind === 'workspace' && s.status === 'done' && parts.screens?.[s.id]).map((s) => ({ id: s.id, path: s.path, host: s.host })), ...lanesWorkspaces(parts.lanes ?? {})];
274
274
  /** Whether a built screen a person views (a workspace or a flow, never content an API client reads) draws a url: its host
275
275
  * one the screen names, its path the screen's or under it (a root screen draws its root alone). */
276
276
  // by what the pack declares built (a screen drawn by another's handler, as X's profile is by its web pages, is built)