@volter/world-runtime 2.0.49 → 2.0.51
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/changeset.d.ts +3 -1
- package/dist/src/changeset.js +6 -2
- package/dist/src/configs.d.ts +4 -1
- package/dist/src/configs.js +15 -2
- package/dist/src/fixture-env.js +4 -0
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +1 -1
- package/dist/src/root.js +3 -3
- package/dist/src/runtime.js +67 -50
- package/dist/src/schema.d.ts +12 -0
- package/dist/src/schema.js +18 -1
- package/dist/src/served-world.d.ts +12 -2
- package/dist/src/served-world.js +108 -21
- package/dist/src/world-view.d.ts +1 -0
- package/dist/src/world-view.js +2 -2
- package/package.json +5 -5
- package/src/changeset.ts +8 -4
- package/src/configs.ts +11 -3
- package/src/fixture-env.ts +4 -0
- package/src/index.ts +2 -1
- package/src/root.ts +3 -3
- package/src/runtime.ts +54 -38
- package/src/schema.ts +21 -1
- package/src/served-world.ts +90 -24
- package/src/world-view.ts +3 -3
package/src/schema.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type WorldManifestV2 = {
|
|
|
32
32
|
runtime?: { isolation?: WorldIsolation; environment?: { values?: Record<string, string>; strip?: string[] }; network?: WorldNetwork };
|
|
33
33
|
serving?: { mode?: 'app' | 'bare'; name?: string; share?: WorldShareConfig };
|
|
34
34
|
remotes?: Record<string, string>;
|
|
35
|
+
changesets?: WorldChangesetRules;
|
|
35
36
|
scenario?: { actors?: Record<string, unknown>; fixtures?: Record<string, unknown> };
|
|
36
37
|
provenance?: { catalog?: { sha: string; protocol?: string } };
|
|
37
38
|
};
|
|
@@ -238,8 +239,15 @@ export type WorldServiceConfig = {
|
|
|
238
239
|
version?: string;
|
|
239
240
|
};
|
|
240
241
|
|
|
242
|
+
/** How this World's changesets are made and readied (docs/reference/config.md, `changesets`): how many people must
|
|
243
|
+
* approve one's current contents before it deploys, and whether a cut needs its author's summary or takes the one
|
|
244
|
+
* generated from its changes. A team's rules, committed with the World, as a repository's are. */
|
|
245
|
+
export type WorldChangesetRules = { approvals?: number; summary?: 'required' | 'generated' };
|
|
246
|
+
|
|
241
247
|
export type WorldConfig = {
|
|
242
248
|
id: string;
|
|
249
|
+
/** How changesets are made and readied here; omitted: one approval, a summary required. */
|
|
250
|
+
changesets?: WorldChangesetRules;
|
|
243
251
|
/** Real external origins; omitted preserves legacy routing, [] explicitly closes egress. */
|
|
244
252
|
network?: WorldNetwork;
|
|
245
253
|
/** Internal normalized source format and saved discovery selection. */
|
|
@@ -512,6 +520,7 @@ function assertLegacyWorldConfig(value: unknown, path: string): WorldConfig {
|
|
|
512
520
|
if (typeof config.remotes !== 'object' || Array.isArray(config.remotes)) throw new Error(`World config remotes must be an object of name → url|path in ${path}`);
|
|
513
521
|
for (const [name, target] of Object.entries(config.remotes as Record<string, unknown>)) if (typeof target !== 'string' || target === '' || !/^[A-Za-z0-9._-]+$/.test(name)) throw new Error(`World config remote "${name}" must be a non-empty url or path in ${path}`);
|
|
514
522
|
}
|
|
523
|
+
if (config.changesets !== undefined) assertChangesetRules(config.changesets, path);
|
|
515
524
|
if (config.isolation !== undefined && config.isolation !== 'process' && config.isolation !== 'colocated' && config.isolation !== 'worker') {
|
|
516
525
|
throw new Error(`World config isolation must be "process", "colocated", or "worker" in ${path}`);
|
|
517
526
|
}
|
|
@@ -789,7 +798,7 @@ function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
|
789
798
|
}
|
|
790
799
|
|
|
791
800
|
function normalizeV2(value: Record<string, unknown>, path: string): WorldConfig {
|
|
792
|
-
assertKeys(value, ['schemaVersion', 'metadata', 'discovery', 'services', 'runtime', 'serving', 'remotes', 'scenario', 'provenance'], 'World manifest', path, true);
|
|
801
|
+
assertKeys(value, ['schemaVersion', 'metadata', 'discovery', 'services', 'runtime', 'serving', 'remotes', 'changesets', 'scenario', 'provenance'], 'World manifest', path, true);
|
|
793
802
|
for (const name of ['metadata', 'discovery', 'runtime', 'serving', 'scenario', 'provenance'] as const) if (value[name] !== undefined && !record(value[name])) throw new Error(`${name} must be an object in ${path}`);
|
|
794
803
|
const metadata = value.metadata as Record<string, unknown> | undefined;
|
|
795
804
|
if (!metadata) throw new Error(`World manifest must define metadata in ${path}`);
|
|
@@ -830,6 +839,7 @@ function normalizeV2(value: Record<string, unknown>, path: string): WorldConfig
|
|
|
830
839
|
...(mode === 'bare' ? { bare: { name: serving.name as string } } : {}),
|
|
831
840
|
...(serving.share === undefined ? {} : { share: serving.share as WorldShareConfig }),
|
|
832
841
|
...(value.remotes === undefined ? {} : { remotes: value.remotes as Record<string, string> }),
|
|
842
|
+
...(value.changesets === undefined ? {} : { changesets: value.changesets as WorldChangesetRules }),
|
|
833
843
|
...(scenario.actors === undefined ? {} : { actors: scenario.actors as Record<string, unknown> }),
|
|
834
844
|
...(scenario.fixtures === undefined ? {} : { fixtures: scenario.fixtures as Record<string, unknown> }),
|
|
835
845
|
...(provenance.catalog === undefined ? {} : { catalog: provenance.catalog as WorldConfig['catalog'] }),
|
|
@@ -922,6 +932,7 @@ export function worldConfigDocument(config: WorldConfig, selection: WorldSelecti
|
|
|
922
932
|
...(config.share === undefined ? {} : { share: config.share }),
|
|
923
933
|
} }),
|
|
924
934
|
...(config.remotes === undefined ? {} : { remotes: config.remotes }),
|
|
935
|
+
...(config.changesets === undefined ? {} : { changesets: config.changesets }),
|
|
925
936
|
...(config.actors === undefined && config.fixtures === undefined ? {} : { scenario: {
|
|
926
937
|
...(config.actors === undefined ? {} : { actors: config.actors }),
|
|
927
938
|
...(config.fixtures === undefined ? {} : { fixtures: config.fixtures }),
|
|
@@ -930,3 +941,12 @@ export function worldConfigDocument(config: WorldConfig, selection: WorldSelecti
|
|
|
930
941
|
...Object.fromEntries(Object.entries(config as Record<string, unknown>).filter(([key]) => key.startsWith('//'))),
|
|
931
942
|
};
|
|
932
943
|
}
|
|
944
|
+
|
|
945
|
+
/** A World's changeset rules, strictly: `approvals` a whole number 0–10, `summary` `required` or `generated`, nothing else. */
|
|
946
|
+
export function assertChangesetRules(rules: unknown, path: string): asserts rules is WorldChangesetRules {
|
|
947
|
+
if (typeof rules !== 'object' || rules === null || Array.isArray(rules)) throw new Error(`World config changesets must be an object in ${path}`);
|
|
948
|
+
for (const key of Object.keys(rules)) if (key !== 'approvals' && key !== 'summary') throw new Error(`World config changesets has no "${key}" (approvals, summary) in ${path}`);
|
|
949
|
+
const { approvals, summary } = rules as Record<string, unknown>;
|
|
950
|
+
if (approvals !== undefined && !(Number.isInteger(approvals) && (approvals as number) >= 0 && (approvals as number) <= 10)) throw new Error(`World config changesets.approvals must be a whole number from 0 to 10 in ${path}`);
|
|
951
|
+
if (summary !== undefined && summary !== 'required' && summary !== 'generated') throw new Error(`World config changesets.summary must be "required" or "generated" in ${path}`);
|
|
952
|
+
}
|
package/src/served-world.ts
CHANGED
|
@@ -32,16 +32,16 @@ import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type Tr
|
|
|
32
32
|
import { createHash } from 'node:crypto';
|
|
33
33
|
import { dirname, join, resolve } from 'node:path';
|
|
34
34
|
import {
|
|
35
|
-
appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow,
|
|
35
|
+
appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, changesetReadiness, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow,
|
|
36
36
|
parentEntries, readBranchMeta, rebaseBranch, type Changeset, type Entry, type Receipt, type RootConfig,
|
|
37
37
|
} from '@volter/world-core';
|
|
38
38
|
import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from './changeset.ts';
|
|
39
39
|
import { fetchFromOrigin } from './origin.ts';
|
|
40
40
|
import { CONSOLE_BASE, consoleRedirect, serveConsoleApart, type ConsoleMount } from './console-apart.ts';
|
|
41
|
-
import { loadWorldConfig } from './configs.ts';
|
|
41
|
+
import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from './configs.ts';
|
|
42
42
|
import { adaptersFor, credentialPath, packMirror, type PackMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, rewrapTwinCredentials, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from './root.ts';
|
|
43
43
|
import { clockFile, downWorld, listWorlds, statusWorld, upWorld } from './runtime.ts';
|
|
44
|
-
import type
|
|
44
|
+
import { assertChangesetRules, type WorldChangesetRules, type WorldInstance } from './schema.ts';
|
|
45
45
|
|
|
46
46
|
export const TOKEN_HEADER = 'x-volter-token';
|
|
47
47
|
/** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
|
|
@@ -83,8 +83,10 @@ export function keysFile(worldRoot: string): string { return join(worldRoot, sta
|
|
|
83
83
|
type HeldKey = { id: string; name: string; scope: 'read' | 'write'; hash: string; createdAt: string; lastUsedAt?: string;
|
|
84
84
|
/** who made it: the World's token, or a person's session (the subject a pass named, and its platform) */
|
|
85
85
|
createdBy?: { via: 'token' } | { via: 'session'; sub?: string; who?: string; issuer?: string } | { via: 'key'; keyId: string };
|
|
86
|
-
/** the person it was made for, when the World's token made it on their behalf (a platform, for a command) */
|
|
86
|
+
/** the person it was made for, when the World's token made it on their behalf (a platform, for a command): their subject */
|
|
87
87
|
for?: string;
|
|
88
|
+
/** that person as attribution names them (their email), beside the subject `for` revocation matches */
|
|
89
|
+
forName?: string;
|
|
88
90
|
/** after this it opens nothing */
|
|
89
91
|
expiresAt?: string;
|
|
90
92
|
/** the branch this key is the parent's key of: the branch reaches its parent with it, and goes when it goes */
|
|
@@ -114,9 +116,9 @@ function configRefFor(worldRoot: string, name: string): string {
|
|
|
114
116
|
* with the call — and keeps them beside the world like `boot` does. */
|
|
115
117
|
export type MountedWorld = { name: string; served: string; root: string; readonly token: string; readonly readToken: string; twins: () => string[]; rotate: () => { token: string; readToken: string }; handle: (request: Request) => Promise<Response>; boot: (url: string) => Promise<void>; stop: () => Promise<void> };
|
|
116
118
|
/** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
|
|
117
|
-
type HeldSession = { scope: 'read' | 'write'; until: number; who?: string; /** the platform whose pass opened it */ issuer?: string; /** the person's subject there */ sub?: string; /** the read key a shared link opened it with: it lasts only while that key does */ key?: string };
|
|
119
|
+
type HeldSession = { scope: 'read' | 'write'; until: number; /** opened by this machine's own page (localSession) */ local?: true; who?: string; /** the platform whose pass opened it */ issuer?: string; /** the person's subject there */ sub?: string; /** the read key a shared link opened it with: it lasts only while that key does */ key?: string };
|
|
118
120
|
|
|
119
|
-
export async function mountWorld(name: string, opts: { /** the platforms whose passes open this World */ passIssuers?: TrustedIssuer[]; root?: string; /** the host's branches of this World, by its served name */ branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */ browserOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean; /** DoorHost.serveBranch */ serveBranch?: (branch: string) => Promise<void> } = {}): Promise<MountedWorld> {
|
|
121
|
+
export async function mountWorld(name: string, opts: { /** the platforms whose passes open this World */ passIssuers?: TrustedIssuer[]; root?: string; /** the host's branches of this World, by its served name */ branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */ browserOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */ localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */ localPerson?: string; /** DoorHost.serveBranch */ serveBranch?: (branch: string) => Promise<void> } = {}): Promise<MountedWorld> {
|
|
120
122
|
const worldRoot = resolve(opts.root ?? process.cwd());
|
|
121
123
|
const configRef = configRefFor(worldRoot, name);
|
|
122
124
|
if (loadWorldConfig(configRef, worldRoot).config.resources) {
|
|
@@ -148,7 +150,7 @@ export async function mountWorld(name: string, opts: { /** the platforms whose p
|
|
|
148
150
|
clockPath: () => clockFile(worldRoot, name),
|
|
149
151
|
...(opts.branches?.(served) ? { branches: opts.branches(served)! } : {}),
|
|
150
152
|
...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin!(served), originOf: (other: string) => opts.browserOrigin!(other) } : {}),
|
|
151
|
-
...(opts.localTrust ? { localTrust: () => true } : {}),
|
|
153
|
+
...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
|
|
152
154
|
...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
|
|
153
155
|
...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers! } : {}),
|
|
154
156
|
// a local twin is its own process: the wire forwards to the port it listens on
|
|
@@ -334,6 +336,8 @@ export type DoorHost = {
|
|
|
334
336
|
* loopback name, is given the World's session without a token (`localSession`). A remote host leaves it absent,
|
|
335
337
|
* and its pages sign in with a token. */
|
|
336
338
|
localTrust?: () => boolean;
|
|
339
|
+
/** whom this machine's own page acts as: the person `volter login` signed in here, if any ("Who acted is recorded") */
|
|
340
|
+
localPerson?: () => string | undefined;
|
|
337
341
|
/** The platforms whose passes open this World (docs/contributing/architecture.md, "The hosted product"): a person a
|
|
338
342
|
* trusted platform signed a pass for is given a session of the pass's scope. Absent, or empty, no pass opens it. */
|
|
339
343
|
passIssuers?: () => TrustedIssuer[];
|
|
@@ -415,13 +419,13 @@ export class WorldDoors {
|
|
|
415
419
|
}
|
|
416
420
|
/** A new session of `scope`: its id, the cookie's value. A token's lasts 30 days; a person's from a pass, `who`,
|
|
417
421
|
* lasts as long as the pass's platform says (twelve hours), so a person removed there loses it within the day. */
|
|
418
|
-
private openSession(scope: 'read' | 'write', person?: { who: string; ttlMs: number; issuer
|
|
422
|
+
private openSession(scope: 'read' | 'write', person?: { who: string; ttlMs: number; issuer?: string; sub?: string }, key?: { id: string; until: number }, local?: boolean): string {
|
|
419
423
|
const id = mintToken('ses_');
|
|
420
424
|
let held = this.sessions();
|
|
421
425
|
// a person holds at most five sessions here: passes (anyone's with one) never crowd out everyone else's
|
|
422
426
|
if (person) { const theirs = Object.entries(held).filter(([, x]) => x.who === person.who).sort(([, a], [, b]) => b.until - a.until); if (theirs.length >= 5) { const drop = new Set(theirs.slice(4).map(([k]) => k)); held = Object.fromEntries(Object.entries(held).filter(([k]) => !drop.has(k))); } }
|
|
423
427
|
const until = Math.min(Date.now() + (person?.ttlMs ?? 30 * 86_400_000), key?.until ?? Infinity);
|
|
424
|
-
this.keepSessions({ ...held, [id]: { scope, until, ...(person ? { who: person.who, issuer: person.issuer, ...(person.sub ? { sub: person.sub } : {}) } : {}), ...(key ? { key: key.id } : {}) } });
|
|
428
|
+
this.keepSessions({ ...held, [id]: { scope, until, ...(local ? { local: true as const } : {}), ...(person ? { who: person.who, ...(person.issuer ? { issuer: person.issuer } : {}), ...(person.sub ? { sub: person.sub } : {}) } : {}), ...(key ? { key: key.id } : {}) } });
|
|
425
429
|
return id;
|
|
426
430
|
}
|
|
427
431
|
/** NAMED KEYS: what an app or a script holds (docs/contributing/architecture.md, "The hosted product"), each named
|
|
@@ -443,6 +447,9 @@ export class WorldDoors {
|
|
|
443
447
|
* revoke every key made by or for a person (their leaving the org). */
|
|
444
448
|
/** The person a key is for: whom it was made for, else the person whose session made it. */
|
|
445
449
|
private keyPerson(k: HeldKey): string | undefined { return k.for ?? (k.createdBy?.via === 'session' ? k.createdBy.sub : undefined); }
|
|
450
|
+
/** The person a key acts for, as attribution names them ("Who acted is recorded"): the name it was made for, else the
|
|
451
|
+
* person whose session made it, as their session names them (a pass's email, this machine's person). */
|
|
452
|
+
private keyName(k: HeldKey): string | undefined { return k.forName ?? k.for ?? (k.createdBy?.via === 'session' ? k.createdBy.who ?? k.createdBy.sub : undefined); }
|
|
446
453
|
/** Revoke the keys `pick` names, and what they reach: the branches whose parent key goes, and the branches (with their
|
|
447
454
|
* keys) a revoked key made. Answers the revoked keys' ids. */
|
|
448
455
|
private async revokeKeys(pick: (k: HeldKey) => boolean): Promise<string[]> {
|
|
@@ -462,21 +469,24 @@ export class WorldDoors {
|
|
|
462
469
|
if (via === 'key') return Response.json({ error: 'a key manages no keys: use the World\'s token, or its page' }, { status: 403 });
|
|
463
470
|
const now = Date.now();
|
|
464
471
|
const live = (): HeldKey[] => this.keys().filter((k) => !k.expiresAt || Date.parse(k.expiresAt) > now);
|
|
465
|
-
const view = (k: HeldKey) => ({ id: k.id, name: k.name, scope: k.scope, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt ?? null, expiresAt: k.expiresAt ?? null, for: k.for ?? null, person: this.
|
|
472
|
+
const view = (k: HeldKey) => ({ id: k.id, name: k.name, scope: k.scope, createdAt: k.createdAt, lastUsedAt: k.lastUsedAt ?? null, expiresAt: k.expiresAt ?? null, for: k.for ?? null, person: this.keyName(k) ?? null, branch: k.branch ?? null, createdBy: k.createdBy?.via === 'session' ? (k.createdBy.who ?? k.createdBy.sub ?? 'a person') : k.createdBy?.via === 'key' ? `the key ${k.createdBy.keyId}` : k.createdBy ? 'the World\'s token' : null });
|
|
466
473
|
const person = (k: HeldKey): string | undefined => this.keyPerson(k);
|
|
467
474
|
if (!id && request.method === 'GET') return Response.json({ keys: live().map(view) });
|
|
468
475
|
if (!id && request.method === 'POST') {
|
|
469
|
-
const body = (await request.json().catch(() => ({}))) as { name?: unknown; scope?: unknown; for?: unknown; expiresAt?: unknown; replace?: unknown };
|
|
476
|
+
const body = (await request.json().catch(() => ({}))) as { name?: unknown; scope?: unknown; for?: unknown; forName?: unknown; expiresAt?: unknown; replace?: unknown };
|
|
470
477
|
const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
|
|
471
478
|
if (!name) return Response.json({ error: 'a key needs a name: what holds it (an app, a CI job, a laptop)' }, { status: 400 });
|
|
479
|
+
// a key's name is what holds it, never whom it is for: the caller a World records reads `key:<name> for:<person>`
|
|
480
|
+
if (/\sfor:/i.test(name)) return Response.json({ error: 'a key\'s name says what holds it; whom it is for is its own field' }, { status: 400 });
|
|
472
481
|
const keyScope = body.scope === 'read' ? 'read' : 'write';
|
|
473
482
|
// a session a platform's pass opened makes no keys: a key outlives the session, and a script on this origin (a
|
|
474
483
|
// stored page) could make one in the person's name. Keys for a platform's Worlds are made on the platform.
|
|
475
484
|
const opener = via === 'session' && session ? this.sessions()[session] : undefined;
|
|
476
485
|
if (opener?.issuer) return Response.json({ error: 'a key for this World is made on the platform it was opened from, not from a browser session here', platform: opener.issuer }, { status: 403 });
|
|
477
486
|
// `for`, `replace` and an end are the token's to give (a platform making a command's key for a person)
|
|
478
|
-
if ((body.for !== undefined || body.replace !== undefined) && via !== 'token') return Response.json({ error: 'for and replace are the World token\'s' }, { status: 403 });
|
|
487
|
+
if ((body.for !== undefined || body.forName !== undefined || body.replace !== undefined) && via !== 'token') return Response.json({ error: 'for and replace are the World token\'s' }, { status: 403 });
|
|
479
488
|
const forWhom = typeof body.for === 'string' && /^[A-Za-z0-9:._@-]{1,160}$/.test(body.for) ? body.for : undefined;
|
|
489
|
+
const forName = forWhom && typeof body.forName === 'string' && /^[A-Za-z0-9:._@+ -]{1,160}$/.test(body.forName) ? body.forName : undefined;
|
|
480
490
|
let expiresAt: string | undefined;
|
|
481
491
|
if (body.expiresAt !== undefined) { const t = typeof body.expiresAt === 'string' ? Date.parse(body.expiresAt) : NaN; if (!(t > now)) return Response.json({ error: 'expiresAt: an ISO instant in the future' }, { status: 400 }); expiresAt = new Date(t).toISOString(); }
|
|
482
492
|
// replace: the key of the same name for the same person goes (one command, one key: asking again does not pile them up)
|
|
@@ -485,7 +495,7 @@ export class WorldDoors {
|
|
|
485
495
|
const opened = via === 'session' && session ? this.sessions()[session] : undefined;
|
|
486
496
|
const createdBy: HeldKey['createdBy'] = via === 'session' ? { via: 'session', ...(opened?.sub ? { sub: opened.sub } : {}), ...(opened?.who ? { who: opened.who } : {}), ...(opened?.issuer ? { issuer: opened.issuer } : {}) } : { via: 'token' };
|
|
487
497
|
const key = mintToken('tok_k_');
|
|
488
|
-
const held: HeldKey = { id: mintToken('key_').slice(0, 20), name, scope: keyScope, hash: keyHash(key), createdAt: new Date(now).toISOString(), createdBy, ...(forWhom ? { for: forWhom } : {}), ...(expiresAt ? { expiresAt } : {}) };
|
|
498
|
+
const held: HeldKey = { id: mintToken('key_').slice(0, 20), name, scope: keyScope, hash: keyHash(key), createdAt: new Date(now).toISOString(), createdBy, ...(forWhom ? { for: forWhom } : {}), ...(forName ? { forName } : {}), ...(expiresAt ? { expiresAt } : {}) };
|
|
489
499
|
this.keepKeys([...kept, held]);
|
|
490
500
|
return Response.json({ ...view(held), key }, { status: 201, headers: { 'cache-control': 'no-store' } });
|
|
491
501
|
}
|
|
@@ -627,10 +637,22 @@ export class WorldDoors {
|
|
|
627
637
|
private callerOf(request: Request): string | null {
|
|
628
638
|
const cred = this.credential(request);
|
|
629
639
|
if (cred.scope === null) return null;
|
|
630
|
-
|
|
631
|
-
if (cred.via === '
|
|
640
|
+
// this machine's own page with no one signed in here is this machine; another session with no name is just that
|
|
641
|
+
if (cred.via === 'session') return cred.who ? `person:${cred.who}` : cred.session && this.sessions()[cred.session]?.local ? 'machine' : 'person';
|
|
642
|
+
// a key acts as itself, for its person (docs/contributing/architecture.md, "Who acted is recorded")
|
|
643
|
+
if (cred.via === 'key') { const held = this.keyOf(cred.token); const person = held ? this.keyName(held) : undefined; return held ? `key:${held.name}${person ? ` for:${person}` : ''}` : 'key'; }
|
|
632
644
|
return cred.scope === 'read' ? 'token:read' : 'token';
|
|
633
645
|
}
|
|
646
|
+
/** The person a request acts for: a session's person, or the person a key is for; none for this machine's page or a
|
|
647
|
+
* World's own token. What lands in a World others read must name one. */
|
|
648
|
+
private personOf(request: Request): string | undefined {
|
|
649
|
+
const cred = this.credential(request);
|
|
650
|
+
if (cred.scope === null) return undefined;
|
|
651
|
+
if (cred.via === 'session') return cred.who;
|
|
652
|
+
if (cred.via === 'key') { const held = this.keyOf(cred.token); return held ? this.keyName(held) : undefined; }
|
|
653
|
+
return undefined;
|
|
654
|
+
}
|
|
655
|
+
private keyOf(token: string | null): HeldKey | undefined { return token ? this.keys().find((k) => sameSecret(keyHash(token), k.hash)) : undefined; }
|
|
634
656
|
/** The session cookie's name where `url` reached this World, or null where a session opens nothing:
|
|
635
657
|
* on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
|
|
636
658
|
* the path-scoped cookie a shared origin has always carried. */
|
|
@@ -1066,10 +1088,12 @@ export class WorldDoors {
|
|
|
1066
1088
|
}
|
|
1067
1089
|
if (kind === 'changesets' && request.method === 'GET') {
|
|
1068
1090
|
// with the body's hash as it stands, so a reader sees an approval or a verification made on other bytes as stale
|
|
1069
|
-
if (name) { const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name }); return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0] }); }
|
|
1091
|
+
if (name) { const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name }); return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0], readiness: changesetReadiness(l.changeset, worldChangesetRules(this.worldRoot)) }); }
|
|
1070
1092
|
const all = listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset);
|
|
1071
1093
|
const deployed = this.deployedOf(all);
|
|
1072
|
-
|
|
1094
|
+
const rules = worldChangesetRules(this.worldRoot);
|
|
1095
|
+
// readiness under this World's rules, as deploy reads it: the console says what the kernel decides
|
|
1096
|
+
return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i], readiness: changesetReadiness(c, rules) })) });
|
|
1073
1097
|
}
|
|
1074
1098
|
if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
|
|
1075
1099
|
const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));
|
|
@@ -1083,7 +1107,8 @@ export class WorldDoors {
|
|
|
1083
1107
|
const held = via === 'session' ? this.sessions()[this.credential(request).session ?? ''] : undefined;
|
|
1084
1108
|
const named = typeof body.as === 'string' && body.as.trim() ? body.as.trim().slice(0, 80) : undefined;
|
|
1085
1109
|
const tokenSession = held !== undefined && !held.who && held.key === undefined && held.scope === 'write';
|
|
1086
|
-
|
|
1110
|
+
// a key approves as itself, for its person, whatever it says: it cannot be several approvers by naming several
|
|
1111
|
+
const principal = via === 'session' ? held?.who ?? (this.host.localTrust?.() ? 'this machine' : tokenSession ? named : undefined) : via === 'key' ? this.callerOf(request) ?? undefined : named;
|
|
1087
1112
|
if (!principal) return Response.json({ error: via === 'session' && !tokenSession ? 'this session names no person to sign as: open the World from its platform, or approve with its token' : 'approve: { as, note? } — name who approves' }, { status: via === 'session' && !tokenSession ? 403 : 400 });
|
|
1088
1113
|
return Response.json(approveWorldChangeset(name, { root: this.worldRoot, world: this.name, principal, ...(body.note ? { note: body.note } : {}) }));
|
|
1089
1114
|
}
|
|
@@ -1189,11 +1214,40 @@ export class WorldDoors {
|
|
|
1189
1214
|
}));
|
|
1190
1215
|
}
|
|
1191
1216
|
}
|
|
1217
|
+
// THE RULES DOOR: how this World's changesets are made and readied (`changesets` in its committed config). Read with any
|
|
1218
|
+
// access; changed by a person, this machine or the World's token, never by a key: a key's holder (an app, an agent)
|
|
1219
|
+
// does not relax the review its own changes go through
|
|
1220
|
+
if (kind === 'rules' && !name) {
|
|
1221
|
+
const path = join(this.worldRoot, stateDirName(), 'world.json');
|
|
1222
|
+
if (request.method === 'GET') return Response.json({ world: this.served, changesets: worldChangesetRules(this.worldRoot) });
|
|
1223
|
+
if (request.method === 'PUT') {
|
|
1224
|
+
if (scope !== 'write') return Response.json({ error: 'the rules are changed with write access' }, { status: 403 });
|
|
1225
|
+
if (via === 'key') return Response.json({ error: 'a key changes no rules: a person sets them, or the World\'s token' }, { status: 403 });
|
|
1226
|
+
const body = (await request.json().catch(() => null)) as { changesets?: unknown } | null;
|
|
1227
|
+
try { assertChangesetRules(body?.changesets, path); } catch (error) { return Response.json({ error: error instanceof Error ? error.message.replace(` in ${path}`, '') : String(error) }, { status: 400 }); }
|
|
1228
|
+
const { config } = loadWorldConfig(path, this.worldRoot);
|
|
1229
|
+
const rules = body!.changesets as WorldChangesetRules;
|
|
1230
|
+
const next = { ...config, changesets: { ...(rules.approvals === undefined ? {} : { approvals: rules.approvals }), ...(rules.summary === undefined ? {} : { summary: rules.summary }) } };
|
|
1231
|
+
writeWorldConfig(path, next);
|
|
1232
|
+
return Response.json({ world: this.served, changesets: next.changesets });
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1192
1235
|
if (kind === 'changesets' && !name && request.method === 'POST') {
|
|
1193
1236
|
const body = (await request.json().catch(() => ({}))) as { name?: string; message?: string };
|
|
1194
1237
|
const slug = body.message ? body.message.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) : '';
|
|
1195
|
-
|
|
1196
|
-
|
|
1238
|
+
// with no summary to name it, the next free `changeset-<n>`, as the command names one
|
|
1239
|
+
const taken = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset.name));
|
|
1240
|
+
let n = taken.size + 1; while (taken.has(`changeset-${n}`)) n += 1;
|
|
1241
|
+
const cut = body.name ?? (slug || `changeset-${n}`);
|
|
1242
|
+
// a cut says what it does and why, unless this World's rules take the summary generated from its changes
|
|
1243
|
+
if (!body.message?.trim() && worldChangesetRules(this.worldRoot).summary !== 'generated') return Response.json({ error: 'a cut needs its summary: what it does and why' }, { status: 400 });
|
|
1244
|
+
const cutBy = this.callerOf(request) ?? undefined; const owner = this.personOf(request);
|
|
1245
|
+
return await this.serialized('push', async () => {
|
|
1246
|
+
// with no summary, a cut must have something to say: an empty one is a slip, refused as the command refuses it
|
|
1247
|
+
// (inside the lock, so two cuts at once never both take the same changes)
|
|
1248
|
+
if (!body.message?.trim() && diffWorld(this.name, { root: this.worldRoot }).actions.length === 0) return Response.json({ error: 'nothing to cut: every change is already in a changeset' }, { status: 400 });
|
|
1249
|
+
return Response.json(createWorldChangeset(this.name, cut, { root: this.worldRoot, ...(body.message?.trim() ? { message: body.message.trim() } : {}), ...(cutBy ? { cutBy } : {}), ...(owner ? { owner } : {}) }));
|
|
1250
|
+
});
|
|
1197
1251
|
}
|
|
1198
1252
|
// THE BLOBS DOOR: a twin's resource payloads (uploaded media, file bytes) by their key under its
|
|
1199
1253
|
// resources directory. A push sends the ones its branch holds and this World lacks before the
|
|
@@ -1339,7 +1393,9 @@ export class WorldDoors {
|
|
|
1339
1393
|
private localSession(url: URL): Response {
|
|
1340
1394
|
const own = this.host.browserOrigin?.() ?? null;
|
|
1341
1395
|
if (this.sessionCookieName(url) === null) return Response.json({ error: `${this.served}'s browser session lives at its own origin`, origin: own }, { status: 409 });
|
|
1342
|
-
|
|
1396
|
+
// this machine's page acts as the person signed in here, when one is (`volter login`); else as this machine
|
|
1397
|
+
const person = this.host.localPerson?.();
|
|
1398
|
+
return Response.json({ world: this.served, scope: 'write', local: true, ...(person ? { who: person } : {}), ...(own ? { origin: own } : {}) }, { headers: { 'set-cookie': this.sessionCookie(url, this.openSession('write', person ? { who: person, ttlMs: 30 * 86_400_000 } : undefined, undefined, true))! } });
|
|
1343
1399
|
}
|
|
1344
1400
|
|
|
1345
1401
|
private session(request: Request, url: URL, scope: 'read' | 'write', key?: { id: string; until: number }): Response {
|
|
@@ -1571,9 +1627,17 @@ export class WorldDoors {
|
|
|
1571
1627
|
|
|
1572
1628
|
/** PUSH: see `landChangeset` — the door hands the body to it. */
|
|
1573
1629
|
private async push(request: Request): Promise<Response> {
|
|
1630
|
+
// who pushed it is recorded, a person or a service (a key, the World's token): an automation pushes as itself
|
|
1631
|
+
// (docs/contributing/architecture.md, "Who acted is recorded")
|
|
1632
|
+
const person = this.personOf(request);
|
|
1574
1633
|
const body = (await request.json()) as Changeset | { changeset: Changeset; expect?: Record<string, HistoryReference> };
|
|
1575
|
-
const
|
|
1576
|
-
if (!
|
|
1634
|
+
const sent = 'changeset' in body && !('actions' in body) ? body.changeset : (body as Changeset);
|
|
1635
|
+
if (!sent || !Array.isArray(sent.actions)) return Response.json({ error: 'push: a changeset object' }, { status: 400 });
|
|
1636
|
+
// who pushed it, and its owner when it came with none and the pusher names a person: outside its hash, so nothing an
|
|
1637
|
+
// approval bound to moves
|
|
1638
|
+
const pushedBy = this.callerOf(request) ?? undefined;
|
|
1639
|
+
const owner = sent.owner ?? person;
|
|
1640
|
+
const changeset: Changeset = { ...sent, ...(owner ? { owner } : {}), ...(pushedBy ? { pushedBy } : {}) };
|
|
1577
1641
|
this.refresh();
|
|
1578
1642
|
// fast-forward only: the client says where it last saw each twin's log (its fetch cursor); a
|
|
1579
1643
|
// twin that moved past it refuses with the drift — fetch, rebase, push again
|
|
@@ -1583,7 +1647,9 @@ export class WorldDoors {
|
|
|
1583
1647
|
const receipts = await landChangeset(this.name, this.worldRoot, this.up(), changeset, expect);
|
|
1584
1648
|
const position = Object.fromEntries([...new Set(changeset.actions.map((a) => a.service))].map((twin) => [twin, positionOf(twin)]));
|
|
1585
1649
|
const snapshots = Object.fromEntries(Object.keys(position).map(twin => { const head = captureHistory(this.stateOf(twin), this.controlRoot(twin)); return [twin, { view: head.view, position: head.position }]; }));
|
|
1586
|
-
|
|
1650
|
+
// who owns and pushed it, as this World keeps it: a changeset landed before keeps its first record
|
|
1651
|
+
const kept = ((): Changeset | null => { try { return JSON.parse(getActiveWorldStore().read(join(worldChangesetsDir(this.name, this.worldRoot), `${changeset.name}.json`)) ?? 'null') as Changeset | null; } catch { return null; } })();
|
|
1652
|
+
return Response.json({ world: this.served, changeset: changeset.name, receipts, position, snapshots, ...(kept?.owner ? { owner: kept.owner } : {}), ...(kept?.pushedBy ? { pushedBy: kept.pushedBy } : {}) });
|
|
1587
1653
|
} catch (error) {
|
|
1588
1654
|
return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
|
|
1589
1655
|
}
|
package/src/world-view.ts
CHANGED
|
@@ -28,7 +28,7 @@ function branchRoots(dir: string): string[] {
|
|
|
28
28
|
return out.sort();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export async function serveWorldView(name: string, opts: { root?: string; port?: number; host?: string; console?: ViewConsole; announce?: (line: string) => void; /** each World a browser steps into at an origin of its own (on by default on loopback) */ origins?: boolean; /** the World's own origin as a browser reaches it through a proxy on this machine (a desktop serving it as an app):
|
|
31
|
+
export async function serveWorldView(name: string, opts: { /** the person signed in on this machine (`volter login`): this World's own page acts as them */ localPerson?: string; root?: string; port?: number; host?: string; console?: ViewConsole; announce?: (line: string) => void; /** each World a browser steps into at an origin of its own (on by default on loopback) */ origins?: boolean; /** the World's own origin as a browser reaches it through a proxy on this machine (a desktop serving it as an app):
|
|
32
32
|
* a request the proxy forwards from that host (X-Forwarded-Host) is answered as at that origin, its local session included */ origin?: string } = {}): Promise<WorldView> {
|
|
33
33
|
const root = resolve(opts.root ?? process.cwd());
|
|
34
34
|
const host = opts.host ?? '127.0.0.1';
|
|
@@ -49,7 +49,7 @@ export async function serveWorldView(name: string, opts: { root?: string; port?:
|
|
|
49
49
|
// (the first World mounted is the World; every later one is a branch of it)
|
|
50
50
|
let base: string | null = null;
|
|
51
51
|
const browserOrigin = origins ? (served: string) => (proxied && (base === null || served === base) ? proxied.origin : port ? localWorldOrigin(served, port) : null) : undefined;
|
|
52
|
-
const mountAt = (at: string): Promise<MountedWorld> => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}) });
|
|
52
|
+
const mountAt = (at: string): Promise<MountedWorld> => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}) });
|
|
53
53
|
const branches: LocalBranches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
|
|
54
54
|
// THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
|
|
55
55
|
// same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
|
|
@@ -80,7 +80,7 @@ export async function serveWorldView(name: string, opts: { root?: string; port?:
|
|
|
80
80
|
if (branch === loadWorldConfig(join(root, stateDirName(), 'world.json'), root).config.id) rmSync(current, { force: true }); else writeFileSync(current, `${branch}\n`);
|
|
81
81
|
} finally { switching = false; }
|
|
82
82
|
};
|
|
83
|
-
const mountOpts = { root, branches: (served: string) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}), serveBranch };
|
|
83
|
+
const mountOpts = { root, branches: (served: string) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
|
|
84
84
|
let world = await mountWorld(name, mountOpts);
|
|
85
85
|
base = world.served;
|
|
86
86
|
worlds.set(world.served, world);
|