@volter/world-runtime 2.0.50 → 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.
@@ -48,7 +48,9 @@ export declare function createWorldChangeset(world: string, name: string, option
48
48
  verifiers?: ChangesetVerifier[];
49
49
  now?: Date;
50
50
  overwrite?: boolean;
51
- message?: string;
51
+ message?: string; /** who cuts it and the person it is owned by, from the cutting grant (a served World's door) */
52
+ cutBy?: string;
53
+ owner?: string;
52
54
  }): Changeset;
53
55
  /** Every changeset in every world under `root` (or one world with `world`), newest first. */
54
56
  export declare function listWorldChangesets(options?: WorldRootOptions & {
@@ -1,3 +1,4 @@
1
+ import { worldChangesetRules } from "./configs.js";
1
2
  import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, getActiveWorldStore, withAncestryLock, worldPaths } from '@volter/world-core';
2
3
  // `volter-world mark` / `diff` / `changeset` — the WORLD-side verbs over the changeset
3
4
  // primitive (docs/concepts/the-model.md v0). Thin, exactly like `covers` and `tail` are thin: all
@@ -183,6 +184,8 @@ export function createWorldChangeset(world, name, options = {}) {
183
184
  ...(options.verifiers ? { verifiers: options.verifiers } : {}),
184
185
  ...(options.now ? { createdAt: options.now.toISOString() } : {}),
185
186
  ...(options.message === undefined ? {} : { message: options.message }),
187
+ ...(options.cutBy === undefined ? {} : { cutBy: options.cutBy }),
188
+ ...(options.owner === undefined ? {} : { owner: options.owner }),
186
189
  });
187
190
  getActiveWorldStore().write(path, `${JSON.stringify(changeset, null, 2)}\n`);
188
191
  // a changeset records where the log was cut (contract "The model"): the next one starts here
