@volter/world-platform 2.0.0
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/LICENSE +202 -0
- package/README.md +29 -0
- package/client/platform.css +228 -0
- package/client/platform.tsx +1049 -0
- package/client/reserved.ts +3 -0
- package/dist/client/platform.bundle.js +237 -0
- package/dist/client/platform.css +228 -0
- package/dist/client/platform.tsx +1049 -0
- package/dist/client/reserved.d.ts +1 -0
- package/dist/client/reserved.js +3 -0
- package/dist/client/reserved.ts +3 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +23 -0
- package/dist/src/backup.d.ts +37 -0
- package/dist/src/backup.js +94 -0
- package/dist/src/biller.d.ts +59 -0
- package/dist/src/biller.js +1 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +112 -0
- package/dist/src/db/migrations/0000_init.sql +143 -0
- package/dist/src/db/migrations/meta/0000_snapshot.json +877 -0
- package/dist/src/db/migrations/meta/_journal.json +13 -0
- package/dist/src/db/migrations.d.ts +4 -0
- package/dist/src/db/migrations.js +33 -0
- package/dist/src/db/open.d.ts +11 -0
- package/dist/src/db/open.js +114 -0
- package/dist/src/db/pack-migrations.d.ts +1 -0
- package/dist/src/db/pack-migrations.js +33 -0
- package/dist/src/db/schema.d.ts +1803 -0
- package/dist/src/db/schema.js +123 -0
- package/dist/src/directory.d.ts +75 -0
- package/dist/src/directory.js +199 -0
- package/dist/src/doors.d.ts +16 -0
- package/dist/src/doors.js +123 -0
- package/dist/src/identity.d.ts +88 -0
- package/dist/src/identity.js +314 -0
- package/dist/src/labs.d.ts +14 -0
- package/dist/src/labs.js +7 -0
- package/dist/src/mail.d.ts +16 -0
- package/dist/src/mail.js +27 -0
- package/dist/src/pages.d.ts +15 -0
- package/dist/src/pages.js +73 -0
- package/dist/src/platform.d.ts +77 -0
- package/dist/src/platform.js +1845 -0
- package/dist/src/sample.d.ts +11 -0
- package/dist/src/sample.js +93 -0
- package/dist/src/store.d.ts +110 -0
- package/dist/src/store.js +142 -0
- package/dist/src/tokens.d.ts +69 -0
- package/dist/src/tokens.js +96 -0
- package/dist/src/webhooks.d.ts +60 -0
- package/dist/src/webhooks.js +92 -0
- package/package.json +78 -0
- package/src/audit.ts +26 -0
- package/src/backup.ts +73 -0
- package/src/biller.ts +45 -0
- package/src/cli.ts +99 -0
- package/src/db/migrations/0000_init.sql +143 -0
- package/src/db/migrations/meta/0000_snapshot.json +877 -0
- package/src/db/migrations/meta/_journal.json +13 -0
- package/src/db/migrations.ts +33 -0
- package/src/db/open.ts +92 -0
- package/src/db/pack-migrations.ts +19 -0
- package/src/db/schema.ts +137 -0
- package/src/directory.ts +216 -0
- package/src/doors.ts +138 -0
- package/src/identity.ts +279 -0
- package/src/labs.ts +8 -0
- package/src/mail.ts +27 -0
- package/src/pages.ts +69 -0
- package/src/platform.ts +1183 -0
- package/src/sample.ts +84 -0
- package/src/store.ts +154 -0
- package/src/tokens.ts +94 -0
- package/src/webhooks.ts +85 -0
package/src/platform.ts
ADDED
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
// THE PLATFORM (docs/contributing/architecture.md, "The hosted product"; docs/reference/platform-api.md): Volter
|
|
2
|
+
// World's open, self-hostable platform. Its model is `org → worlds`, and nothing above the org. It owns what a World
|
|
3
|
+
// and a host do without — people (signed in through its one access provider, identity.ts), the directory of orgs and
|
|
4
|
+
// members that provider brings (directory.ts), and which World lives on which host — and it PROVISIONS by calling a
|
|
5
|
+
// host's admin doors. It never serves a twin or holds a World's log; a host does. It serves its own pages (pages.ts),
|
|
6
|
+
// and opens a World for a person by sending them to the World's own page with a pass. It names no plan or price: an
|
|
7
|
+
// attached biller (biller.ts; Volter's is apps/billing) answers for those, and without one nothing is limited.
|
|
8
|
+
import { Tokens, hasScope, validScopes } from './tokens.ts';
|
|
9
|
+
import { publishedKeys, signPass, type SigningKey } from '@volter/world-access';
|
|
10
|
+
import { serveHttp } from '@volter/world-core';
|
|
11
|
+
import { openapi } from './doors.ts';
|
|
12
|
+
import { DISABLE_AFTER, MAX_ATTEMPTS, WEBHOOK_EVENT_NAMES, attempt as attemptDelivery, refusedEndpoint, backoffSeconds, eventNameFor, newId, newSecret, validEvents, type Delivery, type WebhookEndpoint } from './webhooks.ts';
|
|
13
|
+
import { Audit } from './audit.ts';
|
|
14
|
+
import { type Mailer } from './mail.ts';
|
|
15
|
+
import { type Backups } from './backup.ts';
|
|
16
|
+
import { EARLY_FEATURES } from './labs.ts';
|
|
17
|
+
import type { Biller, BillerFactory } from './biller.ts';
|
|
18
|
+
import { createPages, pageShell, RESERVED_ORG_NAMES } from './pages.ts';
|
|
19
|
+
import { beginSignIn, CALLBACK_PATH, endSession, finishSignIn, providerClient, Sessions, sessionIdOf, whoIs, type IdentityOptions, type ProviderOptions } from './identity.ts';
|
|
20
|
+
import { isAdmin, localDirectory, OnlyAdmin, volterDirectory, type Directory, type OrgRef } from './directory.ts';
|
|
21
|
+
import { eq } from 'drizzle-orm';
|
|
22
|
+
import { billerRecords } from './db/schema.ts';
|
|
23
|
+
import { assertName, Store, type OrgWorld, type Preview, type Security, type SupportRequest } from './store.ts';
|
|
24
|
+
import { SAMPLE_VENDORS, SAMPLE_WORLD, seedSample } from './sample.ts';
|
|
25
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
const WORLD_LOGO = 'https://brand.volter.ai/logo/twin-world/svg?size=64&variant=mono'; // Volter World's mark, served by the brand (brand.volter.ai)
|
|
30
|
+
/** Where a host serves a World's dashboard, on the World's own origin (@volter/world-console). */
|
|
31
|
+
const DASHBOARD_BASE = '/-/console';
|
|
32
|
+
/** The paths of the platform's pages' own assets (pages.ts), answered before any door and never rate limited. */
|
|
33
|
+
const PAGE_ASSET = /^\/-\/(app\.js|styles\.css|brand\/)/;
|
|
34
|
+
|
|
35
|
+
/** An enrolled host: where worlds are provisioned. A world-host, or the hosted World on Cloudflare
|
|
36
|
+
* (`apps/cloud`), which answers the same admin doors with the same inventory row. */
|
|
37
|
+
export type HostProvider = { id: string; base: string; adminToken: string };
|
|
38
|
+
|
|
39
|
+
/** The platform's enrolled providers: the hosts it provisions onto. */
|
|
40
|
+
export type Providers = { hosts: HostProvider[] };
|
|
41
|
+
|
|
42
|
+
export type PlatformOptions = {
|
|
43
|
+
store: Store;
|
|
44
|
+
/** The header the deploy's own proxy sets to the client's address (`cf-connecting-ip` on Cloudflare, `x-forwarded-for`
|
|
45
|
+
* behind a proxy that appends to it): the rate limit keys an unauthenticated request on it. Unset, no request names
|
|
46
|
+
* its own address, and unauthenticated requests share one bucket. */
|
|
47
|
+
clientIpHeader?: string;
|
|
48
|
+
/** The state directory: the operator token (`<state>/admin`) and the providers record live here. */
|
|
49
|
+
stateDir?: string;
|
|
50
|
+
/** Called when the enrolled hosts change (enroll/unenroll) so the record persists. */
|
|
51
|
+
onProviders?: (providers: Providers) => void;
|
|
52
|
+
/** The access provider people sign in with (identity.ts): Volter Identity (its `VOLTER_*` names from the env by
|
|
53
|
+
* default) or any OpenID Connect issuer, whose directory the platform then keeps in its state. */
|
|
54
|
+
provider?: ProviderOptions;
|
|
55
|
+
/** The Volter provider's settings, for callers that name only those. */
|
|
56
|
+
identity?: IdentityOptions;
|
|
57
|
+
/** The enrolled hosts; the first is the default target for provisioning. At least one. */
|
|
58
|
+
hosts: HostProvider[];
|
|
59
|
+
/** This platform's own public origin, for the OAuth redirect URI. */
|
|
60
|
+
origin: string;
|
|
61
|
+
/** A biller to attach (biller.ts; Volter's is apps/billing's `createBiller`). Absent, nothing is limited or priced. */
|
|
62
|
+
billing?: BillerFactory;
|
|
63
|
+
/** The platform's own mail: a member added or invited, a World deleted, and the biller's notices (mail.ts; the resend twin in rehearsal). */
|
|
64
|
+
mail?: Mailer;
|
|
65
|
+
/** The product's site (the front door): its docs, and the terms and privacy a person accepts at org creation.
|
|
66
|
+
* Absent (a self-hosted platform), no terms are asked for. */
|
|
67
|
+
siteUrl?: string;
|
|
68
|
+
/** Where the Help form's mail goes (layer 6, a flag: --support-to); absent, the request is kept and logged only. */
|
|
69
|
+
supportTo?: string;
|
|
70
|
+
/** Backups of the state directory to S3 through the boundary (backup.ts); absent when no --backup-bucket is named. */
|
|
71
|
+
backups?: Backups;
|
|
72
|
+
/** Requests a person or an org may make per minute against the doors (plans.ts may raise it later). */
|
|
73
|
+
rateLimitPerMinute?: number;
|
|
74
|
+
/** The platform's own clock (layer 5): the tick's cadence in seconds (0 = never; default 3600) and the backup's time of day, UTC HH:MM (default 00:00). */
|
|
75
|
+
clock?: { tickEverySeconds?: number; backupAt?: string; deliverEverySeconds?: number; /** how often the keys of people no longer in an org are swept from its Worlds (seconds; 0 = never; default 3600) */ sweepMembersEverySeconds?: number };
|
|
76
|
+
/** The key the platform signs passes with (docs/contributing/architecture.md, "The hosted product"); its public half
|
|
77
|
+
* is published at /.well-known/jwks.json for the hosts that trust this platform. Absent, no World opens by pass. */
|
|
78
|
+
signingKey?: SigningKey;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
type WorldRow = { org: string; world: string; name: string; base: string; /** the World's own origin, where a browser opens it */ origin?: string; /** the org the host records as its owner */ owner?: string; token: string; readToken: string; twins: Array<{ vendor: string; protocol?: unknown; root?: unknown }> };
|
|
82
|
+
|
|
83
|
+
export function createPlatform(opts: PlatformOptions): { fetch: (request: Request) => Promise<Response>; stop: () => void } {
|
|
84
|
+
const pages = createPages();
|
|
85
|
+
// the OPERATOR token: the platform's own, minted once and kept beside its state (the host's pattern)
|
|
86
|
+
const operatorToken = opts.stateDir ? operatorTokenFor(opts.stateDir) : null;
|
|
87
|
+
const isOperator = (request: Request): boolean => operatorToken !== null && request.headers.get('x-volter-token') === operatorToken;
|
|
88
|
+
const persist = (): void => opts.onProviders?.({ hosts: opts.hosts });
|
|
89
|
+
// the access provider, and the directory it brings: Volter's is the identity service's; an OpenID Connect issuer
|
|
90
|
+
// only signs people in, so the platform keeps the directory in its state
|
|
91
|
+
const identity = providerClient(opts.provider ?? { kind: 'volter', ...(opts.identity ?? {}) });
|
|
92
|
+
const dir: Directory = identity.kind === 'volter' ? volterDirectory(identity) : localDirectory(opts.stateDir ?? (() => { throw new Error('an OpenID Connect provider needs a state directory: the platform keeps its directory there'); })());
|
|
93
|
+
const sessions = new Sessions(opts.stateDir); // the platform's own sessions, beside its tokens
|
|
94
|
+
const json = (body: unknown, status = 200, headers: Record<string, string> = {}): Response => Response.json(body, { status, headers });
|
|
95
|
+
// the team surface: personal tokens and the audit log live beside the platform's state
|
|
96
|
+
const tokens = opts.stateDir ? new Tokens(opts.stateDir) : null;
|
|
97
|
+
const audit = opts.stateDir ? new Audit(opts.stateDir) : null;
|
|
98
|
+
const record = (entry: Parameters<Audit['record']>[0]): void => {
|
|
99
|
+
try { audit?.record(entry); } catch { /* the act stands; the log line is best effort */ }
|
|
100
|
+
try { emit(entry); } catch { /* likewise */ }
|
|
101
|
+
};
|
|
102
|
+
// webhooks go to public addresses only, unless the operator allows the platform's own network, or it runs on this machine
|
|
103
|
+
const webhooksInside = process.env.VOLTER_WEBHOOKS_ALLOW_PRIVATE === '1' || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/.test(opts.origin);
|
|
104
|
+
// webhooks out (layer 7): an audit row that has a dotted name becomes one delivery per endpoint that wants it
|
|
105
|
+
const emit = (entry: Parameters<Audit['record']>[0]): void => {
|
|
106
|
+
const name = eventNameFor(entry.event); if (!name || entry.org === 'platform') return;
|
|
107
|
+
const endpoints = opts.store.webhooksOf(entry.org).filter((w) => !w.disabledAt && (w.events.includes('*') || w.events.includes(name)));
|
|
108
|
+
if (endpoints.length === 0) return;
|
|
109
|
+
const eventId = newId('evt'); const createdAt = new Date().toISOString();
|
|
110
|
+
const body = JSON.stringify({ id: eventId, event: name, createdAt, org: { id: entry.org }, data: { target: entry.target ?? null, actor: entry.actor, ...(entry.data ?? {}) } });
|
|
111
|
+
opts.store.enqueueDeliveries(endpoints.map((w) => ({ id: newId('dlv'), orgId: entry.org, endpointId: w.id, eventId, event: name, body, attempt: 0, nextAt: createdAt, attempts: [], state: 'pending' as const })));
|
|
112
|
+
};
|
|
113
|
+
/** Work the queue: every pending delivery whose time has come gets one attempt; the record keeps the answer. */
|
|
114
|
+
const deliverDue = async (): Promise<number> => {
|
|
115
|
+
const now = Date.now(); const all = opts.store.deliveries(); let worked = 0;
|
|
116
|
+
for (const d of all) {
|
|
117
|
+
if (d.state !== 'pending' || Date.parse(d.nextAt) > now) continue;
|
|
118
|
+
const endpoint = opts.store.webhooksOf(d.orgId).find((w) => w.id === d.endpointId);
|
|
119
|
+
if (!endpoint || endpoint.disabledAt) { d.state = 'failed'; d.attempts.push({ at: new Date().toISOString(), status: null, ok: false, response: endpoint ? 'endpoint disabled' : 'endpoint removed' }); continue; }
|
|
120
|
+
const r = await attemptDelivery(endpoint, d, fetch, webhooksInside); d.attempt += 1; d.attempts.push({ at: new Date().toISOString(), ...r }); worked += 1;
|
|
121
|
+
if (r.ok) { d.state = 'delivered'; opts.store.updateWebhook(d.orgId, endpoint.id, { consecutiveFailures: 0 }); continue; }
|
|
122
|
+
const failures = endpoint.consecutiveFailures + 1; const failedAt = new Date().toISOString();
|
|
123
|
+
opts.store.updateWebhook(d.orgId, endpoint.id, { consecutiveFailures: failures, lastFailedAt: failedAt });
|
|
124
|
+
if (d.attempt >= MAX_ATTEMPTS) d.state = 'failed'; else d.nextAt = new Date(now + backoffSeconds(d.attempt) * 1000).toISOString();
|
|
125
|
+
if (failures >= DISABLE_AFTER) { // Dub's threshold: the endpoint is disabled and the admins told
|
|
126
|
+
opts.store.updateWebhook(d.orgId, endpoint.id, { disabledAt: failedAt }); record({ event: 'WEBHOOK_DISABLED', org: d.orgId, actor: { platform: true }, target: endpoint.url, data: { failures } });
|
|
127
|
+
await mail(await adminsOf(d.orgId), 'A webhook endpoint was disabled', `${endpoint.url} failed ${failures} deliveries in a row and is disabled. Fix the receiver, then enable it again under Org settings › Webhooks.`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// keep the recent history: everything pending, and the last fifty settled per endpoint
|
|
131
|
+
const settled = all.filter((d) => d.state !== 'pending'); const keep = new Set<string>();
|
|
132
|
+
for (const d of settled.slice().reverse()) { const k = d.endpointId; const n = [...keep].filter((id) => settled.find((x) => x.id === id)?.endpointId === k).length; if (n < 50) keep.add(d.id); }
|
|
133
|
+
opts.store.setDeliveries(all.filter((d) => d.state === 'pending' || keep.has(d.id)));
|
|
134
|
+
return worked;
|
|
135
|
+
};
|
|
136
|
+
const adminsOf = async (orgId: string): Promise<string[]> => (await dir.membersOf(orgId)).filter((m) => m.role.endsWith('admin') && m.email).map((m) => m.email!);
|
|
137
|
+
const mail = async (to: string[], subject: string, text: string): Promise<void> => { if (!opts.mail || to.length === 0) return; try { await opts.mail.send({ to, subject, text }); } catch { /* the act stands; the mail is best effort and logged by the mailer */ } };
|
|
138
|
+
const orgLabel = (orgId: string, slug?: string | null): string => slug ?? orgId;
|
|
139
|
+
// ── trust (punchlist 4): the operator's notice for the status page, and a token bucket per person/org ──
|
|
140
|
+
const noticeNow = (): { text: string; at: string } | null => opts.store.platform().notice;
|
|
141
|
+
const LIMIT = opts.rateLimitPerMinute ?? 600; // the console alone makes ten reads a load
|
|
142
|
+
const buckets = new Map<string, { tokens: number; at: number }>();
|
|
143
|
+
const take = (key: string): { ok: boolean; remaining: number; reset: number } => {
|
|
144
|
+
const now = Date.now(); const b = buckets.get(key) ?? { tokens: LIMIT, at: now };
|
|
145
|
+
b.tokens = Math.min(LIMIT, b.tokens + ((now - b.at) / 60_000) * LIMIT); b.at = now;
|
|
146
|
+
const ok = b.tokens >= 1; if (ok) b.tokens -= 1; buckets.set(key, b);
|
|
147
|
+
return { ok, remaining: Math.floor(b.tokens), reset: Math.ceil(now / 1000 + (ok ? 0 : (1 - b.tokens) * 60 / LIMIT)) };
|
|
148
|
+
};
|
|
149
|
+
/** The client's address as the deploy's proxy states it (the header it names), else nothing: a header a client sends
|
|
150
|
+
* itself is never believed. `x-forwarded-for` lists the hops, the last added by the proxy in front of this platform. */
|
|
151
|
+
const clientAddress = (request: Request): string | null => {
|
|
152
|
+
const name = opts.clientIpHeader?.toLowerCase(); if (!name) return null;
|
|
153
|
+
const raw = request.headers.get(name); if (!raw) return null;
|
|
154
|
+
const hop = name === 'x-forwarded-for' ? raw.split(',').map((h) => h.trim()).filter(Boolean).pop() : raw.trim();
|
|
155
|
+
return hop ? `ip:${hop}` : null;
|
|
156
|
+
};
|
|
157
|
+
/** A World's previews at once, at most: a CI loop making pr-1…pr-5000 is refused, not served. */
|
|
158
|
+
const PREVIEWS_PER_WORLD = 20;
|
|
159
|
+
/** One change to a preview at a time (its label's make and remove), in the order asked. */
|
|
160
|
+
const previewQueue = new Map<string, Promise<unknown>>();
|
|
161
|
+
const onePreviewAtATime = <T,>(key: string, work: () => Promise<T>): Promise<T> => {
|
|
162
|
+
const before = previewQueue.get(key) ?? Promise.resolve();
|
|
163
|
+
const mine = before.catch(() => undefined).then(work);
|
|
164
|
+
const tail = mine.catch(() => undefined); previewQueue.set(key, tail);
|
|
165
|
+
void tail.then(() => { if (previewQueue.get(key) === tail) previewQueue.delete(key); });
|
|
166
|
+
return mine;
|
|
167
|
+
};
|
|
168
|
+
const limited = (key: string): Response | null => {
|
|
169
|
+
const t = take(key); if (t.ok) return null;
|
|
170
|
+
const retry = Math.max(1, t.reset - Math.floor(Date.now() / 1000));
|
|
171
|
+
return json({ error: `rate limit: ${LIMIT} requests a minute; retry in ${retry}s`, limit: LIMIT, remaining: 0, retryAfterSeconds: retry }, 429, { 'retry-after': String(retry), 'x-ratelimit-limit': String(LIMIT), 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(t.reset) });
|
|
172
|
+
};
|
|
173
|
+
// ── the World keys the platform made or a person holds: ended with the token they were made for, or the membership ──
|
|
174
|
+
/** A World's inventory row (with its tokens) wherever an enrolled host serves it. */
|
|
175
|
+
const worldRow = async (name: string): Promise<WorldRow | null> => {
|
|
176
|
+
for (const host of opts.hosts) {
|
|
177
|
+
const res = await fetch(`${host.base}/-/worlds`, { headers: { 'x-volter-token': host.adminToken } }).catch(() => null);
|
|
178
|
+
const row = res?.ok ? ((await res.json()) as { worlds: WorldRow[] }).worlds.find((r) => r.name === name) : undefined;
|
|
179
|
+
if (row) return { ...row, base: `${host.base}/${row.name}` };
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
};
|
|
183
|
+
/** Revoke the World keys made for a token (best effort: a World gone with its host has none to revoke). */
|
|
184
|
+
const revokeTokenKeys = async (keys: Array<{ world: string; keyId: string }> | undefined): Promise<void> => {
|
|
185
|
+
for (const k of keys ?? []) { const row = await worldRow(k.world); if (row) await fetch(`${new URL(row.base).origin}/-/${row.name}/keys/${encodeURIComponent(k.keyId)}`, { method: 'DELETE', headers: { 'x-volter-token': row.token } }).catch(() => null); }
|
|
186
|
+
};
|
|
187
|
+
/** Revoke every key made by or for a person in each of an org's Worlds: their leaving ends what they held there. */
|
|
188
|
+
/** A person removed from an org where the platform did not see it (at the identity service directly) keeps nothing:
|
|
189
|
+
* every person a key in the org's Worlds is for (or was made by) who is no longer a member has their keys, and the
|
|
190
|
+
* branches those made, revoked there. Org tokens' keys are ended with the org token. */
|
|
191
|
+
const sweepRemovedMembers = async (): Promise<number> => {
|
|
192
|
+
let revoked = 0;
|
|
193
|
+
for (const orgId of opts.store.orgIds()) {
|
|
194
|
+
const gone = new Map<string, boolean>();
|
|
195
|
+
for (const w of opts.store.doc(orgId).worlds) {
|
|
196
|
+
const row = await worldRow(w.name); if (!row) continue;
|
|
197
|
+
const res = await fetch(`${new URL(row.base).origin}/-/${row.name}/keys`, { headers: { 'x-volter-token': row.token } }).catch(() => null);
|
|
198
|
+
const keys = res?.ok ? ((await res.json()) as { keys: Array<{ person?: string | null }> }).keys : [];
|
|
199
|
+
for (const person of new Set(keys.map((k) => k.person).filter((p): p is string => typeof p === 'string' && !p.startsWith('org-token:')))) {
|
|
200
|
+
if (!gone.has(person)) gone.set(person, (await dir.memberOf(person, orgId).catch(() => 'unknown' as const)) === null);
|
|
201
|
+
if (gone.get(person)) { await fetch(`${new URL(row.base).origin}/-/${row.name}/keys?person=${encodeURIComponent(person)}`, { method: 'DELETE', headers: { 'x-volter-token': row.token } }).catch(() => null); revoked += 1; }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return revoked;
|
|
206
|
+
};
|
|
207
|
+
const revokePersonKeys = async (orgId: string, subject: string): Promise<void> => {
|
|
208
|
+
for (const w of opts.store.doc(orgId).worlds) { const row = await worldRow(w.name); if (row) await fetch(`${new URL(row.base).origin}/-/${row.name}/keys?person=${encodeURIComponent(subject)}`, { method: 'DELETE', headers: { 'x-volter-token': row.token } }).catch(() => null); }
|
|
209
|
+
};
|
|
210
|
+
// ── billing: an attached biller answers for plans and prices; without one nothing is limited ──
|
|
211
|
+
const biller: Biller | null = opts.billing ? opts.billing({
|
|
212
|
+
origin: opts.origin,
|
|
213
|
+
orgIds: () => opts.store.orgIds(),
|
|
214
|
+
worldsOf: (orgId) => opts.store.doc(orgId).worlds.map((w) => w.name),
|
|
215
|
+
adminsOf: (orgId) => adminsOf(orgId),
|
|
216
|
+
mail: (to, subject, text) => mail(to, subject, text),
|
|
217
|
+
record: (entry) => record(entry),
|
|
218
|
+
billingPage: (org) => `${opts.origin}/${encodeURIComponent(org.slug ?? org.id)}/billing`,
|
|
219
|
+
records: {
|
|
220
|
+
get: <T>(key: string): T | null => ((opts.store.db.select().from(billerRecords).where(eq(billerRecords.key, key)).get() as { value: T } | undefined)?.value) ?? null,
|
|
221
|
+
put: (key: string, value: unknown): void => { opts.store.db.insert(billerRecords).values({ key, value }).onConflictDoUpdate({ target: billerRecords.key, set: { value } }).run(); },
|
|
222
|
+
},
|
|
223
|
+
}) : null;
|
|
224
|
+
const hostOf = (id: string): HostProvider | undefined => opts.hosts.find((h) => h.id === id);
|
|
225
|
+
const defaultHost = (): HostProvider => { const h = opts.hosts[0]; if (!h) throw new Error('no enrolled host to provision onto — enroll one first (POST /-/hosts)'); return h; };
|
|
226
|
+
/** Make a World for an org on the default host, as a person: the biller asked first, the host records its owner, the
|
|
227
|
+
* org records the World, the meter hears of it. Answers the host's row (its tokens included, for the caller to keep or
|
|
228
|
+
* use, never to hand out) and its base, or the refusal to answer with. */
|
|
229
|
+
const provision = async (person: Person, org: OrgRef, world: string, vendors: string[]): Promise<Response | { made: WorldRow & { token?: string; readToken?: string }; base: string }> => {
|
|
230
|
+
// an attached biller may refuse (402, naming the plan and the limit); without one nothing is limited
|
|
231
|
+
if (biller) { const refusal = await biller.admit(org.id, { twins: vendors.length }); if (refusal) return refusal; }
|
|
232
|
+
const served = org.slug ?? org.id; // the host serves the world under the org's slug
|
|
233
|
+
let host: HostProvider; try { host = defaultHost(); } catch (e) { return json({ error: (e as Error).message }, 503); }
|
|
234
|
+
const res = await fetch(`${host.base}/-/worlds`, { method: 'POST', headers: { 'x-volter-token': host.adminToken, 'content-type': 'application/json' }, body: JSON.stringify({ org: served, world, vendors, owner: org.id }) }); // the host records its owner from its making
|
|
235
|
+
const made = (await res.json()) as WorldRow & { error?: string; token?: string; readToken?: string };
|
|
236
|
+
if (!res.ok) return json({ error: `the host refused: ${made.error ?? res.status}` }, 502);
|
|
237
|
+
const base = `${host.base}/${made.name}`;
|
|
238
|
+
const row: OrgWorld = { name: made.name, host: host.id, base, provisionedAt: new Date().toISOString() };
|
|
239
|
+
opts.store.recordWorld(org.id, row); record({ event: 'WORLD_PROVISION', org: org.id, actor: person, target: made.name, data: { vendors } });
|
|
240
|
+
await biller?.worldMade(org.id, made.name).catch(() => undefined); // the World stands whatever the meter says
|
|
241
|
+
return { made, base };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const presentedToken = (request: Request): string | null => {
|
|
245
|
+
// a token the platform minted (personal, org, support) is deliberate and wins over a session cookie the browser
|
|
246
|
+
// happens to carry — an operator's support view must never be read as the customer whose cookie sits beside it
|
|
247
|
+
const header = request.headers.get('x-volter-token'); const bearer = /^Bearer\s+(\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1] ?? null;
|
|
248
|
+
if (header && Tokens.looksLike(header)) return header;
|
|
249
|
+
if (bearer && Tokens.looksLike(bearer)) return bearer;
|
|
250
|
+
return sessionIdOf(request, opts.origin) ?? header ?? bearer ?? null;
|
|
251
|
+
};
|
|
252
|
+
/** Who a request is — the platform's session, started by a sign-in at the identity service, or a
|
|
253
|
+
* token the platform minted; the person is keyed by their Volter id (`subject`). */
|
|
254
|
+
type Person = { id: string; email?: string; name?: string; /** an org token acting as its org (layer 6): the org, and the token's name */ orgToken?: { orgId: string; name: string }; /** a support session (layer 6): read-only, consented, until when, and why */ support?: { orgId: string; until: string; reason: string; category: string } };
|
|
255
|
+
// an org token is a member of exactly one org and of nothing else; a person's orgs are the identity service's
|
|
256
|
+
const orgsOfP = async (person: Person): Promise<OrgRef[]> => { if (person.orgToken) { const o = await dir.orgById(person.orgToken.orgId); return o ? [o] : []; } return dir.orgsOf(person.id); };
|
|
257
|
+
const memberOfP = async (person: Person, ref: string): Promise<OrgRef | null> => { if (person.orgToken) { const o = await dir.orgById(person.orgToken.orgId); return o && (o.id === ref || o.slug === ref) ? o : null; } return dir.memberOf(person.id, ref); };
|
|
258
|
+
/** The CLI's pending sign-ins, by device code (the /-/cli doors). */
|
|
259
|
+
type DeviceGrant = { userCode: string; name: string; expires: number; state: 'pending' | 'approved' | 'denied'; person?: Person };
|
|
260
|
+
const devices = new Map<string, DeviceGrant>();
|
|
261
|
+
const sessionOf = async (request: Request): Promise<{ person: Person; token: string } | null> => {
|
|
262
|
+
const token = presentedToken(request);
|
|
263
|
+
if (!token) return null;
|
|
264
|
+
if (tokens && Tokens.looksLike(token)) { // a personal access token: the person it was minted for, by the identity service's record of them
|
|
265
|
+
const t = tokens.lookup(token); if (!t || t.expired) return null;
|
|
266
|
+
if (t.support && t.orgId) return { person: { id: `support:${t.hash.slice(0, 12)}`, name: 'Volter support', orgToken: { orgId: t.orgId, name: 'support' }, support: { orgId: t.orgId, until: t.expiresAt ?? '', reason: t.support.reason, category: t.support.category } }, token }; // the operator's consented view: read only, one hour
|
|
267
|
+
if (t.orgId) return { person: { id: `org-token:${t.hash.slice(0, 12)}`, name: `org token ${t.name}`, orgToken: { orgId: t.orgId, name: t.name } }, token }; // acts as the org, never as a person
|
|
268
|
+
const person = await dir.personById(t.userId); return person ? { person, token } : null;
|
|
269
|
+
}
|
|
270
|
+
const signed = whoIs(sessions, request, opts.origin);
|
|
271
|
+
if (!signed) return null;
|
|
272
|
+
return { person: { id: signed.userId, ...(signed.email ? { email: signed.email } : {}), ...(signed.name ? { name: signed.name } : {}) }, token };
|
|
273
|
+
};
|
|
274
|
+
const redirect = (location: string): Response => new Response(null, { status: 302, headers: { location, 'cache-control': 'no-store' } });
|
|
275
|
+
|
|
276
|
+
/** The host inventory rows for the worlds an org holds (their tokens come from the host). */
|
|
277
|
+
const rowsForPerson = async (person: Person): Promise<WorldRow[]> => {
|
|
278
|
+
const wanted = new Map<string, string>(); // served name → host id
|
|
279
|
+
for (const org of await orgsOfP(person)) for (const w of opts.store.doc(org.id).worlds) wanted.set(w.name, w.host);
|
|
280
|
+
const rows: WorldRow[] = [];
|
|
281
|
+
const byHost = new Map<string, WorldRow[]>();
|
|
282
|
+
for (const host of opts.hosts) {
|
|
283
|
+
if (![...wanted.values()].includes(host.id)) continue;
|
|
284
|
+
const res = await fetch(`${host.base}/-/worlds`, { headers: { 'x-volter-token': host.adminToken } });
|
|
285
|
+
if (!res.ok) continue;
|
|
286
|
+
byHost.set(host.id, ((await res.json()) as { worlds: WorldRow[] }).worlds);
|
|
287
|
+
}
|
|
288
|
+
for (const [name, hostId] of wanted) {
|
|
289
|
+
const row = byHost.get(hostId)?.find((r) => r.name === name);
|
|
290
|
+
const host = hostOf(hostId);
|
|
291
|
+
// the world's address is the HOST's enrolled origin + its name: what the platform reached it at
|
|
292
|
+
if (row && host) rows.push({ ...row, base: `${host.base}/${row.name}` });
|
|
293
|
+
}
|
|
294
|
+
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/** The backup: the state archived to the bucket; the clock runs it once a day at `backupAt`, the operator's door on demand. */
|
|
298
|
+
const runBackup = async (actor: { operator: true } | { platform: true }): Promise<{ key: string; bytes: number }> => {
|
|
299
|
+
if (!opts.backups || !opts.stateDir) throw new Error('no backup store');
|
|
300
|
+
const r = await opts.backups.backup(opts.stateDir);
|
|
301
|
+
opts.store.setPlatform({ lastBackupAt: new Date().toISOString() });
|
|
302
|
+
record({ event: 'PLAN_CHANGE', org: 'platform', actor, target: r.key, data: { backup: true } });
|
|
303
|
+
return r;
|
|
304
|
+
};
|
|
305
|
+
// the clock (layer 5): every peer runs its periodic work from a scheduler the product owns — the
|
|
306
|
+
// biller's tick hourly (Dub, Polar), the backup nightly (Dub's trigger-backup). The timers never hold the
|
|
307
|
+
// process open on their own (unref), so an in-process platform under a test still exits.
|
|
308
|
+
const tickEvery = opts.clock?.tickEverySeconds ?? 3600;
|
|
309
|
+
const timers: Array<ReturnType<typeof setInterval>> = [];
|
|
310
|
+
{ const t = setInterval(() => { void deliverDue().catch((e) => process.stderr.write(`webhooks: ${(e as Error).message}\n`)); }, opts.clock?.deliverEverySeconds !== undefined ? opts.clock.deliverEverySeconds * 1000 : 5000); (t as { unref?: () => void }).unref?.(); timers.push(t); }
|
|
311
|
+
{ const every = opts.clock?.sweepMembersEverySeconds ?? 3600; if (every > 0) { const t = setInterval(() => { void sweepRemovedMembers().catch((e) => process.stderr.write(`member sweep: ${(e as Error).message}
|
|
312
|
+
`)); }, every * 1000); (t as { unref?: () => void }).unref?.(); timers.push(t); } }
|
|
313
|
+
if (biller && tickEvery > 0) { const t = setInterval(() => { void biller.tick().catch((e) => process.stderr.write(`tick failed: ${(e as Error).message}\n`)); }, tickEvery * 1000); (t as { unref?: () => void }).unref?.(); timers.push(t); }
|
|
314
|
+
if (opts.backups && opts.stateDir) {
|
|
315
|
+
const at = opts.clock?.backupAt ?? '00:00';
|
|
316
|
+
const t = setInterval(() => {
|
|
317
|
+
const now = new Date(); const hhmm = `${String(now.getUTCHours()).padStart(2, '0')}:${String(now.getUTCMinutes()).padStart(2, '0')}`;
|
|
318
|
+
const last = opts.store.platform().lastBackupAt;
|
|
319
|
+
if (hhmm !== at || (last && last.slice(0, 10) === now.toISOString().slice(0, 10))) return;
|
|
320
|
+
void runBackup({ platform: true }).catch((e) => process.stderr.write(`backup failed: ${(e as Error).message}\n`));
|
|
321
|
+
}, 60_000); (t as { unref?: () => void }).unref?.(); timers.push(t);
|
|
322
|
+
}
|
|
323
|
+
/** The scope a door needs (layer 6) — `object:action` over the platform's objects; null for doors any holder opens. */
|
|
324
|
+
const scopeFor = (method: string, path: string): string | null => {
|
|
325
|
+
const write = method !== 'GET' && method !== 'HEAD';
|
|
326
|
+
if (path === '/-/tokens' || path.startsWith('/-/tokens/')) return write ? 'tokens:write' : 'tokens:read';
|
|
327
|
+
if (/^\/-\/worlds\/[^/]+\/[^/]+\/(token|open|previews\/[^/]+\/open)$/.test(path)) return 'worlds:read';
|
|
328
|
+
if (path === '/-/worlds' || path.startsWith('/-/worlds/')) return write ? 'worlds:write' : 'worlds:read';
|
|
329
|
+
const org = /^\/-\/orgs(?:\/[^/]+(?:\/([^/]+))?)?/.exec(path); if (!org) return null;
|
|
330
|
+
const part = org[1];
|
|
331
|
+
if (part === 'members' || part === 'invitations') return write ? 'members:write' : 'members:read';
|
|
332
|
+
if (part === 'billing' || part === 'checkout' || part === 'billing-portal') return write ? 'billing:write' : 'billing:read';
|
|
333
|
+
if (part === 'audit' || part === 'audit.jsonl' || part === 'export') return 'activity:read';
|
|
334
|
+
if (part === 'tokens') return write ? 'tokens:write' : 'tokens:read';
|
|
335
|
+
return write ? 'orgs:write' : 'orgs:read'; // security, support-access, webhooks: the org's own settings
|
|
336
|
+
};
|
|
337
|
+
/** The org's effective early-access flags (layer 8): each feature on when toggled, or when the org is an early adopter. */
|
|
338
|
+
const flagsOf = (orgId: string): Record<string, boolean> => { const doc = opts.store.doc(orgId); return Object.fromEntries(EARLY_FEATURES.map((f) => [f.key, Boolean(doc.earlyAdopter) || Boolean(doc.flags?.[f.key])])); };
|
|
339
|
+
/** The org's security switches, held at its doors (layer 6): why this session may not open this org right now, or null. */
|
|
340
|
+
const heldBy = (orgId: string, session: { person: Person; token: string }): string | null => {
|
|
341
|
+
const sec = opts.store.securityOf(orgId);
|
|
342
|
+
if (!sec.membersCanUsePersonalTokens && tokens && Tokens.looksLike(session.token) && !Tokens.isOrgToken(session.token)) return 'this org does not allow personal tokens — use an org token, made by an admin under Org settings';
|
|
343
|
+
return null;
|
|
344
|
+
};
|
|
345
|
+
const domainAllowed = (orgId: string, email: string): string | null => {
|
|
346
|
+
const domains = opts.store.securityOf(orgId).approvedEmailDomains; if (domains.length === 0) return null;
|
|
347
|
+
const d = email.split('@')[1]?.toLowerCase() ?? ''; return domains.map((x) => x.toLowerCase()).includes(d) ? null : `this org invites only ${domains.join(', ')} addresses`;
|
|
348
|
+
};
|
|
349
|
+
/** A 401 that says why: an expired personal token is named as such (layer 5), everything else is "sign in". */
|
|
350
|
+
const unauthorized = (request: Request): Response => {
|
|
351
|
+
const t = presentedToken(request);
|
|
352
|
+
if (t && tokens && Tokens.looksLike(t) && tokens.lookup(t)?.expired) return json({ error: 'token expired — sign in again (volter login), or make a new one under Account' }, 401);
|
|
353
|
+
return json({ error: 'sign in' }, 401);
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
stop() { for (const t of timers) clearInterval(t); },
|
|
358
|
+
async fetch(request) {
|
|
359
|
+
const url = new URL(request.url);
|
|
360
|
+
const path = url.pathname;
|
|
361
|
+
try {
|
|
362
|
+
if (path === '/-/health') return json({ ok: true, hosts: opts.hosts.map((h) => h.id) });
|
|
363
|
+
// the platform's pages' own assets: not doors, never rate limited
|
|
364
|
+
if (request.method === 'GET' && PAGE_ASSET.test(path)) return (await pages.handle(request)) ?? json({ error: 'not found' }, 404);
|
|
365
|
+
// what the pages need to know before anyone signs in: the provider people continue with, and what is attached
|
|
366
|
+
if (path === '/-/platform' && request.method === 'GET') return json({ name: 'Volter World', provider: { kind: identity.kind, name: identity.name, ...(identity.account ? { account: identity.account } : {}) }, billing: biller !== null, support: Boolean(opts.supportTo), ...(opts.siteUrl ? { site: opts.siteUrl.replace(/\/+$/, '') } : {}) }, 200, { 'cache-control': 'no-store' });
|
|
367
|
+
// the keys this platform signs passes with, for the hosts that trust it (public halves only)
|
|
368
|
+
if (path === '/.well-known/jwks.json' && request.method === 'GET') return json(publishedKeys(opts.signingKey ? [opts.signingKey] : []), 200, { 'cache-control': 'public, max-age=300', 'access-control-allow-origin': '*' });
|
|
369
|
+
// ── the status page's source: every host asked now, the operator's notice, this platform ──
|
|
370
|
+
if (path === '/-/openapi.json' && request.method === 'GET') return json(openapi(opts.origin), 200, { 'access-control-allow-origin': '*' });
|
|
371
|
+
if (path === '/-/status' && request.method === 'GET') {
|
|
372
|
+
const askedAt = new Date().toISOString();
|
|
373
|
+
const hosts = await Promise.all(opts.hosts.map(async (h) => { const c = new AbortController(); const t = setTimeout(() => c.abort(), 2500); try { const r = await fetch(`${h.base}/-/ping`, { signal: c.signal }); return { id: h.id, up: r.ok }; } catch { return { id: h.id, up: false }; } finally { clearTimeout(t); } }));
|
|
374
|
+
const pos = opts.store.platform();
|
|
375
|
+
const clock = { tick: { everySeconds: biller ? opts.clock?.tickEverySeconds ?? 3600 : null, lastAt: biller?.lastTickAt() ?? null }, backup: { at: opts.backups ? (opts.clock?.backupAt ?? '00:00') : null, lastAt: pos.lastBackupAt } };
|
|
376
|
+
return json({ ok: hosts.every((h) => h.up), platform: 'up', hosts, notice: pos.notice, clock, askedAt }, 200, { 'access-control-allow-origin': '*', 'cache-control': 'no-store' });
|
|
377
|
+
}
|
|
378
|
+
if (path === '/-/status/notice' && request.method === 'POST') {
|
|
379
|
+
if (!isOperator(request)) return json({ error: 'the operator token opens this' }, 401);
|
|
380
|
+
let body: { text?: string | null }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'notice: { text | null }' }, 400); }
|
|
381
|
+
const notice = body.text ? { text: String(body.text).slice(0, 500), at: new Date().toISOString() } : null;
|
|
382
|
+
opts.store.setPlatform({ notice });
|
|
383
|
+
return json({ notice });
|
|
384
|
+
}
|
|
385
|
+
// ── backups: the state archived to S3 through the boundary (the aws twin in rehearsal) ──
|
|
386
|
+
if ((path === '/-/backup' || path === '/-/restore') && request.method === 'POST') {
|
|
387
|
+
if (!isOperator(request)) return json({ error: 'the operator token opens this' }, 401);
|
|
388
|
+
if (!opts.backups || !opts.stateDir) return json({ error: 'no backup store: serve with --backup-bucket (and the vendor\'s AWS_* names in the env)' }, 503);
|
|
389
|
+
if (path === '/-/backup') return json(await runBackup({ operator: true }), 201);
|
|
390
|
+
let body: { key?: string }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'restore: { key }' }, 400); }
|
|
391
|
+
if (!body.key) return json({ keys: await opts.backups.list() });
|
|
392
|
+
return json(await opts.backups.restore(opts.stateDir, body.key));
|
|
393
|
+
}
|
|
394
|
+
if (path === '/-/backups' && request.method === 'GET') { if (!isOperator(request)) return json({ error: 'the operator token opens this' }, 401); if (!opts.backups) return json({ keys: [] }); return json({ keys: await opts.backups.list() }); }
|
|
395
|
+
// the rate limit, per person (or per presented token) on every door below
|
|
396
|
+
if (path.startsWith('/-/') && !isOperator(request)) { const who = presentedToken(request) ?? clientAddress(request) ?? 'anonymous'; const stop = limited(who.slice(0, 64)); if (stop) return stop; }
|
|
397
|
+
// the scope guard (layer 6): a token opens only the doors its scopes name — refused by the missing scope's name
|
|
398
|
+
// a CHANGE made with the browser's session cookie comes from the platform's own pages, as JSON: a page on a sibling
|
|
399
|
+
// origin of the same site (a World's, under the same registrable domain) rides the Lax cookie, but can neither say
|
|
400
|
+
// Sec-Fetch-Site: same-origin nor send JSON without a preflight the platform never answers
|
|
401
|
+
{
|
|
402
|
+
const write = request.method !== 'GET' && request.method !== 'HEAD';
|
|
403
|
+
const cookieSession = sessionIdOf(request, opts.origin);
|
|
404
|
+
const t = presentedToken(request);
|
|
405
|
+
if (write && path.startsWith('/-/') && path !== '/-/cli/approve' && cookieSession !== undefined && t === cookieSession) {
|
|
406
|
+
const site = request.headers.get('sec-fetch-site'); const from = request.headers.get('origin');
|
|
407
|
+
if (!(site === 'same-origin' || (site === null && from === opts.origin))) return json({ error: "a change with the browser's session is made from the platform's own pages" }, 403);
|
|
408
|
+
const type = request.headers.get('content-type');
|
|
409
|
+
if (type !== null && !type.toLowerCase().startsWith('application/json')) return json({ error: 'the platform takes JSON' }, 415);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// a token may always revoke itself (`volter logout`), whatever its scopes
|
|
413
|
+
{ const t = presentedToken(request); const itself = t !== null && request.method === 'DELETE' && path === `/-/tokens/${createHash('sha256').update(t).digest('hex')}`; if (t && tokens && Tokens.looksLike(t) && path.startsWith('/-/') && !itself) { const rec = tokens.lookup(t); const need = scopeFor(request.method, path); if (rec && !rec.expired && need && !hasScope(rec.scopes, need)) return json({ error: `token lacks scope ${need}`, scope: need, scopes: rec.scopes ?? ['*'] }, 403); } }
|
|
414
|
+
|
|
415
|
+
// the console this platform served before its own pages (old links, old mails): its pages are at the root now
|
|
416
|
+
if (path === '/-/console' || path.startsWith('/-/console/')) return redirect(`/${url.search}`);
|
|
417
|
+
// ── signing in: the authorization code with PKCE at the provider, then the platform's own session ──
|
|
418
|
+
if (path === '/-/sign-in' && request.method === 'GET') return beginSignIn(identity, opts.origin, url.searchParams.get('next') ?? undefined);
|
|
419
|
+
|
|
420
|
+
// ── the CLI signs in through the browser (RFC 8628's device authorization, brokered here, as `gh auth login`
|
|
421
|
+
// does): the command asks for a code, the person approves it here signed in as themselves, and the command
|
|
422
|
+
// collects a personal token named for it. Codes live ten minutes, in memory; a restart only asks again. ──
|
|
423
|
+
if (path === '/-/cli/device' && request.method === 'POST') {
|
|
424
|
+
const body = (await request.json().catch(() => ({}))) as { name?: unknown };
|
|
425
|
+
for (const [k, g] of devices) if (g.expires < Date.now()) devices.delete(k);
|
|
426
|
+
// at the cap the oldest pending code goes: a flood of codes cannot keep everyone else from signing in
|
|
427
|
+
while (devices.size >= 1000) { const oldest = devices.keys().next().value; if (oldest === undefined) break; devices.delete(oldest); }
|
|
428
|
+
const letters = 'BCDFGHJKLMNPQRSTVWXZ'; // no vowels (no words), none that read alike
|
|
429
|
+
const code = Array.from(randomBytes(8), (b) => letters[b % letters.length]).join('');
|
|
430
|
+
const userCode = `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
431
|
+
const deviceCode = randomBytes(32).toString('base64url');
|
|
432
|
+
devices.set(deviceCode, { userCode, name: String(body.name ?? 'a terminal').replace(/[^\w .@-]/g, '').slice(0, 60) || 'a terminal', expires: Date.now() + 600_000, state: 'pending' });
|
|
433
|
+
return json({ device_code: deviceCode, user_code: userCode, verification_uri: `${opts.origin}/-/cli/approve`, verification_uri_complete: `${opts.origin}/-/cli/approve?code=${userCode}`, expires_in: 600, interval: 5 }, 200, { 'cache-control': 'no-store' });
|
|
434
|
+
}
|
|
435
|
+
if (path === '/-/cli/approve') {
|
|
436
|
+
const pendingFor = (code: string): [string, DeviceGrant] | undefined => [...devices.entries()].find(([, g]) => g.userCode === code && g.state === 'pending' && g.expires > Date.now());
|
|
437
|
+
const shell = (inner: string): Response => pageShell('Sign in the volter command', inner, { csp: "default-src 'self'; img-src 'self' https://brand.volter.ai; style-src 'self' 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'" });
|
|
438
|
+
if (request.method === 'GET') {
|
|
439
|
+
const code = (url.searchParams.get('code') ?? '').toUpperCase().replace(/[^A-Z-]/g, '').slice(0, 9);
|
|
440
|
+
const session = await sessionOf(request);
|
|
441
|
+
if (!session || Tokens.looksLike(session.token) || session.person.support) return beginSignIn(identity, opts.origin, `/-/cli/approve?code=${encodeURIComponent(code)}`);
|
|
442
|
+
const found = pendingFor(code);
|
|
443
|
+
if (!found) return shell('<p role="alert">That code has expired or was already used. Run <code>volter login</code> again.</p>');
|
|
444
|
+
const [, grant] = found;
|
|
445
|
+
return shell(`<p>Sign in the <code>volter</code> command on <b>${grant.name}</b> as <b>${(session.person.email ?? session.person.id).replace(/[<>&"]/g, '')}</b>?</p><p><b>Only approve if you just ran <code>volter login</code> yourself.</b> Someone who sent you this link could otherwise sign their command in as you.</p><p>Check this code matches the one in your terminal:</p><p style="font:600 22px var(--mono);letter-spacing:.12em">${grant.userCode}</p><form method="post" action="/-/cli/approve" style="display:flex;gap:8px;justify-content:center"><input type="hidden" name="code" value="${grant.userCode}"><button type="submit" name="decision" value="approve">Approve</button><button type="submit" name="decision" value="deny" formnovalidate style="background:none;color:inherit">Deny</button></form>`);
|
|
446
|
+
}
|
|
447
|
+
if (request.method === 'POST') {
|
|
448
|
+
// only this page's own form: a page elsewhere cannot approve an attacker's code in a signed-in browser
|
|
449
|
+
if (request.headers.get('sec-fetch-site') !== 'same-origin') return new Response('Approve from the page itself.', { status: 403 });
|
|
450
|
+
// a person at the platform in their browser, signed in: never a token (a token cannot mint itself a wider one)
|
|
451
|
+
const session = await sessionOf(request);
|
|
452
|
+
if (!session || Tokens.looksLike(session.token) || session.person.support) return json({ error: 'approve from the platform in your browser, signed in' }, 401);
|
|
453
|
+
const form = new URLSearchParams(await request.text());
|
|
454
|
+
const found = pendingFor((form.get('code') ?? '').toUpperCase());
|
|
455
|
+
if (!found) return shell('<p role="alert">That code has expired or was already used. Run <code>volter login</code> again.</p>');
|
|
456
|
+
const [, grant] = found;
|
|
457
|
+
if (form.get('decision') === 'approve') { grant.state = 'approved'; grant.person = session.person; return shell(`<p role="status">The <code>volter</code> command on <b>${grant.name}</b> is signed in. You can close this tab.</p>`); }
|
|
458
|
+
grant.state = 'denied';
|
|
459
|
+
return shell('<p role="status">Refused. The command was not signed in.</p>');
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (path === '/-/cli/token' && request.method === 'POST') {
|
|
463
|
+
const body = (await request.json().catch(() => ({}))) as { device_code?: unknown };
|
|
464
|
+
const deviceCode = typeof body.device_code === 'string' ? body.device_code : '';
|
|
465
|
+
const grant = devices.get(deviceCode);
|
|
466
|
+
if (!grant || grant.expires < Date.now()) { devices.delete(deviceCode); return json({ error: 'expired_token' }, 400); }
|
|
467
|
+
if (grant.state === 'pending') return json({ error: 'authorization_pending' }, 400);
|
|
468
|
+
devices.delete(deviceCode); // collected once, approved or not
|
|
469
|
+
if (grant.state === 'denied' || !grant.person) return json({ error: 'access_denied' }, 400);
|
|
470
|
+
if (!tokens) return json({ error: 'this platform keeps no state directory; personal tokens need one' }, 503);
|
|
471
|
+
const tokenName = `volter CLI on ${grant.name}`;
|
|
472
|
+
// only what the command does: read your orgs, and read and push to their Worlds
|
|
473
|
+
const made = tokens.mint(grant.person.id, tokenName, { expiresInDays: 30, scopes: ['orgs:read', 'worlds:read', 'worlds:write'] });
|
|
474
|
+
for (const org of await orgsOfP(grant.person)) record({ event: 'TOKEN_CREATE', org: org.id, actor: grant.person, target: tokenName });
|
|
475
|
+
return json({ token: made.token, name: tokenName, expiresAt: made.record.expiresAt, person: { email: grant.person.email ?? null, id: grant.person.id } }, 200, { 'cache-control': 'no-store' });
|
|
476
|
+
}
|
|
477
|
+
// the provider's answer: the directory learns who signed in (and lets their invitations in), then the pages
|
|
478
|
+
if (path === CALLBACK_PATH && request.method === 'GET') return finishSignIn(identity, sessions, opts.origin, request, '/', (person) => dir.seen(person));
|
|
479
|
+
// ── sign out: the platform's session ends and its cookie goes ──
|
|
480
|
+
// sign-out changes state: a POST from the platform's own page, never a GET a link or an image could make
|
|
481
|
+
if (path === '/-/sign-out') return request.method === 'POST' ? endSession(sessions, opts.origin, request, '/?signed-out=1') : json({ error: "sign out with POST, from the platform's own page" }, 405);
|
|
482
|
+
|
|
483
|
+
// ── the platform's pages (pages.ts): every GET that is not a door is the page shell, which reads the doors ──
|
|
484
|
+
if (request.method === 'GET' && !path.startsWith('/-/') && !path.startsWith('/.well-known/')) {
|
|
485
|
+
// the operator's consented support view rides a token in the page; a spent or unknown one is refused here
|
|
486
|
+
const su = url.searchParams.get('support');
|
|
487
|
+
if (su !== null && !(tokens && Tokens.isSupportToken(su) && (() => { const rec = tokens.lookup(su); return rec !== null && !rec.expired; })())) return pageShell('Support session ended — Volter World', '<p role="alert">This support session has ended.</p>');
|
|
488
|
+
return (await pages.handle(request)) ?? json({ error: 'not found' }, 404);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── the doors the pages read (a session or a token, never a host's admin token) ──────────
|
|
492
|
+
// the twins a new World may have: what the host Worlds are made on can serve (its own answer, not a list kept here)
|
|
493
|
+
if (path === '/-/vendors' && request.method === 'GET') {
|
|
494
|
+
if (!(await sessionOf(request))) return unauthorized(request);
|
|
495
|
+
let host: HostProvider; try { host = defaultHost(); } catch (e) { return json({ error: (e as Error).message }, 503); }
|
|
496
|
+
const res = await fetch(`${host.base}/-/vendors`, { headers: { 'x-volter-token': host.adminToken } }).catch(() => null);
|
|
497
|
+
const body = res?.ok ? ((await res.json().catch(() => ({}))) as { vendors?: unknown }) : {};
|
|
498
|
+
if (!Array.isArray(body.vendors)) return json({ error: `the host ${host.id} did not say which twins it serves (${res?.status ?? 'unreachable'})` }, 502);
|
|
499
|
+
return json({ host: host.id, vendors: body.vendors.filter((v): v is string => typeof v === 'string') }, 200, { 'cache-control': 'private, max-age=300' });
|
|
500
|
+
}
|
|
501
|
+
if (path === '/-/worlds' && request.method === 'GET') {
|
|
502
|
+
const person = (await sessionOf(request))?.person;
|
|
503
|
+
if (!person) return unauthorized(request);
|
|
504
|
+
// addresses and twins: a World's keys never go to a person's browser (a member opens a World with a pass)
|
|
505
|
+
return json({ worlds: (await rowsForPerson(person)).map(({ token: _t, readToken: _r, ...row }) => row) });
|
|
506
|
+
}
|
|
507
|
+
if (path === '/-/worlds' && request.method === 'POST') {
|
|
508
|
+
const person = (await sessionOf(request))?.person;
|
|
509
|
+
if (!person) return unauthorized(request);
|
|
510
|
+
let body: { org?: string; world?: string; vendors?: string[] };
|
|
511
|
+
try { body = (await request.json()) as typeof body; } catch { return json({ error: 'provision: { org, world, vendors }' }, 400); }
|
|
512
|
+
const org: OrgRef | null = body.org ? await memberOfP(person, body.org) : null;
|
|
513
|
+
if (!org) return json({ error: `you are not a member of org ${body.org}` }, 403);
|
|
514
|
+
if (!body.world || !Array.isArray(body.vendors) || body.vendors.length === 0) return json({ error: 'provision: { org, world, vendors: [vendor, …] }' }, 400);
|
|
515
|
+
try { assertName('a world name', body.world); } catch (e) { return json({ error: (e as Error).message }, 400); }
|
|
516
|
+
const out = await provision(person, org, body.world, body.vendors);
|
|
517
|
+
if (out instanceof Response) return out;
|
|
518
|
+
const { token: _t, readToken: _r, ...kept } = out.made;
|
|
519
|
+
return json({ ...kept, base: out.base }, 201);
|
|
520
|
+
}
|
|
521
|
+
// a sample World for an org that has none: made as any World (metered, deletable), then seeded through the
|
|
522
|
+
// vendors' own APIs with its token, which never leaves the platform (sample.ts)
|
|
523
|
+
if (path === '/-/worlds/sample' && request.method === 'POST') {
|
|
524
|
+
const person = (await sessionOf(request))?.person;
|
|
525
|
+
if (!person) return unauthorized(request);
|
|
526
|
+
let body: { org?: string }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'a sample World: { org }' }, 400); }
|
|
527
|
+
const org: OrgRef | null = body.org ? await memberOfP(person, body.org) : null;
|
|
528
|
+
if (!org) return json({ error: `you are not a member of org ${body.org}` }, 403);
|
|
529
|
+
let host: HostProvider; try { host = defaultHost(); } catch (e) { return json({ error: (e as Error).message }, 503); }
|
|
530
|
+
const offered = await fetch(`${host.base}/-/vendors`, { headers: { 'x-volter-token': host.adminToken } }).then((r) => (r.ok ? r.json() as Promise<{ vendors?: string[] }> : { vendors: [] as string[] })).catch(() => ({ vendors: [] as string[] }));
|
|
531
|
+
const vendors = SAMPLE_VENDORS.filter((v) => offered.vendors?.includes(v));
|
|
532
|
+
if (!vendors.length) return json({ error: `the host serves none of the sample's vendors (${SAMPLE_VENDORS.join(', ')})` }, 409);
|
|
533
|
+
const name = `${org.slug ?? org.id}/${SAMPLE_WORLD}`;
|
|
534
|
+
if (opts.store.doc(org.id).worlds.some((w) => w.name === name)) return json({ error: `${name} exists: open it, or delete it to make a new one` }, 409);
|
|
535
|
+
const out = await provision(person, org, SAMPLE_WORLD, vendors);
|
|
536
|
+
if (out instanceof Response) return out;
|
|
537
|
+
const seeded = out.made.token ? await seedSample(out.base, out.made.token, vendors) : {};
|
|
538
|
+
const { token: _t, readToken: _r, ...kept } = out.made;
|
|
539
|
+
return json({ ...kept, base: out.base, seeded }, 201);
|
|
540
|
+
}
|
|
541
|
+
if (/^\/-\/worlds\/[^/]+\/[^/]+$/.test(path) && request.method === 'DELETE') {
|
|
542
|
+
const person = (await sessionOf(request))?.person;
|
|
543
|
+
if (!person) return unauthorized(request);
|
|
544
|
+
const [, , , orgRef, world] = path.split('/');
|
|
545
|
+
const org = orgRef ? await memberOfP(person, orgRef) : null;
|
|
546
|
+
if (!org) return json({ error: 'not a member' }, 403);
|
|
547
|
+
const name = `${org.slug ?? org.id}/${world}`; const held = opts.store.doc(org.id).worlds.find((w) => w.name === name);
|
|
548
|
+
if (!held) return json({ error: `no world ${name} in ${orgRef}` }, 404);
|
|
549
|
+
const host = hostOf(held.host);
|
|
550
|
+
if (!host) return json({ error: `the host ${held.host} that serves ${name} is not enrolled` }, 502);
|
|
551
|
+
const gone = await fetch(`${host.base}/-/worlds/${name}`, { method: 'DELETE', headers: { 'x-volter-token': host.adminToken } });
|
|
552
|
+
if (!gone.ok && gone.status !== 404) return json({ error: `the host refused to remove ${name}: ${gone.status}` }, 502);
|
|
553
|
+
opts.store.removeWorld(org.id, name); record({ event: 'WORLD_DELETE', org: org.id, actor: person, target: `${orgRef}/${world}` }); // the same name the provision row uses (walk 6)
|
|
554
|
+
await mail(await adminsOf(org.id), `World ${orgRef}/${world} was deleted`, `${person.email ?? person.id} deleted the world ${orgRef}/${world} from ${orgLabel(org.id, org.slug)}. Its host no longer serves it.`);
|
|
555
|
+
return json({ removed: name });
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── the operator's doors: the hosts worlds are provisioned onto ──────────────────────
|
|
559
|
+
if (path === '/-/hosts' && request.method === 'GET') {
|
|
560
|
+
if (!isOperator(request)) return json({ error: 'the operator token opens this' }, 401);
|
|
561
|
+
return json({ hosts: opts.hosts.map((h) => ({ id: h.id, base: h.base })) });
|
|
562
|
+
}
|
|
563
|
+
if (path === '/-/hosts' && request.method === 'POST') {
|
|
564
|
+
if (!isOperator(request)) return json({ error: 'the operator token opens this' }, 401);
|
|
565
|
+
let body: { id?: string; base?: string; adminToken?: string };
|
|
566
|
+
try { body = (await request.json()) as typeof body; } catch { return json({ error: 'enroll a host: { id, base, adminToken }' }, 400); }
|
|
567
|
+
if (!body.id || !body.base || !body.adminToken) return json({ error: 'enroll a host: { id, base, adminToken }' }, 400);
|
|
568
|
+
let base: string;
|
|
569
|
+
try { const u = new URL(body.base); if (u.protocol !== 'https:' && u.protocol !== 'http:') throw new Error(); base = u.origin; } catch { return json({ error: 'base is the host\'s origin (https://…)' }, 400); }
|
|
570
|
+
if (opts.hosts.some((h) => h.id === body.id)) return json({ error: `a host ${body.id} is enrolled` }, 409);
|
|
571
|
+
// a host is enrolled only when its admin door answers the token it was given
|
|
572
|
+
const probe = await fetch(`${base}/-/worlds`, { headers: { 'x-volter-token': body.adminToken } }).catch(() => null);
|
|
573
|
+
if (!probe?.ok) return json({ error: `the host at ${base} did not open its admin door to that token (${probe?.status ?? 'unreachable'})` }, 502);
|
|
574
|
+
const host: HostProvider = { id: body.id, base, adminToken: body.adminToken };
|
|
575
|
+
opts.hosts.push(host); persist();
|
|
576
|
+
return json({ id: host.id, base: host.base }, 201);
|
|
577
|
+
}
|
|
578
|
+
const hostDoor = /^\/-\/hosts\/([^/]+)$/.exec(path);
|
|
579
|
+
if (hostDoor && request.method === 'DELETE') {
|
|
580
|
+
if (!isOperator(request)) return json({ error: 'the operator token opens this' }, 401);
|
|
581
|
+
const id = hostDoor[1]!; const i = opts.hosts.findIndex((h) => h.id === id);
|
|
582
|
+
if (i < 0) return json({ error: `no enrolled host ${id}` }, 404);
|
|
583
|
+
opts.hosts.splice(i, 1); persist();
|
|
584
|
+
return json({ removed: id });
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ── orgs and members ─────────────────────────────────────────────────────────────────
|
|
588
|
+
if (path === '/-/orgs' && request.method === 'GET') {
|
|
589
|
+
const person = (await sessionOf(request))?.person;
|
|
590
|
+
if (!person) return unauthorized(request);
|
|
591
|
+
const orgs = await orgsOfP(person);
|
|
592
|
+
const session = { person, token: presentedToken(request) ?? '' };
|
|
593
|
+
return json({ person, orgs: orgs.map((o) => ({ ...o, worlds: opts.store.doc(o.id).worlds, security: opts.store.securityOf(o.id), held: heldBy(o.id, session), flags: flagsOf(o.id) })) });
|
|
594
|
+
}
|
|
595
|
+
if (path === '/-/orgs' && request.method === 'POST') {
|
|
596
|
+
const person = (await sessionOf(request))?.person;
|
|
597
|
+
if (!person) return unauthorized(request);
|
|
598
|
+
let body: { name?: string; accepted?: boolean };
|
|
599
|
+
try { body = (await request.json()) as typeof body; } catch { return json({ error: 'create org: { name, accepted? }' }, 400); }
|
|
600
|
+
if (!body.name) return json({ error: 'create org: { name, accepted? }' }, 400);
|
|
601
|
+
// a platform with a site asks for its terms; a self-hosted one has none to ask for
|
|
602
|
+
if (opts.siteUrl && body.accepted !== true) return json({ error: `creating an org means accepting the terms of service and the privacy policy (${opts.siteUrl.replace(/\/+$/, '')}/terms.html)` }, 400);
|
|
603
|
+
// an org's slug is the first segment of its pages' paths: never one of the pages' own names
|
|
604
|
+
if ((RESERVED_ORG_NAMES as readonly string[]).includes(body.name)) return json({ error: `${body.name} is a name the platform's pages use: choose another` }, 409);
|
|
605
|
+
try { assertName('an org name', body.name); const made = await dir.createOrg(body.name, person.id); record({ event: 'ORG_CREATE', org: made.id, actor: person, target: made.slug ?? made.name }); return json(made, 201); } catch (e) { return json({ error: (e as Error).message }, 409); }
|
|
606
|
+
}
|
|
607
|
+
// ── billing: the attached biller's doors, the org resolved here; none without a biller ──
|
|
608
|
+
const billingDoor = biller ? /^\/-\/orgs\/([^/]+)\/([a-z-]+)$/.exec(path) : null;
|
|
609
|
+
if (biller && billingDoor && biller.verbs.includes(billingDoor[2]!)) {
|
|
610
|
+
const session = await sessionOf(request);
|
|
611
|
+
if (!session) return unauthorized(request);
|
|
612
|
+
const org = await memberOfP(session.person, billingDoor[1]!);
|
|
613
|
+
if (!org) return json({ error: `you are not a member of org ${billingDoor[1]}` }, 403);
|
|
614
|
+
{ const why = heldBy(org.id, session); if (why) return json({ error: why, held: true }, 403); }
|
|
615
|
+
return biller.door(request, billingDoor[2]!, { org: { id: org.id, slug: org.slug }, admin: isAdmin(org), person: { id: session.person.id, ...(session.person.email ? { email: session.person.email } : {}) } });
|
|
616
|
+
}
|
|
617
|
+
// the biller's hourly tick on demand: the operator's door in rehearsal, the clock in production
|
|
618
|
+
// the member sweep on demand (the clock runs it hourly): keys of people no longer in an org, revoked in its Worlds
|
|
619
|
+
if (path === '/-/operator/sweep-members' && request.method === 'POST') {
|
|
620
|
+
if (!isOperator(request)) return json({ error: 'the operator token opens this' }, 401);
|
|
621
|
+
return json({ revoked: await sweepRemovedMembers() });
|
|
622
|
+
}
|
|
623
|
+
if (path === '/-/meter/tick' && request.method === 'POST') {
|
|
624
|
+
if (!isOperator(request)) return json({ error: 'the tick is the operator\'s' }, 403);
|
|
625
|
+
if (!biller) return json({ error: 'no biller is attached: nothing is metered' }, 404);
|
|
626
|
+
return json(await biller.tick());
|
|
627
|
+
}
|
|
628
|
+
// ── the team surface: members, roles, the org, personal tokens, a world's token, the audit log ──
|
|
629
|
+
const teamOrg = /^\/-\/orgs\/([^/]+)(?:\/(members|invitations|tokens|security|support-access|webhooks|labs|audit|audit\.jsonl|export)(?:\/([^/]+)(?:\/(test|deliveries|resend))?)?)?$/.exec(path);
|
|
630
|
+
if (teamOrg && !(teamOrg[2] === 'members' && request.method === 'POST' && !teamOrg[3])) {
|
|
631
|
+
const session = await sessionOf(request);
|
|
632
|
+
if (!session) return unauthorized(request);
|
|
633
|
+
const person = session.person;
|
|
634
|
+
const org = await memberOfP(person, teamOrg[1]!);
|
|
635
|
+
if (!org) return json({ error: `you are not a member of org ${teamOrg[1]}` }, 403);
|
|
636
|
+
const [, , part, sub, verb] = teamOrg;
|
|
637
|
+
{ const why = heldBy(org.id, session); if (why) return json({ error: why, held: true }, 403); }
|
|
638
|
+
// labs (layer 8): the early-access features, on per org, or all of them with the early-adopter switch
|
|
639
|
+
if (part === 'labs' && !sub && request.method === 'GET') { const doc = opts.store.doc(org.id); return json({ features: EARLY_FEATURES.map((f) => ({ ...f, on: Boolean(doc.flags?.[f.key]) })), earlyAdopter: Boolean(doc.earlyAdopter), flags: flagsOf(org.id) }); }
|
|
640
|
+
if (part === 'labs' && !sub && request.method === 'PATCH') {
|
|
641
|
+
if (!isAdmin(org)) return json({ error: 'only an admin changes the org\'s labs' }, 403);
|
|
642
|
+
let body: { flags?: Record<string, boolean>; earlyAdopter?: boolean }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'labs: { flags?: { <key>: boolean }, earlyAdopter?: boolean }' }, 400); }
|
|
643
|
+
const known = new Set<string>(EARLY_FEATURES.map((f) => f.key));
|
|
644
|
+
const flags = body.flags ? Object.fromEntries(Object.entries(body.flags).filter(([k]) => known.has(k)).map(([k, v]) => [k, Boolean(v)])) : undefined;
|
|
645
|
+
opts.store.setLabs(org.id, { ...(flags ? { flags } : {}), ...(body.earlyAdopter !== undefined ? { earlyAdopter: Boolean(body.earlyAdopter) } : {}) });
|
|
646
|
+
record({ event: 'LABS_CHANGE', org: org.id, actor: person, data: { ...(flags ?? {}), ...(body.earlyAdopter !== undefined ? { earlyAdopter: Boolean(body.earlyAdopter) } : {}) } });
|
|
647
|
+
return json({ flags: flagsOf(org.id), earlyAdopter: Boolean(opts.store.doc(org.id).earlyAdopter) });
|
|
648
|
+
}
|
|
649
|
+
// webhooks out (layer 7): endpoints, the secret once, a test event, the last deliveries
|
|
650
|
+
if (part === 'webhooks') {
|
|
651
|
+
if (!flagsOf(org.id).webhooks) return json({ error: 'webhooks are an early-access feature — turn them on under Org settings › Labs' }, 403);
|
|
652
|
+
if (!isAdmin(org)) return json({ error: 'only an admin of the org manages webhooks' }, 403);
|
|
653
|
+
const shown = (w: WebhookEndpoint) => ({ id: w.id, url: w.url, events: w.events, description: w.description, createdAt: w.createdAt, consecutiveFailures: w.consecutiveFailures, lastFailedAt: w.lastFailedAt, disabledAt: w.disabledAt });
|
|
654
|
+
if (!sub && request.method === 'GET') return json({ webhooks: opts.store.webhooksOf(org.id).map(shown), events: WEBHOOK_EVENT_NAMES });
|
|
655
|
+
if (!sub && request.method === 'POST') {
|
|
656
|
+
let body: { url?: string; events?: string[]; description?: string }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'webhook: { url, events: ["*" | names], description? }' }, 400); }
|
|
657
|
+
let url: URL; try { url = new URL(String(body.url ?? '')); if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('scheme'); } catch { return json({ error: 'url: an http(s) address the platform can reach' }, 400); }
|
|
658
|
+
{ const why = await refusedEndpoint(url.toString(), webhooksInside); if (why) return json({ error: `url: ${why}` }, 400); }
|
|
659
|
+
const events = body.events === undefined ? ['*'] : body.events; if (!validEvents(events)) return json({ error: `events: "*" or any of ${WEBHOOK_EVENT_NAMES.join(', ')}` }, 400);
|
|
660
|
+
if (opts.store.webhooksOf(org.id).length >= 20) return json({ error: 'an org holds at most twenty endpoints' }, 409);
|
|
661
|
+
const w: WebhookEndpoint = { id: newId('whk'), url: url.toString(), secret: newSecret(), events, description: String(body.description ?? '').slice(0, 200), createdAt: new Date().toISOString(), consecutiveFailures: 0, lastFailedAt: null, disabledAt: null };
|
|
662
|
+
opts.store.setWebhooks(org.id, [...opts.store.webhooksOf(org.id), w]); record({ event: 'WEBHOOK_CREATE', org: org.id, actor: person, target: w.url, data: { events } });
|
|
663
|
+
return json({ ...shown(w), secret: w.secret }, 201);
|
|
664
|
+
}
|
|
665
|
+
const w = sub ? opts.store.webhooksOf(org.id).find((x) => x.id === sub) : undefined; if (sub && !w) return json({ error: 'no such endpoint' }, 404);
|
|
666
|
+
if (w && !verb && request.method === 'PATCH') {
|
|
667
|
+
let body: { events?: string[]; description?: string; enabled?: boolean }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'webhook: { events?, description?, enabled? }' }, 400); }
|
|
668
|
+
if (body.events !== undefined && !validEvents(body.events)) return json({ error: `events: "*" or any of ${WEBHOOK_EVENT_NAMES.join(', ')}` }, 400);
|
|
669
|
+
const patch: Partial<WebhookEndpoint> = { ...(body.events ? { events: body.events } : {}), ...(body.description !== undefined ? { description: String(body.description).slice(0, 200) } : {}), ...(body.enabled === true ? { disabledAt: null, consecutiveFailures: 0 } : body.enabled === false ? { disabledAt: new Date().toISOString() } : {}) };
|
|
670
|
+
const next = opts.store.updateWebhook(org.id, w.id, patch)!; record({ event: 'WEBHOOK_CHANGE', org: org.id, actor: person, target: w.url, data: patch as Record<string, unknown> });
|
|
671
|
+
return json(shown(next));
|
|
672
|
+
}
|
|
673
|
+
if (w && !verb && request.method === 'DELETE') { opts.store.setWebhooks(org.id, opts.store.webhooksOf(org.id).filter((x) => x.id !== w.id)); record({ event: 'WEBHOOK_DELETE', org: org.id, actor: person, target: w.url }); return json({ deleted: w.id }); }
|
|
674
|
+
if (w && verb === 'test' && request.method === 'POST') {
|
|
675
|
+
const eventId = newId('evt'); const createdAt = new Date().toISOString();
|
|
676
|
+
const body = JSON.stringify({ id: eventId, event: 'ping', createdAt, org: { id: org.id, slug: org.slug }, data: { by: person.email ?? person.id } });
|
|
677
|
+
const d: Delivery = { id: newId('dlv'), orgId: org.id, endpointId: w.id, eventId, event: 'ping', body, attempt: 0, nextAt: createdAt, attempts: [], state: 'pending' };
|
|
678
|
+
opts.store.enqueueDeliveries([d]); await deliverDue();
|
|
679
|
+
const done = opts.store.deliveries().find((x) => x.id === d.id) ?? d;
|
|
680
|
+
return json({ delivery: done });
|
|
681
|
+
}
|
|
682
|
+
if (w && verb === 'deliveries' && request.method === 'GET') return json({ deliveries: opts.store.deliveries().filter((d) => d.endpointId === w.id).slice(-50).reverse() });
|
|
683
|
+
return json({ error: `no such door: ${request.method} ${path}` }, 404);
|
|
684
|
+
}
|
|
685
|
+
if (part === 'support-access' && !sub && request.method === 'GET') return json({ supportAccess: opts.store.supportAccessOf(org.id) });
|
|
686
|
+
if (part === 'support-access' && !sub && request.method === 'DELETE') { if (!isAdmin(org)) return json({ error: 'only an admin revokes support access' }, 403); opts.store.setSupportAccess(org.id, null); record({ event: 'SUPPORT_ACCESS_REVOKED', org: org.id, actor: person }); return json({ revoked: true }); }
|
|
687
|
+
// the security page (layer 6): four switches, the admin's; every change is an audit row
|
|
688
|
+
if (part === 'security' && !sub && request.method === 'GET') return json({ security: opts.store.securityOf(org.id) });
|
|
689
|
+
if (part === 'security' && !sub && request.method === 'PATCH') {
|
|
690
|
+
if (!isAdmin(org)) return json({ error: 'only an admin changes the org\'s security' }, 403);
|
|
691
|
+
let body: Partial<Security>; try { body = (await request.json()) as Partial<Security>; } catch { return json({ error: 'security: { membersCanInvite?, membersCanUsePersonalTokens?, approvedEmailDomains? }' }, 400); }
|
|
692
|
+
const cur = opts.store.securityOf(org.id);
|
|
693
|
+
const domains = body.approvedEmailDomains === undefined ? cur.approvedEmailDomains : (Array.isArray(body.approvedEmailDomains) ? body.approvedEmailDomains : []).map((d) => String(d).trim().toLowerCase().replace(/^@/, '')).filter((d) => /^[a-z0-9.-]+\.[a-z]{2,}$/.test(d));
|
|
694
|
+
const next: Security = { membersCanInvite: body.membersCanInvite === undefined ? cur.membersCanInvite : Boolean(body.membersCanInvite), membersCanUsePersonalTokens: body.membersCanUsePersonalTokens === undefined ? cur.membersCanUsePersonalTokens : Boolean(body.membersCanUsePersonalTokens), approvedEmailDomains: domains };
|
|
695
|
+
opts.store.setSecurity(org.id, next); record({ event: 'SECURITY_CHANGE', org: org.id, actor: person, data: { ...next } });
|
|
696
|
+
return json({ security: next });
|
|
697
|
+
}
|
|
698
|
+
// org tokens (layer 6): the org's CI credential — admin-made, org-bound, scoped, expiring, shown once
|
|
699
|
+
if (part === 'tokens' && !sub && request.method === 'GET') { if (!isAdmin(org)) return json({ error: 'only an admin sees the org\'s tokens' }, 403); return json({ tokens: (tokens?.listOrg(org.id) ?? []).map(({ hash, ...rest }) => ({ id: hash, ...rest })) }); }
|
|
700
|
+
if (part === 'tokens' && !sub && request.method === 'POST') {
|
|
701
|
+
if (!isAdmin(org)) return json({ error: 'only an admin makes an org token' }, 403);
|
|
702
|
+
if (!tokens) return json({ error: 'this platform keeps no state directory' }, 503);
|
|
703
|
+
let body: { name?: string; expiresInDays?: number | null; scopes?: string[] }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'org token: { name, scopes?, expiresInDays? }' }, 400); }
|
|
704
|
+
if (body.scopes !== undefined && !validScopes(body.scopes)) return json({ error: 'scopes: "*" or <object>:<read|write> over worlds, orgs, members, billing, tokens, activity' }, 400);
|
|
705
|
+
const days = body.expiresInDays === undefined ? undefined : body.expiresInDays === null ? null : Number(body.expiresInDays);
|
|
706
|
+
if (days !== undefined && days !== null && !(days > 0 && days <= 365)) return json({ error: 'expiresInDays: 1–365, or null' }, 400);
|
|
707
|
+
const made = tokens.mint(person.id, body.name || 'ci', { orgId: org.id, ...(days === undefined ? {} : { expiresInDays: days }), scopes: body.scopes ?? ['worlds:read', 'orgs:read', 'activity:read'] });
|
|
708
|
+
record({ event: 'ORG_TOKEN_CREATE', org: org.id, actor: person, target: made.record.name, data: { scopes: made.record.scopes, expiresAt: made.record.expiresAt ?? null } });
|
|
709
|
+
return json({ token: made.token, id: made.record.hash, name: made.record.name, scopes: made.record.scopes, expiresAt: made.record.expiresAt ?? null, createdAt: made.record.createdAt, last4: made.record.last4 }, 201);
|
|
710
|
+
}
|
|
711
|
+
if (part === 'tokens' && sub && request.method === 'DELETE') {
|
|
712
|
+
if (!isAdmin(org)) return json({ error: 'only an admin revokes an org token' }, 403);
|
|
713
|
+
const gone = tokens?.revokeOrg(org.id, sub); if (!gone) return json({ error: 'no such token of this org' }, 404);
|
|
714
|
+
await revokeTokenKeys(gone.worldKeys); // the World keys CI made with it go with it
|
|
715
|
+
record({ event: 'ORG_TOKEN_REVOKE', org: org.id, actor: person, target: gone.name }); return json({ revoked: gone.name });
|
|
716
|
+
}
|
|
717
|
+
if (part === 'members' && request.method === 'GET' && !sub) return json({ org: org.id, members: await dir.membersOf(org.id), pending: await dir.pendingInvitations(org.id) });
|
|
718
|
+
if (part === 'invitations' && sub && request.method === 'DELETE') {
|
|
719
|
+
if (!isAdmin(org)) return json({ error: 'only an admin of the org revokes invitations' }, 403);
|
|
720
|
+
const inv = (await dir.pendingInvitations(org.id)).find((i) => i.id === sub); if (!inv) return json({ error: 'no such pending invitation' }, 404);
|
|
721
|
+
await dir.revokeInvitation(org.id, sub); record({ event: 'INVITE_REVOKE', org: org.id, actor: person, target: inv.email });
|
|
722
|
+
return json({ revoked: inv.email });
|
|
723
|
+
}
|
|
724
|
+
// resend a pending invitation: a fresh one to the same address and role (the directory's id may change), mailed again
|
|
725
|
+
if (part === 'invitations' && sub && verb === 'resend' && request.method === 'POST') {
|
|
726
|
+
if (!isAdmin(org) && !opts.store.securityOf(org.id).membersCanInvite) return json({ error: 'only an admin of the org resends invitations' }, 403);
|
|
727
|
+
const inv = (await dir.pendingInvitations(org.id)).find((i) => i.id === sub); if (!inv) return json({ error: 'no such pending invitation' }, 404);
|
|
728
|
+
if (inv.role.endsWith('admin') && !isAdmin(org)) return json({ error: 'only an admin resends an invitation to be an admin' }, 403);
|
|
729
|
+
{ const why = domainAllowed(org.id, inv.email); if (why) return json({ error: why }, 403); } // the domains may have narrowed since
|
|
730
|
+
await dir.revokeInvitation(org.id, sub);
|
|
731
|
+
const again = await dir.inviteMember(org.id, inv.email, person.id, inv.role);
|
|
732
|
+
if (!dir.mailsInvitations) await mail([again.email], `A reminder: you are invited to ${orgLabel(org.id, org.slug)} on Volter World`, `You are invited to the org ${orgLabel(org.id, org.slug)}. Sign in at ${opts.origin}/ with ${identity.name}, as ${again.email}, before ${(again.expiresAt ?? '').slice(0, 10)} to join it.`);
|
|
733
|
+
record({ event: 'MEMBER_INVITE', org: org.id, actor: person, target: again.email, data: { pending: true, invitation: again.id, resent: true } });
|
|
734
|
+
return json({ invited: again.email, pending: true, id: again.id, role: again.role, expiresAt: again.expiresAt }, 201);
|
|
735
|
+
}
|
|
736
|
+
// leaving: a member removes themselves (the only admin may not, or the org is orphaned)
|
|
737
|
+
const leaving = part === 'members' && sub === person.id && request.method === 'DELETE' && !person.orgToken && !person.support;
|
|
738
|
+
if (part === 'members' && sub && (request.method === 'DELETE' || request.method === 'PATCH')) {
|
|
739
|
+
if (!isAdmin(org) && !leaving) return json({ error: 'only an admin of the org changes members' }, 403);
|
|
740
|
+
if (request.method === 'DELETE') {
|
|
741
|
+
const members = await dir.membersOf(org.id);
|
|
742
|
+
const target = members.find((m) => m.userId === sub); if (!target) return json({ error: 'no such member' }, 404);
|
|
743
|
+
const otherAdmins = members.filter((m) => m.userId !== sub && m.role.endsWith('admin')).length;
|
|
744
|
+
if (target.role.endsWith('admin') && otherAdmins === 0) return json({ error: `${target.email ?? sub} is the org's only admin — make another member admin first` }, 409); // walk 3b: an org left with no admin is orphaned
|
|
745
|
+
try { await dir.removeMember(org.id, sub); } catch (e) { if (e instanceof OnlyAdmin) return json({ error: `${target.email ?? sub} is the org's only admin — make another member admin first` }, 409); throw e; }
|
|
746
|
+
await revokePersonKeys(org.id, sub); record({ event: 'MEMBER_REMOVE', org: org.id, actor: person, target: target.email ?? sub, data: { userId: sub } }); return json({ removed: sub, email: target.email });
|
|
747
|
+
}
|
|
748
|
+
let body: { role?: string }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'role: { role: admin | member }' }, 400); }
|
|
749
|
+
const role = body.role === 'admin' || body.role === 'org:admin' ? 'org:admin' : body.role === 'member' || body.role === 'org:member' ? 'org:member' : null;
|
|
750
|
+
if (!role) return json({ error: 'role: { role: admin | member }' }, 400);
|
|
751
|
+
const members = await dir.membersOf(org.id);
|
|
752
|
+
const who = members.find((m) => m.userId === sub); if (!who) return json({ error: 'no such member' }, 404);
|
|
753
|
+
// the only admin may not step down: an org with no admin cannot be managed, billed or deleted
|
|
754
|
+
if (role === 'org:member' && who.role.endsWith('admin') && !members.some((m) => m.userId !== sub && m.role.endsWith('admin'))) return json({ error: `${who.email ?? sub} is the org's only admin — make another member admin first` }, 409);
|
|
755
|
+
try { await dir.setMemberRole(org.id, sub, role); } catch (e) { if (e instanceof OnlyAdmin) return json({ error: `${who.email ?? sub} is the org's only admin — make another member admin first` }, 409); throw e; }
|
|
756
|
+
record({ event: 'MEMBER_ROLE', org: org.id, actor: person, target: who?.email ?? sub, data: { role, userId: sub } }); return json({ userId: sub, role }); // the log names the person, not the id (walk 3)
|
|
757
|
+
}
|
|
758
|
+
if (part === 'audit' && request.method === 'GET') { if (!isAdmin(org)) return json({ error: 'only an admin reads the audit log' }, 403); return json({ org: org.id, entries: audit?.read(org.id) ?? [] }); }
|
|
759
|
+
if (part === 'audit.jsonl' && request.method === 'GET') { if (!isAdmin(org)) return json({ error: 'only an admin reads the audit log' }, 403); const lines = (audit?.read(org.id, 100_000) ?? []).reverse().map((e) => JSON.stringify(e)).join('\n'); return new Response(`${lines}\n`, { headers: { 'content-type': 'application/x-ndjson', 'content-disposition': `attachment; filename="${orgLabel(org.id, org.slug)}-audit.jsonl"` } }); }
|
|
760
|
+
if (part === 'export' && request.method === 'GET') {
|
|
761
|
+
if (!isAdmin(org)) return json({ error: 'only an admin exports the org' }, 403);
|
|
762
|
+
const worlds = (await rowsForPerson(person)).filter((r) => r.name.startsWith(`${org.slug ?? org.id}/`)).map(({ token, readToken, ...w }) => w);
|
|
763
|
+
return new Response(JSON.stringify({ exportedAt: new Date().toISOString(), org: { id: org.id, slug: org.slug, name: org.name }, record: opts.store.doc(org.id), worlds, members: await dir.membersOf(org.id), audit: audit?.read(org.id, 100_000) ?? [] }, null, 2), { headers: { 'content-type': 'application/json', 'content-disposition': `attachment; filename="${orgLabel(org.id, org.slug)}-export.json"` } });
|
|
764
|
+
}
|
|
765
|
+
if (!part && request.method === 'PATCH') {
|
|
766
|
+
if (!isAdmin(org)) return json({ error: 'only an admin renames the org' }, 403);
|
|
767
|
+
let body: { name?: string }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'rename: { name }' }, 400); }
|
|
768
|
+
if (!body.name) return json({ error: 'rename: { name }' }, 400);
|
|
769
|
+
if (!/^[^\n\r]{1,64}$/.test(body.name.trim())) return json({ error: 'an org name is 1 to 64 characters on one line' }, 400); // a display name, not the slug
|
|
770
|
+
try { const renamed = await dir.renameOrg(org.id, body.name.trim()); record({ event: 'ORG_RENAME', org: org.id, actor: person, target: body.name }); return json(renamed); } catch (e) { return json({ error: (e as Error).message }, 409); }
|
|
771
|
+
}
|
|
772
|
+
if (!part && request.method === 'DELETE') {
|
|
773
|
+
if (!isAdmin(org)) return json({ error: 'only an admin deletes the org' }, 403);
|
|
774
|
+
const everything = new URL(request.url).searchParams.get('everything') === '1';
|
|
775
|
+
const doc = opts.store.doc(org.id); const held = doc.worlds.length;
|
|
776
|
+
if (held > 0 && !everything) return json({ error: `the org still holds ${held} world${held === 1 ? '' : 's'} — delete them first, or delete the org with everything it holds` }, 409);
|
|
777
|
+
if (held > 0) { // children first: every world through its host, then the org
|
|
778
|
+
for (const w of [...doc.worlds]) { const host = hostOf(w.host); if (host) await fetch(`${host.base}/-/worlds/${w.name}`, { method: 'DELETE', headers: { 'x-volter-token': host.adminToken } }).catch(() => undefined); opts.store.removeWorld(org.id, w.name); record({ event: 'WORLD_DELETE', org: org.id, actor: person, target: w.name, data: { withOrg: true } }); }
|
|
779
|
+
}
|
|
780
|
+
await dir.deleteOrg(org.id); record({ event: 'ORG_DELETE', org: org.id, actor: person, target: org.slug ?? org.name }); return json({ deleted: org.id });
|
|
781
|
+
}
|
|
782
|
+
if (!part && request.method === 'GET') return json({ ...org, worlds: opts.store.doc(org.id).worlds });
|
|
783
|
+
return json({ error: `no such door: ${request.method} ${path}` }, 404);
|
|
784
|
+
}
|
|
785
|
+
// ── support and access (layer 6): the Help form, the consent, the operator's consented view ──
|
|
786
|
+
if (path === '/-/support' && request.method === 'POST') {
|
|
787
|
+
const session = await sessionOf(request); if (!session) return unauthorized(request);
|
|
788
|
+
if (session.person.orgToken) return json({ error: 'a token sends no support request; a person does' }, 403);
|
|
789
|
+
let body: { category?: string; severity?: string; subject?: string; message?: string; org?: string; world?: string; allowAccessDays?: number }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'support: { category, severity, subject, message, org?, world?, allowAccessDays? }' }, 400); }
|
|
790
|
+
const categories = ['billing', 'worlds', 'account', 'security', 'other']; const severities = ['low', 'normal', 'high', 'urgent'];
|
|
791
|
+
if (!body.category || !categories.includes(body.category)) return json({ error: `category: ${categories.join(' | ')}` }, 400);
|
|
792
|
+
if (!body.severity || !severities.includes(body.severity)) return json({ error: `severity: ${severities.join(' | ')}` }, 400);
|
|
793
|
+
if (!body.subject?.trim() || !body.message?.trim()) return json({ error: 'a subject and a message' }, 400);
|
|
794
|
+
const org = body.org ? await memberOfP(session.person, body.org) : null; if (body.org && !org) return json({ error: `you are not a member of org ${body.org}` }, 403);
|
|
795
|
+
const id = `sup_${randomBytes(4).toString('hex')}`; const at = new Date().toISOString();
|
|
796
|
+
const req: SupportRequest = { id, at, person: { id: session.person.id, ...(session.person.email ? { email: session.person.email } : {}) }, org: org?.id ?? null, world: body.world ? String(body.world).slice(0, 120) : null, category: body.category, severity: body.severity, subject: body.subject.trim().slice(0, 200), message: body.message.trim().slice(0, 5000), status: 'open' };
|
|
797
|
+
opts.store.addSupportRequest(req);
|
|
798
|
+
if (org) record({ event: 'SUPPORT_REQUEST', org: org.id, actor: session.person, target: id, data: { category: req.category, severity: req.severity, subject: req.subject } });
|
|
799
|
+
let access: { until: string } | null = null;
|
|
800
|
+
if (org && body.allowAccessDays && body.allowAccessDays > 0) {
|
|
801
|
+
if (!isAdmin(org)) return json({ error: 'only an admin grants support access to the org' }, 403);
|
|
802
|
+
const days = Math.min(30, Number(body.allowAccessDays)); const until = new Date(Date.now() + days * 24 * 3600 * 1000).toISOString();
|
|
803
|
+
opts.store.setSupportAccess(org.id, { until, grantedBy: session.person.id, requestId: id }); record({ event: 'SUPPORT_ACCESS_GRANTED', org: org.id, actor: session.person, target: id, data: { until } }); access = { until };
|
|
804
|
+
}
|
|
805
|
+
const text = `${req.subject}\n\nFrom: ${session.person.email ?? session.person.id}\nOrg: ${org ? orgLabel(org.id, org.slug) : '—'} World: ${req.world ?? '—'}\nCategory: ${req.category} Severity: ${req.severity}\nTicket: ${id}\n\n${req.message}${access ? `\n\nSupport access to the org: granted until ${access.until}` : ''}`;
|
|
806
|
+
if (opts.supportTo) await mail([opts.supportTo], `[${id}] ${req.subject}`, text);
|
|
807
|
+
if (session.person.email) await mail([session.person.email], `We got your request — ${id}`, `A copy of what you sent us. A person answers, usually within a working day.\n\n${text}`);
|
|
808
|
+
return json({ id, at, emailed: Boolean(opts.supportTo), copyTo: session.person.email ?? null, access }, 201);
|
|
809
|
+
}
|
|
810
|
+
if (path === '/-/operator' && request.method === 'GET') return new Response(operatorPage(), { headers: { 'content-type': 'text/html; charset=utf-8' } });
|
|
811
|
+
if (path === '/-/operator/overview' && request.method === 'GET') {
|
|
812
|
+
if (!isOperator(request)) return json({ error: 'the operator token opens this' }, 401);
|
|
813
|
+
const orgs = [] as Array<Record<string, unknown>>;
|
|
814
|
+
for (const id of opts.store.orgIds()) { const ref = await dir.orgById(id); const doc = opts.store.doc(id); const { plan, restriction } = biller ? await biller.summary(id) : { plan: null, restriction: null }; orgs.push({ id, slug: ref?.slug ?? null, name: ref?.name ?? null, plan, restriction, worlds: doc.worlds.map((w) => ({ name: w.name, host: w.host })), supportAccess: opts.store.supportAccessOf(id) }); }
|
|
815
|
+
const pos = opts.store.platform();
|
|
816
|
+
return json({ orgs, support: (pos.support ?? []).slice().reverse(), hosts: opts.hosts.map((h) => ({ id: h.id, base: h.base })), clock: { lastTickAt: biller?.lastTickAt() ?? null, lastBackupAt: pos.lastBackupAt }, notice: pos.notice });
|
|
817
|
+
}
|
|
818
|
+
if (path === '/-/operator/open-org' && request.method === 'POST') {
|
|
819
|
+
if (!isOperator(request)) return json({ error: 'the operator token opens this' }, 401);
|
|
820
|
+
if (!tokens) return json({ error: 'this platform keeps no state directory' }, 503);
|
|
821
|
+
let body: { org?: string; category?: string; reason?: string }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'open-org: { org, category, reason }' }, 400); }
|
|
822
|
+
const reason = String(body.reason ?? '').trim(); if (reason.length < 4 || reason.length > 128) return json({ error: 'reason: 4–128 characters — say why you are opening this org' }, 400);
|
|
823
|
+
const category = String(body.category ?? '').trim(); if (!['support', 'billing', 'incident', 'security'].includes(category)) return json({ error: 'category: support | billing | incident | security' }, 400);
|
|
824
|
+
let orgId: string | null = null; for (const id of opts.store.orgIds()) { if (id === body.org) { orgId = id; break; } const ref = await dir.orgById(id); if (ref && ref.slug === body.org) { orgId = id; break; } }
|
|
825
|
+
if (!orgId) return json({ error: `no org ${body.org}` }, 404);
|
|
826
|
+
const consent = opts.store.supportAccessOf(orgId); if (!consent) return json({ error: 'the org has not allowed support access — ask an admin to allow it from the Help form (it lasts up to 30 days)' }, 403);
|
|
827
|
+
const made = tokens.mint('operator', `support ${category}`, { orgId, expiresInHours: 1, scopes: ['worlds:read', 'orgs:read', 'members:read', 'billing:read', 'activity:read'], support: { reason, category } });
|
|
828
|
+
record({ event: 'IMPERSONATION', org: orgId, actor: { operator: true }, target: category, data: { reason, until: made.record.expiresAt, consent: consent.requestId } });
|
|
829
|
+
return json({ token: made.token, until: made.record.expiresAt, console: `${opts.origin}/?support=${encodeURIComponent(made.token)}` }, 201);
|
|
830
|
+
}
|
|
831
|
+
// ── claiming: a World an enrolled host already served before the platform (or made on the host itself) has no
|
|
832
|
+
// owner here until its operator claims it into an org; its apps keep the token they hold ──
|
|
833
|
+
const heldNames = (): Set<string> => new Set(opts.store.orgIds().flatMap((id) => opts.store.doc(id).worlds.map((w) => w.name)));
|
|
834
|
+
const inventoryOf = async (host: HostProvider): Promise<WorldRow[]> => {
|
|
835
|
+
const res = await fetch(`${host.base}/-/worlds`, { headers: { 'x-volter-token': host.adminToken } }).catch(() => null);
|
|
836
|
+
return res?.ok ? ((await res.json()) as { worlds: WorldRow[] }).worlds : [];
|
|
837
|
+
};
|
|
838
|
+
if (path === '/-/operator/unclaimed' && request.method === 'GET') {
|
|
839
|
+
if (!isOperator(request)) return json({ error: 'the operator token opens this' }, 401);
|
|
840
|
+
const held = heldNames(); const worlds: Array<{ host: string; name: string; twins: string[]; owner?: string }> = [];
|
|
841
|
+
for (const host of opts.hosts) for (const row of await inventoryOf(host)) if (!held.has(row.name)) worlds.push({ host: host.id, name: row.name, twins: row.twins.map((t) => t.vendor), ...(row.owner ? { owner: row.owner } : {}) });
|
|
842
|
+
return json({ worlds });
|
|
843
|
+
}
|
|
844
|
+
if (path === '/-/operator/claim' && request.method === 'POST') {
|
|
845
|
+
if (!isOperator(request)) return json({ error: 'the operator token opens this' }, 401);
|
|
846
|
+
let body: { world?: string; org?: string; confirm?: unknown }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'claim: { world: "<org>/<world>", org, confirm: true }' }, 400); }
|
|
847
|
+
const name = String(body.world ?? ''); const [served] = name.split('/');
|
|
848
|
+
if (!/^[^/]+\/[^/]+$/.test(name)) return json({ error: 'claim: { world: "<org>/<world>", org, confirm: true }' }, 400);
|
|
849
|
+
// the org is the directory's (by id or slug), whether or not the platform has recorded anything for it yet
|
|
850
|
+
const ref = body.org ? await dir.orgById(String(body.org)) : null;
|
|
851
|
+
if (!ref) return json({ error: `no org ${body.org} in the directory (an org exists once someone has created it)` }, 404);
|
|
852
|
+
const orgId = ref.id; const slug = ref.slug ?? ref.id;
|
|
853
|
+
// the host serves a World under its org's slug, and the platform finds it by that name
|
|
854
|
+
if (served !== slug) return json({ error: `${name} is served under "${served}", and org ${body.org}'s slug is "${slug}": a World joins only an org whose slug it is served under` }, 409);
|
|
855
|
+
if (heldNames().has(name)) return json({ error: `${name} is already held by an org` }, 409);
|
|
856
|
+
let found: { host: HostProvider; row: WorldRow } | null = null;
|
|
857
|
+
for (const host of opts.hosts) { const row = (await inventoryOf(host)).find((r) => r.name === name); if (row) { found = { host, row }; break; } }
|
|
858
|
+
if (!found) return json({ error: `no enrolled host serves ${name}` }, 404);
|
|
859
|
+
// a host that records another owner refuses; one that records this org is proof; otherwise a matching slug is not
|
|
860
|
+
// (slugs are first come), so the operator says they checked who owns it
|
|
861
|
+
if (found.row.owner && found.row.owner !== orgId) return json({ error: `${name}'s host records another org as its owner` }, 409);
|
|
862
|
+
if (found.row.owner !== orgId && body.confirm !== true) return json({ error: `confirm: true — you have checked that org ${body.org} is the owner of ${name}; an org's slug alone does not show it (slugs are first come)` }, 400);
|
|
863
|
+
if (!found.row.owner) {
|
|
864
|
+
const set = await fetch(`${found.host.base}/-/worlds/${name}/owner`, { method: 'PUT', headers: { 'x-volter-token': found.host.adminToken, 'content-type': 'application/json' }, body: JSON.stringify({ owner: orgId }) }).catch(() => null);
|
|
865
|
+
if (!set?.ok) return json({ error: `the host did not record the owner (${set?.status ?? 'unreachable'})` }, 502);
|
|
866
|
+
}
|
|
867
|
+
const world: OrgWorld = { name, host: found.host.id, base: `${found.host.base}/${name}`, provisionedAt: new Date().toISOString() };
|
|
868
|
+
opts.store.recordWorld(orgId, world);
|
|
869
|
+
record({ event: 'WORLD_CLAIM', org: orgId, actor: { operator: true }, target: name, data: { host: found.host.id } });
|
|
870
|
+
return json({ name, org: slug, host: found.host.id }, 201);
|
|
871
|
+
}
|
|
872
|
+
{ const sup = /^\/-\/operator\/support\/([a-z0-9_]+)$/.exec(path); if (sup && request.method === 'PATCH') {
|
|
873
|
+
if (!isOperator(request)) return json({ error: 'the operator token opens this' }, 401);
|
|
874
|
+
let body: { status?: 'open' | 'closed' }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'support: { status: open | closed }' }, 400); }
|
|
875
|
+
const hit = opts.store.setSupportStatus(sup[1]!, body.status === 'closed' ? 'closed' : 'open'); return hit ? json({ request: hit }) : json({ error: 'no such request' }, 404); } }
|
|
876
|
+
if (path === '/-/tokens' && (request.method === 'GET' || request.method === 'POST')) {
|
|
877
|
+
const session = await sessionOf(request);
|
|
878
|
+
if (!session) return unauthorized(request);
|
|
879
|
+
if (!tokens) return json({ error: 'this platform keeps no state directory; personal tokens need one' }, 503);
|
|
880
|
+
if (request.method === 'GET') return json({ tokens: tokens.list(session.person.id).map(({ hash, ...rest }) => ({ id: hash, ...rest })) });
|
|
881
|
+
let body: { name?: string; expiresInDays?: number | null; scopes?: string[] }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'token: { name, expiresInDays: 30 | 90 | null, scopes?: ["*" | "<object>:<read|write>"] }' }, 400); }
|
|
882
|
+
if (body.scopes !== undefined && !validScopes(body.scopes)) return json({ error: 'scopes: "*" or <object>:<read|write> over worlds, orgs, members, billing, tokens, activity' }, 400);
|
|
883
|
+
const days = body.expiresInDays === undefined ? undefined : body.expiresInDays === null ? null : Number(body.expiresInDays);
|
|
884
|
+
if (days !== undefined && days !== null && !(days > 0 && days <= 365)) return json({ error: 'expiresInDays: 1–365, or null for a token that never expires' }, 400);
|
|
885
|
+
if (session.person.orgToken) return json({ error: 'an org token mints no tokens' }, 403);
|
|
886
|
+
const made = tokens.mint(session.person.id, body.name || 'cli', { ...(days === undefined ? {} : { expiresInDays: days }), ...(body.scopes ? { scopes: body.scopes } : {}) });
|
|
887
|
+
for (const org of await orgsOfP(session.person)) record({ event: 'TOKEN_CREATE', org: org.id, actor: session.person, target: made.record.name, data: { expiresAt: made.record.expiresAt ?? null, scopes: made.record.scopes } });
|
|
888
|
+
return json({ token: made.token, id: made.record.hash, name: made.record.name, createdAt: made.record.createdAt, last4: made.record.last4, expiresAt: made.record.expiresAt ?? null, scopes: made.record.scopes }, 201);
|
|
889
|
+
}
|
|
890
|
+
const tokenDoor = /^\/-\/tokens\/([a-f0-9]{64})$/.exec(path);
|
|
891
|
+
if (tokenDoor && request.method === 'DELETE') {
|
|
892
|
+
const session = await sessionOf(request);
|
|
893
|
+
if (!session) return unauthorized(request);
|
|
894
|
+
if (!tokens) return json({ error: 'this platform keeps no state directory' }, 503);
|
|
895
|
+
const named = tokens.list(session.person.id).find((x) => x.hash === tokenDoor[1])?.name; // the log names the token, not its hash (walk 4)
|
|
896
|
+
const gone = tokens.revoke(session.person.id, tokenDoor[1]!);
|
|
897
|
+
if (gone) { await revokeTokenKeys(gone.worldKeys); for (const org of await orgsOfP(session.person)) record({ event: 'TOKEN_REVOKE', org: org.id, actor: session.person, target: named ?? tokenDoor[1]! }); }
|
|
898
|
+
return gone ? json({ revoked: tokenDoor[1] }) : json({ error: 'no such token of yours' }, 404);
|
|
899
|
+
}
|
|
900
|
+
// OPEN A WORLD as yourself: a member is sent to the World's own page with a pass the platform signed for them (in
|
|
901
|
+
// the address's fragment, which no server or log sees), which the page spends on a World session. Write for a
|
|
902
|
+
// member acting as themselves; read for a support session, or a token without worlds:write. No World key leaves.
|
|
903
|
+
// PREVIEWS (docs/contributing/architecture.md, "CI runs in a World, and a pull request gets its own"): a named
|
|
904
|
+
// branch of one of the org's Worlds, made and removed here with the World's own token, so CI's org token makes one
|
|
905
|
+
// per pull request without a key of its own (a key would be replaced by the next job's, taking its branches); a
|
|
906
|
+
// member opens one as themselves. A preview is an ordinary branch: its lifetime, keys and owner are a branch's.
|
|
907
|
+
const previewDoor = /^\/-\/worlds\/([^/]+)\/([^/]+)\/previews(?:\/([^/]+)(\/open)?)?$/.exec(path);
|
|
908
|
+
if (previewDoor) {
|
|
909
|
+
const session = await sessionOf(request);
|
|
910
|
+
if (!session) return unauthorized(request);
|
|
911
|
+
const org = await memberOfP(session.person, previewDoor[1]!);
|
|
912
|
+
if (!org) return json({ error: `you are not a member of org ${previewDoor[1]}` }, 403);
|
|
913
|
+
{ const why = heldBy(org.id, session); if (why) return json({ error: why, held: true }, 403); }
|
|
914
|
+
const name = `${org.slug ?? org.id}/${previewDoor[2]}`;
|
|
915
|
+
const label = previewDoor[3]; const opening = Boolean(previewDoor[4]);
|
|
916
|
+
const held = opts.store.doc(org.id).worlds.find((w) => w.name === name);
|
|
917
|
+
if (!held) return json({ error: `no world ${name} in your orgs` }, 404);
|
|
918
|
+
const host = hostOf(held.host);
|
|
919
|
+
if (!host) return json({ error: `the host ${held.host} that serves ${name} is not enrolled` }, 502);
|
|
920
|
+
const inventory = async (): Promise<WorldRow[]> => { const r = await fetch(`${host.base}/-/worlds`, { headers: { 'x-volter-token': host.adminToken } }).catch(() => null); return r?.ok ? ((await r.json()) as { worlds: WorldRow[] }).worlds : []; };
|
|
921
|
+
const now = Date.now();
|
|
922
|
+
const all = opts.store.previewsOf(org.id).filter((p) => !p.expiresAt || Date.parse(p.expiresAt) > now); // an expired one is gone at its host
|
|
923
|
+
const mine = all.filter((p) => p.world === name);
|
|
924
|
+
const openUrl = (p: Preview): string => `${opts.origin}/-/worlds/${encodeURIComponent(previewDoor[1]!)}/${encodeURIComponent(previewDoor[2]!)}/previews/${p.label}/open`;
|
|
925
|
+
const shown = (p: Preview, row?: WorldRow) => ({ label: p.label, world: p.world, branch: p.branch, createdAt: p.createdAt, createdBy: p.createdBy, expiresAt: p.expiresAt, open: openUrl(p), live: Boolean(row), ...(row ? { base: `${host.base}/${row.name}`, ...(row.origin ? { origin: row.origin } : {}) } : {}) });
|
|
926
|
+
if (!label && request.method === 'GET') { const rows = await inventory(); return json({ world: name, previews: mine.map((p) => shown(p, rows.find((r) => r.name === p.branch))) }); }
|
|
927
|
+
if (!label || !/^[a-z0-9][a-z0-9-]{0,23}$/.test(label)) return json({ error: 'a preview is named in lowercase letters, digits and hyphens (pr-12), 24 at most' }, 400);
|
|
928
|
+
const existing = mine.find((p) => p.label === label);
|
|
929
|
+
const removeBranch = async (branch: string): Promise<boolean> => {
|
|
930
|
+
const parent = (await inventory()).find((r) => r.name === name); if (!parent) return false;
|
|
931
|
+
const r = await fetch(`${host.base}/-/${name}/branches/${branch}`, { method: 'DELETE', headers: { 'x-volter-token': parent.token } }).catch(() => null);
|
|
932
|
+
return Boolean(r && (r.ok || r.status === 404));
|
|
933
|
+
};
|
|
934
|
+
if (opening && request.method === 'GET') {
|
|
935
|
+
if (!opts.signingKey) return json({ error: 'this platform signs no passes' }, 503);
|
|
936
|
+
if (!existing) return json({ error: `no preview ${label} of ${name}` }, 404);
|
|
937
|
+
const row = (await inventory()).find((r) => r.name === existing.branch);
|
|
938
|
+
if (!row) return json({ error: `the preview ${label} of ${name} is gone (it ended ${existing.expiresAt ?? 'at its host'})` }, 410);
|
|
939
|
+
if (!row.origin) return json({ error: `${existing.branch}'s host gives it no origin of its own, where a pass could open it` }, 409);
|
|
940
|
+
const tokenHeld = tokens && Tokens.looksLike(session.token) ? tokens.lookup(session.token) : null;
|
|
941
|
+
const scope = session.person.support || (tokenHeld && !hasScope(tokenHeld.scopes, 'worlds:write')) ? 'read' : 'write';
|
|
942
|
+
const pass = await signPass(opts.signingKey, { issuer: opts.origin, audience: row.origin, subject: session.person.id, world: existing.branch, scope, org: org.id, ...(session.person.email ? { email: session.person.email } : {}), ...(session.person.name ? { name: session.person.name } : {}) });
|
|
943
|
+
const page = `${new URL(row.origin).origin}${DASHBOARD_BASE}/${existing.branch}#pass=${pass}`;
|
|
944
|
+
record({ event: 'WORLD_OPEN', org: org.id, actor: session.person, target: `${existing.branch}, the preview ${label} of ${name} (${scope})` });
|
|
945
|
+
return (request.headers.get('accept') ?? '').includes('application/json') ? json({ url: page, scope, expiresIn: 120 }, 200, { 'cache-control': 'no-store' }) : new Response(null, { status: 303, headers: { location: page, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
|
|
946
|
+
}
|
|
947
|
+
// A preview is an ordinary branch made by someone: the platform mints a key in the parent for its maker (for the
|
|
948
|
+
// person, recorded against the token that asked, so logout, revoking the token or leaving the org revokes it),
|
|
949
|
+
// and the branch is made with that key, so it lives no longer than it (a key-made branch's 7 days at most) and
|
|
950
|
+
// goes when it goes. The caller gets a key to the branch, named and revocable, never the branch's own token; a
|
|
951
|
+
// browser session gets none (it opens the preview as itself). Changes to one preview run one at a time.
|
|
952
|
+
const tokenHeld = tokens && Tokens.looksLike(session.token) ? tokens.lookup(session.token) : null;
|
|
953
|
+
const maker = session.person.orgToken ? `org token ${session.person.orgToken.name}` : session.person.email ?? session.person.id;
|
|
954
|
+
const forPerson = session.person.orgToken ? {} : { for: session.person.id };
|
|
955
|
+
const keyAt = async (world: string, token: string, body: Record<string, unknown>): Promise<{ id: string; key: string; expiresAt: string | null } | null> => {
|
|
956
|
+
const r = await fetch(`${host.base}/-/${world}/keys`, { method: 'POST', headers: { 'x-volter-token': token, 'content-type': 'application/json' }, body: JSON.stringify(body) }).catch(() => null);
|
|
957
|
+
return r?.ok ? (await r.json()) as { id: string; key: string; expiresAt: string | null } : null;
|
|
958
|
+
};
|
|
959
|
+
const branchKey = async (world: string, token: string, until: string | null): Promise<string | null> =>
|
|
960
|
+
tokenHeld ? (await keyAt(world, token, { name: `preview ${label} · ${maker}`.slice(0, 80), scope: 'write', replace: true, ...forPerson, ...(until ? { expiresAt: until } : {}) }))?.key ?? null : null;
|
|
961
|
+
const dropParentKey = async (keyId: string | undefined): Promise<void> => {
|
|
962
|
+
if (!keyId) return; const parent = (await inventory()).find((r) => r.name === name); if (!parent) return;
|
|
963
|
+
await fetch(`${host.base}/-/${name}/keys/${encodeURIComponent(keyId)}`, { method: 'DELETE', headers: { 'x-volter-token': parent.token } }).catch(() => null);
|
|
964
|
+
};
|
|
965
|
+
if (request.method === 'PUT') {
|
|
966
|
+
if (session.person.support) return json({ error: 'a support session makes no previews' }, 403);
|
|
967
|
+
let body: { ttlDays?: unknown; replace?: unknown } = {}; try { const text = await request.text(); if (text) body = JSON.parse(text) as typeof body; } catch { return json({ error: 'preview: { ttlDays?: 1-7, replace?: true }' }, 400); }
|
|
968
|
+
const days = body.ttlDays === undefined ? 7 : Number(body.ttlDays);
|
|
969
|
+
if (!Number.isInteger(days) || days < 1 || days > 7) return json({ error: 'ttlDays: 1-7 (a preview is a branch made with a key, so a week at most; a pull request\'s next push asks again)' }, 400);
|
|
970
|
+
return await onePreviewAtATime(`${org.id}/${name}/${label}`, async () => {
|
|
971
|
+
const current = opts.store.previewsOf(org.id).find((p) => p.world === name && p.label === label && (!p.expiresAt || Date.parse(p.expiresAt) > Date.now()));
|
|
972
|
+
// asked again (a pull request's next push): the same preview and a fresh key to it, unless a new one is asked for
|
|
973
|
+
if (current && body.replace !== true) {
|
|
974
|
+
const row = (await inventory()).find((r) => r.name === current.branch);
|
|
975
|
+
if (row) { const key = await branchKey(current.branch, row.token, current.expiresAt); return json({ ...shown(current, row), ...(key ? { token: key } : {}), created: false }, 200, { 'cache-control': 'no-store' }); }
|
|
976
|
+
}
|
|
977
|
+
if (current && !(await removeBranch(current.branch))) return json({ error: `the host kept the old preview ${current.branch}` }, 502);
|
|
978
|
+
if (current) await dropParentKey(current.key);
|
|
979
|
+
const live = opts.store.previewsOf(org.id).filter((p) => p.world === name && p.label !== label && (!p.expiresAt || Date.parse(p.expiresAt) > Date.now()));
|
|
980
|
+
if (live.length >= PREVIEWS_PER_WORLD) return json({ error: `${name} has ${live.length} previews, the most a World keeps at once: remove one (its pull request closed?) first` }, 409);
|
|
981
|
+
const parent = (await inventory()).find((r) => r.name === name);
|
|
982
|
+
if (!parent) return json({ error: `${name}'s host does not list it` }, 502);
|
|
983
|
+
const until = new Date(Date.now() + days * 86_400_000).toISOString();
|
|
984
|
+
const made = await keyAt(name, parent.token, { name: `preview ${label} of ${name} · ${maker}`.slice(0, 80), scope: 'write', expiresAt: until, ...forPerson });
|
|
985
|
+
if (!made) return json({ error: `${name}'s host made no key for the preview` }, 502);
|
|
986
|
+
if (tokenHeld) tokens?.noteWorldKey(tokenHeld.hash, name, made.id, false); // revoked with the token that asked
|
|
987
|
+
const res = await fetch(`${host.base}/-/${name}/branches`, { method: 'POST', headers: { 'x-volter-token': made.key, 'content-type': 'application/json' }, body: JSON.stringify({ ttl: days * 86_400, live: true, label }) }).catch(() => null);
|
|
988
|
+
const b = res?.ok ? (await res.json()) as { name: string; token: string; readToken: string; expiresAt: string | null; origin?: string } : null;
|
|
989
|
+
if (!b) { await dropParentKey(made.id); return json({ error: `the host made no branch of ${name} (${res?.status ?? 'unreachable'}${res ? `: ${(await res.text()).slice(0, 200)}` : ''})` }, 502); }
|
|
990
|
+
const p: Preview = { label, world: name, branch: b.name, createdAt: new Date().toISOString(), expiresAt: b.expiresAt, createdBy: maker, key: made.id };
|
|
991
|
+
// read again now, nothing awaited between the read and the write: a preview made meanwhile is kept
|
|
992
|
+
opts.store.setPreviews(org.id, [...opts.store.previewsOf(org.id).filter((x) => !(x.world === name && x.label === label)), p]);
|
|
993
|
+
record({ event: 'PREVIEW_CREATE', org: org.id, actor: session.person, target: `${label} of ${name}`, data: { branch: b.name, expiresAt: b.expiresAt } });
|
|
994
|
+
const key = await branchKey(b.name, b.token, b.expiresAt);
|
|
995
|
+
return json({ ...shown(p), live: true, base: `${host.base}/${b.name}`, ...(b.origin ? { origin: b.origin } : {}), ...(key ? { token: key } : {}), created: true }, 201, { 'cache-control': 'no-store' });
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
if (request.method === 'DELETE') {
|
|
999
|
+
if (session.person.support) return json({ error: 'a support session removes no previews' }, 403);
|
|
1000
|
+
return await onePreviewAtATime(`${org.id}/${name}/${label}`, async () => {
|
|
1001
|
+
const current = opts.store.previewsOf(org.id).find((p) => p.world === name && p.label === label);
|
|
1002
|
+
if (!current) return json({ error: `no preview ${label} of ${name}` }, 404);
|
|
1003
|
+
if (!(await removeBranch(current.branch))) return json({ error: `the host kept ${current.branch}` }, 502);
|
|
1004
|
+
await dropParentKey(current.key);
|
|
1005
|
+
opts.store.setPreviews(org.id, opts.store.previewsOf(org.id).filter((x) => !(x.world === name && x.label === label)));
|
|
1006
|
+
record({ event: 'PREVIEW_DELETE', org: org.id, actor: session.person, target: `${label} of ${name}`, data: { branch: current.branch } });
|
|
1007
|
+
return json({ removed: label, branch: current.branch });
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
return json({ error: `no such door: ${request.method} ${path}` }, 405);
|
|
1011
|
+
}
|
|
1012
|
+
const openDoor = /^\/-\/worlds\/([^/]+)\/([^/]+)\/open$/.exec(path);
|
|
1013
|
+
if (openDoor && request.method === 'GET') {
|
|
1014
|
+
const session = await sessionOf(request);
|
|
1015
|
+
if (!session) return unauthorized(request);
|
|
1016
|
+
if (!opts.signingKey) return json({ error: 'this platform signs no passes' }, 503);
|
|
1017
|
+
const org = await memberOfP(session.person, openDoor[1]!);
|
|
1018
|
+
if (!org) return json({ error: `you are not a member of org ${openDoor[1]}` }, 403);
|
|
1019
|
+
const name = `${org.slug ?? org.id}/${openDoor[2]}`;
|
|
1020
|
+
{ const why = heldBy(org.id, session); if (why) return json({ error: why, held: true }, 403); }
|
|
1021
|
+
const row = (await rowsForPerson(session.person)).find((r) => r.name === name);
|
|
1022
|
+
if (!row) return json({ error: `no world ${name} in your orgs` }, 404);
|
|
1023
|
+
// a pass opens only a World with an origin of its own, and names that origin as the one place it opens
|
|
1024
|
+
if (!row.origin) return json({ error: `${name}'s host gives it no origin of its own, where a pass could open it` }, 409);
|
|
1025
|
+
const held = tokens && Tokens.looksLike(session.token) ? tokens.lookup(session.token) : null;
|
|
1026
|
+
const scope = session.person.support || (held && !hasScope(held.scopes, 'worlds:write')) ? 'read' : 'write';
|
|
1027
|
+
const pass = await signPass(opts.signingKey, { issuer: opts.origin, audience: row.origin, subject: session.person.id, world: name, scope, org: org.id, ...(session.person.email ? { email: session.person.email } : {}), ...(session.person.name ? { name: session.person.name } : {}) });
|
|
1028
|
+
const page = `${new URL(row.origin).origin}${DASHBOARD_BASE}/${name}#pass=${pass}`;
|
|
1029
|
+
record({ event: 'WORLD_OPEN', org: org.id, actor: session.person, target: `${name} (${scope})` });
|
|
1030
|
+
return (request.headers.get('accept') ?? '').includes('application/json') ? json({ url: page, scope, expiresIn: 120 }, 200, { 'cache-control': 'no-store' }) : new Response(null, { status: 303, headers: { location: page, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' } });
|
|
1031
|
+
}
|
|
1032
|
+
// a world's own tokens, for a member: what `volter remote add` resolves through the platform
|
|
1033
|
+
// A KEY FOR A WORLD, for an app or a job, made here and not from a browser session on the World's origin (which
|
|
1034
|
+
// makes none: a script there could): for the person, ending when they say, revoked when they leave the org
|
|
1035
|
+
const worldKeys = /^\/-\/worlds\/([^/]+)\/([^/]+)\/keys$/.exec(path);
|
|
1036
|
+
if (worldKeys && request.method === 'POST') {
|
|
1037
|
+
const session = await sessionOf(request);
|
|
1038
|
+
if (!session) return unauthorized(request);
|
|
1039
|
+
if (session.person.support || session.person.orgToken) return json({ error: 'a person makes a World\'s keys' }, 403);
|
|
1040
|
+
const org = await memberOfP(session.person, worldKeys[1]!);
|
|
1041
|
+
if (!org) return json({ error: `you are not a member of org ${worldKeys[1]}` }, 403);
|
|
1042
|
+
{ const why = heldBy(org.id, session); if (why) return json({ error: why, held: true }, 403); }
|
|
1043
|
+
const name = `${org.slug ?? org.id}/${worldKeys[2]}`;
|
|
1044
|
+
const row = (await rowsForPerson(session.person)).find((r) => r.name === name);
|
|
1045
|
+
if (!row) return json({ error: `no world ${name} in your orgs` }, 404);
|
|
1046
|
+
let body: { name?: unknown; scope?: unknown; expiresInDays?: unknown }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'key: { name, scope?: read | write, expiresInDays?: 1-365 | null }' }, 400); }
|
|
1047
|
+
const keyName = typeof body.name === 'string' ? body.name.trim().slice(0, 80) : '';
|
|
1048
|
+
if (!keyName) return json({ error: 'a key needs a name: what holds it (an app, a CI job)' }, 400);
|
|
1049
|
+
const days = body.expiresInDays === undefined ? 90 : body.expiresInDays === null ? null : Number(body.expiresInDays);
|
|
1050
|
+
if (days !== null && !(Number.isInteger(days) && days >= 1 && days <= 365)) return json({ error: 'expiresInDays: 1-365, or null for a key that does not end' }, 400);
|
|
1051
|
+
// made with a personal token: a key that may write, ending no later than the token and revoked with it, as /token's
|
|
1052
|
+
const held = tokens && Tokens.looksLike(session.token) ? tokens.lookup(session.token) : null;
|
|
1053
|
+
if (held && (held.expired || !hasScope(held.scopes, 'worlds:write'))) return json({ error: 'a token makes a World key only while it may write Worlds (worlds:write)' }, 403);
|
|
1054
|
+
const asked = days === null ? null : Date.now() + days * 86_400_000;
|
|
1055
|
+
const tokenEnd = held?.expiresAt ? Date.parse(held.expiresAt) : null;
|
|
1056
|
+
const ends = tokenEnd === null ? asked : asked === null ? tokenEnd : Math.min(asked, tokenEnd);
|
|
1057
|
+
const made = await fetch(`${new URL(row.base).origin}/-/${row.name}/keys`, { method: 'POST', headers: { 'x-volter-token': row.token, 'content-type': 'application/json' }, body: JSON.stringify({ name: keyName, scope: body.scope === 'read' ? 'read' : 'write', for: session.person.id, ...(ends !== null ? { expiresAt: new Date(ends).toISOString() } : {}) }) }).catch(() => null);
|
|
1058
|
+
const key = made?.ok ? (await made.json()) as { id: string; key: string; scope: string; expiresAt: string | null } : null;
|
|
1059
|
+
if (!key) return json({ error: `the World did not make a key (${made?.status ?? 'unreachable'})` }, 502);
|
|
1060
|
+
if (held) tokens?.noteWorldKey(held.hash, row.name, key.id, false); // revoked with the token (volter logout, or under Account)
|
|
1061
|
+
record({ event: 'TOKEN_CREATE', org: org.id, actor: session.person, target: `key ${key.id} (${keyName}) for ${row.name}` });
|
|
1062
|
+
return json({ world: row.name, base: row.base, id: key.id, name: keyName, scope: key.scope, expiresAt: key.expiresAt, key: key.key }, 201, { 'cache-control': 'no-store' });
|
|
1063
|
+
}
|
|
1064
|
+
const worldToken = /^\/-\/worlds\/([^/]+)\/([^/]+)\/token$/.exec(path);
|
|
1065
|
+
if (worldToken && request.method === 'GET') {
|
|
1066
|
+
// a World's key is for a command (`volter remote add`), never a browser (a person opens a World with a pass) nor
|
|
1067
|
+
// a read-only support session; its write key only for a token that may write Worlds
|
|
1068
|
+
const session = await sessionOf(request);
|
|
1069
|
+
if (!session) return unauthorized(request);
|
|
1070
|
+
const held = tokens && Tokens.looksLike(session.token) ? tokens.lookup(session.token) : null;
|
|
1071
|
+
if (!held || held.expired || session.person.support) return json({ error: "a World's key is handed to the volter command only (volter login), never to a browser or a support session" }, 403);
|
|
1072
|
+
const org = await memberOfP(session.person, worldToken[1]!);
|
|
1073
|
+
if (!org) return json({ error: `you are not a member of org ${worldToken[1]}` }, 403);
|
|
1074
|
+
{ const why = heldBy(org.id, session); if (why) return json({ error: why, held: true }, 403); }
|
|
1075
|
+
const name = `${org.slug ?? org.id}/${worldToken[2]}`;
|
|
1076
|
+
const row = (await rowsForPerson(session.person)).find((r) => r.name === name);
|
|
1077
|
+
if (!row) return json({ error: `no world ${name} in your orgs` }, 404);
|
|
1078
|
+
const writes = hasScope(held.scopes, 'worlds:write');
|
|
1079
|
+
// a NAMED KEY for this command, made in the World (its keys door, with the World's own token): shown once here,
|
|
1080
|
+
// listed and revoked alone in the World's Settings; never the World's own token
|
|
1081
|
+
// for the person, ending when the token does, and one per token: asking again replaces it rather than piling keys up
|
|
1082
|
+
const keyName = `${held.name} · ${session.person.email ?? session.person.id} · ${held.hash.slice(0, 8)}`.slice(0, 80);
|
|
1083
|
+
const madeKey = await fetch(`${new URL(row.base).origin}/-/${row.name}/keys`, { method: 'POST', headers: { 'x-volter-token': row.token, 'content-type': 'application/json' }, body: JSON.stringify({ name: keyName, scope: writes ? 'write' : 'read', for: session.person.id, replace: true, ...(held.expiresAt ? { expiresAt: held.expiresAt } : {}) }) }).catch(() => null);
|
|
1084
|
+
const key = madeKey?.ok ? (await madeKey.json()) as { id: string; key: string; scope: string } : null;
|
|
1085
|
+
if (!key) return json({ error: `the World did not make a key (${madeKey?.status ?? 'unreachable'})` }, 502);
|
|
1086
|
+
tokens?.noteWorldKey(held.hash, row.name, key.id); // revoked with the token (volter logout, or under Account)
|
|
1087
|
+
record({ event: 'TOKEN_CREATE', org: org.id, actor: session.person, target: `key ${key.id} for ${row.name}` });
|
|
1088
|
+
return json({ name: row.name, base: row.base, scope: key.scope, token: key.key, keyId: key.id }, 200, { 'cache-control': 'no-store' });
|
|
1089
|
+
}
|
|
1090
|
+
const member = /^\/-\/orgs\/([^/]+)\/members$/.exec(path);
|
|
1091
|
+
if (member && request.method === 'POST') {
|
|
1092
|
+
const person = (await sessionOf(request))?.person;
|
|
1093
|
+
if (!person) return unauthorized(request);
|
|
1094
|
+
const org = await memberOfP(person, member[1]!);
|
|
1095
|
+
if (!org) return json({ error: `you are not a member of org ${member[1]}` }, 403);
|
|
1096
|
+
{ const why = heldBy(org.id, { person, token: presentedToken(request) ?? '' }); if (why) return json({ error: why, held: true }, 403); }
|
|
1097
|
+
if (!isAdmin(org) && !opts.store.securityOf(org.id).membersCanInvite) return json({ error: 'only an admin of the org adds members — an admin can allow members to under Org settings › Security' }, 403);
|
|
1098
|
+
let body: { sub?: string; email?: string; role?: string };
|
|
1099
|
+
try { body = (await request.json()) as typeof body; } catch { return json({ error: 'add member: { sub | email, role? }' }, 400); }
|
|
1100
|
+
const role = body.role === undefined || body.role === 'member' || body.role === 'org:member' ? 'org:member' : body.role === 'admin' || body.role === 'org:admin' ? 'org:admin' : null;
|
|
1101
|
+
if (!role) return json({ error: 'role: admin | member' }, 400);
|
|
1102
|
+
if (role === 'org:admin' && !isAdmin(org)) return json({ error: 'only an admin makes another admin' }, 403);
|
|
1103
|
+
try {
|
|
1104
|
+
// by id is an admin's act, and the person's own address answers to the approved domains as a typed one does
|
|
1105
|
+
if (body.sub && body.email) return json({ error: 'add a member by id or by email, not both' }, 400);
|
|
1106
|
+
if (body.sub) {
|
|
1107
|
+
if (!isAdmin(org)) return json({ error: 'only an admin adds a member by id; invite by email' }, 403);
|
|
1108
|
+
const known = await dir.personById(body.sub); if (!known) return json({ error: 'no such person here' }, 404);
|
|
1109
|
+
if (opts.store.securityOf(org.id).approvedEmailDomains.length) { const why = known.email ? domainAllowed(org.id, known.email) : 'this org invites only approved domains, and this person has no address here'; if (why) return json({ error: why }, 403); }
|
|
1110
|
+
}
|
|
1111
|
+
if (body.email) { const why = domainAllowed(org.id, body.email); if (why) return json({ error: why }, 403); }
|
|
1112
|
+
// an address the directory does not know is INVITED, pending until that address signs in (layer 5); Volter's
|
|
1113
|
+
// directory mails its own invitation, and the platform mails the one it keeps
|
|
1114
|
+
if (body.email && !body.sub && !(await dir.userIdByEmail(body.email))) {
|
|
1115
|
+
const inv = await dir.inviteMember(org.id, body.email, person.id, role);
|
|
1116
|
+
record({ event: 'MEMBER_INVITE', org: org.id, actor: person, target: body.email, data: { pending: true, invitation: inv.id, role } });
|
|
1117
|
+
if (!dir.mailsInvitations) await mail([inv.email], `${person.email ?? person.name ?? 'Someone'} invited you to ${orgLabel(org.id, org.slug)} on Volter World`, `You are invited to the org ${orgLabel(org.id, org.slug)}. Sign in at ${opts.origin}/ with ${identity.name}, as ${inv.email}, before ${(inv.expiresAt ?? '').slice(0, 10)} to join it.`);
|
|
1118
|
+
return json({ invited: body.email, pending: true, id: inv.id, expiresAt: inv.expiresAt }, 201);
|
|
1119
|
+
}
|
|
1120
|
+
await dir.addMember(org.id, body);
|
|
1121
|
+
if (role === 'org:admin') { const added = (await dir.membersOf(org.id)).find((m) => m.userId === body.sub || (body.email && m.email?.toLowerCase() === body.email.toLowerCase())); if (added) await dir.setMemberRole(org.id, added.userId, 'org:admin'); }
|
|
1122
|
+
record({ event: 'MEMBER_INVITE', org: org.id, actor: person, target: body.email ?? body.sub ?? '', data: { role } });
|
|
1123
|
+
if (body.email) await mail([body.email], `You were added to ${orgLabel(org.id, org.slug)} on Volter World`, `${person.email ?? person.id} added you to the org ${orgLabel(org.id, org.slug)}.\n\nSign in at ${opts.origin}/ to see its worlds.`);
|
|
1124
|
+
return json({ added: true, org: org.id }, 200);
|
|
1125
|
+
} catch (e) { return json({ error: (e as Error).message }, 400); }
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
return json({ error: `no such door: ${request.method} ${path}` }, 404);
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, 500);
|
|
1131
|
+
}
|
|
1132
|
+
},
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** Serve a platform on a port, under Node or Bun (world-core's serve seam). `origin` is where people reach it (behind a
|
|
1137
|
+
* proxy, its public address); by default the address it listens on. */
|
|
1138
|
+
export async function servePlatform(opts: Omit<PlatformOptions, 'origin'> & { port?: number; host?: string; origin?: string }): Promise<{ url: string; port: number; stop: () => Promise<void> }> {
|
|
1139
|
+
const host = opts.host ?? '127.0.0.1';
|
|
1140
|
+
// port-first: the platform needs its origin (for the OAuth redirect_uri) before it is built, so bind
|
|
1141
|
+
// once with a handler slot and fill the slot — never bind, stop and rebind the same port (a race)
|
|
1142
|
+
let handler: (request: Request) => Promise<Response> = () => Promise.resolve(new Response('booting', { status: 503 }));
|
|
1143
|
+
const server = await serveHttp({ hostname: host, port: opts.port ?? 0, fetch: (request) => handler(request) });
|
|
1144
|
+
const origin = (opts.origin ?? `http://${host.includes(':') ? `[${host}]` : host}:${server.port}`).replace(/\/+$/, '');
|
|
1145
|
+
const platform = createPlatform({ ...opts, origin });
|
|
1146
|
+
handler = (request) => platform.fetch(request);
|
|
1147
|
+
return { url: origin, port: server.port, stop: async () => { platform.stop(); await server.stop(true); } };
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** The operator's page (layer 6: Twenty's admin panel, PostHog's staff views): orgs with plan, restriction, worlds and hosts, open support requests, standing consents, the clock — and "open org" with a category and a reason. */
|
|
1151
|
+
function operatorPage(): string {
|
|
1152
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Operator — Volter World</title><link rel="icon" type="image/svg+xml" href="${WORLD_LOGO}"><link rel="stylesheet" href="/-/brand/tokens.css"><link rel="stylesheet" href="/-/styles.css"></head><body class="account"><main>
|
|
1153
|
+
<header><h1><img src="${WORLD_LOGO}" alt="" width="24" height="24" style="vertical-align:middle"> Volter World</h1><span class="mode">operator</span></header>
|
|
1154
|
+
<h2>Operator</h2>
|
|
1155
|
+
<form id="tok"><label>Operator token <input name="operator" type="password" placeholder="tok_op_…" required /></label><button type="submit">Open</button></form>
|
|
1156
|
+
<p id="note" class="mode"></p>
|
|
1157
|
+
<section id="orgs" hidden><h3>Orgs</h3><table><thead><tr><th>org</th><th>plan</th><th>restriction</th><th>worlds</th><th>support access</th><th></th></tr></thead><tbody id="orgRows"></tbody></table>
|
|
1158
|
+
<form id="open" hidden><h4>Open an org as support</h4><p class="mode">Read only, one hour, only while the org's admin has allowed it; the org's activity log records it with your reason.</p><label>Org <input name="org" required /></label><label>Category <select name="category"><option>support</option><option>billing</option><option>incident</option><option>security</option></select></label><label>Reason (4–128 characters) <input name="reason" minlength="4" maxlength="128" required /></label><button type="submit">Open org</button><p id="opened" role="status"></p></form></section>
|
|
1159
|
+
<section id="support" hidden><h3>Support requests</h3><table><thead><tr><th>when</th><th>id</th><th>from</th><th>org</th><th>category</th><th>severity</th><th>subject</th><th>status</th><th></th></tr></thead><tbody id="supRows"></tbody></table></section>
|
|
1160
|
+
<section id="clock" hidden><h3>The clock</h3><p id="clockText"></p></section>
|
|
1161
|
+
<script>(function(){var tok=sessionStorage.getItem('volter.operator')||'';var f=document.getElementById('tok');if(tok)f.operator.value=tok;var note=document.getElementById('note');
|
|
1162
|
+
function h(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c]})}
|
|
1163
|
+
function door(p,init){init=init||{};init.headers=Object.assign({'x-volter-token':tok,'content-type':'application/json'},init.headers||{});return fetch(p,init)}
|
|
1164
|
+
function load(){return door('/-/operator/overview').then(function(r){if(!r.ok)throw new Error('the platform answered '+r.status);return r.json()}).then(function(o){
|
|
1165
|
+
document.getElementById('orgs').hidden=false;document.getElementById('open').hidden=false;document.getElementById('support').hidden=false;document.getElementById('clock').hidden=false;
|
|
1166
|
+
document.getElementById('orgRows').innerHTML=o.orgs.map(function(x){return '<tr><td><code>'+h(x.slug||x.id)+'</code>'+(x.name&&x.name!==x.slug?' '+h(x.name):'')+'</td><td>'+h(x.plan||'—')+'</td><td>'+h(x.restriction||'—')+'</td><td>'+x.worlds.map(function(w){return h(w.name)+' <span class="mode">on '+h(w.host)+'</span>'}).join('<br>')+'</td><td>'+(x.supportAccess?'until '+h(x.supportAccess.until.slice(0,16).replace('T',' '))+' UTC':'not allowed')+'</td><td>'+(x.supportAccess?'<button type="button" data-open="'+h(x.slug||x.id)+'">Open org…</button>':'')+'</td></tr>'}).join('');
|
|
1167
|
+
document.getElementById('supRows').innerHTML=o.support.map(function(s){return '<tr><td><code>'+h(s.at.slice(0,16).replace('T',' '))+'</code></td><td><code>'+h(s.id)+'</code></td><td>'+h(s.person.email||s.person.id)+'</td><td>'+h(s.org||'—')+'</td><td>'+h(s.category)+'</td><td>'+h(s.severity)+'</td><td>'+h(s.subject)+'</td><td>'+h(s.status)+'</td><td>'+(s.status==='open'?'<button type="button" data-close="'+h(s.id)+'">Close</button>':'')+'</td></tr>'}).join('')||'<tr><td colspan="9" class="mode">none</td></tr>';
|
|
1168
|
+
document.getElementById('clockText').textContent='last tick '+(o.clock.lastTickAt||'never')+' · last backup '+(o.clock.lastBackupAt||'never')+(o.notice?' · notice: '+o.notice.text:'');
|
|
1169
|
+
note.textContent='';}).catch(function(e){note.textContent=String(e.message||e)})}
|
|
1170
|
+
f.addEventListener('submit',function(e){e.preventDefault();tok=f.operator.value.trim();sessionStorage.setItem('volter.operator',tok);load()});
|
|
1171
|
+
document.addEventListener('click',function(e){var t=e.target;if(!(t instanceof HTMLElement))return;if(t.dataset.open){document.getElementById('open').org.value=t.dataset.open;document.getElementById('open').reason.focus()}if(t.dataset.close){door('/-/operator/support/'+t.dataset.close,{method:'PATCH',body:JSON.stringify({status:'closed'})}).then(load)}});
|
|
1172
|
+
document.getElementById('open').addEventListener('submit',function(e){e.preventDefault();var fo=e.target;door('/-/operator/open-org',{method:'POST',body:JSON.stringify({org:fo.org.value.trim(),category:fo.category.value,reason:fo.reason.value.trim()})}).then(function(r){return r.json().then(function(b){return [r.status,b]})}).then(function(a){var el=document.getElementById('opened');if(a[0]>=300){el.textContent=a[1].error||('answered '+a[0]);return}el.innerHTML='Opened until '+h(a[1].until.slice(0,16).replace('T',' '))+' UTC — <a href="'+h(a[1].console)+'">open the platform as support</a> (read only).';load()})});
|
|
1173
|
+
if(tok)load();})();</script></main></body></html>`;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** The platform's operator token: minted once, kept at `<state>/admin` (0600) — the host's pattern, never an env. */
|
|
1177
|
+
export function operatorTokenFor(stateDir: string): string {
|
|
1178
|
+
const path = join(stateDir, 'admin');
|
|
1179
|
+
try { const t = readFileSync(path, 'utf8').trim(); if (t.startsWith('tok_op_')) return t; } catch { /* none yet */ }
|
|
1180
|
+
const t = `tok_op_${Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url')}`;
|
|
1181
|
+
mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${t}\n`, { mode: 0o600 }); chmodSync(path, 0o600);
|
|
1182
|
+
return t;
|
|
1183
|
+
}
|