@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.
- package/dist/src/actions.d.ts +3 -1
- package/dist/src/actions.js +4 -2
- package/dist/src/browser-session-permit.d.ts +13 -0
- package/dist/src/browser-session-permit.js +51 -0
- package/dist/src/browser-session.d.ts +9 -0
- package/dist/src/browser-session.js +30 -0
- package/dist/src/derived-core.d.ts +6 -0
- package/dist/src/derived-core.js +26 -19
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/pack-fetch.d.ts +3 -0
- package/dist/src/pack-fetch.js +24 -3
- package/dist/src/runtime.d.ts +2 -1
- package/dist/src/runtime.js +2 -1
- package/dist/src/serve.d.ts +3 -1
- package/dist/src/serve.js +4 -2
- package/package.json +1 -1
- package/src/actions.ts +3 -1
- package/src/browser-session-permit.ts +43 -0
- package/src/browser-session.ts +31 -0
- package/src/derived-core.ts +26 -15
- package/src/index.ts +2 -0
- package/src/pack-fetch.ts +22 -4
- package/src/runtime.ts +3 -1
- package/src/serve.ts +3 -1
package/dist/src/actions.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/actions.js
CHANGED
|
@@ -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 =
|
|
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. */
|
package/dist/src/derived-core.js
CHANGED
|
@@ -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) => {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/src/pack-fetch.d.ts
CHANGED
|
@@ -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<{
|
package/dist/src/pack-fetch.js
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.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 });
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -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';
|
package/dist/src/runtime.js
CHANGED
|
@@ -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";
|
package/dist/src/serve.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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 =
|
|
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
|
+
}
|
package/src/derived-core.ts
CHANGED
|
@@ -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 {
|