@@ -373,7 +376,7 @@ export function approveWorldChangeset(name, options) {
373
376
  export function statusWorldChangeset(name, options = {}) {
374
377
  const root = resolveRoot(options.root);
375
378
  const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
376
- return changesetReadiness(located.changeset);
379
+ return changesetReadiness(located.changeset, worldChangesetRules(root));
377
380
  }
378
381
  export async function pushWorldChangeset(name, options) {
379
382
  // PUSH (docs/concepts/the-model.md): the changeset goes to the served world's push door; its
@@ -541,7 +544,8 @@ export async function pushWorldChangeset(name, options) {
541
544
  const receipts = body.receipts.map((r) => ({ actionId: r.actionId, service: r.twin, stateService: located.changeset.actions.find((e) => e.service === r.twin)?.stateService ?? r.twin, status: statusOf(r.status), ...(r.externalId ? { externalId: r.externalId } : {}), ...(r.reason ? { error: r.reason } : {}) }));
542
545
  const outcome = receipts.every((r) => r.status !== 'failed') ? 'applied' : receipts.some((r) => r.status === 'confirmed') ? 'partial' : 'refused';
543
546
  const application = { at, contentHash: located.changeset.contentHash, outcome, receipts, compensation: [], ...(outcome === 'refused' ? { refusal: receipts.filter((r) => r.error).map((r) => r.error) } : {}) };
544
- const changeset = withApplication(located.changeset, application);
547
+ // the remote's record of who pushed it and who owns it, kept on this copy too (outside the hash)
548
+ const changeset = { ...withApplication(located.changeset, application), ...(body.owner && !located.changeset.owner ? { owner: body.owner } : {}), ...(body.pushedBy ? { pushedBy: body.pushedBy } : {}) };
545
549
  writeChangesetFile(located.path, changeset);
546
550
  const unsent = namedFailure ? `records the landed entries name did not reach the origin (${namedFailure instanceof Error ? namedFailure.message : String(namedFailure)}); the next push from this branch that touches ${twins.map(([t]) => t).join(', ')} sends them` : undefined;
547
551
  return { ...(unsent ? { unsent } : {}), application, changeset, world: located.world, path: located.path, remote: { url, namespace } };
@@ -1,4 +1,4 @@
1
- import { type WorldConfig } from './schema.js';
1
+ import { type WorldChangesetRules, type WorldConfig } from './schema.js';
2
2
  export declare function resolveConfigPath(config: string, root?: string): string;
3
3
  export declare function loadWorldConfig(config: string, root?: string): {
4
4
  path: string;
@@ -16,3 +16,6 @@ export type WorldConfigMigration = {
16
16
  };
17
17
  /** Explicit, backed-up, atomic format-1 → format-2 migration. Ordinary reads never call this. */
18
18
  export declare function migrateWorldConfig(config: string, root?: string): WorldConfigMigration;
19
+ /** The changeset rules of the World rooted at `root` (its committed `.volter/world.json`, `changesets`); none when it
20
+ * has no config or names none, which readiness reads as one approval and a required summary. */
21
+ export declare function worldChangesetRules(root: string): WorldChangesetRules;
@@ -1,6 +1,6 @@
1
1
  import { copyFileSync, constants, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
- import { basename, dirname, resolve } from 'node:path';
3
- import { getActiveWorldStore } from '@volter/world-core';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { getActiveWorldStore, stateDirName } from '@volter/world-core';
4
4
  import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument } from "./schema.js";
5
5
  export function resolveConfigPath(config, root = process.cwd()) {
6
6
  const candidates = config.includes('/') || config.endsWith('.json')
@@ -117,3 +117,16 @@ export function migrateWorldConfig(config, root = process.cwd()) {
117
117
  dropped: normalized.resources === undefined ? [] : ['resources (deprecated; retained in backup)'],
118
118
  };
119
119
  }
120
+ /** The changeset rules of the World rooted at `root` (its committed `.volter/world.json`, `changesets`); none when it
121
+ * has no config or names none, which readiness reads as one approval and a required summary. */
122
+ export function worldChangesetRules(root) {
123
+ const path = join(root, stateDirName(), 'world.json');
124
+ if (!getActiveWorldStore().exists(path) && !existsSync(path))
125
+ return {};
126
+ try {
127
+ return loadWorldConfig(path, root).config.changesets ?? {};
128
+ }
129
+ catch {
130
+ return {};
131
+ }
132
+ }
@@ -194,6 +194,10 @@ const CREDENTIAL_SHAPES = [
194
194
  { match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
195
195
  { match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
196
196
  { match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
197
+ // The slack twin answers a bearer that is none of Slack's token kinds with `invalid_auth`, as Slack does; any `xoxb-`
198
+ // acts as the workspace bot, and an app-level token is `xapp-`.
199
+ { match: /(^|_)SLACK_[A-Z0-9_]*APP_TOKEN$/, value: () => 'xapp-1-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/)' },
200
+ { match: /(^|_)SLACK_([A-Z0-9_]*_)?BOT_TOKEN$|(^|_)SLACK_TOKEN$/, value: () => 'xoxb-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/); GET /twin identity (any other xoxb-* is the workspace bot)' },
197
201
  // tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
198
202
  { match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'packages/twin/tavily/src/tavily-auth.ts' },
199
203
  { match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'packages/twin/firecrawl/src/firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
@@ -1,6 +1,7 @@
1
1
  export type { WorldConfig, WorldInstance, WorldIsolation, WorldMode, WorldServiceConfig, WorldServiceType, WorldExternalServiceConfig, WorldExternalDiscover, WorldExternalDiscoverSource, WorldExternalReadyWhen, WorldRunOutcome, WorldRunRecord, WorldResourceRequirements, WorldManifestV2, WorldSelection, WorldSelectionSelector, WorldUsage, } from './schema.js';
2
2
  export { DEFAULT_WORLD_SELECTION, LEGACY_WORLD_SELECTION, WORLD_SCHEMA_VERSION, WORLD_USAGES, worldConfigDocument, worldSelectionIncludes } from './schema.js';
3
- export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, writeWorldConfig } from './configs.js';
3
+ export type { WorldChangesetRules } from './schema.js';
4
+ export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, worldChangesetRules, writeWorldConfig } from './configs.js';
4
5
  export type { WorldConfigMigration } from './configs.js';
5
6
  export { upWorld, downWorld, pruneWorlds, listWorlds, statusWorld, doctorWorld, runWithWorldEnv, attachWorld, retireWorldConsumers, activateScript, shellWorld, worldShellEnv, ensureWorldProxy, runProxyDaemon, runWorld, shareWorld, shareWorldServices, unshareWorld, urlsWorld, urlWorld, } from './runtime.js';
6
7
  export type { WorldCleanupEntry } from './runtime.js';
package/dist/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { DEFAULT_WORLD_SELECTION, LEGACY_WORLD_SELECTION, WORLD_SCHEMA_VERSION, WORLD_USAGES, worldConfigDocument, worldSelectionIncludes } from "./schema.js";
2
- export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, writeWorldConfig } from "./configs.js";
2
+ export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, worldChangesetRules, writeWorldConfig } from "./configs.js";
3
3
  export { upWorld, downWorld, pruneWorlds, listWorlds, statusWorld, doctorWorld, runWithWorldEnv, attachWorld, retireWorldConsumers, activateScript, shellWorld, worldShellEnv, ensureWorldProxy, runProxyDaemon, runWorld, shareWorld, shareWorldServices, unshareWorld, urlsWorld, urlWorld, } from "./runtime.js";
4
4
  export { worldResourceReport } from "./resources.js";
5
5
  // `volter-world app-url` — the recorded, queryable app endpoint of an instance (see app-url.ts).
package/dist/src/root.js CHANGED
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
12
12
  import { homedir } from 'node:os';
13
13
  import { dirname, join, relative, resolve } from 'node:path';
14
14
  import { authStrategyFor, currentSealer, destinationOf, sealsThroughVault, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, readRoot, registerStateSystem, stateDirName, stateSystemFor, validateRemoteOrigin, writeRoot, } from '@volter/world-core';
15
- import { loadWorldConfig, writeWorldConfig } from "./configs.js";
15
+ import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from "./configs.js";
16
16
  import { changesetReadiness, collectObservations, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding } from '@volter/world-core';
17
17
  import { findWorldChangeset, listWorldChangesets } from "./changeset.js";
18
18
  import { importModule } from "./import-module.js";
@@ -577,7 +577,7 @@ export async function deployWorld(name, opts = {}) {
577
577
  const out = [];
578
578
  if (!opts.changeset) {
579
579
  // no name: every ready changeset, by name, in order — so each outcome says which it was for
580
- const ready = listWorldChangesets({ root: worldRoot, world: name }).filter((l) => changesetReadiness(l.changeset).ready && !l.changeset.actions.every((e) => { const r = rootForControlRoot(join(inst.dirs.data, e.service), e.service); return r === null; })).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
580
+ const ready = listWorldChangesets({ root: worldRoot, world: name }).filter((l) => changesetReadiness(l.changeset, worldChangesetRules(worldRoot)).ready && !l.changeset.actions.every((e) => { const r = rootForControlRoot(join(inst.dirs.data, e.service), e.service); return r === null; })).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
581
581
  const anyGated = Object.keys(inst.services).some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
582
582
  if (anyGated || ready.length) {
583
583
  for (const l of ready)
@@ -589,7 +589,7 @@ export async function deployWorld(name, opts = {}) {
589
589
  const located = opts.changeset ? [findWorldChangeset(opts.changeset, { root: worldRoot, world: name })] : listWorldChangesets({ root: worldRoot, world: name });
590
590
  const ready = new Map();
591
591
  for (const l of located) {
592
- const readiness = changesetReadiness(l.changeset);
592
+ const readiness = changesetReadiness(l.changeset, worldChangesetRules(worldRoot));
593
593
  if (!readiness.ready) {
594
594
  const gated = [...new Set(l.changeset.actions.map((e) => e.service))].some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
595
595
  if (opts.changeset && gated)
@@ -51,6 +51,7 @@ export type WorldManifestV2 = {
51
51
  share?: WorldShareConfig;
52
52
  };
53
53
  remotes?: Record<string, string>;
54
+ changesets?: WorldChangesetRules;
54
55
  scenario?: {
55
56
  actors?: Record<string, unknown>;
56
57
  fixtures?: Record<string, unknown>;
@@ -268,8 +269,17 @@ export type WorldServiceConfig = {
268
269
  * catalog the world was born against. Exact version or `^major.minor` range. */
269
270
  version?: string;
270
271
  };
272
+ /** How this World's changesets are made and readied (docs/reference/config.md, `changesets`): how many people must
273
+ * approve one's current contents before it deploys, and whether a cut needs its author's summary or takes the one
274
+ * generated from its changes. A team's rules, committed with the World, as a repository's are. */
275
+ export type WorldChangesetRules = {
276
+ approvals?: number;
277
+ summary?: 'required' | 'generated';
278
+ };
271
279
  export type WorldConfig = {
272
280
  id: string;
281
+ /** How changesets are made and readied here; omitted: one approval, a summary required. */
282
+ changesets?: WorldChangesetRules;
273
283
  /** Real external origins; omitted preserves legacy routing, [] explicitly closes egress. */
274
284
  network?: WorldNetwork;
275
285
  /** Internal normalized source format and saved discovery selection. */
@@ -452,3 +462,5 @@ export declare function assertWorldConfig(value: unknown, path: string): WorldCo
452
462
  export declare function worldSelectionIncludes(selection: WorldSelection, vendor: string, usage: WorldUsage): boolean;
453
463
  /** Serialize normalized intent as the committed format-2 manifest. */
454
464
  export declare function worldConfigDocument(config: WorldConfig, selection?: WorldSelection): WorldManifestV2 & Record<string, unknown>;
465
+ /** A World's changeset rules, strictly: `approvals` a whole number 0–10, `summary` `required` or `generated`, nothing else. */
466
+ export declare function assertChangesetRules(rules: unknown, path: string): asserts rules is WorldChangesetRules;
@@ -132,6 +132,8 @@ function assertLegacyWorldConfig(value, path) {
132
132
  if (typeof target !== 'string' || target === '' || !/^[A-Za-z0-9._-]+$/.test(name))
133
133
  throw new Error(`World config remote "${name}" must be a non-empty url or path in ${path}`);
134
134
  }
135
+ if (config.changesets !== undefined)
136
+ assertChangesetRules(config.changesets, path);
135
137
  if (config.isolation !== undefined && config.isolation !== 'process' && config.isolation !== 'colocated' && config.isolation !== 'worker') {
136
138
  throw new Error(`World config isolation must be "process", "colocated", or "worker" in ${path}`);
137
139
  }
@@ -458,7 +460,7 @@ function serviceV2ToLegacy(value, path) {
458
460
  return legacy;
459
461
  }
460
462
  function normalizeV2(value, path) {
461
- assertKeys(value, ['schemaVersion', 'metadata', 'discovery', 'services', 'runtime', 'serving', 'remotes', 'scenario', 'provenance'], 'World manifest', path, true);
463
+ assertKeys(value, ['schemaVersion', 'metadata', 'discovery', 'services', 'runtime', 'serving', 'remotes', 'changesets', 'scenario', 'provenance'], 'World manifest', path, true);
462
464
  for (const name of ['metadata', 'discovery', 'runtime', 'serving', 'scenario', 'provenance'])
463
465
  if (value[name] !== undefined && !record(value[name]))
464
466
  throw new Error(`${name} must be an object in ${path}`);
@@ -515,6 +517,7 @@ function normalizeV2(value, path) {
515
517
  ...(mode === 'bare' ? { bare: { name: serving.name } } : {}),
516
518
  ...(serving.share === undefined ? {} : { share: serving.share }),
517
519
  ...(value.remotes === undefined ? {} : { remotes: value.remotes }),
520
+ ...(value.changesets === undefined ? {} : { changesets: value.changesets }),
518
521
  ...(scenario.actors === undefined ? {} : { actors: scenario.actors }),
519
522
  ...(scenario.fixtures === undefined ? {} : { fixtures: scenario.fixtures }),
520
523
  ...(provenance.catalog === undefined ? {} : { catalog: provenance.catalog }),
@@ -604,6 +607,7 @@ export function worldConfigDocument(config, selection = config.selection ?? DEFA
604
607
  ...(config.share === undefined ? {} : { share: config.share }),
605
608
  } }),
606
609
  ...(config.remotes === undefined ? {} : { remotes: config.remotes }),
610
+ ...(config.changesets === undefined ? {} : { changesets: config.changesets }),
607
611
  ...(config.actors === undefined && config.fixtures === undefined ? {} : { scenario: {
608
612
  ...(config.actors === undefined ? {} : { actors: config.actors }),
609
613
  ...(config.fixtures === undefined ? {} : { fixtures: config.fixtures }),
@@ -612,3 +616,16 @@ export function worldConfigDocument(config, selection = config.selection ?? DEFA
612
616
  ...Object.fromEntries(Object.entries(config).filter(([key]) => key.startsWith('//'))),
613
617
  };
614
618
  }
619
+ /** A World's changeset rules, strictly: `approvals` a whole number 0–10, `summary` `required` or `generated`, nothing else. */
620
+ export function assertChangesetRules(rules, path) {
621
+ if (typeof rules !== 'object' || rules === null || Array.isArray(rules))
622
+ throw new Error(`World config changesets must be an object in ${path}`);
623
+ for (const key of Object.keys(rules))
624
+ if (key !== 'approvals' && key !== 'summary')
625
+ throw new Error(`World config changesets has no "${key}" (approvals, summary) in ${path}`);
626
+ const { approvals, summary } = rules;
627
+ if (approvals !== undefined && !(Number.isInteger(approvals) && approvals >= 0 && approvals <= 10))
628
+ throw new Error(`World config changesets.approvals must be a whole number from 0 to 10 in ${path}`);
629
+ if (summary !== undefined && summary !== 'required' && summary !== 'generated')
630
+ throw new Error(`World config changesets.summary must be "required" or "generated" in ${path}`);
631
+ }
@@ -3,7 +3,7 @@ import { type TwinStreamConnection, type TwinStreamSink } from '@volter/world-co
3
3
  import { type TrustedIssuer } from '@volter/world-access';
4
4
  import { type Changeset, type Receipt } from '@volter/world-core';
5
5
  import { rootForControlRoot } from './root.js';
6
- import type { WorldInstance } from './schema.js';
6
+ import { type WorldInstance } from './schema.js';
7
7
  export declare const TOKEN_HEADER = "x-volter-token";
8
8
  /** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
9
9
  export declare const TWINS_KEY_HEADER = "x-twins-key";
@@ -63,7 +63,8 @@ export declare function mountWorld(name: string, opts?: {
63
63
  root?: string; /** the host's branches of this World, by its served name */
64
64
  branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */
65
65
  browserOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */
66
- localTrust?: boolean; /** DoorHost.serveBranch */
66
+ localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */
67
+ localPerson?: string; /** DoorHost.serveBranch */
67
68
  serveBranch?: (branch: string) => Promise<void>;
68
69
  }): Promise<MountedWorld>;
69
70
  /** Boot the world (state kept) and serve it on its own port. Announces and returns after boot; `stop` downs the world. */
@@ -138,6 +139,8 @@ export type DoorHost = {
138
139
  * loopback name, is given the World's session without a token (`localSession`). A remote host leaves it absent,
139
140
  * and its pages sign in with a token. */
140
141
  localTrust?: () => boolean;
142
+ /** whom this machine's own page acts as: the person `volter login` signed in here, if any ("Who acted is recorded") */
143
+ localPerson?: () => string | undefined;
141
144
  /** The platforms whose passes open this World (docs/contributing/architecture.md, "The hosted product"): a person a
142
145
  * trusted platform signed a pass for is given a session of the pass's scope. Absent, or empty, no pass opens it. */
143
146
  passIssuers?: () => TrustedIssuer[];
@@ -220,6 +223,9 @@ export declare class WorldDoors {
220
223
  * revoke every key made by or for a person (their leaving the org). */
221
224
  /** The person a key is for: whom it was made for, else the person whose session made it. */
222
225
  private keyPerson;
226
+ /** The person a key acts for, as attribution names them ("Who acted is recorded"): the name it was made for, else the
227
+ * person whose session made it, as their session names them (a pass's email, this machine's person). */
228
+ private keyName;
223
229
  /** Revoke the keys `pick` names, and what they reach: the branches whose parent key goes, and the branches (with their
224
230
  * keys) a revoked key made. Answers the revoked keys' ids. */
225
231
  private revokeKeys;
@@ -276,6 +282,10 @@ export declare class WorldDoors {
276
282
  * (`person` when it names no one), `token` / `token:read` for the World's own tokens; null when it presented nothing
277
283
  * this World issued. */
278
284
  private callerOf;
285
+ /** 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
286
+ * World's own token. What lands in a World others read must name one. */
287
+ private personOf;
288
+ private keyOf;
279
289
  /** The session cookie's name where `url` reached this World, or null where a session opens nothing:
280
290
  * on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
281
291
  * the path-scoped cookie a shared origin has always carried. */
@@ -31,13 +31,14 @@ import { APP_READ_ENDPOINT_ENV } from "./init.js";
31
31
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
32
32
  import { createHash } from 'node:crypto';
33
33
  import { dirname, join, resolve } from 'node:path';
34
- import { appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow, parentEntries, readBranchMeta, rebaseBranch, } from '@volter/world-core';
34
+ import { appendActionIfAbsent, branchEntries, branchLogPath, branchMetaPath, changesetContentHash, changesetReadiness, confirmAction, parentLogPath, readTree, stateDirName, wholeLog, worldNow, parentEntries, readBranchMeta, rebaseBranch, } from '@volter/world-core';
35
35
  import { approveWorldChangeset, createWorldChangeset, diffWorld, findWorldChangeset, listWorldChangesets, pushWorldChangeset, verifyWorldChangeset, worldChangesetsDir } from "./changeset.js";
36
36
  import { fetchFromOrigin } from "./origin.js";
37
37
  import { CONSOLE_BASE, consoleRedirect, serveConsoleApart } from "./console-apart.js";
38
- import { loadWorldConfig } from "./configs.js";
38
+ import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from "./configs.js";
39
39
  import { adaptersFor, credentialPath, packMirror, credentialPayloadFrom, deployTwin, deployWorld, loadWorldChecks, twinSigningSecret, materializeRoots, refreshPosture, refreshTwin, rootForControlRoot, rewrapTwinCredentials, sealTwinCredential, sealedCredentialInfo, bindLegacyCredentials, setTwinRoot, spanMs } from "./root.js";
40
40
  import { clockFile, downWorld, listWorlds, statusWorld, upWorld } from "./runtime.js";
41
+ import { assertChangesetRules } from "./schema.js";
41
42
  export const TOKEN_HEADER = 'x-volter-token';
42
43
  /** The header the injector carries a hosted World's token in (inject.cjs, VOLTER_TWINS_KEY). */
43
44
  export const TWINS_KEY_HEADER = 'x-twins-key';
@@ -142,7 +143,7 @@ export async function mountWorld(name, opts = {}) {
142
143
  clockPath: () => clockFile(worldRoot, name),
143
144
  ...(opts.branches?.(served) ? { branches: opts.branches(served) } : {}),
144
145
  ...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin(served), originOf: (other) => opts.browserOrigin(other) } : {}),
145
- ...(opts.localTrust ? { localTrust: () => true } : {}),
146
+ ...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
146
147
  ...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
147
148
  ...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers } : {}),
148
149
  // a local twin is its own process: the wire forwards to the port it listens on
@@ -423,7 +424,7 @@ export class WorldDoors {
423
424
  }
424
425
  /** A new session of `scope`: its id, the cookie's value. A token's lasts 30 days; a person's from a pass, `who`,
425
426
  * lasts as long as the pass's platform says (twelve hours), so a person removed there loses it within the day. */
426
- openSession(scope, person, key) {
427
+ openSession(scope, person, key, local) {
427
428
  const id = mintToken('ses_');
428
429
  let held = this.sessions();
429
430
  // a person holds at most five sessions here: passes (anyone's with one) never crowd out everyone else's
@@ -435,7 +436,7 @@ export class WorldDoors {
435
436
  }
436
437
  }
437
438
  const until = Math.min(Date.now() + (person?.ttlMs ?? 30 * 86_400_000), key?.until ?? Infinity);
438
- this.keepSessions({ ...held, [id]: { scope, until, ...(person ? { who: person.who, issuer: person.issuer, ...(person.sub ? { sub: person.sub } : {}) } : {}), ...(key ? { key: key.id } : {}) } });
439
+ this.keepSessions({ ...held, [id]: { scope, until, ...(local ? { local: true } : {}), ...(person ? { who: person.who, ...(person.issuer ? { issuer: person.issuer } : {}), ...(person.sub ? { sub: person.sub } : {}) } : {}), ...(key ? { key: key.id } : {}) } });
439
440
  return id;
440
441
  }
441
442
  /** NAMED KEYS: what an app or a script holds (docs/contributing/architecture.md, "The hosted product"), each named
@@ -466,6 +467,9 @@ export class WorldDoors {
466
467
  * revoke every key made by or for a person (their leaving the org). */
467
468
  /** The person a key is for: whom it was made for, else the person whose session made it. */
468
469
  keyPerson(k) { return k.for ?? (k.createdBy?.via === 'session' ? k.createdBy.sub : undefined); }
470
+ /** The person a key acts for, as attribution names them ("Who acted is recorded"): the name it was made for, else the
471
+ * person whose session made it, as their session names them (a pass's email, this machine's person). */
472
+ keyName(k) { return k.forName ?? k.for ?? (k.createdBy?.via === 'session' ? k.createdBy.who ?? k.createdBy.sub : undefined); }
469
473
  /** Revoke the keys `pick` names, and what they reach: the branches whose parent key goes, and the branches (with their
470
474
  * keys) a revoked key made. Answers the revoked keys' ids. */
471
475
  async revokeKeys(pick) {
@@ -491,7 +495,7 @@ export class WorldDoors {
491
495
  return Response.json({ error: 'a key manages no keys: use the World\'s token, or its page' }, { status: 403 });
492
496
  const now = Date.now();
493
497
  const live = () => this.keys().filter((k) => !k.expiresAt || Date.parse(k.expiresAt) > now);
494
- const view = (k) => ({ 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 });
498
+ const view = (k) => ({ 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 });
495
499
  const person = (k) => this.keyPerson(k);
496
500
  if (!id && request.method === 'GET')
497
501
  return Response.json({ keys: live().map(view) });
@@ -500,6 +504,9 @@ export class WorldDoors {
500
504
  const name = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
501
505
  if (!name)
502
506
  return Response.json({ error: 'a key needs a name: what holds it (an app, a CI job, a laptop)' }, { status: 400 });
507
+ // a key's name is what holds it, never whom it is for: the caller a World records reads `key:<name> for:<person>`
508
+ if (/\sfor:/i.test(name))
509
+ return Response.json({ error: 'a key\'s name says what holds it; whom it is for is its own field' }, { status: 400 });
503
510
  const keyScope = body.scope === 'read' ? 'read' : 'write';
504
511
  // a session a platform's pass opened makes no keys: a key outlives the session, and a script on this origin (a
505
512
  // stored page) could make one in the person's name. Keys for a platform's Worlds are made on the platform.
@@ -507,9 +514,10 @@ export class WorldDoors {
507
514
  if (opener?.issuer)
508
515
  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 });
509
516
  // `for`, `replace` and an end are the token's to give (a platform making a command's key for a person)
510
- if ((body.for !== undefined || body.replace !== undefined) && via !== 'token')
517
+ if ((body.for !== undefined || body.forName !== undefined || body.replace !== undefined) && via !== 'token')
511
518
  return Response.json({ error: 'for and replace are the World token\'s' }, { status: 403 });
512
519
  const forWhom = typeof body.for === 'string' && /^[A-Za-z0-9:._@-]{1,160}$/.test(body.for) ? body.for : undefined;
520
+ const forName = forWhom && typeof body.forName === 'string' && /^[A-Za-z0-9:._@+ -]{1,160}$/.test(body.forName) ? body.forName : undefined;
513
521
  let expiresAt;
514
522
  if (body.expiresAt !== undefined) {
515
523
  const t = typeof body.expiresAt === 'string' ? Date.parse(body.expiresAt) : NaN;
@@ -526,7 +534,7 @@ export class WorldDoors {
526
534
  const opened = via === 'session' && session ? this.sessions()[session] : undefined;
527
535
  const createdBy = via === 'session' ? { via: 'session', ...(opened?.sub ? { sub: opened.sub } : {}), ...(opened?.who ? { who: opened.who } : {}), ...(opened?.issuer ? { issuer: opened.issuer } : {}) } : { via: 'token' };
528
536
  const key = mintToken('tok_k_');
529
- const held = { id: mintToken('key_').slice(0, 20), name, scope: keyScope, hash: keyHash(key), createdAt: new Date(now).toISOString(), createdBy, ...(forWhom ? { for: forWhom } : {}), ...(expiresAt ? { expiresAt } : {}) };
537
+ const held = { 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 } : {}) };
530
538
  this.keepKeys([...kept, held]);
531
539
  return Response.json({ ...view(held), key }, { status: 201, headers: { 'cache-control': 'no-store' } });
532
540
  }
@@ -692,15 +700,32 @@ export class WorldDoors {
692
700
  const cred = this.credential(request);
693
701
  if (cred.scope === null)
694
702
  return null;
703
+ // this machine's own page with no one signed in here is this machine; another session with no name is just that
695
704
  if (cred.via === 'session')
696
- return cred.who ? `person:${cred.who}` : 'person';
705
+ return cred.who ? `person:${cred.who}` : cred.session && this.sessions()[cred.session]?.local ? 'machine' : 'person';
706
+ // a key acts as itself, for its person (docs/contributing/architecture.md, "Who acted is recorded")
697
707
  if (cred.via === 'key') {
698
- const token = cred.token;
699
- const held = token ? this.keys().find((k) => sameSecret(keyHash(token), k.hash)) : undefined;
700
- return held ? `key:${held.name}` : 'key';
708
+ const held = this.keyOf(cred.token);
709
+ const person = held ? this.keyName(held) : undefined;
710
+ return held ? `key:${held.name}${person ? ` for:${person}` : ''}` : 'key';
701
711
  }
702
712
  return cred.scope === 'read' ? 'token:read' : 'token';
703
713
  }
714
+ /** 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
715
+ * World's own token. What lands in a World others read must name one. */
716
+ personOf(request) {
717
+ const cred = this.credential(request);
718
+ if (cred.scope === null)
719
+ return undefined;
720
+ if (cred.via === 'session')
721
+ return cred.who;
722
+ if (cred.via === 'key') {
723
+ const held = this.keyOf(cred.token);
724
+ return held ? this.keyName(held) : undefined;
725
+ }
726
+ return undefined;
727
+ }
728
+ keyOf(token) { return token ? this.keys().find((k) => sameSecret(keyHash(token), k.hash)) : undefined; }
704
729
  /** The session cookie's name where `url` reached this World, or null where a session opens nothing:
705
730
  * on the World's own origin a host-only cookie (`__Host-` over https); without an origin of its own,
706
731
  * the path-scoped cookie a shared origin has always carried. */
@@ -1225,11 +1250,13 @@ export class WorldDoors {
1225
1250
  // with the body's hash as it stands, so a reader sees an approval or a verification made on other bytes as stale
1226
1251
  if (name) {
1227
1252
  const l = findWorldChangeset(name, { root: this.worldRoot, world: this.name });
1228
- return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0] });
1253
+ return Response.json({ ...l.changeset, currentHash: changesetContentHash(l.changeset), deployed: this.deployedOf([l.changeset])[0], readiness: changesetReadiness(l.changeset, worldChangesetRules(this.worldRoot)) });
1229
1254
  }
1230
1255
  const all = listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset);
1231
1256
  const deployed = this.deployedOf(all);
1232
- return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i] })) });
1257
+ const rules = worldChangesetRules(this.worldRoot);
1258
+ // readiness under this World's rules, as deploy reads it: the console says what the kernel decides
1259
+ return Response.json({ changesets: all.map((c, i) => ({ ...c, currentHash: changesetContentHash(c), deployed: deployed[i], readiness: changesetReadiness(c, rules) })) });
1233
1260
  }
1234
1261
  if (kind === 'changesets' && name && verb === 'verify' && request.method === 'POST') {
1235
1262
  const outcome = await this.serialized('changesets', () => verifyWorldChangeset(name, { root: this.worldRoot, world: this.name, ephemeral: true }));
@@ -1243,7 +1270,8 @@ export class WorldDoors {
1243
1270
  const held = via === 'session' ? this.sessions()[this.credential(request).session ?? ''] : undefined;
1244
1271
  const named = typeof body.as === 'string' && body.as.trim() ? body.as.trim().slice(0, 80) : undefined;
1245
1272
  const tokenSession = held !== undefined && !held.who && held.key === undefined && held.scope === 'write';
1246
- const principal = via === 'session' ? held?.who ?? (this.host.localTrust?.() ? 'this machine' : tokenSession ? named : undefined) : named;
1273
+ // a key approves as itself, for its person, whatever it says: it cannot be several approvers by naming several
1274
+ const principal = via === 'session' ? held?.who ?? (this.host.localTrust?.() ? 'this machine' : tokenSession ? named : undefined) : via === 'key' ? this.callerOf(request) ?? undefined : named;
1247
1275
  if (!principal)
1248
1276
  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 });
1249
1277
  return Response.json(approveWorldChangeset(name, { root: this.worldRoot, world: this.name, principal, ...(body.note ? { note: body.note } : {}) }));
@@ -1381,11 +1409,53 @@ export class WorldDoors {
1381
1409
  }));
1382
1410
  }
1383
1411
  }
