@volter/world-core 3.0.28 → 3.0.30

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.
@@ -167,7 +167,9 @@ export type AtomicActionDecision<T> = {
167
167
  * supplied snapshot. Durable state changes only through the returned action, which this helper
168
168
  * appends before releasing the lock.
169
169
  */
170
- export declare function decideAndAppendAction<T>(service: string, decide: (resources: TwinResource[]) => AtomicActionDecision<T>, root?: string): {
170
+ export declare function decideAndAppendAction<T>(service: string, decide: (resources: TwinResource[]) => AtomicActionDecision<T>, root?: string,
171
+ /** A narrower indexed snapshot, read under the same lock; it must cover every decision and precondition. */
172
+ read?: () => TwinResource[]): {
171
173
  value: T;
172
174
  action?: TwinAction;
173
175
  appended: boolean;
@@ -252,9 +252,11 @@ export function occurrenceId(base, ordinal) {
252
252
  * supplied snapshot. Durable state changes only through the returned action, which this helper
253
253
  * appends before releasing the lock.
254
254
  */
255
- export function decideAndAppendAction(service, decide, root) {
255
+ export function decideAndAppendAction(service, decide, root,
256
+ /** A narrower indexed snapshot, read under the same lock; it must cover every decision and precondition. */
257
+ read = () => projectResources(service, root)) {
256
258
  return withProjection(service, root, () => withFileLock(actionsLock(service, root), () => {
257
- const resources = projectResources(service, root);
259
+ const resources = read();
258
260
  const decision = decide(resources);
259
261
  if (decision.kind === 'skip')
260
262
  return { value: decision.value, appended: false };
@@ -0,0 +1,13 @@
1
+ export declare const BROWSER_SESSION_HEADER = "x-volter-browser-session-permit";
2
+ export declare const BROWSER_SESSION_PATH = "/_twin/browser-session";
3
+ export type BrowserSessionChoice = {
4
+ person: string | null;
5
+ land: string;
6
+ until: number;
7
+ };
8
+ /** No inbound route can supply the host's forwarding permit. Preserve streamed request bodies. */
9
+ export declare function stripBrowserSessionPermit(request: Request): Request;
10
+ /** Forward once with a secret permit; even a failed forward leaves no live permit behind. */
11
+ export declare function withBrowserSessionPermit<T>(service: string, root: string, choice: BrowserSessionChoice, forward: (permit: string) => Promise<T>): Promise<T>;
12
+ /** The twin takes the host's choice once, under the store's cross-process lock. No header alone grants it. */
13
+ export declare function takeBrowserSessionPermit(request: Request, service: string, root?: string): BrowserSessionChoice | null;
@@ -0,0 +1,51 @@
1
+ // A host-only permission to change a browser session. Kept outside the twin's log and never sent to a browser.
2
+ import { withHeaders } from "./with-headers.js";
3
+ import { join } from 'node:path';
4
+ import { getActiveWorldStore } from "./world-store.js";
5
+ import { worldPaths } from "./storage.js";
6
+ export const BROWSER_SESSION_HEADER = 'x-volter-browser-session-permit';
7
+ export const BROWSER_SESSION_PATH = '/_twin/browser-session';
8
+ const directory = (service, root) => join(worldPaths(service, root).dir, 'browser-session-permits');
9
+ /** No inbound route can supply the host's forwarding permit. Preserve streamed request bodies. */
10
+ export function stripBrowserSessionPermit(request) {
11
+ if (!request.headers.has(BROWSER_SESSION_HEADER))
12
+ return request;
13
+ const headers = new Headers(request.headers);
14
+ headers.delete(BROWSER_SESSION_HEADER);
15
+ return withHeaders(request, headers);
16
+ }
17
+ /** Forward once with a secret permit; even a failed forward leaves no live permit behind. */
18
+ export async function withBrowserSessionPermit(service, root, choice, forward) {
19
+ const store = getActiveWorldStore();
20
+ const permit = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(32))).toString('hex');
21
+ const path = join(directory(service, root), permit);
22
+ store.write(path, JSON.stringify(choice), { secret: true });
23
+ try {
24
+ return await forward(permit);
25
+ }
26
+ finally {
27
+ store.remove(path);
28
+ }
29
+ }
30
+ /** The twin takes the host's choice once, under the store's cross-process lock. No header alone grants it. */
31
+ export function takeBrowserSessionPermit(request, service, root) {
32
+ const permit = request.headers.get(BROWSER_SESSION_HEADER);
33
+ if (!permit || !/^[0-9a-f]{64}$/.test(permit))
34
+ return null;
35
+ const store = getActiveWorldStore();
36
+ const dir = directory(service, root);
37
+ return store.withLock(`${dir}.lock`, () => {
38
+ const path = join(dir, permit);
39
+ const held = store.read(path);
40
+ if (held === null)
41
+ return null;
42
+ store.remove(path);
43
+ try {
44
+ const choice = JSON.parse(held);
45
+ return choice.until > Date.now() && (choice.person === null || typeof choice.person === 'string') && typeof choice.land === 'string' ? choice : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ });
51
+ }
@@ -0,0 +1,9 @@
1
+ import type { HandlerContext } from './derived-core.js';
2
+ type Writer = Pick<HandlerContext, 'record' | 'secret' | 'occurredAt'>;
3
+ /** The named cookie, without interpreting any other vendor's cookie. */
4
+ export declare function browserSessionToken(request: Request, name: string): string | undefined;
5
+ /** A new, independent sign-in, returning the cookie the vendor places on its response. */
6
+ export declare function startBrowserSession(ctx: Writer, email: string, cookie: string, scope?: string): Promise<string>;
7
+ /** End the session the request holds, then clear its cookie. Unknown cookies create no rows. */
8
+ export declare function endBrowserSession(ctx: Pick<HandlerContext, 'call' | 'record' | 'rowsRaw' | 'occurredAt'>, cookie: string): Promise<string>;
9
+ export {};
@@ -0,0 +1,30 @@
1
+ /** The named cookie, without interpreting any other vendor's cookie. */
2
+ export function browserSessionToken(request, name) {
3
+ for (const part of (request.headers.get('cookie') ?? '').split(';')) {
4
+ const at = part.indexOf('=');
5
+ if (at > 0 && part.slice(0, at).trim() === name) {
6
+ try {
7
+ return decodeURIComponent(part.slice(at + 1).trim());
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ }
13
+ }
14
+ return undefined;
15
+ }
16
+ /** A new, independent sign-in, returning the cookie the vendor places on its response. */
17
+ export async function startBrowserSession(ctx, email, cookie, scope) {
18
+ const issued = await ctx.record('_web_session_issue', { email, ...(scope !== undefined ? { scope } : {}) });
19
+ const token = await ctx.secret(`web-session:${issued}`);
20
+ await ctx.record('_web_session', { token, email, ...(scope !== undefined ? { scope } : {}), created_at: ctx.occurredAt }, `websession:${token}`);
21
+ return `${cookie}=${token}; path=/; HttpOnly; Secure; SameSite=Lax`;
22
+ }
23
+ /** End the session the request holds, then clear its cookie. Unknown cookies create no rows. */
24
+ export async function endBrowserSession(ctx, cookie) {
25
+ const token = browserSessionToken(ctx.call.request, cookie);
26
+ const session = token ? ctx.rowsRaw('_web_session').find((s) => s.token === token) : undefined;
27
+ if (session)
28
+ await ctx.record('_web_session', { token: null, ended_at: ctx.occurredAt }, String(session.id));
29
+ return `${cookie}=; path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
30
+ }
@@ -268,6 +268,7 @@ export type VendorManifest = {
268
268
  descriptor: Omit<PackDescriptor, 'vendor'>;
269
269
  ingest?: IngestDecl;
270
270
  rateBudget?: RateBudgetDeclaration;
271
+ browserSession?: DerivedManifest['browserSession'];
271
272
  /** the vendor has no vendor-backed half (architecture, "The descriptor"): packOf copies it, and derives no state system */
272
273
  vendorBacked?: DerivedManifest['vendorBacked'];
273
274
  };
@@ -543,6 +544,11 @@ export type DerivedManifest = {
543
544
  owner: string;
544
545
  resource: string;
545
546
  }>;
547
+ /** The browser session of a World's person, issued with world-ui's kit. Declaring it lets the World offer View as. */
548
+ browserSession?: {
549
+ cookie: string;
550
+ scope?: string;
551
+ };
546
552
  /** the vendor's screens this pack serves or owes (demand decides which exist) */
547
553
  screens?: ScreenDecl[];
548
554
  /** The discovery door's facts (`GET /twin`): what the twin is of, what it stores, and how it is authenticated. */
@@ -1166,7 +1166,6 @@ export async function contextFor(m, call, scope) {
1166
1166
  body,
1167
1167
  text,
1168
1168
  root,
1169
- occurredAt: at,
1170
1169
  publicBase: twinPublicBase(call.request),
1171
1170
  scenario: scenarioDecisionOf(call.request),
1172
1171
  deliver: (url, init) => deliverToApplication(url, init),
@@ -1204,7 +1203,33 @@ export async function contextFor(m, call, scope) {
1204
1203
  }
1205
1204
  return id;
1206
1205
  },
1206
+ occurredAt: at,
1207
1207
  secret: async (label) => hmac(await worldSeed(m.service, root, at), label, 'base64url'),
1208
+ record: async (type, fields, id) => {
1209
+ if (!type.startsWith('_'))
1210
+ throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
1211
+ if (id !== undefined) {
1212
+ await applyTwinWrite(m.service, { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: id, fields, occurredAt: at, actor: { kind: 'system' } }, root);
1213
+ return id;
1214
+ }
1215
+ const { value } = await applyTwinWriteAtomic(m.service, (resources) => {
1216
+ // Allocate and append under the same state lock: concurrent sign-ins must never share an issue id.
1217
+ const subject = (() => {
1218
+ const prefix = `${type.slice(1)}_`;
1219
+ let max = 0;
1220
+ for (const r of resources) {
1221
+ if (r.type !== type)
1222
+ continue;
1223
+ const n = r.id.startsWith(prefix) ? Number(r.id.slice(prefix.length)) : NaN;
1224
+ if (Number.isInteger(n) && n > max)
1225
+ max = n;
1226
+ }
1227
+ return `${prefix}${max + 1}`;
1228
+ })();
1229
+ return { kind: 'write', value: subject, write: { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } } };
1230
+ }, root, () => resourcesOfType(m.service, type, root));
1231
+ return value;
1232
+ },
1208
1233
  get machines() {
1209
1234
  const enrolled = resourcesOfType(m.service, MACHINE_POOL, root).find((r) => r.id === 'pool');
1210
1235
  return poolFor(m.service, root, enrolled?.kind ?? 'none');
@@ -1237,24 +1262,6 @@ export async function contextFor(m, call, scope) {
1237
1262
  rowsRaw: (resource, opts) => stored(m, resource, root, opts),
1238
1263
  tree: () => twinResources(m.service, root),
1239
1264
  history: (resource, id) => subjectHistory(m.service, { type: storedType(m, resource), id }, root).map((e) => ({ ...(e.operation ? { operation: e.operation } : {}), ...(e.fields ? { fields: e.fields } : {}), ...(e.occurredAt ? { occurredAt: e.occurredAt } : {}) })),
1240
- record: async (type, fields, id) => {
1241
- if (!type.startsWith('_'))
1242
- throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
1243
- // an id the caller does not give is the next past every one of this type the tree holds (as `{n}` ids are minted),
1244
- // never a count of the rows, which a removed row would make repeat
1245
- const subject = id ?? (() => {
1246
- const prefix = `${type.slice(1)}_`;
1247
- let max = 0;
1248
- for (const r of resourcesOfType(m.service, type, root)) {
1249
- const n = r.id.startsWith(prefix) ? Number(r.id.slice(prefix.length)) : NaN;
1250
- if (Number.isInteger(n) && n > max)
1251
- max = n;
1252
- }
1253
- return `${prefix}${max + 1}`;
1254
- })();
1255
- await applyTwinWrite(m.service, { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } }, root);
1256
- return subject;
1257
- },
1258
1265
  raw: (body, init = {}) => new Response(body, { status: init.status ?? 200, headers: init.headers ?? {} }),
1259
1266
  atomically: async (decide) => {
1260
1267
  const { value } = await applyTwinWriteAtomic(m.service, (resources) => {
@@ -64,3 +64,4 @@ export * as redis from './redis/index.js';
64
64
  export * as git from './git/index.js';
65
65
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError } from './scenario.js';
66
66
  export type { PackScenarioAdapter, ScenarioDecision, ScenarioDocument, ScenarioExtractorSpec, ScenarioFault, ScenarioFaultResult, ScenarioFeatures, ScenarioHandler, ScenarioMatcher, ScenarioMissRecord, ScenarioStatus } from './scenario.js';
67
+ export { browserSessionToken, startBrowserSession, endBrowserSession } from './browser-session.js';
package/dist/src/index.js CHANGED
@@ -59,3 +59,4 @@ export * as redis from "./redis/index.js";
59
59
  export * as git from "./git/index.js";
60
60
  // ── the scenario grammar (architecture, "Behaviour") ───────────────────────────────────────────
61
61
  export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioError } from "./scenario.js";
62
+ export { browserSessionToken, startBrowserSession, endBrowserSession } from "./browser-session.js";
@@ -56,9 +56,12 @@ export type LanePart = {
56
56
  fetch: ((request: Request) => Promise<Response>) & {
57
57
  serves?: (method: string, path: string) => boolean;
58
58
  boards?: boolean;
59
+ drawsScreens?: boolean;
59
60
  draws?: (url: string) => boolean;
60
61
  /** the hosts the lane's built content screens name: its vendor's router sends `<base>/@<host>/` there */
61
62
  contentHosts?: ReadonlyArray<string>;
63
+ /** A vendor's browser session applies to its lanes without a lane importing its parent's manifest. */
64
+ sessionDeclaration?: (session: NonNullable<DerivedManifest['browserSession']>) => void;
62
65
  };
63
66
  manifest: {
64
67
  doors?: ReadonlyArray<{
@@ -5,6 +5,8 @@
5
5
  // `screens/<id>.tsx`), and the API (the derived dispatch over the generated surface, the handlers and the core). Every
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
+ import { startBrowserSession, endBrowserSession } from "./browser-session.js";
9
+ import { BROWSER_SESSION_PATH, takeBrowserSessionPermit } from "./browser-session-permit.js";
8
10
  import { ORIGINAL_PATH_HEADER } from "./sigv4.js";
9
11
  import { placedCookies, TWIN_PREFIX_HEADER } from "./twin-fetch.js";
10
12
  import { PREFIX_PARAMS_HEADER } from "./derived.js";
@@ -110,6 +112,7 @@ async function encoded(m, request, res) {
110
112
  /** A pack's HTTP surface, as the kernel serves it from the pack's parts. */
111
113
  export function createPackFetch(parts, options = {}) {
112
114
  const m = parts.manifest;
115
+ let browserSession = m.browserSession;
113
116
  const scope = {
114
117
  ...(options.root !== undefined ? { root: options.root } : {}),
115
118
  ...(options.clock ? { clock: options.clock } : {}),
@@ -228,6 +231,7 @@ export function createPackFetch(parts, options = {}) {
228
231
  const hosts = [s.host, ...(s.hosts ?? [])].map(hostMatcher);
229
232
  return { root: s.path.replace(/\/+$/, '') === '', host: (h) => hosts.some((match) => match(h)), path: pathMatcher(s.path, true) };
230
233
  });
234
+ const drawsScreens = viewed.length > 0 || Object.values(parts.lanes ?? {}).some((l) => l.fetch.drawsScreens);
231
235
  const drawsUrl = (at) => {
232
236
  try {
233
237
  const u = new URL(at);
@@ -377,7 +381,7 @@ export function createPackFetch(parts, options = {}) {
377
381
  if (request.method === 'GET' && path === '/twin' && m.discovery) {
378
382
  const d = m.discovery;
379
383
  // the workspaces a person can be shown (Viewing a World): each built workspace screen's id and path
380
- const shown = workspaces().length ? { screens: workspaces() } : {};
384
+ const shown = { drawsScreens, ...(workspaces().length ? { screens: workspaces() } : {}), ...(browserSession ? { browserSession } : {}) };
381
385
  // a model vendor's: its state, and what its scenario scripts, with the scenario's live counts
382
386
  if (parts.scenario && d.behavior) {
383
387
  return Response.json({ ...shown, ...twinManifest({
@@ -395,6 +399,20 @@ export function createPackFetch(parts, options = {}) {
395
399
  // header would make a payout time makes the caller's), but a tenant's, when the vendor keeps tenants apart
396
400
  if (parts.clock)
397
401
  await runAsVendorMove(async () => parts.clock(await open(new Request(request.url, m.tenant ? { headers: request.headers } : {}), 'clock', 'POST', '/_twin/clock', {})));
402
+ if (path === BROWSER_SESSION_PATH && browserSession) {
403
+ if (request.method !== 'POST' || readOnly)
404
+ return Response.json({ error: 'a browser session needs a permitted write' }, { status: 403 });
405
+ const choice = takeBrowserSessionPermit(request, m.service, options.root);
406
+ if (!choice)
407
+ return Response.json({ error: 'only the World can change this browser session' }, { status: 403 });
408
+ const ctx = await open(request, 'browser-session', 'POST', BROWSER_SESSION_PATH, {});
409
+ const person = choice.person === null ? null : ctx.rowsRaw('_person').find((p) => p.id === choice.person && typeof p.email === 'string');
410
+ if (person === undefined)
411
+ return Response.json({ error: 'that person is no longer in the World' }, { status: 404 });
412
+ const cleared = await endBrowserSession(ctx, browserSession.cookie);
413
+ const cookie = person ? await startBrowserSession(ctx, String(person.email), browserSession.cookie, browserSession.scope) : cleared;
414
+ return new Response(null, { status: 303, headers: { location: choice.land, 'set-cookie': cookie } });
415
+ }
398
416
  if (path.startsWith('/_twin/')) {
399
417
  // the World's machine pool, enrolled by the kernel for a vendor that runs images (machines.ts)
400
418
  if (m.machines && path === '/_twin/machine-pool' && request.method === 'POST') {
@@ -513,7 +531,7 @@ export function createPackFetch(parts, options = {}) {
513
531
  async (asked) => engineOf()?.serve(asked)) : undefined;
514
532
  // what serves each operation: the pack's own, and each lane's
515
533
  const owners = () => ({ ...api.owners(), ...lanesOwners(parts.lanes ?? {}) });
516
- return derivedRequestScopes(m, Object.assign(served, { owners, serves, workspaces, boards: Boolean(parts.board), draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}) }));
534
+ return derivedRequestScopes(m, Object.assign(served, { sessionDeclaration: (session) => { browserSession ??= session; }, owners, serves, workspaces, boards: Boolean(parts.board), drawsScreens, draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}) }));
517
535
  }
518
536
  /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
519
537
  * route whose conditions all hold, then the default), behind the gateway's CORS; undefined when none takes it. */
@@ -573,6 +591,9 @@ const lanesOwners = (lanes) => Object.assign({}, ...Object.values(lanes).map((l)
573
591
  /** The workspaces a vendor's lanes draw, as one list. */
574
592
  const lanesWorkspaces = (lanes) => Object.values(lanes).flatMap((l) => l.fetch.workspaces?.() ?? []);
575
593
  export function createVendorFetch(manifest, lanes) {
594
+ if (manifest.browserSession)
595
+ for (const part of Object.values(lanes))
596
+ part.fetch.sessionDeclaration?.(manifest.browserSession);
576
597
  const lane = laneRouter(manifest.vendor, manifest.lanes, lanes);
577
598
  return Object.assign(async (request) => {
578
599
  const path = new URL(request.url).pathname.replace(/\/+$/, '') || '/';
@@ -595,7 +616,7 @@ export function createVendorFetch(manifest, lanes) {
595
616
  if (request.method === 'GET' && path === '/twin' && manifest.discovery) {
596
617
  // the vendor's screens are its lanes' (a dashboard drawn by one lane is the vendor's)
597
618
  const screens = lanesWorkspaces(lanes);
598
- return Response.json({ ...statefulTwinManifest({ vendor: manifest.vendor, ...manifest.discovery }), ...(screens.length ? { screens } : {}) });
619
+ return Response.json({ ...statefulTwinManifest({ vendor: manifest.vendor, ...manifest.discovery }), drawsScreens: Object.values(lanes).some((l) => l.fetch.drawsScreens), ...(manifest.browserSession ? { browserSession: manifest.browserSession } : {}), ...(screens.length ? { screens } : {}) });
599
620
  }
600
621
  const to = lane(request);
601
622
  return to ? to(request) : Response.json({ error: `${manifest.vendor}: no lane serves ${request.method} ${path}` }, { status: 404 });
@@ -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 { BROWSER_SESSION_HEADER, BROWSER_SESSION_PATH, stripBrowserSessionPermit, withBrowserSessionPermit } from './browser-session-permit.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 { BROWSER_SESSION_HEADER, BROWSER_SESSION_PATH, stripBrowserSessionPermit, withBrowserSessionPermit } from "./browser-session-permit.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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-core",
3
- "version": "3.0.28",
3
+ "version": "3.0.30",
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",
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,43 @@
1
+ // A host-only permission to change a browser session. Kept outside the twin's log and never sent to a browser.
2
+ import { withHeaders } from './with-headers.ts';
3
+ import { join } from 'node:path';
4
+ import { getActiveWorldStore } from './world-store.ts';
5
+ import { worldPaths } from './storage.ts';
6
+
7
+ export const BROWSER_SESSION_HEADER = 'x-volter-browser-session-permit';
8
+ export const BROWSER_SESSION_PATH = '/_twin/browser-session';
9
+ export type BrowserSessionChoice = { person: string | null; land: string; until: number };
10
+ const directory = (service: string, root?: string): string => join(worldPaths(service, root).dir, 'browser-session-permits');
11
+
12
+ /** No inbound route can supply the host's forwarding permit. Preserve streamed request bodies. */
13
+ export function stripBrowserSessionPermit(request: Request): Request {
14
+ if (!request.headers.has(BROWSER_SESSION_HEADER)) return request;
15
+ const headers = new Headers(request.headers);
16
+ headers.delete(BROWSER_SESSION_HEADER);
17
+ return withHeaders(request, headers);
18
+ }
19
+
20
+ /** Forward once with a secret permit; even a failed forward leaves no live permit behind. */
21
+ export async function withBrowserSessionPermit<T>(service: string, root: string, choice: BrowserSessionChoice, forward: (permit: string) => Promise<T>): Promise<T> {
22
+ const store = getActiveWorldStore();
23
+ const permit = Buffer.from(globalThis.crypto.getRandomValues(new Uint8Array(32))).toString('hex');
24
+ const path = join(directory(service, root), permit);
25
+ store.write(path, JSON.stringify(choice), { secret: true });
26
+ try { return await forward(permit); } finally { store.remove(path); }
27
+ }
28
+
29
+ /** The twin takes the host's choice once, under the store's cross-process lock. No header alone grants it. */
30
+ export function takeBrowserSessionPermit(request: Request, service: string, root?: string): BrowserSessionChoice | null {
31
+ const permit = request.headers.get(BROWSER_SESSION_HEADER);
32
+ if (!permit || !/^[0-9a-f]{64}$/.test(permit)) return null;
33
+ const store = getActiveWorldStore(); const dir = directory(service, root);
34
+ return store.withLock(`${dir}.lock`, () => {
35
+ const path = join(dir, permit); const held = store.read(path);
36
+ if (held === null) return null;
37
+ store.remove(path);
38
+ try {
39
+ const choice = JSON.parse(held) as BrowserSessionChoice;
40
+ return choice.until > Date.now() && (choice.person === null || typeof choice.person === 'string') && typeof choice.land === 'string' ? choice : null;
41
+ } catch { return null; }
42
+ });
43
+ }
@@ -0,0 +1,31 @@
1
+ // A vendor's browser session: the bookkeeping used by its sign-in page and the World's View as door.
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
+ }
@@ -214,7 +214,7 @@ export type LaneRoute = {
214
214
  export type LanesDecl = { cors?: CorsDecl; routes: ReadonlyArray<LaneRoute>; default?: string };
215
215
  /** The manifest of a vendor whose every API is a lane (Cloudflare's API v4 and R2, AWS's services): no API of its own,
216
216
  * only what the vendor's front says — its discovery facts, its lanes and its descriptor. */
217
- export type VendorManifest = { vendor: string; discovery?: DerivedManifest['discovery']; lanes: LanesDecl; descriptor: Omit<PackDescriptor, 'vendor'>; ingest?: IngestDecl; rateBudget?: RateBudgetDeclaration;
217
+ export type VendorManifest = { vendor: string; discovery?: DerivedManifest['discovery']; lanes: LanesDecl; descriptor: Omit<PackDescriptor, 'vendor'>; ingest?: IngestDecl; rateBudget?: RateBudgetDeclaration; browserSession?: DerivedManifest['browserSession'];
218
218
  /** the vendor has no vendor-backed half (architecture, "The descriptor"): packOf copies it, and derives no state system */
219
219
  vendorBacked?: DerivedManifest['vendorBacked'] };
220
220
 
@@ -395,6 +395,8 @@ export type DerivedManifest = {
395
395
  resources: Record<string, ResourceDecl>;
396
396
  /** Read-only cross-pack credential subjects of the same vendor (architecture A3); no root is exposed to handlers. */
397
397
  ownerReads?: ReadonlyArray<{ owner: string; resource: string }>;
398
+ /** The browser session of a World's person, issued with world-ui's kit. Declaring it lets the World offer View as. */
399
+ browserSession?: { cookie: string; scope?: string };
398
400
  /** the vendor's screens this pack serves or owes (demand decides which exist) */
399
401
  screens?: ScreenDecl[];
400
402
  /** The discovery door's facts (`GET /twin`): what the twin is of, what it stores, and how it is authenticated. */
@@ -1702,7 +1704,6 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
1702
1704
  body,
1703
1705
  text,
1704
1706
  root,
1705
- occurredAt: at,
1706
1707
  publicBase: twinPublicBase(call.request),
1707
1708
  scenario: scenarioDecisionOf(call.request),
1708
1709
  deliver: (url, init) => deliverToApplication(url, init),
@@ -1737,7 +1738,30 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
1737
1738
  }
1738
1739
  return id;
1739
1740
  },
1741
+ occurredAt: at,
1740
1742
  secret: async (label) => hmac(await worldSeed(m.service, root, at), label, 'base64url'),
1743
+ record: async (type, fields, id) => {
1744
+ if (!type.startsWith('_')) throw new Error(`semantics: ${type} is not a bookkeeping type (bookkeeping types start with _)`);
1745
+ if (id !== undefined) {
1746
+ await applyTwinWrite(m.service, { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: id, fields, occurredAt: at, actor: { kind: 'system' } }, root);
1747
+ return id;
1748
+ }
1749
+ const { value } = await applyTwinWriteAtomic(m.service, (resources) => {
1750
+ // Allocate and append under the same state lock: concurrent sign-ins must never share an issue id.
1751
+ const subject = (() => {
1752
+ const prefix = `${type.slice(1)}_`;
1753
+ let max = 0;
1754
+ for (const r of resources) {
1755
+ if (r.type !== type) continue;
1756
+ const n = r.id.startsWith(prefix) ? Number(r.id.slice(prefix.length)) : NaN;
1757
+ if (Number.isInteger(n) && n > max) max = n;
1758
+ }
1759
+ return `${prefix}${max + 1}`;
1760
+ })();
1761
+ return { kind: 'write', value: subject, write: { operation: `${type.slice(1)}.record`, subjectType: type, subjectId: subject, fields, occurredAt: at, actor: { kind: 'system' } } };
1762
+ }, root, () => resourcesOfType(m.service, type, root));
1763
+ return value;
1764
+ },
1741
1765
  get machines() {
1742
1766
  const enrolled = resourcesOfType(m.service, MACHINE_POOL, root).find((r) => r.id === 'pool') as unknown as { kind?: MachinePool['kind'] } | undefined;
1743
1767
  return poolFor(m.service, root, enrolled?.kind ?? 'none');
@@ -1767,19 +1791,6 @@ export async function contextFor(m: DerivedManifest, call: DerivedCall, scope: C
1767
1791
  rowsRaw: (resource, opts) => stored(m, resource, root, opts) as Array<Record<string, unknown>>,
1768
1792
  tree: () => twinResources(m.service, root) as Array<Record<string, unknown>>,
1769
1793
  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
1794
  raw: (body, init = {}) => new Response(body, { status: init.status ?? 200, headers: init.headers ?? {} }),
1784
1795
  atomically: async (decide) => {
1785
1796
  const { value } = await applyTwinWriteAtomic(
package/src/index.ts CHANGED
@@ -89,3 +89,5 @@ 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';
package/src/pack-fetch.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  // `screens/<id>.tsx`), and the API (the derived dispatch over the generated surface, the handlers and the core). Every
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
+ import { startBrowserSession, endBrowserSession } from './browser-session.ts';
9
+ import { BROWSER_SESSION_PATH, takeBrowserSessionPermit } from './browser-session-permit.ts';
8
10
  import { ORIGINAL_PATH_HEADER } from './sigv4.ts';
9
11
  import { placedCookies, TWIN_PREFIX_HEADER } from './twin-fetch.ts';
10
12
  import { PREFIX_PARAMS_HEADER } from './derived.ts';
@@ -77,9 +79,11 @@ export type PackScenario<Req = any> = { adapter: PackScenarioAdapter<Req>; opera
77
79
  /** A lane as its vendor's fetch is given it: its own pack fetch, and its manifest (the doors it declares). */
78
80
  export type LanePart = {
79
81
  fetch: ((request: Request) => Promise<Response>) & {
80
- serves?: (method: string, path: string) => boolean; boards?: boolean; draws?: (url: string) => boolean;
82
+ serves?: (method: string, path: string) => boolean; boards?: boolean; drawsScreens?: boolean; draws?: (url: string) => boolean;
81
83
  /** the hosts the lane's built content screens name: its vendor's router sends `<base>/@<host>/` there */
82
84
  contentHosts?: ReadonlyArray<string>;
85
+ /** A vendor's browser session applies to its lanes without a lane importing its parent's manifest. */
86
+ sessionDeclaration?: (session: NonNullable<DerivedManifest['browserSession']>) => void;
83
87
  };
84
88
  manifest: { doors?: ReadonlyArray<{ method: string; path: string }> };
85
89
  };
@@ -173,6 +177,7 @@ async function encoded(m: DerivedManifest, request: Request, res: Response): Pro
173
177
  /** A pack's HTTP surface, as the kernel serves it from the pack's parts. */
174
178
  export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}): DerivedFetch {
175
179
  const m = parts.manifest;
180
+ let browserSession = m.browserSession;
176
181
  const scope: CoreScope = {
177
182
  ...(options.root !== undefined ? { root: options.root } : {}),
178
183
  ...(options.clock ? { clock: options.clock } : {}),
@@ -278,6 +283,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
278
283
  const hosts = [s.host, ...(s.hosts ?? [])].map(hostMatcher);
279
284
  return { root: s.path.replace(/\/+$/, '') === '', host: (h: string) => hosts.some((match) => match(h)), path: pathMatcher(s.path, true) };
280
285
  });
286
+ const drawsScreens = viewed.length > 0 || Object.values(parts.lanes ?? {}).some((l) => l.fetch.drawsScreens);
281
287
  const drawsUrl = (at: string): boolean => {
282
288
  try {
283
289
  const u = new URL(at); const path = u.pathname.replace(/\/+$/, '') || '/';
@@ -406,7 +412,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
406
412
  if (request.method === 'GET' && path === '/twin' && m.discovery) {
407
413
  const d = m.discovery;
408
414
  // the workspaces a person can be shown (Viewing a World): each built workspace screen's id and path
409
- const shown = workspaces().length ? { screens: workspaces() } : {};
415
+ const shown = { drawsScreens, ...(workspaces().length ? { screens: workspaces() } : {}), ...(browserSession ? { browserSession } : {}) };
410
416
  // a model vendor's: its state, and what its scenario scripts, with the scenario's live counts
411
417
  if (parts.scenario && d.behavior) {
412
418
  return Response.json({ ...shown, ...twinManifest({
@@ -422,6 +428,17 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
422
428
  // the vendor's own moves, not the caller's: its context carries none of the request's headers (a Stripe-Account
423
429
  // header would make a payout time makes the caller's), but a tenant's, when the vendor keeps tenants apart
424
430
  if (parts.clock) await runAsVendorMove(async () => parts.clock!(await open(new Request(request.url, m.tenant ? { headers: request.headers } : {}), 'clock', 'POST', '/_twin/clock', {})));
431
+ if (path === BROWSER_SESSION_PATH && browserSession) {
432
+ if (request.method !== 'POST' || readOnly) return Response.json({ error: 'a browser session needs a permitted write' }, { status: 403 });
433
+ const choice = takeBrowserSessionPermit(request, m.service, options.root);
434
+ if (!choice) return Response.json({ error: 'only the World can change this browser session' }, { status: 403 });
435
+ const ctx = await open(request, 'browser-session', 'POST', BROWSER_SESSION_PATH, {});
436
+ const person = choice.person === null ? null : ctx.rowsRaw('_person').find((p) => p.id === choice.person && typeof p.email === 'string');
437
+ if (person === undefined) return Response.json({ error: 'that person is no longer in the World' }, { status: 404 });
438
+ const cleared = await endBrowserSession(ctx, browserSession.cookie);
439
+ const cookie = person ? await startBrowserSession(ctx, String(person.email), browserSession.cookie, browserSession.scope) : cleared;
440
+ return new Response(null, { status: 303, headers: { location: choice.land, 'set-cookie': cookie } });
441
+ }
425
442
  if (path.startsWith('/_twin/')) {
426
443
  // the World's machine pool, enrolled by the kernel for a vendor that runs images (machines.ts)
427
444
  if (m.machines && path === '/_twin/machine-pool' && request.method === 'POST') {
@@ -519,7 +536,7 @@ export function createPackFetch(parts: PackParts, options: PackFetchOptions = {}
519
536
  async (asked) => engineOf()?.serve(asked)) : undefined;
520
537
  // what serves each operation: the pack's own, and each lane's
521
538
  const owners = (): Record<string, DerivedOwner> => ({ ...api.owners(), ...lanesOwners(parts.lanes ?? {}) });
522
- return derivedRequestScopes(m, Object.assign(served, { owners, serves, workspaces, boards: Boolean(parts.board), draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}) }));
539
+ return derivedRequestScopes(m, Object.assign(served, { sessionDeclaration: (session: NonNullable<DerivedManifest['browserSession']>) => { browserSession ??= session; }, owners, serves, workspaces, boards: Boolean(parts.board), drawsScreens, draws: drawsUrl, contentHosts, ...(upgrade ? { upgrade } : {}) }));
523
540
  }
524
541
 
525
542
  /** The vendor's gateway over its lanes: the lane a request goes to (a door to the lane that declares it, then the first
@@ -583,6 +600,7 @@ const lanesWorkspaces = (lanes: Record<string, LanePart>): Workspace[] =>
583
600
  Object.values(lanes).flatMap((l) => (l.fetch as { workspaces?: () => Workspace[] }).workspaces?.() ?? []);
584
601
 
585
602
  export function createVendorFetch(manifest: VendorManifest, lanes: Record<string, LanePart>): ((request: Request) => Promise<Response>) & { owners: () => Record<string, DerivedOwner>; workspaces: () => Workspace[] } {
603
+ if (manifest.browserSession) for (const part of Object.values(lanes)) part.fetch.sessionDeclaration?.(manifest.browserSession);
586
604
  const lane = laneRouter(manifest.vendor, manifest.lanes, lanes);
587
605
  return Object.assign(async (request: Request): Promise<Response> => {
588
606
  const path = new URL(request.url).pathname.replace(/\/+$/, '') || '/';
@@ -602,7 +620,7 @@ export function createVendorFetch(manifest: VendorManifest, lanes: Record<string
602
620
  if (request.method === 'GET' && path === '/twin' && manifest.discovery) {
603
621
  // the vendor's screens are its lanes' (a dashboard drawn by one lane is the vendor's)
604
622
  const screens = lanesWorkspaces(lanes);
605
- return Response.json({ ...statefulTwinManifest({ vendor: manifest.vendor, ...manifest.discovery }), ...(screens.length ? { screens } : {}) });
623
+ return Response.json({ ...statefulTwinManifest({ vendor: manifest.vendor, ...manifest.discovery }), drawsScreens: Object.values(lanes).some((l) => l.fetch.drawsScreens), ...(manifest.browserSession ? { browserSession: manifest.browserSession } : {}), ...(screens.length ? { screens } : {}) });
606
624
  }
607
625
  const to = lane(request);
608
626
  return to ? to(request) : Response.json({ error: `${manifest.vendor}: no lane serves ${request.method} ${path}` }, { status: 404 });
package/src/runtime.ts CHANGED
@@ -13,7 +13,7 @@ export { parseScenarioDocument, scenarioFaultResult, ScenarioEngine, ScenarioErr
13
13
  export { WORLD_CLOCK_ENV, worldNow } from './world-clock.ts';
14
14
  export { WORLD_ENV_NAMES_ENV, worldEnvValue } from './world-env.ts';
15
15
  export { ORIGINAL_PATH_HEADER } from './sigv4.ts';
16
- export { createTwinFetchFromHandler, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, siteLabel, siteUrlOf, twinPublicBase, twinSiteUrl, withRequestScopes } from './twin-fetch.ts';
16
+ export { createTwinFetchFromHandler, placedCookies, TWIN_PREFIX_HEADER, TWIN_REQUEST_SCOPES, TWIN_SITES_HEADER, siteLabel, siteUrlOf, twinPublicBase, twinSiteUrl, withRequestScopes } from './twin-fetch.ts';
17
17
  export { isReadOnlyRequest, READ_ONLY_REQUEST_HEADER, ReadOnlyRequestError, runAsReadOnlyRequest, runAsVendorMove, writesRefused } from './request-scope.ts';
18
18
  export { compileSurface, createDerivedFetch, matchOperation } from './derived.ts';
19
19
  export { currentTraceparent, deliveryTraceHeaders, newTraceparent, parseTraceparent, runWithRequestTrace, runWithTraceparent, TRACEPARENT_HEADER, traceparentForDelivery, validTraceparent } from './trace-context.ts';
@@ -343,3 +343,5 @@ export { captureHistory, captureParentHistory, historyChanges, historyAtInstant,
343
343
  export type { HistoryView, HistoryLayout, HistoryReference, HistoryOrigin } from './history.ts';
344
344
  export { foldHistory } from './log.ts';
345
345
  export { volterHome } from './volter-home.ts';
346
+
347
+ export { BROWSER_SESSION_HEADER, BROWSER_SESSION_PATH, stripBrowserSessionPermit, withBrowserSessionPermit } from './browser-session-permit.ts';
package/src/serve.ts CHANGED
@@ -594,6 +594,8 @@ export async function applyTwinWriteAtomic<T>(
594
594
  service: string,
595
595
  prepare: (resources: readonly TwinResource[]) => AtomicTwinWriteDecision<T>,
596
596
  root?: string,
597
+ /** A type index when the decision and its preconditions need only that type, read inside the state lock. */
598
+ read?: () => TwinResource[],
597
599
  ): Promise<{ value: T; result?: TwinWriteResult }> {
598
600
  // Same ruling as applyTwinWrite: a local write is an OCCURRENCE unless the caller supplied
599
601
  // identity. The mode rides on the decision because only the callback knows which write it chose.
@@ -606,7 +608,7 @@ export async function applyTwinWriteAtomic<T>(
606
608
  action: actionForTwinWrite(service, decision.write),
607
609
  identity: decision.write.idempotencyKey || decision.write.actionId ? 'caller' : 'occurrence',
608
610
  };
609
- }, root);
611
+ }, root, read);
610
612
  // as applyTwinWrite: a seed's write is the placeholder's default data, never performed
611
613
  const head = committed.action && committed.appended && !committed.placeholder ? await performAtHead(service, committed.action, root) : { performed: false };
612
614
  return {