@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/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
+ }
@@ -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 { WorldInstance } from './schema.ts';
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: string; sub?: string }, key?: { id: string; until: number }): string {
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.keyPerson(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 });
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
- if (cred.via === 'session') return cred.who ? `person:${cred.who}` : 'person';
631
- if (cred.via === 'key') { const token = cred.token; const held = token ? this.keys().find((k) => sameSecret(keyHash(token), k.hash)) : undefined; return held ? `key:${held.name}` : 'key'; }
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
- return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i] })) });
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
- const principal = via === 'session' ? held?.who ?? (this.host.localTrust?.() ? 'this machine' : tokenSession ? named : undefined) : named;
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
- const cut = body.name ?? (slug || `changeset-${Date.now()}`);
1196
- return Response.json(await this.serialized('push', async () => createWorldChangeset(this.name, cut, { root: this.worldRoot, ...(body.message ? { message: body.message } : {}) })));
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
- return Response.json({ world: this.served, scope: 'write', local: true, ...(own ? { origin: own } : {}) }, { headers: { 'set-cookie': this.sessionCookie(url, this.openSession('write'))! } });
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 changeset = 'changeset' in body && !('actions' in body) ? body.changeset : (body as Changeset);
1576
- if (!changeset || !Array.isArray(changeset.actions)) return Response.json({ error: 'push: a changeset object' }, { status: 400 });
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
- return Response.json({ world: this.served, changeset: changeset.name, receipts, position, snapshots });
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);