@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,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "sqlite",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "6",
8
+ "when": 1790550512216,
9
+ "tag": "0000_init",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,33 @@
1
+ // GENERATED by `bun src/db/pack-migrations.ts --write` from ./migrations/*.sql (drizzle-kit's): do not edit.
2
+ export const MIGRATIONS: ReadonlyArray<{ tag: string; statements: readonly string[] }> = [
3
+ {
4
+ "tag": "0000_init",
5
+ "statements": [
6
+ "CREATE TABLE `audit` (\n\t`seq` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`org` text NOT NULL,\n\t`at` text NOT NULL,\n\t`event` text NOT NULL,\n\t`actor` text NOT NULL,\n\t`target` text,\n\t`data` text\n);",
7
+ "CREATE INDEX `audit_org` ON `audit` (`org`,`seq`);",
8
+ "CREATE TABLE `biller_records` (\n\t`key` text PRIMARY KEY NOT NULL,\n\t`value` text NOT NULL\n);",
9
+ "CREATE TABLE `deliveries` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`org_id` text NOT NULL,\n\t`endpoint_id` text NOT NULL,\n\t`event_id` text NOT NULL,\n\t`event` text NOT NULL,\n\t`body` text NOT NULL,\n\t`attempt` integer NOT NULL,\n\t`next_at` text NOT NULL,\n\t`attempts` text NOT NULL,\n\t`state` text NOT NULL,\n\t`seq` integer NOT NULL\n);",
10
+ "CREATE INDEX `deliveries_state` ON `deliveries` (`state`,`next_at`);",
11
+ "CREATE INDEX `deliveries_endpoint` ON `deliveries` (`endpoint_id`);",
12
+ "CREATE TABLE `dir_members` (\n\t`org_id` text NOT NULL,\n\t`user_id` text NOT NULL,\n\t`role` text NOT NULL,\n\tPRIMARY KEY(`org_id`, `user_id`)\n);",
13
+ "CREATE INDEX `dir_members_user` ON `dir_members` (`user_id`);",
14
+ "CREATE TABLE `dir_orgs` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`slug` text NOT NULL,\n\t`name` text NOT NULL,\n\t`created_at` text NOT NULL\n);",
15
+ "CREATE UNIQUE INDEX `dir_orgs_slug_unique` ON `dir_orgs` (`slug`);",
16
+ "CREATE TABLE `invitations` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`org_id` text NOT NULL,\n\t`email` text NOT NULL,\n\t`role` text NOT NULL,\n\t`inviter_id` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`expires_at` text NOT NULL\n);",
17
+ "CREATE INDEX `invitations_org` ON `invitations` (`org_id`);",
18
+ "CREATE INDEX `invitations_email` ON `invitations` (`email`);",
19
+ "CREATE TABLE `meta` (\n\t`key` text PRIMARY KEY NOT NULL,\n\t`value` text\n);",
20
+ "CREATE TABLE `org_settings` (\n\t`org_id` text PRIMARY KEY NOT NULL,\n\t`security` text,\n\t`flags` text,\n\t`early_adopter` integer,\n\t`support_access` text\n);",
21
+ "CREATE TABLE `org_worlds` (\n\t`org_id` text NOT NULL,\n\t`name` text NOT NULL,\n\t`host` text NOT NULL,\n\t`base` text NOT NULL,\n\t`provisioned_at` text NOT NULL,\n\tPRIMARY KEY(`org_id`, `name`)\n);",
22
+ "CREATE TABLE `people` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`email` text,\n\t`email_verified` integer NOT NULL,\n\t`name` text,\n\t`first_seen_at` text NOT NULL,\n\t`last_seen_at` text NOT NULL\n);",
23
+ "CREATE INDEX `people_email` ON `people` (`email`);",
24
+ "CREATE TABLE `sessions` (\n\t`id_hash` text PRIMARY KEY NOT NULL,\n\t`subject` text NOT NULL,\n\t`email` text,\n\t`name` text,\n\t`expires_at` text NOT NULL\n);",
25
+ "CREATE TABLE `support_requests` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`at` text NOT NULL,\n\t`person` text NOT NULL,\n\t`org` text,\n\t`world` text,\n\t`category` text NOT NULL,\n\t`severity` text NOT NULL,\n\t`subject` text NOT NULL,\n\t`message` text NOT NULL,\n\t`status` text NOT NULL\n);",
26
+ "CREATE TABLE `tokens` (\n\t`hash` text PRIMARY KEY NOT NULL,\n\t`user_id` text NOT NULL,\n\t`name` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`last4` text NOT NULL,\n\t`last_used_at` text,\n\t`expires_at` text,\n\t`scopes` text,\n\t`org_id` text,\n\t`support` text,\n\t`world_keys` text\n);",
27
+ "CREATE INDEX `tokens_user` ON `tokens` (`user_id`);",
28
+ "CREATE INDEX `tokens_org` ON `tokens` (`org_id`);",
29
+ "CREATE TABLE `webhooks` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`org_id` text NOT NULL,\n\t`url` text NOT NULL,\n\t`secret` text NOT NULL,\n\t`events` text NOT NULL,\n\t`description` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`consecutive_failures` integer NOT NULL,\n\t`last_failed_at` text,\n\t`disabled_at` text\n);",
30
+ "CREATE INDEX `webhooks_org` ON `webhooks` (`org_id`);"
31
+ ]
32
+ }
33
+ ];
package/src/db/open.ts ADDED
@@ -0,0 +1,92 @@
1
+ // OPENING THE PLATFORM'S DATABASE (docs/contributing/architecture.md, "The platform's state is SQLite"): one Drizzle
2
+ // database, synchronous on every driver the platform runs on — Bun's `bun:sqlite`, Node's built-in `node:sqlite` (behind
3
+ // Drizzle's better-sqlite3 session, which it answers like), and a Durable Object's SQLite (`openDurableDatabase`). The
4
+ // migrations (./migrations.ts) are applied once, in order, and recorded.
5
+ import { createRequire } from 'node:module';
6
+ import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { BaseSQLiteDatabase, SQLiteSyncDialect } from 'drizzle-orm/sqlite-core';
9
+ import { BetterSQLiteSession } from 'drizzle-orm/better-sqlite3/session';
10
+ import { MIGRATIONS } from './migrations.ts';
11
+
12
+ // biome-ignore lint/suspicious/noExplicitAny: the three drivers' result types differ; every store reads rows the schema types
13
+ export type PlatformDb = BaseSQLiteDatabase<'sync', any>;
14
+
15
+ type Statement = { run: (...p: unknown[]) => unknown; all: (...p: unknown[]) => unknown[]; get: (...p: unknown[]) => unknown; raw: () => { all: (...p: unknown[]) => unknown[]; get: (...p: unknown[]) => unknown } };
16
+ /** Node's DatabaseSync, answering as better-sqlite3 does where Drizzle's session reads it. */
17
+ function nodeClient(path: string): { prepare: (sql: string) => Statement; transaction: (fn: (...a: unknown[]) => unknown) => unknown; exec: (sql: string) => void; close: () => void } {
18
+ const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as typeof import('node:sqlite');
19
+ const raw = new DatabaseSync(path);
20
+ const prepare = (sql: string): Statement => {
21
+ const st = raw.prepare(sql);
22
+ const arrays = <T>(fn: () => T): T => { st.setReturnArrays(true); try { return fn(); } finally { st.setReturnArrays(false); } };
23
+ return {
24
+ run: (...p) => { const r = st.run(...(p as never[])); return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid }; },
25
+ all: (...p) => st.all(...(p as never[])),
26
+ get: (...p) => st.get(...(p as never[])),
27
+ raw: () => ({ all: (...p) => arrays(() => st.all(...(p as never[]))), get: (...p) => arrays(() => st.get(...(p as never[]))) }),
28
+ };
29
+ };
30
+ // better-sqlite3's transaction(fn): a function that runs fn between BEGIN and COMMIT, rolling back on a throw
31
+ const transaction = (fn: (...a: unknown[]) => unknown) => {
32
+ const run = (mode: string) => (...a: unknown[]): unknown => { raw.exec(`BEGIN ${mode}`); try { const r = fn(...a); raw.exec('COMMIT'); return r; } catch (e) { raw.exec('ROLLBACK'); throw e; } };
33
+ return Object.assign(run('DEFERRED'), { deferred: run('DEFERRED'), immediate: run('IMMEDIATE'), exclusive: run('EXCLUSIVE') });
34
+ };
35
+ return { prepare, transaction, exec: (sql) => raw.exec(sql), close: () => raw.close() };
36
+ }
37
+
38
+ /** Apply what has not been applied, each migration in one transaction, and record it. */
39
+ function migrate(exec: (sql: string) => void, applied: () => string[], inTransaction: (fn: () => void) => void): void {
40
+ exec('CREATE TABLE IF NOT EXISTS _migrations (tag TEXT PRIMARY KEY, applied_at TEXT NOT NULL)');
41
+ const done = new Set(applied());
42
+ for (const m of MIGRATIONS) {
43
+ if (done.has(m.tag)) continue;
44
+ inTransaction(() => { for (const s of m.statements) exec(s); exec(`INSERT INTO _migrations (tag, applied_at) VALUES ('${m.tag.replace(/'/g, "''")}', '${new Date().toISOString()}')`); });
45
+ }
46
+ }
47
+
48
+ /** The database's file in the state directory, and a restored one staged beside it (backup.ts). */
49
+ export const DB_FILE = 'platform.db';
50
+ export const RESTORE_FILE = 'platform.db.restore';
51
+
52
+ const opened = new Map<string, PlatformDb>();
53
+ const closers = new Map<string, () => void>();
54
+ /** The platform's database at `<stateDir>/platform.db` (`:memory:` with no state directory), opened once per process.
55
+ * A restore staged since the last start (`platform.db.restore`) is put in place first. */
56
+ export function openDatabase(stateDir?: string): PlatformDb {
57
+ const path = stateDir ? join(resolve(stateDir), DB_FILE) : ':memory:';
58
+ const known = opened.get(path); if (known) return known;
59
+ if (stateDir) {
60
+ mkdirSync(dirname(path), { recursive: true });
61
+ const staged = join(resolve(stateDir), RESTORE_FILE);
62
+ if (existsSync(staged)) { for (const f of [`${path}-wal`, `${path}-shm`]) rmSync(f, { force: true }); renameSync(staged, path); }
63
+ // owner-only, like the platform's other state; SQLite gives its write-ahead log and index the database's mode
64
+ if (!existsSync(path)) writeFileSync(path, '', { mode: 0o600 });
65
+ chmodSync(path, 0o600);
66
+ }
67
+ const req = createRequire(import.meta.url);
68
+ let db: PlatformDb;
69
+ if ((globalThis as { Bun?: unknown }).Bun) {
70
+ const { Database } = req('bun:sqlite') as typeof import('bun:sqlite');
71
+ const { drizzle } = req('drizzle-orm/bun-sqlite') as typeof import('drizzle-orm/bun-sqlite');
72
+ const raw = new Database(path, { create: true });
73
+ raw.run('PRAGMA journal_mode = WAL'); raw.run('PRAGMA busy_timeout = 5000'); raw.run('PRAGMA foreign_keys = ON');
74
+ migrate((s) => raw.run(s), () => (raw.query('SELECT tag FROM _migrations').all() as Array<{ tag: string }>).map((r) => r.tag), (fn) => raw.transaction(fn)());
75
+ db = drizzle(raw) as unknown as PlatformDb; closers.set(path, () => raw.close());
76
+ } else {
77
+ const client = nodeClient(path);
78
+ client.exec('PRAGMA journal_mode = WAL'); client.exec('PRAGMA busy_timeout = 5000'); client.exec('PRAGMA foreign_keys = ON');
79
+ migrate((s) => client.exec(s), () => (client.prepare('SELECT tag FROM _migrations').all() as Array<{ tag: string }>).map((r) => r.tag), (fn) => (client.transaction(fn) as () => void)());
80
+ const dialect = new SQLiteSyncDialect();
81
+ db = new BaseSQLiteDatabase('sync', dialect, new BetterSQLiteSession(client as never, dialect, undefined, {}), undefined as never) as PlatformDb; closers.set(path, client.close);
82
+ }
83
+ opened.set(path, db);
84
+ return db;
85
+ }
86
+
87
+ /** Close the state directory's database in this process (its file can then be moved or removed); the next
88
+ * openDatabase opens it again. */
89
+ export function closeDatabase(stateDir: string): void {
90
+ const path = join(resolve(stateDir), DB_FILE);
91
+ closers.get(path)?.(); closers.delete(path); opened.delete(path);
92
+ }
@@ -0,0 +1,19 @@
1
+ // The migrations as a module (./migrations.ts): what `drizzle-kit generate` wrote under ./migrations, split into
2
+ // statements, so every driver (a Durable Object reads no files) applies the same SQL.
3
+ // bun src/db/pack-migrations.ts --write after `drizzle-kit generate --dialect sqlite --schema ./src/db/schema.ts --out ./src/db/migrations`
4
+ // bun src/db/pack-migrations.ts --check exits 1 when migrations.ts is behind the .sql files
5
+ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ const dir = join(import.meta.dir, 'migrations');
9
+ const target = join(import.meta.dir, 'migrations.ts');
10
+ export function packed(): string {
11
+ const files = readdirSync(dir).filter((f) => /^\d{4}_.+\.sql$/.test(f)).sort();
12
+ const list = files.map((f) => ({ tag: f.replace(/\.sql$/, ''), statements: readFileSync(join(dir, f), 'utf8').split('--> statement-breakpoint').map((s) => s.trim()).filter(Boolean) }));
13
+ return `// GENERATED by \`bun src/db/pack-migrations.ts --write\` from ./migrations/*.sql (drizzle-kit's): do not edit.\nexport const MIGRATIONS: ReadonlyArray<{ tag: string; statements: readonly string[] }> = ${JSON.stringify(list, null, 2)};\n`;
14
+ }
15
+ if (import.meta.main) {
16
+ if (process.argv.includes('--write')) { writeFileSync(target, packed()); console.log(`wrote ${target}`); }
17
+ else if (process.argv.includes('--check')) { let current = ''; try { current = readFileSync(target, 'utf8'); } catch { /* none */ } if (current.replace(/\r\n/g, '\n') !== packed()) { console.error('src/db/migrations.ts is behind src/db/migrations/*.sql — run: bun src/db/pack-migrations.ts --write'); process.exit(1); } console.log('migrations.ts is current'); }
18
+ else process.stdout.write(packed());
19
+ }
@@ -0,0 +1,137 @@
1
+ // THE PLATFORM'S STATE (docs/contributing/architecture.md, "The platform's state is SQLite"): one schema, whose
2
+ // migrations `drizzle-kit generate` writes as plain SQL under ./migrations. Every table is the platform's own record:
3
+ // a World's state is never here (it stays behind WorldStore). JSON columns hold only small nested lists.
4
+ import { index, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
5
+
6
+ /** The Worlds an org holds, and where each lives: the platform records where, never a World's log. */
7
+ export const orgWorlds = sqliteTable('org_worlds', {
8
+ orgId: text('org_id').notNull(),
9
+ name: text('name').notNull(),
10
+ host: text('host').notNull(),
11
+ base: text('base').notNull(),
12
+ provisionedAt: text('provisioned_at').notNull(),
13
+ }, (t) => [primaryKey({ columns: [t.orgId, t.name] })]);
14
+
15
+ /** An org's settings: its security switches, labs and support consent (absent: the defaults). */
16
+ export const orgSettings = sqliteTable('org_settings', {
17
+ orgId: text('org_id').primaryKey(),
18
+ security: text('security', { mode: 'json' }).$type<{ membersCanInvite: boolean; membersCanUsePersonalTokens: boolean; approvedEmailDomains: string[] }>(),
19
+ flags: text('flags', { mode: 'json' }).$type<Record<string, boolean>>(),
20
+ earlyAdopter: integer('early_adopter', { mode: 'boolean' }),
21
+ supportAccess: text('support_access', { mode: 'json' }).$type<{ until: string; grantedBy: string; requestId: string } | null>(),
22
+ });
23
+
24
+ export const webhooks = sqliteTable('webhooks', {
25
+ id: text('id').primaryKey(),
26
+ orgId: text('org_id').notNull(),
27
+ url: text('url').notNull(),
28
+ secret: text('secret').notNull(),
29
+ events: text('events', { mode: 'json' }).$type<string[]>().notNull(),
30
+ description: text('description').notNull(),
31
+ createdAt: text('created_at').notNull(),
32
+ consecutiveFailures: integer('consecutive_failures').notNull(),
33
+ lastFailedAt: text('last_failed_at'),
34
+ disabledAt: text('disabled_at'),
35
+ }, (t) => [index('webhooks_org').on(t.orgId)]);
36
+
37
+ export const deliveries = sqliteTable('deliveries', {
38
+ id: text('id').primaryKey(),
39
+ orgId: text('org_id').notNull(),
40
+ endpointId: text('endpoint_id').notNull(),
41
+ eventId: text('event_id').notNull(),
42
+ event: text('event').notNull(),
43
+ body: text('body').notNull(),
44
+ attempt: integer('attempt').notNull(),
45
+ nextAt: text('next_at').notNull(),
46
+ attempts: text('attempts', { mode: 'json' }).$type<Array<{ at: string; status: number | null; ok: boolean; response: string }>>().notNull(),
47
+ state: text('state', { enum: ['pending', 'delivered', 'failed'] }).notNull(),
48
+ /** the order the queue keeps */
49
+ seq: integer('seq').notNull(),
50
+ }, (t) => [index('deliveries_state').on(t.state, t.nextAt), index('deliveries_endpoint').on(t.endpointId)]);
51
+
52
+ export const supportRequests = sqliteTable('support_requests', {
53
+ id: text('id').primaryKey(),
54
+ at: text('at').notNull(),
55
+ person: text('person', { mode: 'json' }).$type<{ id: string; email?: string }>().notNull(),
56
+ org: text('org'),
57
+ world: text('world'),
58
+ category: text('category').notNull(),
59
+ severity: text('severity').notNull(),
60
+ subject: text('subject').notNull(),
61
+ message: text('message').notNull(),
62
+ status: text('status', { enum: ['open', 'closed'] }).notNull(),
63
+ });
64
+
65
+ /** The platform's own position: the last backup, the operator's notice; one row per key. */
66
+ export const meta = sqliteTable('meta', { key: text('key').primaryKey(), value: text('value', { mode: 'json' }) });
67
+
68
+ /** Personal, org and support tokens: only their hash is kept. */
69
+ export const tokens = sqliteTable('tokens', {
70
+ hash: text('hash').primaryKey(),
71
+ userId: text('user_id').notNull(),
72
+ name: text('name').notNull(),
73
+ createdAt: text('created_at').notNull(),
74
+ last4: text('last4').notNull(),
75
+ lastUsedAt: text('last_used_at'),
76
+ expiresAt: text('expires_at'),
77
+ scopes: text('scopes', { mode: 'json' }).$type<string[]>(),
78
+ orgId: text('org_id'),
79
+ support: text('support', { mode: 'json' }).$type<{ reason: string; category: string }>(),
80
+ worldKeys: text('world_keys', { mode: 'json' }).$type<Array<{ world: string; keyId: string; command?: boolean }>>(),
81
+ }, (t) => [index('tokens_user').on(t.userId), index('tokens_org').on(t.orgId)]);
82
+
83
+ /** The platform's browser sessions, by the SHA-256 of the cookie's id. */
84
+ export const sessions = sqliteTable('sessions', {
85
+ idHash: text('id_hash').primaryKey(),
86
+ subject: text('subject').notNull(),
87
+ email: text('email'),
88
+ name: text('name'),
89
+ expiresAt: text('expires_at').notNull(),
90
+ });
91
+
92
+ /** The audit log: append-only, per org, in order. */
93
+ export const audit = sqliteTable('audit', {
94
+ seq: integer('seq').primaryKey({ autoIncrement: true }),
95
+ org: text('org').notNull(),
96
+ at: text('at').notNull(),
97
+ event: text('event').notNull(),
98
+ actor: text('actor', { mode: 'json' }).notNull(),
99
+ target: text('target'),
100
+ data: text('data', { mode: 'json' }),
101
+ }, (t) => [index('audit_org').on(t.org, t.seq)]);
102
+
103
+ // ── the directory the platform keeps when its provider only signs people in (directory.ts localDirectory) ──
104
+ export const people = sqliteTable('people', {
105
+ id: text('id').primaryKey(),
106
+ email: text('email'),
107
+ emailVerified: integer('email_verified', { mode: 'boolean' }).notNull(),
108
+ name: text('name'),
109
+ firstSeenAt: text('first_seen_at').notNull(),
110
+ lastSeenAt: text('last_seen_at').notNull(),
111
+ }, (t) => [index('people_email').on(t.email)]);
112
+
113
+ export const dirOrgs = sqliteTable('dir_orgs', {
114
+ id: text('id').primaryKey(),
115
+ slug: text('slug').notNull().unique(),
116
+ name: text('name').notNull(),
117
+ createdAt: text('created_at').notNull(),
118
+ });
119
+
120
+ export const dirMembers = sqliteTable('dir_members', {
121
+ orgId: text('org_id').notNull(),
122
+ userId: text('user_id').notNull(),
123
+ role: text('role', { enum: ['org:admin', 'org:member'] }).notNull(),
124
+ }, (t) => [primaryKey({ columns: [t.orgId, t.userId] }), index('dir_members_user').on(t.userId)]);
125
+
126
+ export const invitations = sqliteTable('invitations', {
127
+ id: text('id').primaryKey(),
128
+ orgId: text('org_id').notNull(),
129
+ email: text('email').notNull(),
130
+ role: text('role', { enum: ['org:admin', 'org:member'] }).notNull(),
131
+ inviterId: text('inviter_id').notNull(),
132
+ createdAt: text('created_at').notNull(),
133
+ expiresAt: text('expires_at').notNull(),
134
+ }, (t) => [index('invitations_org').on(t.orgId), index('invitations_email').on(t.email)]);
135
+
136
+ /** What an attached biller keeps (apps/billing), by its own keys: the platform names no plan or price. */
137
+ export const billerRecords = sqliteTable('biller_records', { key: text('key').primaryKey(), value: text('value', { mode: 'json' }).notNull() });
@@ -0,0 +1,216 @@
1
+ // THE DIRECTORY (docs/contributing/architecture.md, "The hosted product": a platform has one access provider, which
2
+ // brings its directory): who the people are, the orgs, their members, roles and invitations. The platform reads one
3
+ // vocabulary through this interface and never learns which directory answers:
4
+ //
5
+ // - `volterDirectory`: the Volter identity service's (volter-ai/identity ADR-0002, "a product keeps its
6
+ // organizations here, with its own credential"), through its product door (`/api/organizations`, `/api/people`)
7
+ // with the platform's own `client_credentials` token. The service mails its own invitations.
8
+ // - `localDirectory`: the platform's own, in its state directory, for an OpenID Connect provider that only signs
9
+ // people in (Google, Entra, Okta, Keycloak). A person is known once they have signed in; an invitation waits for
10
+ // the address to sign in with a verified email, and the platform mails it.
11
+ //
12
+ // Roles are one vocabulary here: `org:admin` and `org:member` (the identity service's `owner` and `admin` read as
13
+ // `org:admin`).
14
+ import { randomBytes } from 'node:crypto';
15
+ import { and, asc, eq, gt, ne, or, sql } from 'drizzle-orm';
16
+ import { openDatabase } from './db/open.ts';
17
+ import { dirMembers, dirOrgs, invitations, people } from './db/schema.ts';
18
+ import type { Identity } from './identity.ts';
19
+
20
+ export type OrgRef = { id: string; slug: string | null; name: string; role: string };
21
+ export type Member = { id: string; userId: string; email: string | null; name: string | null; role: string };
22
+ export type PendingInvitation = { id: string; email: string; role: string; createdAt: string | null; expiresAt: string | null };
23
+ export type Person = { id: string; email?: string; name?: string };
24
+ /** Who a provider signed in, as its id token (or userinfo) named them. */
25
+ export type SignedInPerson = { subject: string; email?: string; emailVerified?: boolean; name?: string };
26
+
27
+ export type Directory = {
28
+ /** The organizations a person belongs to, with their role, by slug. */
29
+ orgsOf: (userId: string) => Promise<OrgRef[]>;
30
+ /** An org a person belongs to, by id or slug — the only way the platform resolves a person's org. */
31
+ memberOf: (userId: string, ref: string) => Promise<OrgRef | null>;
32
+ /** An org by id or slug, whoever asks (an org token, the operator's claim). */
33
+ orgById: (ref: string) => Promise<OrgRef | null>;
34
+ /** Make an org named `name` (its slug) with the person as its admin. */
35
+ createOrg: (name: string, userId: string) => Promise<OrgRef>;
36
+ renameOrg: (orgId: string, name: string) => Promise<OrgRef>;
37
+ deleteOrg: (orgId: string) => Promise<void>;
38
+ membersOf: (orgId: string) => Promise<Member[]>;
39
+ /** Add a person the directory knows, by id or by email. */
40
+ addMember: (orgId: string, who: { sub?: string; email?: string }) => Promise<void>;
41
+ /** Remove a member; a directory that can refuses, in the same write, to leave the org without an admin (OnlyAdmin). */
42
+ removeMember: (orgId: string, userId: string) => Promise<void>;
43
+ /** Change a role; the org's only admin stepping down is refused as removeMember's is. */
44
+ setMemberRole: (orgId: string, userId: string, role: 'org:admin' | 'org:member') => Promise<void>;
45
+ /** Invite an address the directory does not know yet; it joins when that address signs in. */
46
+ inviteMember: (orgId: string, email: string, inviterUserId: string, role?: string) => Promise<PendingInvitation>;
47
+ pendingInvitations: (orgId: string) => Promise<PendingInvitation[]>;
48
+ revokeInvitation: (orgId: string, invitationId: string) => Promise<void>;
49
+ /** The person an address belongs to, when the directory knows it (verified). */
50
+ userIdByEmail: (email: string) => Promise<string | null>;
51
+ personById: (userId: string) => Promise<Person | null>;
52
+ /** A person signed in: the directory records them and lets their invitations in. */
53
+ seen: (person: SignedInPerson) => Promise<void>;
54
+ /** Whether the directory mails its own invitations (else the platform does). */
55
+ mailsInvitations: boolean;
56
+ };
57
+
58
+ export const isAdmin = (o: OrgRef): boolean => o.role === 'org:admin' || o.role === 'admin';
59
+
60
+ // ── the Volter identity service's ───────────────────────────────────────────────────────────────
61
+ type OrgRow = { id: string; slug: string; name: string; role?: string };
62
+ type InvitationRow = { id: string; email: string; role: string; createdAt?: string | null; expiresAt?: string | null };
63
+ const readRole = (role: string): 'org:admin' | 'org:member' => (role.split(',').some((r) => r.trim() === 'owner' || r.trim() === 'admin') ? 'org:admin' : 'org:member');
64
+ const writeRole = (role: string): 'admin' | 'member' => (role === 'org:admin' || role === 'admin' ? 'admin' : 'member');
65
+ const seg = (s: string): string => encodeURIComponent(s);
66
+
67
+ export function volterDirectory(identity: Identity): Directory {
68
+ const call = identity.call;
69
+ const invitationView = (inv: InvitationRow): PendingInvitation => ({ id: inv.id, email: inv.email, role: readRole(inv.role), createdAt: inv.createdAt ?? null, expiresAt: inv.expiresAt ?? null });
70
+ const userIdByEmail = async (email: string): Promise<string | null> => (await call<{ id: string }>('GET', `/api/people?email=${encodeURIComponent(email)}`))?.id ?? null;
71
+ const orgsOf = async (userId: string): Promise<OrgRef[]> => ((await call<OrgRow[]>('GET', `/api/people/${seg(userId)}/organizations`)) ?? [])
72
+ .map((o) => ({ id: o.id, slug: o.slug, name: o.name, role: readRole(o.role ?? 'member') }))
73
+ .sort((a, b) => (a.slug ?? a.name).localeCompare(b.slug ?? b.name));
74
+ return {
75
+ mailsInvitations: true,
76
+ orgsOf,
77
+ memberOf: async (userId, ref) => (await orgsOf(userId)).find((o) => o.id === ref || o.slug === ref) ?? null,
78
+ // the service answers an org by id or by slug
79
+ async orgById(ref) { try { const o = await call<OrgRow>('GET', `/api/organizations/${seg(ref)}`); return o ? { id: o.id, slug: o.slug, name: o.name, role: 'org:member' } : null; } catch { return null; } },
80
+ async createOrg(name, userId) {
81
+ const org = await call<OrgRow>('POST', '/api/organizations', { name, slug: name, createdBy: userId });
82
+ if (!org) throw new Error('the identity service has no organizations door');
83
+ return { id: org.id, slug: org.slug, name: org.name, role: 'org:admin' };
84
+ },
85
+ async renameOrg(orgId, name) {
86
+ const org = await call<OrgRow>('PATCH', `/api/organizations/${seg(orgId)}`, { name }); // the slug stays: it is the served prefix of every World the org holds
87
+ if (!org) throw new Error('no such organization');
88
+ return { id: org.id, slug: org.slug, name: org.name, role: 'org:admin' };
89
+ },
90
+ async deleteOrg(orgId) { await call('DELETE', `/api/organizations/${seg(orgId)}`); },
91
+ async membersOf(orgId) {
92
+ const rows = (await call<Array<{ id: string; userId: string; email?: string | null; name?: string | null; role: string }>>('GET', `/api/organizations/${seg(orgId)}/members`)) ?? [];
93
+ return rows.map((m) => ({ id: m.id, userId: m.userId, email: m.email ?? null, name: m.name || null, role: readRole(m.role) }));
94
+ },
95
+ async addMember(orgId, who) {
96
+ let userId = who.sub;
97
+ if (!userId && who.email) userId = (await userIdByEmail(who.email)) ?? undefined;
98
+ if (!userId) throw new Error('no such person at the identity service: a member is named by Volter id or by an email that has a Volter identity');
99
+ if (!(await call('POST', `/api/organizations/${seg(orgId)}/members`, { userId, role: 'member' }))) throw new Error('no such organization');
100
+ },
101
+ async removeMember(orgId, userId) { await call('DELETE', `/api/organizations/${seg(orgId)}/members/${seg(userId)}`); },
102
+ async setMemberRole(orgId, userId, role) { if (!(await call('PATCH', `/api/organizations/${seg(orgId)}/members/${seg(userId)}`, { role: writeRole(role) }))) throw new Error('not a member'); },
103
+ async inviteMember(orgId, email, inviterUserId, role = 'org:member') {
104
+ const inv = await call<InvitationRow>('POST', `/api/organizations/${seg(orgId)}/invitations`, { email, role: writeRole(role), inviterId: inviterUserId });
105
+ if (!inv) throw new Error('no such organization');
106
+ return invitationView(inv);
107
+ },
108
+ pendingInvitations: async (orgId) => ((await call<InvitationRow[]>('GET', `/api/organizations/${seg(orgId)}/invitations`)) ?? []).map(invitationView),
109
+ async revokeInvitation(orgId, invitationId) { await call('DELETE', `/api/organizations/${seg(orgId)}/invitations/${seg(invitationId)}`); },
110
+ userIdByEmail,
111
+ async personById(userId) {
112
+ try {
113
+ const p = await call<{ id: string; email?: string; name?: string }>('GET', `/api/people/${seg(userId)}`);
114
+ return p ? { id: p.id, ...(p.email ? { email: p.email } : {}), ...(p.name ? { name: p.name } : {}) } : null;
115
+ } catch { return null; }
116
+ },
117
+ // the identity service keeps its people and accepts its own invitations
118
+ async seen() { /* nothing to record */ },
119
+ };
120
+ }
121
+
122
+ /** Refused: the change would leave the org without an admin. */
123
+ export class OnlyAdmin extends Error { constructor() { super("the org's only admin"); } }
124
+
125
+ // ── the platform's own, in its database ────────────────────────────────────────────────────────
126
+ const INVITATION_DAYS = 7;
127
+ type Role = 'org:admin' | 'org:member';
128
+
129
+ /** The directory the platform keeps in its database (db/schema.ts people, dir_orgs, dir_members, invitations), for a
130
+ * platform whose provider only signs people in. Each change is one statement or one transaction. */
131
+ export function localDirectory(stateDir: string): Directory {
132
+ const db = openDatabase(stateDir);
133
+ const orgRow = (ref: string) => db.select().from(dirOrgs).where(or(eq(dirOrgs.id, ref), eq(dirOrgs.slug, ref))).get() as (typeof dirOrgs.$inferSelect) | undefined;
134
+ const refOf = (o: typeof dirOrgs.$inferSelect, role: string): OrgRef => ({ id: o.id, slug: o.slug, name: o.name, role });
135
+ const mustOrg = (orgId: string) => { const o = db.select().from(dirOrgs).where(eq(dirOrgs.id, orgId)).get() as (typeof dirOrgs.$inferSelect) | undefined; if (!o) throw new Error('no such organization'); return o; };
136
+ const roleOf = (orgId: string, userId: string): Role | null => (db.select({ role: dirMembers.role }).from(dirMembers).where(and(eq(dirMembers.orgId, orgId), eq(dirMembers.userId, userId))).get() as { role: Role } | undefined)?.role ?? null;
137
+ // the member is not an admin, or another admin remains
138
+ const keepsAnAdmin = (orgId: string, userId: string) => or(ne(dirMembers.role, 'org:admin'), sql`exists (select 1 from ${dirMembers} as other where other.org_id = ${orgId} and other.user_id <> ${userId} and other.role = 'org:admin')`);
139
+ const view = (i: typeof invitations.$inferSelect): PendingInvitation => ({ id: i.id, email: i.email, role: i.role, createdAt: i.createdAt, expiresAt: i.expiresAt });
140
+ // only an address the provider verified names a person: an unverified one could be anyone's
141
+ const byEmail = (email: string): string | null => (db.select({ id: people.id }).from(people).where(and(eq(people.email, email.trim().toLowerCase()), eq(people.emailVerified, true))).get() as { id: string } | undefined)?.id ?? null;
142
+ return {
143
+ mailsInvitations: false,
144
+ async orgsOf(userId) {
145
+ return (db.select({ o: dirOrgs, role: dirMembers.role }).from(dirMembers).innerJoin(dirOrgs, eq(dirOrgs.id, dirMembers.orgId)).where(eq(dirMembers.userId, userId)).orderBy(asc(dirOrgs.slug)).all() as Array<{ o: typeof dirOrgs.$inferSelect; role: Role }>)
146
+ .map(({ o, role }) => refOf(o, role));
147
+ },
148
+ async memberOf(userId, r) { const o = orgRow(r); const role = o ? roleOf(o.id, userId) : null; return o && role ? refOf(o, role) : null; },
149
+ async orgById(r) { const o = orgRow(r); return o ? refOf(o, 'org:member') : null; },
150
+ async createOrg(name, userId) {
151
+ const id = `org_${randomBytes(9).toString('base64url').replace(/[^A-Za-z0-9]/g, 'x')}`;
152
+ try {
153
+ db.transaction((tx) => {
154
+ tx.insert(dirOrgs).values({ id, slug: name, name, createdAt: new Date().toISOString() }).run();
155
+ tx.insert(dirMembers).values({ orgId: id, userId, role: 'org:admin' }).run();
156
+ });
157
+ } catch (e) { if (/UNIQUE/i.test(String((e as Error).message))) throw new Error(`an org named ${name} exists`); throw e; }
158
+ return { id, slug: name, name, role: 'org:admin' };
159
+ },
160
+ async renameOrg(orgId, name) { const o = mustOrg(orgId); db.update(dirOrgs).set({ name }).where(eq(dirOrgs.id, orgId)).run(); return refOf({ ...o, name }, 'org:admin'); },
161
+ async deleteOrg(orgId) {
162
+ db.transaction((tx) => { tx.delete(dirMembers).where(eq(dirMembers.orgId, orgId)).run(); tx.delete(invitations).where(eq(invitations.orgId, orgId)).run(); tx.delete(dirOrgs).where(eq(dirOrgs.id, orgId)).run(); });
163
+ },
164
+ async membersOf(orgId) {
165
+ mustOrg(orgId);
166
+ return (db.select({ userId: dirMembers.userId, role: dirMembers.role, email: people.email, name: people.name }).from(dirMembers).leftJoin(people, eq(people.id, dirMembers.userId)).where(eq(dirMembers.orgId, orgId)).all() as Array<{ userId: string; role: Role; email: string | null; name: string | null }>)
167
+ .map((m) => ({ id: `${orgId}:${m.userId}`, userId: m.userId, email: m.email ?? null, name: m.name ?? null, role: m.role }));
168
+ },
169
+ async addMember(orgId, who) {
170
+ mustOrg(orgId);
171
+ const known = who.sub ? (db.select({ id: people.id }).from(people).where(eq(people.id, who.sub)).get() as { id: string } | undefined)?.id ?? null : null;
172
+ const userId = known ?? (who.email ? byEmail(who.email) : null);
173
+ if (!userId) throw new Error('no such person here: a member is someone who has signed in to this platform (invite an address that has not)');
174
+ db.insert(dirMembers).values({ orgId, userId, role: 'org:member' }).onConflictDoNothing().run();
175
+ },
176
+ // one statement each: the check that another admin remains and the write cannot be split by a concurrent change
177
+ async removeMember(orgId, userId) {
178
+ mustOrg(orgId);
179
+ const r = db.delete(dirMembers).where(and(eq(dirMembers.orgId, orgId), eq(dirMembers.userId, userId), keepsAnAdmin(orgId, userId))).run() as { changes?: number };
180
+ if (!r.changes && roleOf(orgId, userId)) throw new OnlyAdmin();
181
+ },
182
+ async setMemberRole(orgId, userId, role) {
183
+ mustOrg(orgId);
184
+ const r = db.update(dirMembers).set({ role }).where(and(eq(dirMembers.orgId, orgId), eq(dirMembers.userId, userId), role === 'org:member' ? keepsAnAdmin(orgId, userId) : undefined)).run() as { changes?: number };
185
+ if (!r.changes) { if (roleOf(orgId, userId)) throw new OnlyAdmin(); throw new Error('not a member'); }
186
+ },
187
+ async inviteMember(orgId, email, inviterUserId, role = 'org:member') {
188
+ mustOrg(orgId);
189
+ const address = email.trim().toLowerCase();
190
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(address)) throw new Error('not an email address');
191
+ const now = Date.now();
192
+ const inv = { id: `inv_${randomBytes(8).toString('hex')}`, orgId, email: address, role: (role === 'org:admin' ? 'org:admin' : 'org:member') as Role, inviterId: inviterUserId, createdAt: new Date(now).toISOString(), expiresAt: new Date(now + INVITATION_DAYS * 86_400_000).toISOString() };
193
+ // a second invitation to an address replaces the first
194
+ db.transaction((tx) => { tx.delete(invitations).where(and(eq(invitations.orgId, orgId), eq(invitations.email, address))).run(); tx.insert(invitations).values(inv).run(); });
195
+ return view(inv);
196
+ },
197
+ async pendingInvitations(orgId) { return (db.select().from(invitations).where(and(eq(invitations.orgId, orgId), gt(invitations.expiresAt, new Date().toISOString()))).orderBy(asc(invitations.createdAt)).all() as Array<typeof invitations.$inferSelect>).map(view); },
198
+ async revokeInvitation(orgId, invitationId) { db.delete(invitations).where(and(eq(invitations.orgId, orgId), eq(invitations.id, invitationId))).run(); },
199
+ async userIdByEmail(email) { return byEmail(email); },
200
+ async personById(userId) { const p = db.select().from(people).where(eq(people.id, userId)).get() as (typeof people.$inferSelect) | undefined; return p ? { id: userId, ...(p.email ? { email: p.email } : {}), ...(p.name ? { name: p.name } : {}) } : null; },
201
+ async seen(person) {
202
+ const now = new Date().toISOString();
203
+ const email = person.email ? person.email.toLowerCase() : null;
204
+ db.transaction((tx) => {
205
+ tx.insert(people).values({ id: person.subject, email, emailVerified: Boolean(person.emailVerified), name: person.name ?? null, firstSeenAt: now, lastSeenAt: now })
206
+ .onConflictDoUpdate({ target: people.id, set: { email, emailVerified: Boolean(person.emailVerified), name: person.name ?? null, lastSeenAt: now } }).run();
207
+ // an invitation lets in the person who signs in with its address, verified by the provider
208
+ if (email && person.emailVerified) {
209
+ const due = tx.select().from(invitations).where(and(eq(invitations.email, email), gt(invitations.expiresAt, now))).all() as Array<typeof invitations.$inferSelect>;
210
+ for (const inv of due) tx.insert(dirMembers).values({ orgId: inv.orgId, userId: person.subject, role: inv.role }).onConflictDoNothing().run();
211
+ tx.delete(invitations).where(eq(invitations.email, email)).run();
212
+ }
213
+ });
214
+ },
215
+ };
216
+ }