1412
+ // THE RULES DOOR: how this World's changesets are made and readied (`changesets` in its committed config). Read with any
1413
+ // access; changed by a person, this machine or the World's token, never by a key: a key's holder (an app, an agent)
1414
+ // does not relax the review its own changes go through
1415
+ if (kind === 'rules' && !name) {
1416
+ const path = join(this.worldRoot, stateDirName(), 'world.json');
1417
+ if (request.method === 'GET')
1418
+ return Response.json({ world: this.served, changesets: worldChangesetRules(this.worldRoot) });
1419
+ if (request.method === 'PUT') {
1420
+ if (scope !== 'write')
1421
+ return Response.json({ error: 'the rules are changed with write access' }, { status: 403 });
1422
+ if (via === 'key')
1423
+ return Response.json({ error: 'a key changes no rules: a person sets them, or the World\'s token' }, { status: 403 });
1424
+ const body = (await request.json().catch(() => null));
1425
+ try {
1426
+ assertChangesetRules(body?.changesets, path);
1427
+ }
1428
+ catch (error) {
1429
+ return Response.json({ error: error instanceof Error ? error.message.replace(` in ${path}`, '') : String(error) }, { status: 400 });
1430
+ }
1431
+ const { config } = loadWorldConfig(path, this.worldRoot);
1432
+ const rules = body.changesets;
1433
+ const next = { ...config, changesets: { ...(rules.approvals === undefined ? {} : { approvals: rules.approvals }), ...(rules.summary === undefined ? {} : { summary: rules.summary }) } };
1434
+ writeWorldConfig(path, next);
1435
+ return Response.json({ world: this.served, changesets: next.changesets });
1436
+ }
1437
+ }
1384
1438
  if (kind === 'changesets' && !name && request.method === 'POST') {
1385
1439
  const body = (await request.json().catch(() => ({})));
1386
1440
  const slug = body.message ? body.message.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) : '';
