@volter/world-runtime 3.0.12 → 3.0.14
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/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/init.d.ts +1 -1
- package/dist/src/init.js +1 -0
- package/dist/src/local-branches.d.ts +11 -0
- package/dist/src/local-branches.js +44 -6
- package/dist/src/served-world.d.ts +61 -3
- package/dist/src/served-world.js +199 -35
- package/dist/src/world-origins.d.ts +9 -0
- package/dist/src/world-origins.js +21 -0
- package/dist/src/world-view.js +11 -4
- package/package.json +4 -4
- package/src/index.ts +2 -2
- package/src/init.ts +1 -0
- package/src/local-branches.ts +42 -6
- package/src/served-world.ts +186 -39
- package/src/world-origins.ts +19 -0
- package/src/world-view.ts +10 -4
package/dist/src/index.d.ts
CHANGED
|
@@ -44,9 +44,9 @@ export { deployWorld, refreshTwin, adaptersFor, credentialPath, rewrapTwinCreden
|
|
|
44
44
|
export type { DeployTwinOutcome, MaterializedRoot } from './root.js';
|
|
45
45
|
export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from './served-world.js';
|
|
46
46
|
export type { BranchDoors, BranchRow, DoorHost, MountedWorld, ServeRecord, WorldLayout } from './served-world.js';
|
|
47
|
-
export { LocalBranches } from './local-branches.js';
|
|
47
|
+
export { followParentPins, LocalBranches } from './local-branches.js';
|
|
48
48
|
export { serveWorldView, VIEW_CONSOLE_BASE } from './world-view.js';
|
|
49
|
-
export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from './world-origins.js';
|
|
49
|
+
export { LOCAL_ORIGIN_BASE, localWorldOrigin, pagesOfHost, pagesOriginOf, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from './world-origins.js';
|
|
50
50
|
export type { ViewConsole, WorldView } from './world-view.js';
|
|
51
51
|
export type { LocalBranchesOptions } from './local-branches.js';
|
|
52
52
|
export type { ServedWorld } from './served-world.js';
|
package/dist/src/index.js
CHANGED
|
@@ -31,9 +31,9 @@ export { fetchFromOrigin, worldOrigin } from "./origin.js";
|
|
|
31
31
|
export { branchWorld, checkoutWorld } from "./branch.js";
|
|
32
32
|
export { deployWorld, refreshTwin, adaptersFor, credentialPath, rewrapTwinCredentials, credentialPayloadFrom, rootCredentialInput, bindLegacyCredentials, deployTwin, loadWorldChecks, materializeRoots, rootForControlRoot, sealTwinCredential, sealedCredentialInfo, setTwinRoot, userKekPath } from "./root.js";
|
|
33
33
|
export { landReceipts, mountWorld, READ_ONLY_HEADER, worldOriginLabel, readServeRecord, refreshSchedule, servedName, serveWorld, sessionsFile, keysFile, TOKEN_HEADER, WorldDoors } from "./served-world.js";
|
|
34
|
-
export { LocalBranches } from "./local-branches.js";
|
|
34
|
+
export { followParentPins, LocalBranches } from "./local-branches.js";
|
|
35
35
|
export { serveWorldView, VIEW_CONSOLE_BASE } from "./world-view.js";
|
|
36
|
-
export { LOCAL_ORIGIN_BASE, localWorldOrigin, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
|
|
36
|
+
export { LOCAL_ORIGIN_BASE, localWorldOrigin, pagesOfHost, pagesOriginOf, pathOfWorld, siteOfHost, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
|
|
37
37
|
export { CONSOLE_BASE, consoleRedirect, serveConsoleApart, serveConsoleFor } from "./console-apart.js";
|
|
38
38
|
export { assertWorldStateRemovable } from "./ancestry.js";
|
|
39
39
|
export { withoutVaultSignIn } from "./runtime.js";
|
package/dist/src/init.d.ts
CHANGED
|
@@ -227,7 +227,7 @@ export declare function renderEnvFile(plan: InitPlan): string;
|
|
|
227
227
|
/** Write the plan's two artifacts. Byte-identical for the same repo + catalog: sorted keys, no
|
|
228
228
|
* timestamps, no absolute paths inside the files. */
|
|
229
229
|
/** What `.volter/.gitignore` says: the story is committed, the running state never is. */
|
|
230
|
-
export declare const STATE_GITIGNORE = "# Volter: the world's running state and live env \u2014 never committed. world.json, handlers/ and seeds/ are the story.\nworlds/\ncurrent\n*.env\ncredentials/\ntoken\ntoken.read\nsessions.json\nkeys.json\n";
|
|
230
|
+
export declare const STATE_GITIGNORE = "# Volter: the world's running state and live env \u2014 never committed. world.json, handlers/ and seeds/ are the story.\nworlds/\ncurrent\n*.env\ncredentials/\ntoken\ntoken.read\nsessions.json\npages-sessions.json\nkeys.json\n";
|
|
231
231
|
export declare function writeWorldInit(plan: InitPlan, options?: {
|
|
232
232
|
force?: boolean;
|
|
233
233
|
}): void;
|
package/dist/src/init.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import { type BranchDoors, type BranchRow, type MountedWorld } from './served-world.js';
|
|
2
|
+
/** A branch mounted again beside its parent takes the parent's twin versions: it is the parent's config under another
|
|
3
|
+
* name and runs on the packages the parent's root installs, so a parent moved to a new twin version (its pin and its
|
|
4
|
+
* install, together) never leaves a branch pinning the old one and failing the next boot. */
|
|
5
|
+
export declare function followParentPins(parentRoot: string, branchRoot: string): void;
|
|
6
|
+
/** A write key of a branch, named for the key whose holder made it (the branch's own keys door): what that holder gets in
|
|
7
|
+
* place of the branch's token, so the branch refuses it what a key is refused (seals, checks, keys, rules, sessions). */
|
|
8
|
+
export declare function keyOfBranch(branch: {
|
|
9
|
+
served: string;
|
|
10
|
+
token: string;
|
|
11
|
+
handle: (request: Request) => Promise<Response>;
|
|
12
|
+
}, origin: string, maker: string): Promise<string>;
|
|
2
13
|
export type LocalBranchesOptions = {
|
|
3
14
|
/** where branches live: `<dir>/<org>/<world>/` */
|
|
4
15
|
dir: string;
|
|
@@ -19,6 +19,37 @@ function renamed(config, id, name) {
|
|
|
19
19
|
}
|
|
20
20
|
return { ...config, id, bare: { ...config.bare, name } };
|
|
21
21
|
}
|
|
22
|
+
/** A branch mounted again beside its parent takes the parent's twin versions: it is the parent's config under another
|
|
23
|
+
* name and runs on the packages the parent's root installs, so a parent moved to a new twin version (its pin and its
|
|
24
|
+
* install, together) never leaves a branch pinning the old one and failing the next boot. */
|
|
25
|
+
export function followParentPins(parentRoot, branchRoot) {
|
|
26
|
+
const file = join(branchRoot, stateDirName(), 'world.json');
|
|
27
|
+
const pinOf = (s) => s.source?.version ?? s.version;
|
|
28
|
+
const parent = JSON.parse(readFileSync(join(parentRoot, stateDirName(), 'world.json'), 'utf8'));
|
|
29
|
+
const branch = JSON.parse(readFileSync(file, 'utf8'));
|
|
30
|
+
const pins = new Map((parent.services ?? []).flatMap((s) => { const pin = pinOf(s); return s.id && pin ? [[s.id, pin]] : []; }));
|
|
31
|
+
let moved = false;
|
|
32
|
+
for (const s of branch.services ?? []) {
|
|
33
|
+
const pin = s.id ? pins.get(s.id) : undefined;
|
|
34
|
+
if (!pin || pinOf(s) === pin)
|
|
35
|
+
continue;
|
|
36
|
+
if (s.source && typeof s.source === 'object')
|
|
37
|
+
s.source.version = pin;
|
|
38
|
+
else
|
|
39
|
+
s.version = pin;
|
|
40
|
+
moved = true;
|
|
41
|
+
}
|
|
42
|
+
if (moved)
|
|
43
|
+
writeFileSync(file, `${JSON.stringify(branch, null, 2)}\n`);
|
|
44
|
+
}
|
|
45
|
+
/** A write key of a branch, named for the key whose holder made it (the branch's own keys door): what that holder gets in
|
|
46
|
+
* place of the branch's token, so the branch refuses it what a key is refused (seals, checks, keys, rules, sessions). */
|
|
47
|
+
export async function keyOfBranch(branch, origin, maker) {
|
|
48
|
+
const made = await branch.handle(new Request(`${origin}/-/${branch.served}/keys`, { method: 'POST', headers: { [TOKEN_HEADER]: branch.token, 'content-type': 'application/json' }, body: JSON.stringify({ name: `${maker} (made this branch)`.slice(0, 100), scope: 'write' }) }));
|
|
49
|
+
if (!made.ok)
|
|
50
|
+
throw new Error(`a key of ${branch.served} for ${maker}: ${await made.text()}`);
|
|
51
|
+
return (await made.json()).key;
|
|
52
|
+
}
|
|
22
53
|
export class LocalBranches {
|
|
23
54
|
o;
|
|
24
55
|
sweeper = null;
|
|
@@ -50,15 +81,20 @@ export class LocalBranches {
|
|
|
50
81
|
doorsFor(parent) {
|
|
51
82
|
return {
|
|
52
83
|
list: async () => this.list(parent),
|
|
53
|
-
create: (at, ttl, _origin, parentKey) => this.create(parent, at, ttl, parentKey),
|
|
84
|
+
create: (at, ttl, _origin, parentKey, byKey) => this.create(parent, at, ttl, parentKey, byKey),
|
|
54
85
|
remove: async (name) => { const w = this.o.worlds.get(name); if (!w || this.record(w)?.from !== parent)
|
|
55
86
|
return false; await this.remove(name); return true; },
|
|
87
|
+
// a branch of this parent only, served in this process beside it: its own content plane's pass
|
|
88
|
+
pagesPass: async (name, scope, land, grant) => {
|
|
89
|
+
const w = this.o.worlds.get(name);
|
|
90
|
+
return w && this.record(w)?.from === parent ? w.pagesPass(scope, land, grant) : null;
|
|
91
|
+
},
|
|
56
92
|
};
|
|
57
93
|
}
|
|
58
|
-
async create(parent, at, ttlSeconds, parentKey) {
|
|
94
|
+
async create(parent, at, ttlSeconds, parentKey, byKey) {
|
|
59
95
|
this.making.set(parent, (this.making.get(parent) ?? 0) + 1);
|
|
60
96
|
try {
|
|
61
|
-
return await this.make(parent, at, ttlSeconds, parentKey);
|
|
97
|
+
return await this.make(parent, at, ttlSeconds, parentKey, byKey);
|
|
62
98
|
}
|
|
63
99
|
finally {
|
|
64
100
|
const left = (this.making.get(parent) ?? 1) - 1;
|
|
@@ -68,7 +104,7 @@ export class LocalBranches {
|
|
|
68
104
|
this.making.delete(parent);
|
|
69
105
|
}
|
|
70
106
|
}
|
|
71
|
-
async make(parent, at, ttlSeconds, parentKey) {
|
|
107
|
+
async make(parent, at, ttlSeconds, parentKey, byKey) {
|
|
72
108
|
const from = this.o.worlds.get(parent);
|
|
73
109
|
if (!from)
|
|
74
110
|
throw new Error(`no world ${parent} here`);
|
|
@@ -129,9 +165,11 @@ export class LocalBranches {
|
|
|
129
165
|
}
|
|
130
166
|
const createdAt = new Date().toISOString();
|
|
131
167
|
const expiresAt = ttlSeconds === null ? null : new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
132
|
-
|
|
168
|
+
// what it was made as (a pull request's preview is labelled and may be live)
|
|
169
|
+
const made = { ...(at.instant ? { instant: at.instant } : {}), ...(at.label ? { label: at.label } : {}), ...(at.live ? { live: true } : {}) };
|
|
170
|
+
writeFileSync(join(root, stateDirName(), RECORD), `${JSON.stringify({ from: parent, at: Object.keys(made).length ? made : null, createdAt, expiresAt }, null, 2)}\n`);
|
|
133
171
|
this.o.announce?.(`branch ${name} of ${parent}${at.instant ? ` as of ${at.instant}` : ''}${expiresAt ? `, until ${expiresAt}` : ''}`);
|
|
134
|
-
return { name, token: mounted.token, readToken: mounted.readToken, expiresAt };
|
|
172
|
+
return { name, token: byKey ? await keyOfBranch(mounted, origin, byKey) : mounted.token, readToken: mounted.readToken, expiresAt };
|
|
135
173
|
}
|
|
136
174
|
catch (error) {
|
|
137
175
|
this.o.worlds.delete(name);
|
|
@@ -57,16 +57,36 @@ export type MountedWorld = {
|
|
|
57
57
|
readToken: string;
|
|
58
58
|
};
|
|
59
59
|
handle: (request: Request) => Promise<Response>; /** a hostname this World's twins answer, as a browser reads it (siteOfHost) */
|
|
60
|
-
site: (request: Request, host: string) => Promise<Response>;
|
|
60
|
+
site: (request: Request, host: string) => Promise<Response>; /** WorldDoors.pagesPass */
|
|
61
|
+
pagesPass: (scope: 'read' | 'write', land: string, grant?: {
|
|
62
|
+
who?: string;
|
|
63
|
+
framer?: string;
|
|
64
|
+
asker?: string;
|
|
65
|
+
}) => string | null;
|
|
61
66
|
boot: (url: string) => Promise<void>;
|
|
62
67
|
stop: () => Promise<void>;
|
|
63
68
|
};
|
|
69
|
+
/** A browser session a World keeps: its scope, when it ends, and the person a pass named, when one did. */
|
|
70
|
+
/** What a content-plane pass, and the session it opens, carry: its scope; the control session that asked (the content
|
|
71
|
+
* session ends with it; none when the World's token asked); the person; the console that frames it; and whether it
|
|
72
|
+
* is this machine's own page (attributed to the machine). */
|
|
73
|
+
type PagesGrant = {
|
|
74
|
+
scope: 'read' | 'write';
|
|
75
|
+
session?: string;
|
|
76
|
+
who?: string;
|
|
77
|
+
framer?: string;
|
|
78
|
+
local?: true; /** a parent World's session that asked
|
|
79
|
+
* for a branch's page, as an opaque mark: the branch keeps one content session per asker and caps passes by it, but
|
|
80
|
+
* cannot tell when it ends, so such a session lasts an hour */
|
|
81
|
+
asker?: string;
|
|
82
|
+
};
|
|
64
83
|
export declare function mountWorld(name: string, opts?: {
|
|
65
84
|
passIssuers?: TrustedIssuer[];
|
|
66
85
|
root?: string; /** the host's branches of this World, by its served name */
|
|
67
86
|
branches?: (served: string) => BranchDoors | undefined; /** the origin a browser reaches this World at, when the host gives each World one */
|
|
68
87
|
browserOrigin?: (served: string) => string | null; /** DoorHost.sitesOrigin */
|
|
69
|
-
sitesOrigin?: (served: string) => string | null; /**
|
|
88
|
+
sitesOrigin?: (served: string) => string | null; /** DoorHost.pagesOrigin */
|
|
89
|
+
pagesOrigin?: (served: string) => string | null; /** served on this machine's loopback: its own pages need no token (DoorHost.localTrust) */
|
|
70
90
|
localTrust?: boolean; /** the person signed in on this machine: its own page acts as them (DoorHost.localPerson) */
|
|
71
91
|
localPerson?: string; /** DoorHost.serveBranch */
|
|
72
92
|
serveBranch?: (branch: string) => Promise<void>;
|
|
@@ -121,6 +141,10 @@ export type DoorHost = {
|
|
|
121
141
|
* host gives each World one: its session lives only there (docs/contributing/architecture.md,
|
|
122
142
|
* "Viewing a World"). Null, or absent, on a host whose Worlds share one origin. */
|
|
123
143
|
browserOrigin?: () => string | null;
|
|
144
|
+
/** THE CONTENT PLANE: the origin this World's twins' pages are shown at for a browser (`<world>--<org>--pages`), apart
|
|
145
|
+
* from the origin of its doors and its session (docs/contributing/architecture.md, "Viewing a World"): a twin's page
|
|
146
|
+
* runs there with a session that uses the twins and opens no door. Null, or absent, where Worlds share one origin. */
|
|
147
|
+
pagesOrigin?: () => string | null;
|
|
124
148
|
/** The origin under which this host routes the hostnames the World's twins answer, each at `<hostname>.<origin's host>`
|
|
125
149
|
* (siteOfHost); none where it routes no sites (behind a proxy, on a hosted World). */
|
|
126
150
|
sitesOrigin?: () => string | null;
|
|
@@ -164,13 +188,21 @@ export type BranchDoors = {
|
|
|
164
188
|
instant?: string;
|
|
165
189
|
live?: boolean;
|
|
166
190
|
label?: string;
|
|
167
|
-
}, ttlSeconds: number | null, origin: string, parentKey?: string
|
|
191
|
+
}, ttlSeconds: number | null, origin: string, parentKey?: string, /** the name of the key that made it: whoever holds a key gets a key of the branch (named for it),
|
|
192
|
+
* never the branch's own token, so the branch's doors refuse it what a key is refused */ byKey?: string) => Promise<{
|
|
168
193
|
name: string;
|
|
169
194
|
token: string;
|
|
170
195
|
readToken: string;
|
|
171
196
|
expiresAt: string | null;
|
|
172
197
|
}>;
|
|
173
198
|
remove: (name: string) => Promise<boolean>;
|
|
199
|
+
/** the address that opens a branch's content plane with a one-time pass at `scope`, landing on `land` (a path of the
|
|
200
|
+
* branch): how its parent's console frames the branch's pages beside its own (the comparison) */
|
|
201
|
+
pagesPass?: (name: string, scope: 'read' | 'write', land: string, grant?: {
|
|
202
|
+
who?: string;
|
|
203
|
+
framer?: string;
|
|
204
|
+
asker?: string;
|
|
205
|
+
}) => Promise<string | null>;
|
|
174
206
|
};
|
|
175
207
|
/** A World's browser-origin label, `<world>--<org>`, or null when its names cannot make one (a DNS
|
|
176
208
|
* label: lowercase letters, digits and single hyphens, 63 at most). A host maps a label back by the
|
|
@@ -191,6 +223,31 @@ export declare class WorldDoors {
|
|
|
191
223
|
constructor(name: string, worldRoot: string, configRef: string, served: string, host: DoorHost, token: string, readToken: string);
|
|
192
224
|
/** New tokens; a request presenting the old ones is refused from the next call on. */
|
|
193
225
|
retoken(token: string, readToken: string): void;
|
|
226
|
+
/** THE CONTENT PLANE (DoorHost.pagesOrigin): a browser sees this World's twins' pages at an origin apart from its doors
|
|
227
|
+
* and its session, so a twin's page (another app's content, a pack's markup) runs with nothing that opens a door. A
|
|
228
|
+
* page there holds a session of its own, opened from a one-time pass the World's own origin gives its session or
|
|
229
|
+
* token: it uses the twins at its scope and nothing else. One per session that gave it a pass (every frame of a board
|
|
230
|
+
* shares it), kept in the World's store, ended with that session; framed only by the console that asked. */
|
|
231
|
+
private onPages;
|
|
232
|
+
private readonly pagePasses;
|
|
233
|
+
private pagesPath;
|
|
234
|
+
private pagesHeld;
|
|
235
|
+
private pagesSessions;
|
|
236
|
+
private keepPagesSessions;
|
|
237
|
+
private pagesIgnored;
|
|
238
|
+
/** The origin of the World this one is a branch of, whose console frames its pages beside its own: none for a World
|
|
239
|
+
* that is no branch (its record, written by the host that made it). */
|
|
240
|
+
private parentOrigin;
|
|
241
|
+
/** Whether what gave a content session its pass still stands: the control session that asked (ended, signed out, or
|
|
242
|
+
* its shared link revoked, the content session ends with it), or this World's token (rotated, every one ends). */
|
|
243
|
+
private pagesParentLive;
|
|
244
|
+
/** The address that opens this World's content plane for a minute's one-time pass and lands on `land` (a path of this
|
|
245
|
+
* World); null where the World has no content plane, or the asker holds fifty passes not yet used. */
|
|
246
|
+
pagesPass(scope: 'read' | 'write', land: string, grant?: Omit<PagesGrant, 'scope'>): string | null;
|
|
247
|
+
private pagesCookie;
|
|
248
|
+
private pageSession;
|
|
249
|
+
/** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
|
|
250
|
+
private pagesPlane;
|
|
194
251
|
/** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
|
|
195
252
|
* not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
|
|
196
253
|
* keeps them; each lasts 30 days from its opening. */
|
|
@@ -426,3 +483,4 @@ export declare function localWorld(target: string): {
|
|
|
426
483
|
served: string;
|
|
427
484
|
instance: WorldInstance;
|
|
428
485
|
};
|
|
486
|
+
export {};
|
package/dist/src/served-world.js
CHANGED
|
@@ -189,6 +189,7 @@ export async function mountWorld(name, opts = {}) {
|
|
|
189
189
|
...(opts.branches?.(served) ? { branches: opts.branches(served) } : {}),
|
|
190
190
|
...(opts.browserOrigin ? { browserOrigin: () => opts.browserOrigin(served), originOf: (other) => opts.browserOrigin(other) } : {}),
|
|
191
191
|
...(opts.sitesOrigin ? { sitesOrigin: () => opts.sitesOrigin(served) } : {}),
|
|
192
|
+
...(opts.pagesOrigin ? { pagesOrigin: () => opts.pagesOrigin(served) } : {}),
|
|
192
193
|
...(opts.localTrust ? { localTrust: () => true, ...(opts.localPerson ? { localPerson: () => opts.localPerson } : {}) } : {}),
|
|
193
194
|
...(opts.serveBranch ? { serveBranch: opts.serveBranch } : {}),
|
|
194
195
|
...(opts.passIssuers?.length ? { passIssuers: () => opts.passIssuers } : {}),
|
|
@@ -213,6 +214,7 @@ export async function mountWorld(name, opts = {}) {
|
|
|
213
214
|
rotate: () => { world.retoken(mintToken('tok_'), mintToken('tok_r_')); keep(); return { token: world.token, readToken: world.readToken }; },
|
|
214
215
|
handle: (request) => world.handle(request),
|
|
215
216
|
site: (request, host) => world.site(request, host),
|
|
217
|
+
pagesPass: (scope, land, grant) => world.pagesPass(scope, land, grant),
|
|
216
218
|
async boot(url) {
|
|
217
219
|
keep();
|
|
218
220
|
const port = Number(new URL(url).port) || 0;
|
|
@@ -416,7 +418,154 @@ export class WorldDoors {
|
|
|
416
418
|
}
|
|
417
419
|
/** New tokens; a request presenting the old ones is refused from the next call on. */
|
|
418
420
|
// new tokens end everything the old ones reached: every session and every named key
|
|
419
|
-
retoken(token, readToken) { this.token = token; this.readToken = readToken; this.endSessions(); this.keepKeys([]); }
|
|
421
|
+
retoken(token, readToken) { this.token = token; this.readToken = readToken; this.endSessions(); this.keepKeys([]); this.keepPagesSessions({}); this.pagePasses.clear(); }
|
|
422
|
+
/** THE CONTENT PLANE (DoorHost.pagesOrigin): a browser sees this World's twins' pages at an origin apart from its doors
|
|
423
|
+
* and its session, so a twin's page (another app's content, a pack's markup) runs with nothing that opens a door. A
|
|
424
|
+
* page there holds a session of its own, opened from a one-time pass the World's own origin gives its session or
|
|
425
|
+
* token: it uses the twins at its scope and nothing else. One per session that gave it a pass (every frame of a board
|
|
426
|
+
* shares it), kept in the World's store, ended with that session; framed only by the console that asked. */
|
|
427
|
+
onPages(url) { const at = this.host.pagesOrigin?.() ?? null; return at !== null && url.host === new URL(at).host; }
|
|
428
|
+
pagePasses = new Map();
|
|
429
|
+
pagesPath() { return join(this.worldRoot, stateDirName(), 'pages-sessions.json'); }
|
|
430
|
+
pagesHeld = null;
|
|
431
|
+
pagesSessions() {
|
|
432
|
+
if (this.pagesHeld === null) {
|
|
433
|
+
try {
|
|
434
|
+
this.pagesHeld = JSON.parse(getActiveWorldStore().read(this.pagesPath()) ?? '{}');
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
this.pagesHeld = {};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return this.pagesHeld;
|
|
441
|
+
}
|
|
442
|
+
keepPagesSessions(all) {
|
|
443
|
+
const now = Date.now();
|
|
444
|
+
this.pagesHeld = Object.fromEntries(Object.entries(all).filter(([, x]) => x.until > now).sort(([, a], [, b]) => b.until - a.until).slice(0, 2000));
|
|
445
|
+
getActiveWorldStore().write(this.pagesPath(), `${JSON.stringify(this.pagesHeld)}\n`, { secret: true });
|
|
446
|
+
// never committed, as a World's other sessions are not: a `.volter/.gitignore` written before this file existed names it
|
|
447
|
+
if (!this.pagesIgnored) {
|
|
448
|
+
this.pagesIgnored = true;
|
|
449
|
+
const ignore = join(this.worldRoot, stateDirName(), '.gitignore');
|
|
450
|
+
const held = getActiveWorldStore().read(ignore);
|
|
451
|
+
if (held !== null && !held.split('\n').includes('pages-sessions.json'))
|
|
452
|
+
getActiveWorldStore().write(ignore, `${held.replace(/\n?$/, '\n')}pages-sessions.json\n`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
pagesIgnored = false;
|
|
456
|
+
/** The origin of the World this one is a branch of, whose console frames its pages beside its own: none for a World
|
|
457
|
+
* that is no branch (its record, written by the host that made it). */
|
|
458
|
+
parentOrigin() {
|
|
459
|
+
try {
|
|
460
|
+
const from = JSON.parse(getActiveWorldStore().read(join(this.worldRoot, stateDirName(), 'branch.json')) ?? 'null')?.from;
|
|
461
|
+
return from ? this.host.originOf?.(from) ?? null : null;
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/** Whether what gave a content session its pass still stands: the control session that asked (ended, signed out, or
|
|
468
|
+
* its shared link revoked, the content session ends with it), or this World's token (rotated, every one ends). */
|
|
469
|
+
pagesParentLive(grant) { return grant.session === undefined || this.issued().session(grant.session) !== null; }
|
|
470
|
+
/** The address that opens this World's content plane for a minute's one-time pass and lands on `land` (a path of this
|
|
471
|
+
* World); null where the World has no content plane, or the asker holds fifty passes not yet used. */
|
|
472
|
+
pagesPass(scope, land, grant = {}) {
|
|
473
|
+
const at = this.host.pagesOrigin?.() ?? null;
|
|
474
|
+
if (at === null)
|
|
475
|
+
return null;
|
|
476
|
+
const now = Date.now();
|
|
477
|
+
for (const [k, v] of this.pagePasses)
|
|
478
|
+
if (v.until <= now)
|
|
479
|
+
this.pagePasses.delete(k);
|
|
480
|
+
const askerOf = (g) => g.session ?? g.asker ?? g.framer ?? 'token';
|
|
481
|
+
const asker = askerOf(grant);
|
|
482
|
+
if ([...this.pagePasses.values()].filter((v) => askerOf(v) === asker).length >= 50 || this.pagePasses.size >= 5000)
|
|
483
|
+
return null;
|
|
484
|
+
const pass = mintToken('pass_');
|
|
485
|
+
this.pagePasses.set(pass, { ...grant, scope, until: now + 60_000 });
|
|
486
|
+
return `${at}/-/${this.served}/pages/open#${new URLSearchParams({ pass, land }).toString()}`;
|
|
487
|
+
}
|
|
488
|
+
pagesCookie(url) { return url.protocol === 'https:' ? '__Host-volter_pages' : 'volter_pages'; }
|
|
489
|
+
pageSession(request, url) {
|
|
490
|
+
const id = new RegExp(`(?:^|;\\s*)${this.pagesCookie(url)}=([^;]+)`).exec(request.headers.get('cookie') ?? '')?.[1];
|
|
491
|
+
const held = id && Object.hasOwn(this.pagesSessions(), id) ? this.pagesSessions()[id] : undefined;
|
|
492
|
+
if (!id || !held || held.until <= Date.now() || !this.pagesParentLive(held))
|
|
493
|
+
return null;
|
|
494
|
+
const { until: _until, ...grant } = held;
|
|
495
|
+
return { id, ...grant };
|
|
496
|
+
}
|
|
497
|
+
/** What the content plane answers: its opening page and session, and the twins' pages; no door of the World. */
|
|
498
|
+
async pagesPlane(request, url) {
|
|
499
|
+
const doors = `/-/${this.served}/pages/`;
|
|
500
|
+
const door = url.pathname.startsWith(doors) ? url.pathname.slice(doors.length) : null;
|
|
501
|
+
const own = this.host.browserOrigin?.() ?? null;
|
|
502
|
+
if (door === 'open' && request.method === 'GET') {
|
|
503
|
+
// framed by this World's console, or its parent's (a comparison frames its branch's pages); the pages it opens are
|
|
504
|
+
// framed only by the console that asked
|
|
505
|
+
const consoles = [own, this.parentOrigin()].filter((x) => Boolean(x)).join(' ') || "'none'";
|
|
506
|
+
return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}"><script src="${doors}open.js"></script></body>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': `default-src 'none'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors ${consoles}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
|
|
507
|
+
}
|
|
508
|
+
if (door === 'open.js' && request.method === 'GET') {
|
|
509
|
+
const script = `(() => {
|
|
510
|
+
const world = document.body.dataset.world;
|
|
511
|
+
const carried = new URLSearchParams(location.hash.slice(1));
|
|
512
|
+
history.replaceState(null, '', location.pathname);
|
|
513
|
+
const pass = carried.get('pass') || '';
|
|
514
|
+
const land = carried.get('land') || '';
|
|
515
|
+
// resolved as the browser will (encoded dots too), on this origin and under this World
|
|
516
|
+
let to = null;
|
|
517
|
+
try { const u = new URL(land, location.origin); if (land.startsWith('/') && !/[\\\\\\s]/.test(land) && u.origin === location.origin && u.pathname.startsWith('/' + world + '/')) to = u.pathname + u.search; } catch {}
|
|
518
|
+
if (!pass || !to) { document.body.textContent = 'Nothing to open.'; return; }
|
|
519
|
+
fetch('/-/' + world + '/pages/session', { method: 'POST', headers: { 'x-volter-pages-pass': pass } })
|
|
520
|
+
.then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
|
|
521
|
+
.catch(() => { document.body.textContent = 'This World did not answer.'; });
|
|
522
|
+
})();
|
|
523
|
+
`;
|
|
524
|
+
return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
|
|
525
|
+
}
|
|
526
|
+
if (door === 'session' && request.method === 'POST') {
|
|
527
|
+
// only this plane's own opening page asks (a browser sets the header; a page elsewhere cannot)
|
|
528
|
+
if (request.headers.get('sec-fetch-site') !== 'same-origin')
|
|
529
|
+
return Response.json({ error: 'the content plane opens its session from its own page' }, { status: 403 });
|
|
530
|
+
const pass = request.headers.get('x-volter-pages-pass') ?? '';
|
|
531
|
+
const given = this.pagePasses.get(pass);
|
|
532
|
+
this.pagePasses.delete(pass);
|
|
533
|
+
if (!given || given.until <= Date.now() || !this.pagesParentLive(given))
|
|
534
|
+
return Response.json({ error: 'that pass is used or expired: open the page again from the World' }, { status: 401 });
|
|
535
|
+
const { until: _until, ...grant } = given;
|
|
536
|
+
// one content session per asker at a scope and framer: a board's frames opening together share it
|
|
537
|
+
const same = (x) => x.scope === grant.scope && x.session === grant.session && x.asker === grant.asker && x.framer === grant.framer && x.who === grant.who;
|
|
538
|
+
const now = Date.now();
|
|
539
|
+
const found = Object.entries(this.pagesSessions()).find(([, x]) => x.until > now && same(x))?.[0];
|
|
540
|
+
const id = found ?? mintToken('pgs_');
|
|
541
|
+
// twelve hours from its last opening while the session that asked stands; an hour where that cannot be known
|
|
542
|
+
this.keepPagesSessions({ ...this.pagesSessions(), [id]: { ...grant, until: now + (grant.session !== undefined || grant.asker === undefined ? 12 : 1) * 3_600_000 } });
|
|
543
|
+
return Response.json({ world: this.served, scope: grant.scope }, { headers: { 'set-cookie': `${this.pagesCookie(url)}=${id}; Path=/; HttpOnly; Secure; SameSite=None; Partitioned` } });
|
|
544
|
+
}
|
|
545
|
+
if (url.pathname.startsWith(`/${this.served}/`) && !url.pathname.startsWith(`/${this.served}/.well-known/`)) {
|
|
546
|
+
const answer = await this.wire(request, url, url.pathname.slice(`/${this.served}/`.length));
|
|
547
|
+
if (answer.status === 101)
|
|
548
|
+
return answer;
|
|
549
|
+
const out = new Headers(answer.headers);
|
|
550
|
+
// framed by the console whose session asked, and by the page itself
|
|
551
|
+
const framer = this.pageSession(request, url)?.framer ?? own;
|
|
552
|
+
out.append('content-security-policy', `frame-ancestors 'self'${framer ? ` ${framer}` : ''}`);
|
|
553
|
+
// a twin's own sign-in kept in the frame a console shows it in, which may be another site (each *.localhost is):
|
|
554
|
+
// its cookies are partitioned by that console, as this plane's own session is
|
|
555
|
+
const cookies = answer.headers.getSetCookie();
|
|
556
|
+
if (cookies.length) {
|
|
557
|
+
out.delete('set-cookie');
|
|
558
|
+
for (const c of cookies)
|
|
559
|
+
out.append('set-cookie', `${c.replace(/;\s*(?:samesite=[^;]*|secure|partitioned)\s*(?=;|$)/gi, '')}; SameSite=None; Secure; Partitioned`);
|
|
560
|
+
}
|
|
561
|
+
return new Response(answer.body, { status: answer.status, statusText: answer.statusText, headers: out });
|
|
562
|
+
}
|
|
563
|
+
// a navigation elsewhere (a twin sending the person to its root, `/`) goes to the World's own origin, as it always did
|
|
564
|
+
if (own && request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '') && !url.pathname.startsWith('/-/')) {
|
|
565
|
+
return new Response(null, { status: 303, headers: { location: `${own}${url.pathname}${url.search}`, 'cache-control': 'no-store' } });
|
|
566
|
+
}
|
|
567
|
+
return Response.json({ error: `this origin shows ${this.served}'s pages only` }, { status: 404 });
|
|
568
|
+
}
|
|
420
569
|
/** BROWSER SESSIONS: the session cookie carries an opaque id, never a token, so a leaked cookie is one session and
|
|
421
570
|
* not the World's write token; rotating the tokens ends every session. Kept in the World's store, so a restart
|
|
422
571
|
* keeps them; each lasts 30 days from its opening. */
|
|
@@ -669,6 +818,12 @@ export class WorldDoors {
|
|
|
669
818
|
};
|
|
670
819
|
}
|
|
671
820
|
credential(request) {
|
|
821
|
+
// on the content plane only its own session counts: a page there presents nothing that opens a door
|
|
822
|
+
const at = new URL(request.url);
|
|
823
|
+
if (this.onPages(at)) {
|
|
824
|
+
const held = this.pageSession(request, at);
|
|
825
|
+
return held ? { scope: held.scope, via: 'session', token: null, session: `pages:${held.id}`, ...(held.who ? { who: held.who } : {}) } : { scope: null, via: null, token: null };
|
|
826
|
+
}
|
|
672
827
|
const presented = presentedSecrets(request.headers, { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER });
|
|
673
828
|
const name = this.sessionCookieName(new URL(request.url));
|
|
674
829
|
// the name is one of two fixed literals (sessionCookieName); the separator browsers use is "; "
|
|
@@ -719,7 +874,7 @@ export class WorldDoors {
|
|
|
719
874
|
return null;
|
|
720
875
|
// this machine's own page with no one signed in here is this machine; another session with no name is just that
|
|
721
876
|
if (cred.via === 'session')
|
|
722
|
-
return cred.who ? `person:${cred.who}` : cred.session && this.sessions()[cred.session]?.local ? 'machine' : 'person';
|
|
877
|
+
return cred.who ? `person:${cred.who}` : cred.session && (cred.session.startsWith('pages:') ? this.pagesSessions()[cred.session.slice(6)]?.local : this.sessions()[cred.session]?.local) ? 'machine' : 'person';
|
|
723
878
|
// a key acts as itself, for its person (docs/contributing/architecture.md, "Who acted is recorded")
|
|
724
879
|
if (cred.via === 'key') {
|
|
725
880
|
const held = this.keyOf(cred.token);
|
|
@@ -864,6 +1019,8 @@ export class WorldDoors {
|
|
|
864
1019
|
const prefix = `/${this.served}/`;
|
|
865
1020
|
if (url.pathname === '/-/ping')
|
|
866
1021
|
return Response.json({ ok: true, world: this.served, worlds: [this.served] });
|
|
1022
|
+
if (this.onPages(url))
|
|
1023
|
+
return await this.pagesPlane(request, url);
|
|
867
1024
|
if (url.pathname.startsWith(`/-/${this.served}/`))
|
|
868
1025
|
return await this.door(request, url, url.pathname.slice(`/-/${this.served}/`.length));
|
|
869
1026
|
// the attach manifest (@volter/world-core/attach, VOLTER_WORLD=<this World's URL>): every twin at its
|
|
@@ -950,17 +1107,36 @@ export class WorldDoors {
|
|
|
950
1107
|
const [vendor, ...tail] = rest.split('/');
|
|
951
1108
|
const { scope, via } = this.credential(request);
|
|
952
1109
|
const manifestAsk = request.method === 'GET' && tail.join('/').replace(/\/+$/, '') === 'twin';
|
|
1110
|
+
const browserAt = this.host.browserOrigin?.() ?? null;
|
|
1111
|
+
if (scope === null && this.onPages(url) && browserAt && request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '')) {
|
|
1112
|
+
return new Response(null, { status: 303, headers: { location: `${browserAt}${url.pathname}${url.search}`, 'cache-control': 'no-store' } });
|
|
1113
|
+
}
|
|
953
1114
|
if (scope === null && !manifestAsk)
|
|
954
1115
|
return Response.json({ error: 'token required' }, { status: 401 });
|
|
955
1116
|
const foreign = this.crossOriginSession(request, via);
|
|
956
1117
|
if (foreign)
|
|
957
1118
|
return foreign;
|
|
958
1119
|
const reads = request.method === 'GET' || request.method === 'HEAD';
|
|
1120
|
+
// THE TWO PLANES: a browser's session never sees a twin's page on the World's own origin. Its navigation there goes to
|
|
1121
|
+
// the content plane with a pass (a frame or a tab alike); anything else its pages would send is refused. A page on the
|
|
1122
|
+
// content plane that has no session yet is sent back here to get one
|
|
1123
|
+
const navigation = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
|
|
1124
|
+
const pagesAt = this.host.pagesOrigin?.() ?? null;
|
|
1125
|
+
if (pagesAt !== null && !this.onPages(url) && via === 'session') {
|
|
1126
|
+
const asked = this.credential(request);
|
|
1127
|
+
const to = navigation ? this.pagesPass(scope, `${url.pathname}${url.search}`, { ...(asked.session ? { session: asked.session } : {}), ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin() } : {}), ...(asked.session && this.sessions()[asked.session]?.local ? { local: true } : {}) }) : null;
|
|
1128
|
+
if (to)
|
|
1129
|
+
return new Response(null, { status: 303, headers: { location: to, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
|
|
1130
|
+
return Response.json({ error: `${this.served}'s twins' pages are shown at ${pagesAt}` }, { status: 403 });
|
|
1131
|
+
}
|
|
959
1132
|
// A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
|
|
960
1133
|
// the World (the World holds the key; its callers hold the World's token). Journaled as a twin's request is.
|
|
961
1134
|
// a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
|
|
962
1135
|
const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
|
|
963
1136
|
const link = this.link(vendor);
|
|
1137
|
+
// a link spends the World's sealed vendor credential: never from the content plane, where any twin's markup runs
|
|
1138
|
+
if (link && this.onPages(url))
|
|
1139
|
+
return Response.json({ error: `${vendor} is linked to the vendor itself: it is used through the World's own origin` }, { status: 404 });
|
|
964
1140
|
if (link) {
|
|
965
1141
|
if (scope === 'read' && !reads)
|
|
966
1142
|
return Response.json({ error: 'the read token opens only reads' }, { status: 403 });
|
|
@@ -997,7 +1173,7 @@ export class WorldDoors {
|
|
|
997
1173
|
if (enforced)
|
|
998
1174
|
headers.set(READ_ONLY_HEADER, '1');
|
|
999
1175
|
// the browser's World session is the World's credential, never the twin's (a tunnel would hand it on)
|
|
1000
|
-
const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?
|
|
1176
|
+
const cookies = (headers.get('cookie') ?? '').split(/;\s*/).filter((c) => c && !/^(?:__Host-)?volter_(?:world|pages)=/.test(c));
|
|
1001
1177
|
if (cookies.length)
|
|
1002
1178
|
headers.set('cookie', cookies.join('; '));
|
|
1003
1179
|
else
|
|
@@ -1036,10 +1212,10 @@ export class WorldDoors {
|
|
|
1036
1212
|
// above): caches keep them apart
|
|
1037
1213
|
out.append('vary', 'Sec-Fetch-Dest');
|
|
1038
1214
|
const setCookies = answer.headers.getSetCookie();
|
|
1039
|
-
if (setCookies.some((c) => /^\s*(?:__Host-)?
|
|
1215
|
+
if (setCookies.some((c) => /^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c))) {
|
|
1040
1216
|
out.delete('set-cookie');
|
|
1041
1217
|
for (const c of setCookies)
|
|
1042
|
-
if (!/^\s*(?:__Host-)?
|
|
1218
|
+
if (!/^\s*(?:__Host-)?volter_(?:world|pages)=/i.test(c))
|
|
1043
1219
|
out.append('set-cookie', c);
|
|
1044
1220
|
}
|
|
1045
1221
|
return new Response(answer.body, { status: answer.status, headers: out });
|
|
@@ -1150,35 +1326,6 @@ export class WorldDoors {
|
|
|
1150
1326
|
// ending a browser's session needs no token: it only clears the caller's own cookie, which never
|
|
1151
1327
|
// reaches these /-/ paths, so a page holding just the session could not otherwise sign out
|
|
1152
1328
|
const sessionPath = path.replace(/\/+$/, '');
|
|
1153
|
-
// THE HAND-OFF: a page of no UI that opens this World's session from the token in its fragment, then goes to the page
|
|
1154
|
-
// of this World it names (`land`): how a page elsewhere links to this World's own pages (the console's comparison
|
|
1155
|
-
// opening a vendor's page in a copy of the World, read only). The token never reaches the server in a URL; its script
|
|
1156
|
-
// is a file of this origin, so the page keeps a strict policy. It is never framed: a session opened in another
|
|
1157
|
-
// site's frame is not this World's own page's
|
|
1158
|
-
if (sessionPath === 'session/hand-off' && request.method === 'GET') {
|
|
1159
|
-
return new Response(`<!doctype html><meta charset="utf-8"><title>${this.served}</title><body data-world="${this.served}"><script src="/-/${this.served}/session/hand-off.js"></script></body>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'content-security-policy': "default-src 'none'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'", 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
|
|
1160
|
-
}
|
|
1161
|
-
if (sessionPath === 'session/hand-off.js' && request.method === 'GET') {
|
|
1162
|
-
const script = `(() => {
|
|
1163
|
-
const world = document.body.dataset.world;
|
|
1164
|
-
const carried = new URLSearchParams(location.hash.slice(1));
|
|
1165
|
-
history.replaceState(null, '', location.pathname);
|
|
1166
|
-
const token = carried.get('token') || '';
|
|
1167
|
-
const land = carried.get('land') || '';
|
|
1168
|
-
// resolved as the browser will (encoded dots too), on this origin and under this World
|
|
1169
|
-
let to = null;
|
|
1170
|
-
try { const u = new URL(land, location.origin); if (land.startsWith('/') && !/[\\\\\\s]/.test(land) && u.origin === location.origin && u.pathname.startsWith('/' + world + '/')) to = u.pathname + u.search; } catch {}
|
|
1171
|
-
const open = () => fetch('/-/' + world + '/session', { method: 'POST', headers: { 'x-volter-token': token } });
|
|
1172
|
-
if (!token || !to) { document.body.textContent = 'Nothing to open.'; return; }
|
|
1173
|
-
// a session this browser already holds here is kept: a page elsewhere cannot replace, downgrade or fix it
|
|
1174
|
-
fetch('/-/' + world + '/session')
|
|
1175
|
-
.then((held) => (held.ok ? held : open()))
|
|
1176
|
-
.then((r) => { if (r.ok) location.replace(to); else document.body.textContent = 'This World did not open.'; })
|
|
1177
|
-
.catch(() => { document.body.textContent = 'This World did not answer.'; });
|
|
1178
|
-
})();
|
|
1179
|
-
`;
|
|
1180
|
-
return new Response(script, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' } });
|
|
1181
|
-
}
|
|
1182
1329
|
// a person signing in from a page (the console's form, posted to this origin): the token in the
|
|
1183
1330
|
// form body, the session set here, then on to a page of this World
|
|
1184
1331
|
// a form or beacon from another site never opens or ends a session here (the console, on this host's
|
|
@@ -1838,6 +1985,23 @@ export class WorldDoors {
|
|
|
1838
1985
|
catch { /* a World with no local branches */ }
|
|
1839
1986
|
return Response.json({ world: this.served, branches: await branches.list(), checkedOut: this.name, named, canServe: this.host.serveBranch !== undefined });
|
|
1840
1987
|
}
|
|
1988
|
+
// A BRANCH'S PAGES (`GET branches/<org>/<branch>/pages?land=<path>`): a navigation to the branch's own content plane
|
|
1989
|
+
// with a one-time read pass, for a person or the World's token: how the console frames a branch's page beside this
|
|
1990
|
+
// World's (its comparison); the branch's pages never run on this World's origin
|
|
1991
|
+
if (rest.length === 3 && rest[2] === 'pages' && request.method === 'GET') {
|
|
1992
|
+
if (via !== 'session' && via !== 'token')
|
|
1993
|
+
return Response.json({ error: "a branch's pages open for a person or the World's token" }, { status: 403 });
|
|
1994
|
+
const name = `${decodeURIComponent(rest[0])}/${decodeURIComponent(rest[1])}`;
|
|
1995
|
+
if (!(await branches.list()).some((b) => b.name === name))
|
|
1996
|
+
return Response.json({ error: `no branch ${name} of ${this.served}` }, { status: 404 });
|
|
1997
|
+
const land = url.searchParams.get('land') ?? `/${name}/`;
|
|
1998
|
+
const asked = this.credential(request);
|
|
1999
|
+
const mark = asked.session ? createHash('sha256').update(`${this.served}\0${asked.session}`).digest('base64url').slice(0, 22) : 'token';
|
|
2000
|
+
const to = branches.pagesPass ? await branches.pagesPass(name, 'read', land, { asker: mark, ...(asked.who ? { who: asked.who } : {}), ...(this.host.browserOrigin?.() ? { framer: this.host.browserOrigin() } : {}) }) : null;
|
|
2001
|
+
if (!to)
|
|
2002
|
+
return Response.json({ error: `this host shows ${name}'s pages only at its own address` }, { status: 501 });
|
|
2003
|
+
return new Response(null, { status: 303, headers: { location: to, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
|
|
2004
|
+
}
|
|
1841
2005
|
// SERVE ANOTHER NAMED BRANCH here (`POST branches/<branch>/serve`): only who may write the World switches what it serves
|
|
1842
2006
|
if (rest.length === 2 && rest[1] === 'serve' && request.method === 'POST') {
|
|
1843
2007
|
if (!this.host.serveBranch)
|
|
@@ -1896,7 +2060,7 @@ export class WorldDoors {
|
|
|
1896
2060
|
const expiresAt = ttl === null ? undefined : new Date(now + ttl * 1000).toISOString();
|
|
1897
2061
|
this.keepKeys([...this.keys(), { id: keyId, name: 'a branch (being made)', scope: 'write', hash: keyHash(parentKey), createdAt: new Date(now).toISOString(), createdBy, ...(person ? { for: person } : {}), ...(expiresAt ? { expiresAt } : {}), ...(maker ? { madeWith: maker.id } : {}) }]);
|
|
1898
2062
|
try {
|
|
1899
|
-
const made = await branches.create(typeof instant === 'string' ? { instant: new Date(Date.parse(instant)).toISOString() } : { ...(body?.live === true ? { live: true } : {}), ...(typeof body?.label === 'string' ? { label: body.label } : {}) }, ttl, url.origin, parentKey);
|
|
2063
|
+
const made = await branches.create(typeof instant === 'string' ? { instant: new Date(Date.parse(instant)).toISOString() } : { ...(body?.live === true ? { live: true } : {}), ...(typeof body?.label === 'string' ? { label: body.label } : {}) }, ttl, url.origin, parentKey, maker ? maker.name : undefined);
|
|
1900
2064
|
this.keepKeys(this.keys().map((k) => (k.id === keyId ? { ...k, name: `the branch ${made.name}`, branch: made.name, ...(made.expiresAt ? { expiresAt: made.expiresAt } : {}) } : k)));
|
|
1901
2065
|
// a browser's session is answered the branch's read token only: a page views a branch, and the parent's
|
|
1902
2066
|
// session removes it, so no write token ever reaches a page's script (or a frame beside it)
|
|
@@ -2,6 +2,15 @@ export declare const LOCAL_ORIGIN_BASE = "localhost";
|
|
|
2
2
|
/** The origin a browser reaches `served` at on a local host listening on `port`, or null when its
|
|
3
3
|
* names make no label. */
|
|
4
4
|
export declare function localWorldOrigin(served: string, port: number, base?: string): string | null;
|
|
5
|
+
/** THE CONTENT PLANE's label beside a World's own: `<world>--<org>--pages`, where a browser sees the World's twins'
|
|
6
|
+
* pages apart from its doors and its session. A World of an org named `pages` (`<world>--pages`) is no content plane:
|
|
7
|
+
* its label names no World once the suffix is taken off, so it falls through to the World it is. */
|
|
8
|
+
export declare const PAGES_LABEL_SUFFIX = "--pages";
|
|
9
|
+
/** The content plane's origin for `served` on a host whose World origins are `<label>.<base>`, or null when its names
|
|
10
|
+
* make no label. */
|
|
11
|
+
export declare function pagesOriginOf(served: string, protocol: string, base: string, port?: number | string): string | null;
|
|
12
|
+
/** Which World's content plane a request's host names, or undefined for any other host. */
|
|
13
|
+
export declare function pagesOfHost(hostname: string, served: Iterable<string>, base?: string): string | undefined;
|
|
5
14
|
/** Which World a request's host names: undefined for a host that is not a World's origin (the host's
|
|
6
15
|
* own, path-addressed), null for a World-origin-shaped host naming no World served here. */
|
|
7
16
|
export declare function worldOfHost(hostname: string, served: Iterable<string>, base?: string): string | null | undefined;
|