@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 @@
1
+ export declare const RESERVED_ORG_NAMES: readonly ["account", "help", "new", "sign-in", "sign-out", "settings", "api", "app", "www", "docs", "status", "operator"];
@@ -0,0 +1,3 @@
1
+ // The platform's own top-level pages and words an org can never be named, so an org's address (`/<org>`) never shadows
2
+ // a page (apps/platform/src/pages.ts re-exports it for the door that makes orgs).
3
+ export const RESERVED_ORG_NAMES = ['account', 'help', 'new', 'sign-in', 'sign-out', 'settings', 'api', 'app', 'www', 'docs', 'status', 'operator'];
@@ -0,0 +1,3 @@
1
+ // The platform's own top-level pages and words an org can never be named, so an org's address (`/<org>`) never shadows
2
+ // a page (apps/platform/src/pages.ts re-exports it for the door that makes orgs).
3
+ export const RESERVED_ORG_NAMES = ['account', 'help', 'new', 'sign-in', 'sign-out', 'settings', 'api', 'app', 'www', 'docs', 'status', 'operator'] as const;
@@ -0,0 +1,24 @@
1
+ export declare const AUDIT_EVENTS: readonly ["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"];
2
+ export type AuditEvent = (typeof AUDIT_EVENTS)[number];
3
+ export type AuditEntry = {
4
+ at: string;
5
+ event: AuditEvent;
6
+ org: string;
7
+ actor: {
8
+ id: string;
9
+ email?: string;
10
+ } | {
11
+ operator: true;
12
+ } | {
13
+ platform: true;
14
+ };
15
+ target?: string;
16
+ data?: Record<string, unknown>;
17
+ };
18
+ export declare class Audit {
19
+ private readonly db;
20
+ constructor(stateDir: string);
21
+ record(entry: Omit<AuditEntry, 'at'>): AuditEntry;
22
+ /** The org's log, newest first, at most `limit` entries. */
23
+ read(org: string, limit?: number): AuditEntry[];
24
+ }
@@ -0,0 +1,23 @@
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 } from "./db/open.js";
6
+ import { audit as auditRows } from "./db/schema.js";
7
+ 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'];
8
+ export class Audit {
9
+ db;
10
+ constructor(stateDir) { this.db = openDatabase(stateDir); }
11
+ record(entry) {
12
+ if (!/^[A-Za-z0-9_-]{1,80}$/.test(entry.org))
13
+ throw new Error('bad org id');
14
+ const full = { at: new Date().toISOString(), ...entry };
15
+ 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();
16
+ return full;
17
+ }
18
+ /** The org's log, newest first, at most `limit` entries. */
19
+ read(org, limit = 200) {
20
+ return this.db.select().from(auditRows).where(and(eq(auditRows.org, org))).orderBy(desc(auditRows.seq)).limit(limit).all()
21
+ .map((r) => ({ at: r.at, event: r.event, org: r.org, actor: r.actor, ...(r.target !== null ? { target: r.target } : {}), ...(r.data !== null && r.data !== undefined ? { data: r.data } : {}) }));
22
+ }
23
+ }
@@ -0,0 +1,37 @@
1
+ import { S3Client } from '@aws-sdk/client-s3';
2
+ export type Archive = {
3
+ madeAt: string;
4
+ files: Record<string, string>;
5
+ };
6
+ export type BackupOptions = {
7
+ bucket?: string;
8
+ prefix?: string;
9
+ client?: S3Client;
10
+ };
11
+ /** The state directory as one archive: every regular file, base64, keyed by its relative path; the database as a
12
+ * consistent snapshot of itself (its live file and write-ahead log are never read mid-write). */
13
+ export declare function archiveOf(stateDir: string): Archive;
14
+ /** Write an archive back: the files as they were, and the database staged beside the live one, put in place when the
15
+ * platform next starts (it has the live one open now). */
16
+ export declare function restoreArchive(stateDir: string, archive: Archive): number;
17
+ export declare class Backups {
18
+ private readonly s3;
19
+ private readonly bucket;
20
+ private readonly prefix;
21
+ constructor(opts?: BackupOptions);
22
+ private ensureBucket;
23
+ /** Put the archive; answers its key. */
24
+ backup(stateDir: string): Promise<{
25
+ key: string;
26
+ files: number;
27
+ bytes: number;
28
+ }>;
29
+ list(): Promise<string[]>;
30
+ /** Fetch an archive and write its files back into the state directory. */
31
+ restore(stateDir: string, key: string): Promise<{
32
+ key: string;
33
+ files: number;
34
+ madeAt: string;
35
+ restart: string;
36
+ }>;
37
+ }
@@ -0,0 +1,94 @@
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.js";
15
+ /** The state directory as one archive: every regular file, base64, keyed by its relative path; the database as a
16
+ * consistent snapshot of itself (its live file and write-ahead log are never read mid-write). */
17
+ export function archiveOf(stateDir) {
18
+ const files = {};
19
+ const live = new Set([DB_FILE, `${DB_FILE}-wal`, `${DB_FILE}-shm`, RESTORE_FILE]);
20
+ const walk = (dir) => { for (const e of readdirSync(dir)) {
21
+ const p = join(dir, e);
22
+ const st = statSync(p);
23
+ if (st.isDirectory())
24
+ walk(p);
25
+ else if (st.isFile() && !(dir === stateDir && live.has(e)))
26
+ files[relative(stateDir, p)] = readFileSync(p).toString('base64');
27
+ } };
28
+ walk(stateDir);
29
+ const snapshot = join(tmpdir(), `platform-${randomBytes(6).toString('hex')}.db`);
30
+ try {
31
+ openDatabase(stateDir).run(sql.raw(`VACUUM INTO '${snapshot.replace(/'/g, "''")}'`));
32
+ files[DB_FILE] = readFileSync(snapshot).toString('base64');
33
+ }
34
+ finally {
35
+ rmSync(snapshot, { force: true });
36
+ }
37
+ return { madeAt: new Date().toISOString(), files };
38
+ }
39
+ /** Write an archive back: the files as they were, and the database staged beside the live one, put in place when the
40
+ * platform next starts (it has the live one open now). */
41
+ export function restoreArchive(stateDir, archive) {
42
+ let n = 0;
43
+ for (const [rel, b64] of Object.entries(archive.files)) {
44
+ if (rel.includes('..'))
45
+ continue;
46
+ const p = join(stateDir, rel === DB_FILE ? RESTORE_FILE : rel);
47
+ mkdirSync(dirname(p), { recursive: true });
48
+ writeFileSync(p, Buffer.from(b64, 'base64'));
49
+ n++;
50
+ }
51
+ return n;
52
+ }
53
+ export class Backups {
54
+ s3;
55
+ bucket;
56
+ prefix;
57
+ constructor(opts = {}) {
58
+ const bucket = opts.bucket;
59
+ if (!bucket)
60
+ throw new Error('backups: no bucket named (--backup-bucket)');
61
+ this.bucket = bucket;
62
+ this.prefix = opts.prefix ?? 'platform-state/';
63
+ 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
64
+ }
65
+ async ensureBucket() {
66
+ try {
67
+ await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket }));
68
+ }
69
+ catch {
70
+ await this.s3.send(new CreateBucketCommand({ Bucket: this.bucket }));
71
+ }
72
+ }
73
+ /** Put the archive; answers its key. */
74
+ async backup(stateDir) {
75
+ await this.ensureBucket();
76
+ const archive = archiveOf(stateDir);
77
+ const body = JSON.stringify(archive);
78
+ const key = `${this.prefix}${archive.madeAt.replace(/[:.]/g, '-')}.json`;
79
+ await this.s3.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body, ContentType: 'application/json' }));
80
+ return { key, files: Object.keys(archive.files).length, bytes: body.length };
81
+ }
82
+ async list() {
83
+ await this.ensureBucket();
84
+ const r = await this.s3.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: this.prefix }));
85
+ return (r.Contents ?? []).map((o) => o.Key).filter(Boolean).sort();
86
+ }
87
+ /** Fetch an archive and write its files back into the state directory. */
88
+ async restore(stateDir, key) {
89
+ const r = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
90
+ const text = await r.Body.transformToString();
91
+ const archive = JSON.parse(text);
92
+ return { key, files: restoreArchive(stateDir, archive), madeAt: archive.madeAt, restart: 'the database is staged: restart the platform to put it in place' };
93
+ }
94
+ }
@@ -0,0 +1,59 @@
1
+ import type { AuditEntry } from './audit.js';
2
+ /** What the platform lends a biller: its record of each org's Worlds, the org's admins, its mail and its audit log. */
3
+ export type BillerContext = {
4
+ /** This platform's public origin: where a checkout returns and a mail links. */
5
+ origin: string;
6
+ /** Every org the platform holds a record for, by Volter organization id (the tick's roster). */
7
+ orgIds: () => string[];
8
+ /** The Worlds an org holds now, by served name. */
9
+ worldsOf: (orgId: string) => string[];
10
+ /** The org's admins' addresses. */
11
+ adminsOf: (orgId: string) => Promise<string[]>;
12
+ /** The platform's mail (best effort). */
13
+ mail: (to: string[], subject: string, text: string) => Promise<void>;
14
+ /** An act on the org's audit log (and its webhooks). */
15
+ record: (entry: Omit<AuditEntry, 'at'>) => void;
16
+ /** Where a person manages billing: the platform's page for the org. */
17
+ billingPage: (org: {
18
+ id: string;
19
+ slug: string | null;
20
+ }) => string;
21
+ /** Where the biller keeps what it knows (a pending checkout, the restriction, usage by day), by its own keys: the
22
+ * platform's database, which names no plan or price. */
23
+ records: {
24
+ get: <T>(key: string) => T | null;
25
+ 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: {
31
+ twins: number;
32
+ }) => Promise<Response | null>;
33
+ /** A World was made (metered once, by its name). */
34
+ worldMade: (orgId: string, name: string) => Promise<void>;
35
+ /** `/-/orgs/<org>/<verb>` for the biller's verbs, the org resolved and the person a member of it. */
36
+ door: (request: Request, verb: string, ctx: {
37
+ org: {
38
+ id: string;
39
+ slug: string | null;
40
+ };
41
+ admin: boolean;
42
+ person: {
43
+ id: string;
44
+ email?: string;
45
+ };
46
+ }) => Promise<Response>;
47
+ /** The verbs `door` answers under an org. */
48
+ verbs: readonly string[];
49
+ /** The hourly work: usage metered, every org through its quota, reminders sent. */
50
+ tick: () => Promise<Record<string, unknown>>;
51
+ /** The operator's line for an org: its plan and restriction, in words. */
52
+ summary: (orgId: string) => Promise<{
53
+ plan: string;
54
+ restriction: string | null;
55
+ }>;
56
+ /** When the tick last ran. */
57
+ lastTickAt: () => string | null;
58
+ };
59
+ export type BillerFactory = (ctx: BillerContext) => Biller;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
3
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
4
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
5
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
6
+ });
7
+ }
8
+ return path;
9
+ };
10
+ // volter-platform — the Volter World platform as a PROCESS: an app in a world.
11
+ //
12
+ // volter-platform serve --dir <state> [--port P] [--host H] [--url <origin reached at>] [--provider volter|oidc|github] [--site <front door origin>]
13
+ //
14
+ // Its substrate is its state directory and its listener. People sign in through ONE access provider
15
+ // (identity.ts): `volter`, Volter Identity (VOLTER_ISSUER, and the client registered for this platform
16
+ // there: VOLTER_CLIENT_ID and VOLTER_CLIENT_SECRET — in a world, the identity twin and the client the
17
+ // drive registered on it), or `oidc`, any OpenID Connect issuer (OIDC_ISSUER, OIDC_CLIENT_ID,
18
+ // OIDC_CLIENT_SECRET, OIDC_NAME), or `github`, a GitHub OAuth app (GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and
19
+ // GITHUB_URL / GITHUB_API_URL for GitHub Enterprise Server); for both the platform keeps the directory in its state.
20
+ // 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
21
+ // POLAR_ACCESS_TOKEN is in the env; without it nothing is limited or priced.
22
+ // The hosts it provisions onto are ENROLLED through the operator's door (POST /-/hosts), kept in
23
+ // `<state>/providers.json` as `{ hosts: [{ id, base, adminToken }] }`.
24
+ import { Backups } from "./backup.js";
25
+ import { mailer } from "./mail.js";
26
+ import { chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
27
+ import { makeSigningKey } from '@volter/world-access';
28
+ import { join, resolve } from 'node:path';
29
+ import { servePlatform, operatorTokenFor } from "./platform.js";
30
+ import { writeFileSync } from 'node:fs';
31
+ import { Store } from "./store.js";
32
+ const [cmd, ...rest] = process.argv.slice(2);
33
+ const value = (flag) => { const i = rest.indexOf(flag); return i >= 0 ? rest[i + 1] : undefined; };
34
+ 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';
35
+ if (cmd !== 'serve' || !value('--dir')) {
36
+ process.stderr.write(usage);
37
+ process.exit(2);
38
+ }
39
+ const dir = resolve(value('--dir'));
40
+ const providersPath = join(dir, 'providers.json');
41
+ const providers = existsSync(providersPath) ? { hosts: [], ...JSON.parse(readFileSync(providersPath, 'utf8')) } : { hosts: [] };
42
+ const hosts = providers.hosts;
43
+ // nothing enrolled yet is fine: the operator enrolls hosts through the door (POST /-/hosts)
44
+ // the key passes are signed with, kept beside the operator token (owner-only): made once, then the same key, so a
45
+ // restart does not strand the hosts that cached its public half
46
+ const signingKeyPath = join(dir, 'signing-key.json');
47
+ const signingKey = existsSync(signingKeyPath) ? JSON.parse(readFileSync(signingKeyPath, 'utf8')) : await (async () => {
48
+ const made = await makeSigningKey();
49
+ mkdirSync(dir, { recursive: true });
50
+ writeFileSync(signingKeyPath, `${JSON.stringify(made)}\n`, { mode: 0o600 });
51
+ return made;
52
+ })();
53
+ const provider = value('--provider') ?? (process.env.OIDC_ISSUER ? 'oidc' : 'volter');
54
+ if (provider !== 'volter' && provider !== 'oidc' && provider !== 'github') {
55
+ process.stderr.write(`--provider is volter, oidc or github, not ${provider}\n${usage}`);
56
+ process.exit(2);
57
+ }
58
+ const BILLER = '@volter/world-billing';
59
+ const billing = process.env.POLAR_ACCESS_TOKEN
60
+ ? await import(__rewriteRelativeImportExtension(BILLER)).then((m) => m.createBiller({ notify: (note) => process.stdout.write(`notify ${note.kind} org ${note.org} ${note.meter}${note.graceEnds ? ` grace ends ${note.graceEnds}` : ''}\n`) }), () => { 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); })
61
+ : undefined;
62
+ // https, or http only on this machine: over plain http on a public name the session cookies cannot be `__Host-` (a sibling
63
+ // name could set its own), and they would travel in clear
64
+ {
65
+ const url = value('--url');
66
+ if (url) {
67
+ let at;
68
+ try {
69
+ at = new URL(url);
70
+ }
71
+ catch {
72
+ process.stderr.write(`--url ${url} is not an address\n`);
73
+ process.exit(2);
74
+ }
75
+ const local = at.hostname === 'localhost' || at.hostname.endsWith('.localhost') || at.hostname === '127.0.0.1' || at.hostname === '[::1]';
76
+ if (at.protocol !== 'https:' && !(at.protocol === 'http:' && local)) {
77
+ process.stderr.write(`--url ${url}: the platform is reached over https (or http on this machine's loopback)\n`);
78
+ process.exit(2);
79
+ }
80
+ }
81
+ }
82
+ const host = value('--host') ?? '127.0.0.1';
83
+ // reached at a public address with no proxy header named: unauthenticated requests share one rate-limit bucket (no client
84
+ // names its own address), so one noisy caller slows every sign-in; the deploy's proxy header gives each client its own
85
+ {
86
+ const url = value('--url');
87
+ const local = url ? ((h) => h === 'localhost' || h.endsWith('.localhost') || h === '127.0.0.1' || h === '[::1]')(new URL(url).hostname) : true;
88
+ if (!value('--client-ip-header') && !local)
89
+ 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");
90
+ }
91
+ const served = await servePlatform({
92
+ host, port: Number(value('--port') ?? 0), ...(value('--url') ? { origin: value('--url') } : {}),
93
+ mail: mailer(value('--mail-from') ? { from: value('--mail-from') } : {}), // Resend through the boundary when RESEND_API_KEY is in the env; the log otherwise
94
+ ...(value('--backup-bucket') ? { backups: new Backups({ bucket: value('--backup-bucket') }) } : {}), // S3 through the boundary: the aws twin in rehearsal
95
+ provider: { kind: provider },
96
+ ...(billing ? { billing } : {}),
97
+ ...(value('--site') ? { siteUrl: value('--site') } : {}), // the front door: its docs, and the terms accepted at org creation
98
+ store: new Store(dir),
99
+ ...(value('--support-to') ? { supportTo: value('--support-to') } : {}), // where the Help form's mail goes (layer 6)
100
+ ...(value('--client-ip-header') ? { clientIpHeader: value('--client-ip-header') } : {}), // the header the deploy's proxy sets to the client's address
101
+ 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)
102
+ stateDir: dir,
103
+ hosts,
104
+ // the enrolled hosts' admin tokens: owner-only, as the operator token and the signing key are
105
+ onProviders: (p) => { writeFileSync(providersPath, `${JSON.stringify(p, null, 2)}\n`, { mode: 0o600 }); chmodSync(providersPath, 0o600); },
106
+ signingKey,
107
+ });
108
+ const origin = served.url;
109
+ 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`);
110
+ const shutdown = () => { void served.stop().finally(() => process.exit(0)); };
111
+ process.on('SIGTERM', shutdown);
112
+ 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`);