1387
- const cut = body.name ?? (slug || `changeset-${Date.now()}`);
1388
- return Response.json(await this.serialized('push', async () => createWorldChangeset(this.name, cut, { root: this.worldRoot, ...(body.message ? { message: body.message } : {}) })));
1441
+ // with no summary to name it, the next free `changeset-<n>`, as the command names one
1442
+ const taken = new Set(listWorldChangesets({ root: this.worldRoot, world: this.name }).map((l) => l.changeset.name));
1443
+ let n = taken.size + 1;
1444
+ while (taken.has(`changeset-${n}`))
1445
+ n += 1;
1446
+ const cut = body.name ?? (slug || `changeset-${n}`);
1447
+ // a cut says what it does and why, unless this World's rules take the summary generated from its changes
1448
+ if (!body.message?.trim() && worldChangesetRules(this.worldRoot).summary !== 'generated')
1449
+ return Response.json({ error: 'a cut needs its summary: what it does and why' }, { status: 400 });
1450
+ const cutBy = this.callerOf(request) ?? undefined;
1451
+ const owner = this.personOf(request);
1452
+ return await this.serialized('push', async () => {
1453
+ // with no summary, a cut must have something to say: an empty one is a slip, refused as the command refuses it
1454
+ // (inside the lock, so two cuts at once never both take the same changes)
1455
+ if (!body.message?.trim() && diffWorld(this.name, { root: this.worldRoot }).actions.length === 0)
1456
+ return Response.json({ error: 'nothing to cut: every change is already in a changeset' }, { status: 400 });
1457
+ return Response.json(createWorldChangeset(this.name, cut, { root: this.worldRoot, ...(body.message?.trim() ? { message: body.message.trim() } : {}), ...(cutBy ? { cutBy } : {}), ...(owner ? { owner } : {}) }));
1458
+ });
1389
1459
  }
