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