@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.
Files changed (75) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +29 -0
  3. package/client/platform.css +228 -0
  4. package/client/platform.tsx +1049 -0
  5. package/client/reserved.ts +3 -0
  6. package/dist/client/platform.bundle.js +237 -0
  7. package/dist/client/platform.css +228 -0
  8. package/dist/client/platform.tsx +1049 -0
  9. package/dist/client/reserved.d.ts +1 -0
  10. package/dist/client/reserved.js +3 -0
  11. package/dist/client/reserved.ts +3 -0
  12. package/dist/src/audit.d.ts +24 -0
  13. package/dist/src/audit.js +23 -0
  14. package/dist/src/backup.d.ts +37 -0
  15. package/dist/src/backup.js +94 -0
  16. package/dist/src/biller.d.ts +59 -0
  17. package/dist/src/biller.js +1 -0
  18. package/dist/src/cli.d.ts +2 -0
  19. package/dist/src/cli.js +112 -0
  20. package/dist/src/db/migrations/0000_init.sql +143 -0
  21. package/dist/src/db/migrations/meta/0000_snapshot.json +877 -0
  22. package/dist/src/db/migrations/meta/_journal.json +13 -0
  23. package/dist/src/db/migrations.d.ts +4 -0
  24. package/dist/src/db/migrations.js +33 -0
  25. package/dist/src/db/open.d.ts +11 -0
  26. package/dist/src/db/open.js +114 -0
  27. package/dist/src/db/pack-migrations.d.ts +1 -0
  28. package/dist/src/db/pack-migrations.js +33 -0
  29. package/dist/src/db/schema.d.ts +1803 -0
  30. package/dist/src/db/schema.js +123 -0
  31. package/dist/src/directory.d.ts +75 -0
  32. package/dist/src/directory.js +199 -0
  33. package/dist/src/doors.d.ts +16 -0
  34. package/dist/src/doors.js +123 -0
  35. package/dist/src/identity.d.ts +88 -0
  36. package/dist/src/identity.js +314 -0
  37. package/dist/src/labs.d.ts +14 -0
  38. package/dist/src/labs.js +7 -0
  39. package/dist/src/mail.d.ts +16 -0
  40. package/dist/src/mail.js +27 -0
  41. package/dist/src/pages.d.ts +15 -0
  42. package/dist/src/pages.js +73 -0
  43. package/dist/src/platform.d.ts +77 -0
  44. package/dist/src/platform.js +1845 -0
  45. package/dist/src/sample.d.ts +11 -0
  46. package/dist/src/sample.js +93 -0
  47. package/dist/src/store.d.ts +110 -0
  48. package/dist/src/store.js +142 -0
  49. package/dist/src/tokens.d.ts +69 -0
  50. package/dist/src/tokens.js +96 -0
  51. package/dist/src/webhooks.d.ts +60 -0
  52. package/dist/src/webhooks.js +92 -0
  53. package/package.json +78 -0
  54. package/src/audit.ts +26 -0
  55. package/src/backup.ts +73 -0
  56. package/src/biller.ts +45 -0
  57. package/src/cli.ts +99 -0
  58. package/src/db/migrations/0000_init.sql +143 -0
  59. package/src/db/migrations/meta/0000_snapshot.json +877 -0
  60. package/src/db/migrations/meta/_journal.json +13 -0
  61. package/src/db/migrations.ts +33 -0
  62. package/src/db/open.ts +92 -0
  63. package/src/db/pack-migrations.ts +19 -0
  64. package/src/db/schema.ts +137 -0
  65. package/src/directory.ts +216 -0
  66. package/src/doors.ts +138 -0
  67. package/src/identity.ts +279 -0
  68. package/src/labs.ts +8 -0
  69. package/src/mail.ts +27 -0
  70. package/src/pages.ts +69 -0
  71. package/src/platform.ts +1183 -0
  72. package/src/sample.ts +84 -0
  73. package/src/store.ts +154 -0
  74. package/src/tokens.ts +94 -0
  75. package/src/webhooks.ts +85 -0