1390
1460
  // THE BLOBS DOOR: a twin's resource payloads (uploaded media, file bytes) by their key under its
1391
1461
  // resources directory. A push sends the ones its branch holds and this World lacks before the
@@ -1573,7 +1643,9 @@ export class WorldDoors {
1573
1643
  const own = this.host.browserOrigin?.() ?? null;
1574
1644
  if (this.sessionCookieName(url) === null)
1575
1645
  return Response.json({ error: `${this.served}'s browser session lives at its own origin`, origin: own }, { status: 409 });
1576
- return Response.json({ world: this.served, scope: 'write', local: true, ...(own ? { origin: own } : {}) }, { headers: { 'set-cookie': this.sessionCookie(url, this.openSession('write')) } });
1646
+ // this machine's page acts as the person signed in here, when one is (`volter login`); else as this machine
1647
+ const person = this.host.localPerson?.();
1648
+ 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)) } });
1577
1649
  }
1578
1650
  session(request, url, scope, key) {
1579
1651
  const own = this.host.browserOrigin?.() ?? null;
@@ -1859,10 +1931,18 @@ export class WorldDoors {
1859
1931
  }
1860
1932
  /** PUSH: see `landChangeset` — the door hands the body to it. */
1861
1933
  async push(request) {
1934
+ // who pushed it is recorded, a person or a service (a key, the World's token): an automation pushes as itself
1935
+ // (docs/contributing/architecture.md, "Who acted is recorded")
1936
+ const person = this.personOf(request);
1862
1937
  const body = (await request.json());
1863
- const changeset = 'changeset' in body && !('actions' in body) ? body.changeset : body;
1864
- if (!changeset || !Array.isArray(changeset.actions))
1938
+ const sent = 'changeset' in body && !('actions' in body) ? body.changeset : body;
1939
+ if (!sent || !Array.isArray(sent.actions))
1865
1940
  return Response.json({ error: 'push: a changeset object' }, { status: 400 });
1941
+ // who pushed it, and its owner when it came with none and the pusher names a person: outside its hash, so nothing an
1942
+ // approval bound to moves
1943
+ const pushedBy = this.callerOf(request) ?? undefined;
1944
+ const owner = sent.owner ?? person;
1945
+ const changeset = { ...sent, ...(owner ? { owner } : {}), ...(pushedBy ? { pushedBy } : {}) };
1866
1946
  this.refresh();
1867
1947
  // fast-forward only: the client says where it last saw each twin's log (its fetch cursor); a
1868
1948
  // twin that moved past it refuses with the drift — fetch, rebase, push again
@@ -1872,7 +1952,14 @@ export class WorldDoors {
1872
1952
  const receipts = await landChangeset(this.name, this.worldRoot, this.up(), changeset, expect);
1873
1953
  const position = Object.fromEntries([...new Set(changeset.actions.map((a) => a.service))].map((twin) => [twin, positionOf(twin)]));
1874
1954
  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 }]; }));
