@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
|
@@ -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 {};
|
package/dist/src/cli.js
ADDED
|
@@ -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`);
|