@@ -0,0 +1,92 @@
1
+ // Webhooks out (layer 7) — the peers' shape, read in their code (HOSTED-PRODUCT-COMPLETENESS.md,
2
+ // "webhooks out"): an endpoint is a URL, a secret shown once and the events it wants (Dub, Cal.com,
3
+ // Twenty); events are `object.verb` with a `{ id, event, createdAt, data }` body (Dub); deliveries are
4
+ // signed with Standard Webhooks (Polar: webhook-id, webhook-timestamp, webhook-signature), retried
5
+ // with backoff up to ten attempts (Polar's WEBHOOK_MAX_RETRIES) and kept with their response; twenty
6
+ // consecutive failures disable the endpoint and the admins are emailed (Dub's threshold).
7
+ import { createHmac, randomBytes } from 'node:crypto';
8
+ import { lookup } from 'node:dns/promises';
9
+ /** The catalog: the audit's events in dotted form, plus `ping` for the test button. */
10
+ export const WEBHOOK_EVENTS = {
11
+ WORLD_PROVISION: 'world.provisioned', WORLD_DELETE: 'world.deleted', PREVIEW_CREATE: 'preview.created', PREVIEW_DELETE: 'preview.deleted',
12
+ MEMBER_INVITE: 'member.added', INVITE_REVOKE: 'member.invite_revoked', MEMBER_REMOVE: 'member.removed', MEMBER_ROLE: 'member.role_changed',
13
+ ORG_RENAME: 'org.renamed', PLAN_CHANGE: 'plan.changed',
14
+ QUOTA_WARNED: 'quota.warned', QUOTA_RESTRICTED: 'quota.restricted', QUOTA_CLEARED: 'quota.cleared',
15
+ IMPERSONATION: 'support.opened',
16
+ };
17
+ export const WEBHOOK_EVENT_NAMES = [...new Set(Object.values(WEBHOOK_EVENTS))].sort();
18
+ export const eventNameFor = (audit) => WEBHOOK_EVENTS[audit] ?? null;
19
+ export function validEvents(events) { return Array.isArray(events) && events.length > 0 && events.every((e) => typeof e === 'string' && (WEBHOOK_EVENT_NAMES.includes(e) || e === '*')); }
20
+ export const MAX_ATTEMPTS = 10;
21
+ export const DISABLE_AFTER = 20;
22
+ /** Seconds until the next attempt after attempt n (1-based): 30 s, 2 min, 10 min, then 20 min (Polar's cap). */
23
+ export const backoffSeconds = (attempt) => [30, 120, 600][attempt - 1] ?? 1200;
24
+ export const newSecret = () => `whsec_${randomBytes(24).toString('base64')}`;
25
+ export const newId = (prefix) => `${prefix}_${randomBytes(8).toString('hex')}`;
26
+ /** Standard Webhooks: HMAC-SHA256 over `id.timestamp.body` with the secret's base64 decoded, sent as `v1,<base64>`. */
27
+ export function sign(secret, id, timestamp, body) {
28
+ const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
29
+ return `v1,${createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest('base64')}`;
30
+ }
31
+ /** What a receiver does: recompute and compare (constant time). */
32
+ export function verify(secret, headers, body, toleranceSeconds = 300) {
33
+ const ts = Number(headers.timestamp);
34
+ if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds)
35
+ return false;
36
+ const expected = sign(secret, headers.id, ts, body).slice(3);
37
+ return headers.signature.split(' ').some((part) => { const [v, sig] = part.split(','); if (v !== 'v1' || !sig)
38
+ return false; const a = Buffer.from(sig, 'base64'); const b = Buffer.from(expected, 'base64'); return a.length === b.length && a.equals(b); });
39
+ }
40
+ /** Whether an address is this machine's, a private network's, link-local or carrier-grade NAT: never a webhook's target. */
41
+ export function privateAddress(ip) {
42
+ const v4 = /^(?:::ffff:)?(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(ip);
43
+ if (v4) {
44
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
45
+ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
46
+ }
47
+ const v6 = ip.toLowerCase();
48
+ return v6 === '::' || v6 === '::1' || v6.startsWith('fc') || v6.startsWith('fd') || v6.startsWith('fe8') || v6.startsWith('fe9') || v6.startsWith('fea') || v6.startsWith('feb');
49
+ }
50
+ /** Why an endpoint may not be delivered to (its name resolves to an address inside a network), or null. A platform may
51
+ * deliver inside its own network only where its operator allows it (VOLTER_WEBHOOKS_ALLOW_PRIVATE=1, or a local run). */
52
+ export async function refusedEndpoint(url, allowPrivate) {
53
+ if (allowPrivate)
54
+ return null;
55
+ let host;
56
+ try {
57
+ host = new URL(url).hostname.replace(/^\[|\]$/g, '');
58
+ }
59
+ catch {
60
+ return 'not an address';
61
+ }
62
+ let addresses;
63
+ try {
64
+ addresses = (await lookup(host, { all: true })).map((a) => a.address);
65
+ }
66
+ catch {
67
+ return `${host} does not resolve`;
68
+ }
69
+ const inside = addresses.find(privateAddress);
70
+ return inside ? `${host} resolves to ${inside}, inside a network: a webhook goes to a public address` : null;
71
+ }
72
+ /** One attempt at one delivery; the caller keeps the record. Redirects are not followed (a public name answering with an
73
+ * inside address would carry the event there). */
74
+ export async function attempt(endpoint, d, fetchImpl = fetch, allowPrivate = false) {
75
+ const refused = await refusedEndpoint(endpoint.url, allowPrivate);
76
+ if (refused)
77
+ return { status: null, ok: false, response: refused };
78
+ const timestamp = Math.floor(Date.now() / 1000);
79
+ const c = new AbortController();
80
+ const t = setTimeout(() => c.abort(), 10_000);
81
+ try {
82
+ const r = await fetchImpl(endpoint.url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'volter-world webhooks', 'webhook-id': d.eventId, 'webhook-timestamp': String(timestamp), 'webhook-signature': sign(endpoint.secret, d.eventId, timestamp, d.body) }, body: d.body, signal: c.signal, redirect: 'manual' });
83
+ const text = (await r.text().catch(() => '')).slice(0, 2048);
84
+ return { status: r.status, ok: r.ok, response: r.status >= 300 && r.status < 400 ? `a redirect (${r.status}), not followed` : text };
85
+ }
86
+ catch (e) {
87
+ return { status: null, ok: false, response: String(e.message ?? e).slice(0, 2048) };
88
+ }
89
+ finally {
90
+ clearTimeout(t);
91
+ }
92
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@volter/world-platform",
3
+ "version": "2.0.0",
4
+ "description": "The Volter World platform: people and orgs through an access provider (Volter Identity, GitHub, or any OpenID Connect issuer), members and invitations, tokens, Worlds made on enrolled hosts, and opening a World for a person with a pass. Open and self-hostable; serves its own pages and never a twin.",
5
+ "keywords": [
6
+ "volter",
7
+ "world",
8
+ "platform",
9
+ "orgs",
10
+ "oidc",
11
+ "self-hosted"
12
+ ],
13
+ "author": "Volter (https://github.com/volter-ai)",
14
+ "license": "Apache-2.0",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "engines": {
20
+ "node": ">=22.13"
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/src/platform.d.ts",
25
+ "default": "./dist/src/platform.js"
26
+ }
27
+ },
28
+ "bin": {
29
+ "volter-platform": "dist/src/cli.js"
30
+ },
31
+ "scripts": {
32
+ "test": "bun test src/*.uitest.ts",
33
+ "typecheck": "tsc --noEmit",
34
+ "build": "node ../../scripts/publish/build.mjs",
35
+ "prepack": "node ../../scripts/publish/prepare-publish.mjs prepack",
36
+ "postpack": "node ../../scripts/publish/prepare-publish.mjs postpack"
37
+ },
38
+ "dependencies": {
39
+ "@aws-sdk/client-s3": "3.1081.0",
40
+ "@volter/identity": "^0.1.0",
41
+ "@volter/world-access": "2.0.0",
42
+ "@volter/world-console": "2.0.2",
43
+ "@volter/world-core": "2.0.2",
44
+ "drizzle-orm": "0.45.3",
45
+ "react": "^19.2.7",
46
+ "react-dom": "^19.2.7",
47
+ "resend": "^6.26.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/bun": "^1.2.20",
51
+ "@types/node": "^24.0.0",
52
+ "@types/react": "^19.2.7",
53
+ "@types/react-dom": "^19.2.7",
54
+ "@volter/twin-volteridentity": "0.1.2",
55
+ "@volter/world-host": "2.0.2",
56
+ "@volter/world-runtime": "2.0.2",
57
+ "@volter/world-tooling": "0.1.0",
58
+ "drizzle-kit": "0.31.11",
59
+ "playwright": "^1.61.0",
60
+ "typescript": "^5.9.0"
61
+ },
62
+ "files": [
63
+ "src",
64
+ "client",
65
+ "README.md",
66
+ "LICENSE",
67
+ "!**/*.test.ts",
68
+ "!**/*.uitest.ts",
69
+ "!src/rehearsal.ts",
70
+ "!census.json",
71
+ "dist"
72
+ ],
73
+ "repository": {
74
+ "type": "git",
75
+ "url": "git+https://github.com/volter-ai/world.git",
76
+ "directory": "apps/platform"
77
+ }
78
+ }
package/src/audit.ts ADDED
@@ -0,0 +1,26 @@
1
+ // The platform's audit log (punchlist 4.1, written now so layer 2's acts land in it): a catalog of
2
+ // named admin acts, append-only per org, one row per act — Sentry's shape in World's words. A table in
3
+ // the platform's database (db/schema.ts `audit`), in the order the acts happened.
4
+ import { and, desc, eq } from 'drizzle-orm';
5
+ import { openDatabase, type PlatformDb } from './db/open.ts';
6
+ import { audit as auditRows } from './db/schema.ts';
7
+
8
+ export const AUDIT_EVENTS = ['ORG_CREATE', 'ORG_RENAME', 'ORG_DELETE', 'MEMBER_INVITE', 'INVITE_REVOKE', 'MEMBER_REMOVE', 'MEMBER_ROLE', 'WORLD_PROVISION', 'WORLD_DELETE', 'WORLD_OPEN', 'WORLD_CLAIM', 'PREVIEW_CREATE', 'PREVIEW_DELETE', 'TOKEN_CREATE', 'TOKEN_REVOKE', 'ORG_TOKEN_CREATE', 'ORG_TOKEN_REVOKE', 'SECURITY_CHANGE', 'SUPPORT_REQUEST', 'SUPPORT_ACCESS_GRANTED', 'SUPPORT_ACCESS_REVOKED', 'IMPERSONATION', 'WEBHOOK_CREATE', 'WEBHOOK_CHANGE', 'WEBHOOK_DELETE', 'WEBHOOK_DISABLED', 'SPEND_CAP_CHANGE', 'LABS_CHANGE', 'REMINDER_SENT', 'PLAN_CHECKOUT', 'PLAN_CHANGE', 'QUOTA_WARNED', 'QUOTA_RESTRICTED', 'QUOTA_CLEARED'] as const;
9
+ export type AuditEvent = (typeof AUDIT_EVENTS)[number];
10
+ export type AuditEntry = { at: string; event: AuditEvent; org: string; actor: { id: string; email?: string } | { operator: true } | { platform: true }; target?: string; data?: Record<string, unknown> };
11
+
12
+ export class Audit {
13
+ private readonly db: PlatformDb;
14
+ constructor(stateDir: string) { this.db = openDatabase(stateDir); }
15
+ record(entry: Omit<AuditEntry, 'at'>): AuditEntry {
16
+ if (!/^[A-Za-z0-9_-]{1,80}$/.test(entry.org)) throw new Error('bad org id');
17
+ const full: AuditEntry = { at: new Date().toISOString(), ...entry };
18
+ this.db.insert(auditRows).values({ org: full.org, at: full.at, event: full.event, actor: full.actor, target: full.target ?? null, data: full.data ?? null }).run();
19
+ return full;
20
+ }
21
+ /** The org's log, newest first, at most `limit` entries. */
22
+ read(org: string, limit = 200): AuditEntry[] {
23
+ return (this.db.select().from(auditRows).where(and(eq(auditRows.org, org))).orderBy(desc(auditRows.seq)).limit(limit).all() as Array<typeof auditRows.$inferSelect>)
24
+ .map((r) => ({ at: r.at, event: r.event as AuditEvent, org: r.org, actor: r.actor as AuditEntry['actor'], ...(r.target !== null ? { target: r.target } : {}), ...(r.data !== null && r.data !== undefined ? { data: r.data as Record<string, unknown> } : {}) }));
25
+ }
26
+ }
package/src/backup.ts ADDED
@@ -0,0 +1,73 @@
1
+ // Backups of the platform's state (punchlist 4.4): everything under the state directory — the org
2
+ // records, personal tokens, the audit logs, the enrolled providers, the operator token — as ONE
3
+ // JSON archive, put into an S3 bucket through the UNMODIFIED @aws-sdk/client-s3: the aws twin in
4
+ // rehearsal, real S3 (or any S3-compatible store) in production, named by the vendor's own
5
+ // AWS_* env; the bucket is named on the command line (--backup-bucket). The database goes in as a snapshot SQLite
6
+ // makes of itself (VACUUM INTO), never its live file; a restore stages it, and the platform puts it in place when it
7
+ // next starts (db/open.ts).
8
+ import { CreateBucketCommand, GetObjectCommand, HeadBucketCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
9
+ import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import { dirname, join, relative } from 'node:path';
12
+ import { randomBytes } from 'node:crypto';
13
+ import { sql } from 'drizzle-orm';
14
+ import { DB_FILE, openDatabase, RESTORE_FILE } from './db/open.ts';
15
+
16
+ export type Archive = { madeAt: string; files: Record<string, string> }; // path → base64
17
+ export type BackupOptions = { bucket?: string; prefix?: string; client?: S3Client };
18
+
19
+ /** The state directory as one archive: every regular file, base64, keyed by its relative path; the database as a
20
+ * consistent snapshot of itself (its live file and write-ahead log are never read mid-write). */
21
+ export function archiveOf(stateDir: string): Archive {
22
+ const files: Record<string, string> = {};
23
+ const live = new Set([DB_FILE, `${DB_FILE}-wal`, `${DB_FILE}-shm`, RESTORE_FILE]);
24
+ const walk = (dir: string): void => { for (const e of readdirSync(dir)) { const p = join(dir, e); const st = statSync(p); if (st.isDirectory()) walk(p); else if (st.isFile() && !(dir === stateDir && live.has(e))) files[relative(stateDir, p)] = readFileSync(p).toString('base64'); } };
25
+ walk(stateDir);
26
+ const snapshot = join(tmpdir(), `platform-${randomBytes(6).toString('hex')}.db`);
27
+ try { openDatabase(stateDir).run(sql.raw(`VACUUM INTO '${snapshot.replace(/'/g, "''")}'`)); files[DB_FILE] = readFileSync(snapshot).toString('base64'); }
28
+ finally { rmSync(snapshot, { force: true }); }
29
+ return { madeAt: new Date().toISOString(), files };
30
+ }
31
+ /** Write an archive back: the files as they were, and the database staged beside the live one, put in place when the
32
+ * platform next starts (it has the live one open now). */
33
+ export function restoreArchive(stateDir: string, archive: Archive): number {
34
+ let n = 0;
35
+ for (const [rel, b64] of Object.entries(archive.files)) {
36
+ if (rel.includes('..')) continue;
37
+ const p = join(stateDir, rel === DB_FILE ? RESTORE_FILE : rel); mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, Buffer.from(b64, 'base64')); n++;
38
+ }
39
+ return n;
40
+ }
41
+
42
+ export class Backups {
43
+ private readonly s3: S3Client; private readonly bucket: string; private readonly prefix: string;
44
+ constructor(opts: BackupOptions = {}) {
45
+ const bucket = opts.bucket;
46
+ if (!bucket) throw new Error('backups: no bucket named (--backup-bucket)');
47
+ this.bucket = bucket; this.prefix = opts.prefix ?? 'platform-state/';
48
+ this.s3 = opts.client ?? new S3Client({ region: process.env.AWS_REGION || 'us-east-1', ...(process.env.AWS_ENDPOINT_URL ? { endpoint: process.env.AWS_ENDPOINT_URL, forcePathStyle: true } : {}) }); // a blank region (the world mints AWS_REGION empty) is the default region
49
+ }
50
+ private async ensureBucket(): Promise<void> {
51
+ try { await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket })); } catch { await this.s3.send(new CreateBucketCommand({ Bucket: this.bucket })); }
52
+ }
53
+ /** Put the archive; answers its key. */
54
+ async backup(stateDir: string): Promise<{ key: string; files: number; bytes: number }> {
55
+ await this.ensureBucket();
56
+ const archive = archiveOf(stateDir); const body = JSON.stringify(archive);
57
+ const key = `${this.prefix}${archive.madeAt.replace(/[:.]/g, '-')}.json`;
58
+ await this.s3.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body, ContentType: 'application/json' }));
59
+ return { key, files: Object.keys(archive.files).length, bytes: body.length };
60
+ }
61
+ async list(): Promise<string[]> {
62
+ await this.ensureBucket();
63
+ const r = await this.s3.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: this.prefix }));
64
+ return (r.Contents ?? []).map((o) => o.Key!).filter(Boolean).sort();
65
+ }
66
+ /** Fetch an archive and write its files back into the state directory. */
67
+ async restore(stateDir: string, key: string): Promise<{ key: string; files: number; madeAt: string; restart: string }> {
68
+ const r = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
69
+ const text = await r.Body!.transformToString();
70
+ const archive = JSON.parse(text) as Archive;
71
+ return { key, files: restoreArchive(stateDir, archive), madeAt: archive.madeAt, restart: 'the database is staged: restart the platform to put it in place' };
72
+ }
73
+ }
package/src/biller.ts ADDED
@@ -0,0 +1,45 @@
1
+ // THE BILLER (docs/contributing/architecture.md, "The hosted product": billing is Volter's alone, `apps/billing`).
2
+ // The platform names no plan, price or meter: it asks an attached biller what an org may do, tells it what happened,
3
+ // and passes the org's billing doors through. With no biller attached nothing is limited and no billing door answers.
4
+ // This module is the whole contract, types only: the platform depends on no biller's code.
5
+ import type { AuditEntry } from './audit.ts';
6
+
7
+ /** What the platform lends a biller: its record of each org's Worlds, the org's admins, its mail and its audit log. */
8
+ export type BillerContext = {
9
+ /** This platform's public origin: where a checkout returns and a mail links. */
10
+ origin: string;
11
+ /** Every org the platform holds a record for, by Volter organization id (the tick's roster). */
12
+ orgIds: () => string[];
13
+ /** The Worlds an org holds now, by served name. */
14
+ worldsOf: (orgId: string) => string[];
15
+ /** The org's admins' addresses. */
16
+ adminsOf: (orgId: string) => Promise<string[]>;
17
+ /** The platform's mail (best effort). */
18
+ mail: (to: string[], subject: string, text: string) => Promise<void>;
19
+ /** An act on the org's audit log (and its webhooks). */
20
+ record: (entry: Omit<AuditEntry, 'at'>) => void;
21
+ /** Where a person manages billing: the platform's page for the org. */
22
+ billingPage: (org: { id: string; slug: string | null }) => string;
23
+ /** Where the biller keeps what it knows (a pending checkout, the restriction, usage by day), by its own keys: the
24
+ * platform's database, which names no plan or price. */
25
+ records: { get: <T>(key: string) => T | null; put: (key: string, value: unknown) => void };
26
+ };
27
+
28
+ export type Biller = {
29
+ /** Before a World is made for an org: null when it may be, else the refusal the person reads (402). */
30
+ admit: (orgId: string, want: { twins: number }) => Promise<Response | null>;
31
+ /** A World was made (metered once, by its name). */
32
+ worldMade: (orgId: string, name: string) => Promise<void>;
33
+ /** `/-/orgs/<org>/<verb>` for the biller's verbs, the org resolved and the person a member of it. */
34
+ door: (request: Request, verb: string, ctx: { org: { id: string; slug: string | null }; admin: boolean; person: { id: string; email?: string } }) => Promise<Response>;
35
+ /** The verbs `door` answers under an org. */
36
+ verbs: readonly string[];
37
+ /** The hourly work: usage metered, every org through its quota, reminders sent. */
38
+ tick: () => Promise<Record<string, unknown>>;
39
+ /** The operator's line for an org: its plan and restriction, in words. */
40
+ summary: (orgId: string) => Promise<{ plan: string; restriction: string | null }>;
41
+ /** When the tick last ran. */
42
+ lastTickAt: () => string | null;
43
+ };
44
+
45
+ export type BillerFactory = (ctx: BillerContext) => Biller;
package/src/cli.ts ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // volter-platform — the Volter World platform as a PROCESS: an app in a world.
3
+ //
4
+ // volter-platform serve --dir <state> [--port P] [--host H] [--url <origin reached at>] [--provider volter|oidc|github] [--site <front door origin>]
5
+ //
6
+ // Its substrate is its state directory and its listener. People sign in through ONE access provider
7
+ // (identity.ts): `volter`, Volter Identity (VOLTER_ISSUER, and the client registered for this platform
8
+ // there: VOLTER_CLIENT_ID and VOLTER_CLIENT_SECRET — in a world, the identity twin and the client the
9
+ // drive registered on it), or `oidc`, any OpenID Connect issuer (OIDC_ISSUER, OIDC_CLIENT_ID,
10
+ // OIDC_CLIENT_SECRET, OIDC_NAME), or `github`, a GitHub OAuth app (GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and
11
+ // GITHUB_URL / GITHUB_API_URL for GitHub Enterprise Server); for both the platform keeps the directory in its state.
12
+ // Without --provider, OIDC_ISSUER in the env chooses `oidc`, else `volter`; GitHub is chosen only by name. Volter's biller (apps/billing) is attached when
13
+ // POLAR_ACCESS_TOKEN is in the env; without it nothing is limited or priced.
14
+ // The hosts it provisions onto are ENROLLED through the operator's door (POST /-/hosts), kept in
15
+ // `<state>/providers.json` as `{ hosts: [{ id, base, adminToken }] }`.
16
+ import { Backups } from './backup.ts';
17
+ import { mailer } from './mail.ts';
18
+ import { chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
19
+ import { makeSigningKey, type SigningKey } from '@volter/world-access';
20
+ import { join, resolve } from 'node:path';
21
+ import { servePlatform, operatorTokenFor, type HostProvider, type Providers } from './platform.ts';
22
+ import type { BillerFactory } from './biller.ts';
23
+ import { writeFileSync } from 'node:fs';
24
+ import { Store } from './store.ts';
25
+
26
+ const [cmd, ...rest] = process.argv.slice(2);
27
+ const value = (flag: string): string | undefined => { const i = rest.indexOf(flag); return i >= 0 ? rest[i + 1] : undefined; };
28
+ const usage = 'volter-platform serve --dir <state> [--port P] [--host H] [--url <origin>] [--provider volter|oidc|github] [--site <front door origin>] [--backup-bucket <s3 bucket>] [--mail-from <sender>] [--tick-every <seconds>] [--backup-at <HH:MM UTC>] [--deliver-every <seconds>] [--support-to <address>] [--client-ip-header <name>]\n';
29
+
30
+ if (cmd !== 'serve' || !value('--dir')) { process.stderr.write(usage); process.exit(2); }
31
+ const dir = resolve(value('--dir')!);
32
+ const providersPath = join(dir, 'providers.json');
33
+ const providers: Providers = existsSync(providersPath) ? { hosts: [], ...(JSON.parse(readFileSync(providersPath, 'utf8')) as Partial<Providers>) } : { hosts: [] };
34
+ const hosts: HostProvider[] = providers.hosts;
35
+ // nothing enrolled yet is fine: the operator enrolls hosts through the door (POST /-/hosts)
36
+
37
+ // the key passes are signed with, kept beside the operator token (owner-only): made once, then the same key, so a
38
+ // restart does not strand the hosts that cached its public half
39
+ const signingKeyPath = join(dir, 'signing-key.json');
40
+ const signingKey: SigningKey = existsSync(signingKeyPath) ? (JSON.parse(readFileSync(signingKeyPath, 'utf8')) as SigningKey) : await (async () => {
41
+ const made = await makeSigningKey();
42
+ mkdirSync(dir, { recursive: true }); writeFileSync(signingKeyPath, `${JSON.stringify(made)}\n`, { mode: 0o600 });
43
+ return made;
44
+ })();
45
+
46
+ const provider = value('--provider') ?? (process.env.OIDC_ISSUER ? 'oidc' : 'volter');
47
+ if (provider !== 'volter' && provider !== 'oidc' && provider !== 'github') { process.stderr.write(`--provider is volter, oidc or github, not ${provider}\n${usage}`); process.exit(2); }
48
+ // Volter's biller, only where Polar is configured: a self-hosted platform carries no billing. It is Volter's and not
49
+ // published, so it is named by a string the build does not follow; where it is absent the platform refuses to start
50
+ // rather than run unmetered while Polar was meant to bill.
51
+ type BillerModule = { createBiller: (opts: { notify?: (note: { kind: string; org: string; meter: string; graceEnds?: string }) => void }) => BillerFactory };
52
+ const BILLER = '@volter/world-billing';
53
+ const billing: BillerFactory | undefined = process.env.POLAR_ACCESS_TOKEN
54
+ ? await (import(BILLER) as Promise<BillerModule>).then(
55
+ (m) => m.createBiller({ notify: (note) => process.stdout.write(`notify ${note.kind} org ${note.org} ${note.meter}${note.graceEnds ? ` grace ends ${note.graceEnds}` : ''}\n`) }),
56
+ (): never => { process.stderr.write(`POLAR_ACCESS_TOKEN is set, but Volter's biller (${BILLER}) is not installed here. It is private to Volter and not published: Volter's platform runs from its repository's workspace, where it resolves (deploy/RUNBOOK.md). A self-hosted platform bills nothing: unset POLAR_ACCESS_TOKEN.\n`); process.exit(2); },
57
+ )
58
+ : undefined;
59
+
60
+ // https, or http only on this machine: over plain http on a public name the session cookies cannot be `__Host-` (a sibling
61
+ // name could set its own), and they would travel in clear
62
+ {
63
+ const url = value('--url');
64
+ if (url) {
65
+ let at: URL; try { at = new URL(url); } catch { process.stderr.write(`--url ${url} is not an address\n`); process.exit(2); }
66
+ const local = at.hostname === 'localhost' || at.hostname.endsWith('.localhost') || at.hostname === '127.0.0.1' || at.hostname === '[::1]';
67
+ if (at.protocol !== 'https:' && !(at.protocol === 'http:' && local)) { process.stderr.write(`--url ${url}: the platform is reached over https (or http on this machine's loopback)\n`); process.exit(2); }
68
+ }
69
+ }
70
+
71
+ const host = value('--host') ?? '127.0.0.1';
72
+ // reached at a public address with no proxy header named: unauthenticated requests share one rate-limit bucket (no client
73
+ // names its own address), so one noisy caller slows every sign-in; the deploy's proxy header gives each client its own
74
+ {
75
+ const url = value('--url');
76
+ const local = url ? ((h) => h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '[::1]')(new URL(url).hostname) : true;
77
+ if (!value('--client-ip-header') && !local) process.stderr.write("warning no --client-ip-header: requests without a token share one rate-limit bucket. Name the header your proxy sets to the client's address (cf-connecting-ip on Cloudflare, x-forwarded-for behind a proxy that appends to it).\n");
78
+ }
79
+ const served = await servePlatform({
80
+ host, port: Number(value('--port') ?? 0), ...(value('--url') ? { origin: value('--url')! } : {}),
81
+ mail: mailer(value('--mail-from') ? { from: value('--mail-from')! } : {}), // Resend through the boundary when RESEND_API_KEY is in the env; the log otherwise
82
+ ...(value('--backup-bucket') ? { backups: new Backups({ bucket: value('--backup-bucket')! }) } : {}), // S3 through the boundary: the aws twin in rehearsal
83
+ provider: { kind: provider },
84
+ ...(billing ? { billing } : {}),
85
+ ...(value('--site') ? { siteUrl: value('--site')! } : {}), // the front door: its docs, and the terms accepted at org creation
86
+ store: new Store(dir),
87
+ ...(value('--support-to') ? { supportTo: value('--support-to')! } : {}), // where the Help form's mail goes (layer 6)
88
+ ...(value('--client-ip-header') ? { clientIpHeader: value('--client-ip-header')! } : {}), // the header the deploy's proxy sets to the client's address
89
+ clock: { ...(value('--tick-every') ? { tickEverySeconds: Number(value('--tick-every')) } : {}), ...(value('--backup-at') ? { backupAt: value('--backup-at')! } : {}), ...(value('--deliver-every') ? { deliverEverySeconds: Number(value('--deliver-every')) } : {}) }, // the platform's own clock (layer 5; webhook deliveries, layer 7)
90
+ stateDir: dir,
91
+ hosts,
92
+ // the enrolled hosts' admin tokens: owner-only, as the operator token and the signing key are
93
+ onProviders: (p) => { writeFileSync(providersPath, `${JSON.stringify(p, null, 2)}\n`, { mode: 0o600 }); chmodSync(providersPath, 0o600); },
94
+ signingKey,
95
+ });
96
+ const origin = served.url;
97
+ process.stdout.write(`platform ready ${origin} (${hosts.length} host${hosts.length === 1 ? '' : 's'} enrolled; sign-in ${provider}; ${billing ? 'billing attached' : 'no billing'})\noperator token ${operatorTokenFor(dir)}\n`);
98
+ const shutdown = (): void => { void served.stop().finally(() => process.exit(0)); };
99
+ process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown);
@@ -0,0 +1,143 @@
1
+ CREATE TABLE `audit` (
2
+ `seq` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
3
+ `org` text NOT NULL,
4
+ `at` text NOT NULL,
5
+ `event` text NOT NULL,
6
+ `actor` text NOT NULL,
7
+ `target` text,
8
+ `data` text
9
+ );
10
+ --> statement-breakpoint
11
+ CREATE INDEX `audit_org` ON `audit` (`org`,`seq`);--> statement-breakpoint
12
+ CREATE TABLE `biller_records` (
13
+ `key` text PRIMARY KEY NOT NULL,
14
+ `value` text NOT NULL
15
+ );
16
+ --> statement-breakpoint
17
+ CREATE TABLE `deliveries` (
18
+ `id` text PRIMARY KEY NOT NULL,
19
+ `org_id` text NOT NULL,
20
+ `endpoint_id` text NOT NULL,
21
+ `event_id` text NOT NULL,
22
+ `event` text NOT NULL,
23
+ `body` text NOT NULL,
24
+ `attempt` integer NOT NULL,
25
+ `next_at` text NOT NULL,
26
+ `attempts` text NOT NULL,
27
+ `state` text NOT NULL,
28
+ `seq` integer NOT NULL
29
+ );
30
+ --> statement-breakpoint
31
+ CREATE INDEX `deliveries_state` ON `deliveries` (`state`,`next_at`);--> statement-breakpoint
32
+ CREATE INDEX `deliveries_endpoint` ON `deliveries` (`endpoint_id`);--> statement-breakpoint
33
+ CREATE TABLE `dir_members` (
34
+ `org_id` text NOT NULL,
35
+ `user_id` text NOT NULL,
36
+ `role` text NOT NULL,
37
+ PRIMARY KEY(`org_id`, `user_id`)
38
+ );
39
+ --> statement-breakpoint
40
+ CREATE INDEX `dir_members_user` ON `dir_members` (`user_id`);--> statement-breakpoint
41
+ CREATE TABLE `dir_orgs` (
42
+ `id` text PRIMARY KEY NOT NULL,
43
+ `slug` text NOT NULL,
44
+ `name` text NOT NULL,
45
+ `created_at` text NOT NULL
46
+ );
47
+ --> statement-breakpoint
48
+ CREATE UNIQUE INDEX `dir_orgs_slug_unique` ON `dir_orgs` (`slug`);--> statement-breakpoint
49
+ CREATE TABLE `invitations` (
50
+ `id` text PRIMARY KEY NOT NULL,
51
+ `org_id` text NOT NULL,
52
+ `email` text NOT NULL,
53
+ `role` text NOT NULL,
54
+ `inviter_id` text NOT NULL,
55
+ `created_at` text NOT NULL,
56
+ `expires_at` text NOT NULL
57
+ );
58
+ --> statement-breakpoint
59
+ CREATE INDEX `invitations_org` ON `invitations` (`org_id`);--> statement-breakpoint
60
+ CREATE INDEX `invitations_email` ON `invitations` (`email`);--> statement-breakpoint
61
+ CREATE TABLE `meta` (
62
+ `key` text PRIMARY KEY NOT NULL,
63
+ `value` text
64
+ );
65
+ --> statement-breakpoint
66
+ CREATE TABLE `org_settings` (
67
+ `org_id` text PRIMARY KEY NOT NULL,
68
+ `security` text,
69
+ `flags` text,
70
+ `early_adopter` integer,
71
+ `support_access` text
72
+ );
73
+ --> statement-breakpoint
74
+ CREATE TABLE `org_worlds` (
75
+ `org_id` text NOT NULL,
76
+ `name` text NOT NULL,
77
+ `host` text NOT NULL,
78
+ `base` text NOT NULL,
79
+ `provisioned_at` text NOT NULL,
80
+ PRIMARY KEY(`org_id`, `name`)
81
+ );
82
+ --> statement-breakpoint
83
+ CREATE TABLE `people` (
84
+ `id` text PRIMARY KEY NOT NULL,
85
+ `email` text,
86
+ `email_verified` integer NOT NULL,
87
+ `name` text,
88
+ `first_seen_at` text NOT NULL,
89
+ `last_seen_at` text NOT NULL
90
+ );
91
+ --> statement-breakpoint
92
+ CREATE INDEX `people_email` ON `people` (`email`);--> statement-breakpoint
93
+ CREATE TABLE `sessions` (
94
+ `id_hash` text PRIMARY KEY NOT NULL,
95
+ `subject` text NOT NULL,
96
+ `email` text,
97
+ `name` text,
98
+ `expires_at` text NOT NULL
99
+ );
100
+ --> statement-breakpoint
101
+ CREATE TABLE `support_requests` (
102
+ `id` text PRIMARY KEY NOT NULL,
103
+ `at` text NOT NULL,
104
+ `person` text NOT NULL,
105
+ `org` text,
106
+ `world` text,
107
+ `category` text NOT NULL,
108
+ `severity` text NOT NULL,
109
+ `subject` text NOT NULL,
110
+ `message` text NOT NULL,
111
+ `status` text NOT NULL
112
+ );
113
+ --> statement-breakpoint
114
+ CREATE TABLE `tokens` (
115
+ `hash` text PRIMARY KEY NOT NULL,
116
+ `user_id` text NOT NULL,
117
+ `name` text NOT NULL,
118
+ `created_at` text NOT NULL,
119
+ `last4` text NOT NULL,
120
+ `last_used_at` text,
121
+ `expires_at` text,
122
+ `scopes` text,
123
+ `org_id` text,
124
+ `support` text,
125
+ `world_keys` text
126
+ );
127
+ --> statement-breakpoint
128
+ CREATE INDEX `tokens_user` ON `tokens` (`user_id`);--> statement-breakpoint
129
+ CREATE INDEX `tokens_org` ON `tokens` (`org_id`);--> statement-breakpoint
130
+ CREATE TABLE `webhooks` (
131
+ `id` text PRIMARY KEY NOT NULL,
132
+ `org_id` text NOT NULL,
133
+ `url` text NOT NULL,
134
+ `secret` text NOT NULL,
135
+ `events` text NOT NULL,
136
+ `description` text NOT NULL,
137
+ `created_at` text NOT NULL,
138
+ `consecutive_failures` integer NOT NULL,
139
+ `last_failed_at` text,
140
+ `disabled_at` text
141
+ );
142
+ --> statement-breakpoint
143
+ CREATE INDEX `webhooks_org` ON `webhooks` (`org_id`);