1875
- return Response.json({ world: this.served, changeset: changeset.name, receipts, position, snapshots });
1955
+ // who owns and pushed it, as this World keeps it: a changeset landed before keeps its first record
1956
+ const kept = (() => { try {
1957
+ return JSON.parse(getActiveWorldStore().read(join(worldChangesetsDir(this.name, this.worldRoot), `${changeset.name}.json`)) ?? 'null');
1958
+ }
1959
+ catch {
1960
+ return null;
1961
+ } })();
1962
+ return Response.json({ world: this.served, changeset: changeset.name, receipts, position, snapshots, ...(kept?.owner ? { owner: kept.owner } : {}), ...(kept?.pushedBy ? { pushedBy: kept.pushedBy } : {}) });
1876
1963
  }
1877
1964
  catch (error) {
1878
1965
  return Response.json({ error: error instanceof Error ? error.message : String(error) }, { status: 409 });
@@ -16,6 +16,7 @@ export type WorldView = {
16
16
  stop: () => Promise<void>;
17
17
  };
18
18
  export declare function serveWorldView(name: string, opts?: {
19
+ localPerson?: string;
19
20
  root?: string;
20
21
  port?: number;
21
22
  host?: string;
@@ -48,7 +48,7 @@ export async function serveWorldView(name, opts = {}) {
48
48
  // (the first World mounted is the World; every later one is a branch of it)
49
49
  let base = null;
50
50
  const browserOrigin = origins ? (served) => (proxied && (base === null || served === base) ? proxied.origin : port ? localWorldOrigin(served, port) : null) : undefined;
51
- const mountAt = (at) => mountWorld(loadWorldConfig(join(at, stateDirName(), 'world.json'), at).config.id, { root: at, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}) });
51
+ const mountAt = (at) => 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 } : {}) } : {}) });
52
52
  const branches = new LocalBranches({ dir: join(root, stateDirName(), 'branches'), origin: () => loopback, worlds, mount: mountAt, ...(opts.announce ? { announce: opts.announce } : {}) });
53
53
  // THE WORLD'S OWN BRANCHES, one served at a time: serving another stops this one's twins and starts that one's under the
54
54
  // same served name (its tokens and sessions are the World's, so the page stays open); `.volter/current` follows, as
@@ -90,7 +90,7 @@ export async function serveWorldView(name, opts = {}) {
90
90
  switching = false;
91
91
  }
92
92
  };
93
- const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true } : {}), serveBranch };
93
+ const mountOpts = { root, branches: (served) => branches.doorsFor(served), ...(browserOrigin ? { browserOrigin } : {}), ...(local ? { localTrust: true, ...(opts.localPerson ? { localPerson: opts.localPerson } : {}) } : {}), serveBranch };
94
94
  let world = await mountWorld(name, mountOpts);
95
95
  base = world.served;
96
96
  worlds.set(world.served, world);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.50",
3
+ "version": "2.0.51",
4
4
  "description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
5
5
  "keywords": [
6
6
  "twin",
@@ -67,14 +67,14 @@
67
67
  "node": ">=22.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@volter/world-core": "2.0.32",
71
- "@volter/world-console": "2.0.13"
70
+ "@volter/world-core": "2.0.33",
71
+ "@volter/world-console": "2.0.14"
72
72
  },
73
73
  "dependencies": {
74
74
  "@electric-sql/pglite": "0.5.8",
75
75
  "@electric-sql/pglite-pgvector": "0.0.9",
76
76
  "@volter/world-access": "2.0.2",
77
- "@volter/world-core": "2.0.32",
77
+ "@volter/world-core": "2.0.33",
78
78
  "pg-gateway": "0.3.0-beta.4",
79
79
  "smol-toml": "^1.8.0"
80
80
  },
@@ -84,6 +84,6 @@
84
84
  }
85
85
  },
86
86
  "optionalDependencies": {
87
- "@volter/twin-mongodb": "0.1.32"
87
+ "@volter/twin-mongodb": "0.1.33"
88
88
  }
89
89
  }
package/src/changeset.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { worldChangesetRules } from './configs.ts';
1
2
  import { blobDigest, captureHistory, captureParentHistory, getActiveBlobStore, getActiveWorldStore, originHead, withAncestryLock, worldPaths, type HistoryReference } from '@volter/world-core';
2
3
  // `volter-world mark` / `diff` / `changeset` — the WORLD-side verbs over the changeset
3
4
  // primitive (docs/concepts/the-model.md v0). Thin, exactly like `covers` and `tail` are thin: all
@@ -202,7 +203,7 @@ export type ChangesetLocation = { changeset: Changeset; world: string; path: str
202
203
  export function createWorldChangeset(
203
204
  world: string,
204
205
  name: string,
205
- options: WorldRootOptions & { base?: string; verifiers?: ChangesetVerifier[]; now?: Date; overwrite?: boolean; message?: string } = {},
206
+ options: WorldRootOptions & { base?: string; verifiers?: ChangesetVerifier[]; now?: Date; overwrite?: boolean; message?: string; /** who cuts it and the person it is owned by, from the cutting grant (a served World's door) */ cutBy?: string; owner?: string } = {},
206
207
  ): Changeset {
207
208
  const root = resolveRoot(options.root);
208
209
  assertSafeChangesetName(name);
@@ -227,6 +228,8 @@ export function createWorldChangeset(
227
228
  ...(options.verifiers ? { verifiers: options.verifiers } : {}),
228
229
  ...(options.now ? { createdAt: options.now.toISOString() } : {}),
229
230
  ...(options.message === undefined ? {} : { message: options.message }),
231
+ ...(options.cutBy === undefined ? {} : { cutBy: options.cutBy }),
232
+ ...(options.owner === undefined ? {} : { owner: options.owner }),
230
233
  });
231
234
  getActiveWorldStore().write(path, `${JSON.stringify(changeset, null, 2)}\n`);
232
235
  // a changeset records where the log was cut (contract "The model"): the next one starts here
@@ -447,7 +450,7 @@ export function statusWorldChangeset(
447
450
  ): ChangesetReadiness {
448
451
  const root = resolveRoot(options.root);
449
452
  const located = findWorldChangeset(name, { root, ...(options.world ? { world: options.world } : {}) });
450
- return changesetReadiness(located.changeset);
453
+ return changesetReadiness(located.changeset, worldChangesetRules(root));
451
454
  }
452
455
 
453
456
  // ── governed push: `volter-world changeset push` ─────────────────────────────────────────
@@ -481,7 +484,7 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
481
484
  const namespace = options.namespace ?? instance.origin?.namespace;
482
485
  if (!url || !namespace) throw new Error(`World "${located.world}" has no origin to push to — \`volter remote add origin <url> --token <token>\` names one`);
483
486
  // over http to a served world's push door, or straight into a world at a path (git's file transport)
484
- let res: { ok: boolean; status: number }; let body: { receipts?: Array<{ actionId: string; twin: string } & Receipt>; error?: string; refusal?: string[]; position?: Record<string, number>; snapshots?: Record<string, HistoryReference> };
487
+ let res: { ok: boolean; status: number }; let body: { receipts?: Array<{ actionId: string; twin: string } & Receipt>; error?: string; refusal?: string[]; position?: Record<string, number>; snapshots?: Record<string, HistoryReference>; /** whom the remote records as its owner and as who pushed it */ owner?: string; pushedBy?: string };
485
488
  const expect: Record<string, HistoryReference> = {};
486
489
  for (const a of located.changeset.actions) {
487
490
  const meta = readBranchMeta(a.stateService ?? a.service, join(instance.dirs.data, a.service));
@@ -597,7 +600,8 @@ export async function pushWorldChangeset(name: string, options: PushWorldOptions
597
600
  const receipts: ApplyReceipt[] = body.receipts.map((r) => ({ actionId: r.actionId, service: r.twin, stateService: located.changeset.actions.find((e) => e.service === r.twin)?.stateService ?? r.twin, status: statusOf(r.status), ...(r.externalId ? { externalId: r.externalId } : {}), ...(r.reason ? { error: r.reason } : {}) }));
598
601
  const outcome: ChangesetApplication['outcome'] = receipts.every((r) => r.status !== 'failed') ? 'applied' : receipts.some((r) => r.status === 'confirmed') ? 'partial' : 'refused';
599
602
  const application: ChangesetApplication = { at, contentHash: located.changeset.contentHash, outcome, receipts, compensation: [], ...(outcome === 'refused' ? { refusal: receipts.filter((r) => r.error).map((r) => r.error!) } : {}) };
600
- const changeset = withApplication(located.changeset, application);
603
+ // the remote's record of who pushed it and who owns it, kept on this copy too (outside the hash)
604
+ const changeset = { ...withApplication(located.changeset, application), ...(body.owner && !located.changeset.owner ? { owner: body.owner } : {}), ...(body.pushedBy ? { pushedBy: body.pushedBy } : {}) };
601
605
  writeChangesetFile(located.path, changeset);
602
606
  const unsent = namedFailure ? `records the landed entries name did not reach the origin (${namedFailure instanceof Error ? namedFailure.message : String(namedFailure)}); the next push from this branch that touches ${twins.map(([t]) => t).join(', ')} sends them` : undefined;
603
607
  return { ...(unsent ? { unsent } : {}), application, changeset, world: located.world, path: located.path, remote: { url, namespace } };
package/src/configs.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { copyFileSync, constants, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
- import { basename, dirname, resolve } from 'node:path';
3
- import { getActiveWorldStore } from '@volter/world-core';
4
- import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument, type WorldConfig } from './schema.ts';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { getActiveWorldStore, stateDirName } from '@volter/world-core';
4
+ import { assertWorldConfig, LEGACY_WORLD_SELECTION, worldConfigDocument, type WorldChangesetRules, type WorldConfig } from './schema.ts';
5
5
 
6
6
  export function resolveConfigPath(config: string, root = process.cwd()): string {
7
7
  const candidates = config.includes('/') || config.endsWith('.json')
@@ -108,3 +108,11 @@ export function migrateWorldConfig(config: string, root = process.cwd()): WorldC
108
108
  dropped: normalized.resources === undefined ? [] : ['resources (deprecated; retained in backup)'],
109
109
  };
110
110
  }
111
+
112
+ /** The changeset rules of the World rooted at `root` (its committed `.volter/world.json`, `changesets`); none when it
113
+ * has no config or names none, which readiness reads as one approval and a required summary. */
114
+ export function worldChangesetRules(root: string): WorldChangesetRules {
115
+ const path = join(root, stateDirName(), 'world.json');
116
+ if (!getActiveWorldStore().exists(path) && !existsSync(path)) return {};
117
+ try { return loadWorldConfig(path, root).config.changesets ?? {}; } catch { return {}; }
118
+ }
@@ -210,6 +210,10 @@ const CREDENTIAL_SHAPES: Array<{ match: RegExp; value: (name: string) => string;
210
210
  { match: /(^|_)STRIPE_[A-Z0-9_]*WEBHOOK_SECRET$/, value: () => 'whsec_twinfake0000000000000000000000000000', source: 'https://docs.stripe.com/webhooks#verify-events' },
211
211
  { match: /(^|_)STRIPE_[A-Z0-9_]*(SECRET|PRIVATE|API)(_API)?_KEY$/, value: () => 'sk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (sk_test_)' },
212
212
  { match: /(^|_)STRIPE_[A-Z0-9_]*(PUBLISHABLE|PUBLIC)(_API)?_KEY$/, value: () => 'pk_test_twinfake000000000000000000000000000000', source: 'https://docs.stripe.com/keys (pk_test_)' },
213
+ // The slack twin answers a bearer that is none of Slack's token kinds with `invalid_auth`, as Slack does; any `xoxb-`
214
+ // acts as the workspace bot, and an app-level token is `xapp-`.
215
+ { match: /(^|_)SLACK_[A-Z0-9_]*APP_TOKEN$/, value: () => 'xapp-1-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/)' },
216
+ { match: /(^|_)SLACK_([A-Z0-9_]*_)?BOT_TOKEN$|(^|_)SLACK_TOKEN$/, value: () => 'xoxb-twinfake', source: 'packages/twin/slack/src/slack-server.ts (bearer /^(xox|xapp-)/); GET /twin identity (any other xoxb-* is the workspace bot)' },
213
217
  // tavily-auth.ts and firecrawl-auth.ts refuse a key without the vendor's prefix with the vendor's 401
214
218
  { match: /(^|_)TAVILY_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'tvly-twinfake00000000000000000000', source: 'packages/twin/tavily/src/tavily-auth.ts' },
215
219
  { match: /(^|_)FIRECRAWL_[A-Z0-9_]*(KEY|TOKEN)$/, value: () => 'fc-twinfake00000000000000000000000', source: 'packages/twin/firecrawl/src/firecrawl-auth.ts FIRECRAWL_KEY_PREFIX' },
package/src/index.ts CHANGED
@@ -18,7 +18,8 @@ export type {
18
18
  WorldUsage,
19
19
  } from './schema.ts';
20
20
  export { DEFAULT_WORLD_SELECTION, LEGACY_WORLD_SELECTION, WORLD_SCHEMA_VERSION, WORLD_USAGES, worldConfigDocument, worldSelectionIncludes } from './schema.ts';
21
- export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, writeWorldConfig } from './configs.ts';
21
+ export type { WorldChangesetRules } from './schema.ts';
22
+ export { loadWorldConfig, migrateWorldConfig, resolveConfigPath, worldChangesetRules, writeWorldConfig } from './configs.ts';
22
23
  export type { WorldConfigMigration } from './configs.ts';
23
24
  export {
24
25
  upWorld,
package/src/root.ts CHANGED
@@ -14,7 +14,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path';
14
14
  import {
15
15
  NO_SECRETS_CHECK, authStrategyFor, currentSealer, destinationOf, sealsThroughVault, openSealedAt, getActiveWorldStore, getPack, buildRemoteExecute, clearRoot, observeAppends, readRoot, registerStateSystem, stateDirName, stateSystemFor, type Check, type CredentialPayload, type PerformAction, type PushReport, type RemoteExecute, type RootConfig, type SealedCredential, type TwinAction, validateRemoteOrigin, writeRoot,
16
16
  } from '@volter/world-core';
17
- import { loadWorldConfig, writeWorldConfig } from './configs.ts';
17
+ import { loadWorldConfig, worldChangesetRules, writeWorldConfig } from './configs.ts';
18
18
  import { changesetReadiness, collectObservations, type Observation, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding, userKek, type DeployReport, type ObserveReport } from '@volter/world-core';
19
19
  import { findWorldChangeset, listWorldChangesets } from './changeset.ts';
20
20
  import { importModule } from './import-module.ts';
@@ -501,7 +501,7 @@ export async function deployWorld(name: string, opts: { root?: string; changeset
501
501
  const out: DeployTwinOutcome[] = [];
502
502
  if (!opts.changeset) {
503
503
  // no name: every ready changeset, by name, in order — so each outcome says which it was for
504
- const ready = listWorldChangesets({ root: worldRoot, world: name }).filter((l) => changesetReadiness(l.changeset).ready && !l.changeset.actions.every((e) => { const r = rootForControlRoot(join(inst.dirs.data, e.service), e.service); return r === null; })).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
504
+ const ready = listWorldChangesets({ root: worldRoot, world: name }).filter((l) => changesetReadiness(l.changeset, worldChangesetRules(worldRoot)).ready && !l.changeset.actions.every((e) => { const r = rootForControlRoot(join(inst.dirs.data, e.service), e.service); return r === null; })).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
505
505
  const anyGated = Object.keys(inst.services).some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
506
506
  if (anyGated || ready.length) {
507
507
  for (const l of ready) out.push(...(await deployWorld(name, { ...opts, changeset: l.changeset.name })).map((o) => ({ ...o, changeset: l.changeset.name })));
@@ -511,7 +511,7 @@ export async function deployWorld(name: string, opts: { root?: string; changeset
511
511
  const located = opts.changeset ? [findWorldChangeset(opts.changeset, { root: worldRoot, world: name })] : listWorldChangesets({ root: worldRoot, world: name });
512
512
  const ready = new Map<string, string[]>();
513
513
  for (const l of located) {
514
- const readiness = changesetReadiness(l.changeset);
514
+ const readiness = changesetReadiness(l.changeset, worldChangesetRules(worldRoot));
515
515
  if (!readiness.ready) {
516
516
  const gated = [...new Set(l.changeset.actions.map((e) => e.service))].some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
517
517
  if (opts.changeset && gated) throw new Error(`changeset ${opts.changeset} is not ready: ${readiness.reasons.join('; ')}`);
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);