@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,123 @@
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
+ /** The Worlds an org holds, and where each lives: the platform records where, never a World's log. */
6
+ export const orgWorlds = sqliteTable('org_worlds', {
7
+ orgId: text('org_id').notNull(),
8
+ name: text('name').notNull(),
9
+ host: text('host').notNull(),
10
+ base: text('base').notNull(),
11
+ provisionedAt: text('provisioned_at').notNull(),
12
+ }, (t) => [primaryKey({ columns: [t.orgId, t.name] })]);
13
+ /** An org's settings: its security switches, labs and support consent (absent: the defaults). */
14
+ export const orgSettings = sqliteTable('org_settings', {
15
+ orgId: text('org_id').primaryKey(),
16
+ security: text('security', { mode: 'json' }).$type(),
17
+ flags: text('flags', { mode: 'json' }).$type(),
18
+ earlyAdopter: integer('early_adopter', { mode: 'boolean' }),
19
+ supportAccess: text('support_access', { mode: 'json' }).$type(),
20
+ });
21
+ export const webhooks = sqliteTable('webhooks', {
22
+ id: text('id').primaryKey(),
23
+ orgId: text('org_id').notNull(),
24
+ url: text('url').notNull(),
25
+ secret: text('secret').notNull(),
26
+ events: text('events', { mode: 'json' }).$type().notNull(),
27
+ description: text('description').notNull(),
28
+ createdAt: text('created_at').notNull(),
29
+ consecutiveFailures: integer('consecutive_failures').notNull(),
30
+ lastFailedAt: text('last_failed_at'),
31
+ disabledAt: text('disabled_at'),
32
+ }, (t) => [index('webhooks_org').on(t.orgId)]);
33
+ export const deliveries = sqliteTable('deliveries', {
34
+ id: text('id').primaryKey(),
35
+ orgId: text('org_id').notNull(),
36
+ endpointId: text('endpoint_id').notNull(),
37
+ eventId: text('event_id').notNull(),
38
+ event: text('event').notNull(),
39
+ body: text('body').notNull(),
40
+ attempt: integer('attempt').notNull(),
41
+ nextAt: text('next_at').notNull(),
42
+ attempts: text('attempts', { mode: 'json' }).$type().notNull(),
43
+ state: text('state', { enum: ['pending', 'delivered', 'failed'] }).notNull(),
44
+ /** the order the queue keeps */
45
+ seq: integer('seq').notNull(),
46
+ }, (t) => [index('deliveries_state').on(t.state, t.nextAt), index('deliveries_endpoint').on(t.endpointId)]);
47
+ export const supportRequests = sqliteTable('support_requests', {
48
+ id: text('id').primaryKey(),
49
+ at: text('at').notNull(),
50
+ person: text('person', { mode: 'json' }).$type().notNull(),
51
+ org: text('org'),
52
+ world: text('world'),
53
+ category: text('category').notNull(),
54
+ severity: text('severity').notNull(),
55
+ subject: text('subject').notNull(),
56
+ message: text('message').notNull(),
57
+ status: text('status', { enum: ['open', 'closed'] }).notNull(),
58
+ });
59
+ /** The platform's own position: the last backup, the operator's notice; one row per key. */
60
+ export const meta = sqliteTable('meta', { key: text('key').primaryKey(), value: text('value', { mode: 'json' }) });
61
+ /** Personal, org and support tokens: only their hash is kept. */
62
+ export const tokens = sqliteTable('tokens', {
63
+ hash: text('hash').primaryKey(),
64
+ userId: text('user_id').notNull(),
65
+ name: text('name').notNull(),
66
+ createdAt: text('created_at').notNull(),
67
+ last4: text('last4').notNull(),
68
+ lastUsedAt: text('last_used_at'),
69
+ expiresAt: text('expires_at'),
70
+ scopes: text('scopes', { mode: 'json' }).$type(),
71
+ orgId: text('org_id'),
72
+ support: text('support', { mode: 'json' }).$type(),
73
+ worldKeys: text('world_keys', { mode: 'json' }).$type(),
74
+ }, (t) => [index('tokens_user').on(t.userId), index('tokens_org').on(t.orgId)]);
75
+ /** The platform's browser sessions, by the SHA-256 of the cookie's id. */
76
+ export const sessions = sqliteTable('sessions', {
77
+ idHash: text('id_hash').primaryKey(),
78
+ subject: text('subject').notNull(),
79
+ email: text('email'),
80
+ name: text('name'),
81
+ expiresAt: text('expires_at').notNull(),
82
+ });
83
+ /** The audit log: append-only, per org, in order. */
84
+ export const audit = sqliteTable('audit', {
85
+ seq: integer('seq').primaryKey({ autoIncrement: true }),
86
+ org: text('org').notNull(),
87
+ at: text('at').notNull(),
88
+ event: text('event').notNull(),
89
+ actor: text('actor', { mode: 'json' }).notNull(),
90
+ target: text('target'),
91
+ data: text('data', { mode: 'json' }),
92
+ }, (t) => [index('audit_org').on(t.org, t.seq)]);
93
+ // ── the directory the platform keeps when its provider only signs people in (directory.ts localDirectory) ──
94
+ export const people = sqliteTable('people', {
95
+ id: text('id').primaryKey(),
96
+ email: text('email'),
97
+ emailVerified: integer('email_verified', { mode: 'boolean' }).notNull(),
98
+ name: text('name'),
99
+ firstSeenAt: text('first_seen_at').notNull(),
100
+ lastSeenAt: text('last_seen_at').notNull(),
101
+ }, (t) => [index('people_email').on(t.email)]);
102
+ export const dirOrgs = sqliteTable('dir_orgs', {
103
+ id: text('id').primaryKey(),
104
+ slug: text('slug').notNull().unique(),
105
+ name: text('name').notNull(),
106
+ createdAt: text('created_at').notNull(),
107
+ });
108
+ export const dirMembers = sqliteTable('dir_members', {
109
+ orgId: text('org_id').notNull(),
110
+ userId: text('user_id').notNull(),
111
+ role: text('role', { enum: ['org:admin', 'org:member'] }).notNull(),
112
+ }, (t) => [primaryKey({ columns: [t.orgId, t.userId] }), index('dir_members_user').on(t.userId)]);
113
+ export const invitations = sqliteTable('invitations', {
114
+ id: text('id').primaryKey(),
115
+ orgId: text('org_id').notNull(),
116
+ email: text('email').notNull(),
117
+ role: text('role', { enum: ['org:admin', 'org:member'] }).notNull(),
118
+ inviterId: text('inviter_id').notNull(),
119
+ createdAt: text('created_at').notNull(),
120
+ expiresAt: text('expires_at').notNull(),
121
+ }, (t) => [index('invitations_org').on(t.orgId), index('invitations_email').on(t.email)]);
122
+ /** What an attached biller keeps (apps/billing), by its own keys: the platform names no plan or price. */
123
+ export const billerRecords = sqliteTable('biller_records', { key: text('key').primaryKey(), value: text('value', { mode: 'json' }).notNull() });
@@ -0,0 +1,75 @@
1
+ import type { Identity } from './identity.js';
2
+ export type OrgRef = {
3
+ id: string;
4
+ slug: string | null;
5
+ name: string;
6
+ role: string;
7
+ };
8
+ export type Member = {
9
+ id: string;
10
+ userId: string;
11
+ email: string | null;
12
+ name: string | null;
13
+ role: string;
14
+ };
15
+ export type PendingInvitation = {
16
+ id: string;
17
+ email: string;
18
+ role: string;
19
+ createdAt: string | null;
20
+ expiresAt: string | null;
21
+ };
22
+ export type Person = {
23
+ id: string;
24
+ email?: string;
25
+ name?: string;
26
+ };
27
+ /** Who a provider signed in, as its id token (or userinfo) named them. */
28
+ export type SignedInPerson = {
29
+ subject: string;
30
+ email?: string;
31
+ emailVerified?: boolean;
32
+ name?: string;
33
+ };
34
+ export type Directory = {
35
+ /** The organizations a person belongs to, with their role, by slug. */
36
+ orgsOf: (userId: string) => Promise<OrgRef[]>;
37
+ /** An org a person belongs to, by id or slug — the only way the platform resolves a person's org. */
38
+ memberOf: (userId: string, ref: string) => Promise<OrgRef | null>;
39
+ /** An org by id or slug, whoever asks (an org token, the operator's claim). */
40
+ orgById: (ref: string) => Promise<OrgRef | null>;
41
+ /** Make an org named `name` (its slug) with the person as its admin. */
42
+ createOrg: (name: string, userId: string) => Promise<OrgRef>;
43
+ renameOrg: (orgId: string, name: string) => Promise<OrgRef>;
44
+ deleteOrg: (orgId: string) => Promise<void>;
45
+ membersOf: (orgId: string) => Promise<Member[]>;
46
+ /** Add a person the directory knows, by id or by email. */
47
+ addMember: (orgId: string, who: {
48
+ sub?: string;
49
+ email?: string;
50
+ }) => Promise<void>;
51
+ /** Remove a member; a directory that can refuses, in the same write, to leave the org without an admin (OnlyAdmin). */
52
+ removeMember: (orgId: string, userId: string) => Promise<void>;
53
+ /** Change a role; the org's only admin stepping down is refused as removeMember's is. */
54
+ setMemberRole: (orgId: string, userId: string, role: 'org:admin' | 'org:member') => Promise<void>;
55
+ /** Invite an address the directory does not know yet; it joins when that address signs in. */
56
+ inviteMember: (orgId: string, email: string, inviterUserId: string, role?: string) => Promise<PendingInvitation>;
57
+ pendingInvitations: (orgId: string) => Promise<PendingInvitation[]>;
58
+ revokeInvitation: (orgId: string, invitationId: string) => Promise<void>;
59
+ /** The person an address belongs to, when the directory knows it (verified). */
60
+ userIdByEmail: (email: string) => Promise<string | null>;
61
+ personById: (userId: string) => Promise<Person | null>;
62
+ /** A person signed in: the directory records them and lets their invitations in. */
63
+ seen: (person: SignedInPerson) => Promise<void>;
64
+ /** Whether the directory mails its own invitations (else the platform does). */
65
+ mailsInvitations: boolean;
66
+ };
67
+ export declare const isAdmin: (o: OrgRef) => boolean;
68
+ export declare function volterDirectory(identity: Identity): Directory;
69
+ /** Refused: the change would leave the org without an admin. */
70
+ export declare class OnlyAdmin extends Error {
71
+ constructor();
72
+ }
73
+ /** The directory the platform keeps in its database (db/schema.ts people, dir_orgs, dir_members, invitations), for a
74
+ * platform whose provider only signs people in. Each change is one statement or one transaction. */
75
+ export declare function localDirectory(stateDir: string): Directory;
@@ -0,0 +1,199 @@
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.js";
17
+ import { dirMembers, dirOrgs, invitations, people } from "./db/schema.js";
18
+ export const isAdmin = (o) => o.role === 'org:admin' || o.role === 'admin';
19
+ const readRole = (role) => (role.split(',').some((r) => r.trim() === 'owner' || r.trim() === 'admin') ? 'org:admin' : 'org:member');
20
+ const writeRole = (role) => (role === 'org:admin' || role === 'admin' ? 'admin' : 'member');
21
+ const seg = (s) => encodeURIComponent(s);
22
+ export function volterDirectory(identity) {
23
+ const call = identity.call;
24
+ const invitationView = (inv) => ({ id: inv.id, email: inv.email, role: readRole(inv.role), createdAt: inv.createdAt ?? null, expiresAt: inv.expiresAt ?? null });
25
+ const userIdByEmail = async (email) => (await call('GET', `/api/people?email=${encodeURIComponent(email)}`))?.id ?? null;
26
+ const orgsOf = async (userId) => ((await call('GET', `/api/people/${seg(userId)}/organizations`)) ?? [])
27
+ .map((o) => ({ id: o.id, slug: o.slug, name: o.name, role: readRole(o.role ?? 'member') }))
28
+ .sort((a, b) => (a.slug ?? a.name).localeCompare(b.slug ?? b.name));
29
+ return {
30
+ mailsInvitations: true,
31
+ orgsOf,
32
+ memberOf: async (userId, ref) => (await orgsOf(userId)).find((o) => o.id === ref || o.slug === ref) ?? null,
33
+ // the service answers an org by id or by slug
34
+ async orgById(ref) { try {
35
+ const o = await call('GET', `/api/organizations/${seg(ref)}`);
36
+ return o ? { id: o.id, slug: o.slug, name: o.name, role: 'org:member' } : null;
37
+ }
38
+ catch {
39
+ return null;
40
+ } },
41
+ async createOrg(name, userId) {
42
+ const org = await call('POST', '/api/organizations', { name, slug: name, createdBy: userId });
43
+ if (!org)
44
+ throw new Error('the identity service has no organizations door');
45
+ return { id: org.id, slug: org.slug, name: org.name, role: 'org:admin' };
46
+ },
47
+ async renameOrg(orgId, name) {
48
+ const org = await call('PATCH', `/api/organizations/${seg(orgId)}`, { name }); // the slug stays: it is the served prefix of every World the org holds
49
+ if (!org)
50
+ throw new Error('no such organization');
51
+ return { id: org.id, slug: org.slug, name: org.name, role: 'org:admin' };
52
+ },
53
+ async deleteOrg(orgId) { await call('DELETE', `/api/organizations/${seg(orgId)}`); },
54
+ async membersOf(orgId) {
55
+ const rows = (await call('GET', `/api/organizations/${seg(orgId)}/members`)) ?? [];
56
+ return rows.map((m) => ({ id: m.id, userId: m.userId, email: m.email ?? null, name: m.name || null, role: readRole(m.role) }));
57
+ },
58
+ async addMember(orgId, who) {
59
+ let userId = who.sub;
60
+ if (!userId && who.email)
61
+ userId = (await userIdByEmail(who.email)) ?? undefined;
62
+ if (!userId)
63
+ 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');
64
+ if (!(await call('POST', `/api/organizations/${seg(orgId)}/members`, { userId, role: 'member' })))
65
+ throw new Error('no such organization');
66
+ },
67
+ async removeMember(orgId, userId) { await call('DELETE', `/api/organizations/${seg(orgId)}/members/${seg(userId)}`); },
68
+ async setMemberRole(orgId, userId, role) { if (!(await call('PATCH', `/api/organizations/${seg(orgId)}/members/${seg(userId)}`, { role: writeRole(role) })))
69
+ throw new Error('not a member'); },
70
+ async inviteMember(orgId, email, inviterUserId, role = 'org:member') {
71
+ const inv = await call('POST', `/api/organizations/${seg(orgId)}/invitations`, { email, role: writeRole(role), inviterId: inviterUserId });
72
+ if (!inv)
73
+ throw new Error('no such organization');
74
+ return invitationView(inv);
75
+ },
76
+ pendingInvitations: async (orgId) => ((await call('GET', `/api/organizations/${seg(orgId)}/invitations`)) ?? []).map(invitationView),
77
+ async revokeInvitation(orgId, invitationId) { await call('DELETE', `/api/organizations/${seg(orgId)}/invitations/${seg(invitationId)}`); },
78
+ userIdByEmail,
79
+ async personById(userId) {
80
+ try {
81
+ const p = await call('GET', `/api/people/${seg(userId)}`);
82
+ return p ? { id: p.id, ...(p.email ? { email: p.email } : {}), ...(p.name ? { name: p.name } : {}) } : null;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ },
88
+ // the identity service keeps its people and accepts its own invitations
89
+ async seen() { },
90
+ };
91
+ }
92
+ /** Refused: the change would leave the org without an admin. */
93
+ export class OnlyAdmin extends Error {
94
+ constructor() { super("the org's only admin"); }
95
+ }
96
+ // ── the platform's own, in its database ────────────────────────────────────────────────────────
97
+ const INVITATION_DAYS = 7;
98
+ /** The directory the platform keeps in its database (db/schema.ts people, dir_orgs, dir_members, invitations), for a
99
+ * platform whose provider only signs people in. Each change is one statement or one transaction. */
100
+ export function localDirectory(stateDir) {
101
+ const db = openDatabase(stateDir);
102
+ const orgRow = (ref) => db.select().from(dirOrgs).where(or(eq(dirOrgs.id, ref), eq(dirOrgs.slug, ref))).get();
103
+ const refOf = (o, role) => ({ id: o.id, slug: o.slug, name: o.name, role });
104
+ const mustOrg = (orgId) => { const o = db.select().from(dirOrgs).where(eq(dirOrgs.id, orgId)).get(); if (!o)
105
+ throw new Error('no such organization'); return o; };
106
+ const roleOf = (orgId, userId) => db.select({ role: dirMembers.role }).from(dirMembers).where(and(eq(dirMembers.orgId, orgId), eq(dirMembers.userId, userId))).get()?.role ?? null;
107
+ // the member is not an admin, or another admin remains
108
+ const keepsAnAdmin = (orgId, userId) => 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')`);
109
+ const view = (i) => ({ id: i.id, email: i.email, role: i.role, createdAt: i.createdAt, expiresAt: i.expiresAt });
110
+ // only an address the provider verified names a person: an unverified one could be anyone's
111
+ const byEmail = (email) => db.select({ id: people.id }).from(people).where(and(eq(people.email, email.trim().toLowerCase()), eq(people.emailVerified, true))).get()?.id ?? null;
112
+ return {
113
+ mailsInvitations: false,
114
+ async orgsOf(userId) {
115
+ 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()
116
+ .map(({ o, role }) => refOf(o, role));
117
+ },
118
+ async memberOf(userId, r) { const o = orgRow(r); const role = o ? roleOf(o.id, userId) : null; return o && role ? refOf(o, role) : null; },
119
+ async orgById(r) { const o = orgRow(r); return o ? refOf(o, 'org:member') : null; },
120
+ async createOrg(name, userId) {
121
+ const id = `org_${randomBytes(9).toString('base64url').replace(/[^A-Za-z0-9]/g, 'x')}`;
122
+ try {
123
+ db.transaction((tx) => {
124
+ tx.insert(dirOrgs).values({ id, slug: name, name, createdAt: new Date().toISOString() }).run();
125
+ tx.insert(dirMembers).values({ orgId: id, userId, role: 'org:admin' }).run();
126
+ });
127
+ }
128
+ catch (e) {
129
+ if (/UNIQUE/i.test(String(e.message)))
130
+ throw new Error(`an org named ${name} exists`);
131
+ throw e;
132
+ }
133
+ return { id, slug: name, name, role: 'org:admin' };
134
+ },
135
+ 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'); },
136
+ async deleteOrg(orgId) {
137
+ 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(); });
138
+ },
139
+ async membersOf(orgId) {
140
+ mustOrg(orgId);
141
+ 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()
142
+ .map((m) => ({ id: `${orgId}:${m.userId}`, userId: m.userId, email: m.email ?? null, name: m.name ?? null, role: m.role }));
143
+ },
144
+ async addMember(orgId, who) {
145
+ mustOrg(orgId);
146
+ const known = who.sub ? db.select({ id: people.id }).from(people).where(eq(people.id, who.sub)).get()?.id ?? null : null;
147
+ const userId = known ?? (who.email ? byEmail(who.email) : null);
148
+ if (!userId)
149
+ throw new Error('no such person here: a member is someone who has signed in to this platform (invite an address that has not)');
150
+ db.insert(dirMembers).values({ orgId, userId, role: 'org:member' }).onConflictDoNothing().run();
151
+ },
152
+ // one statement each: the check that another admin remains and the write cannot be split by a concurrent change
153
+ async removeMember(orgId, userId) {
154
+ mustOrg(orgId);
155
+ const r = db.delete(dirMembers).where(and(eq(dirMembers.orgId, orgId), eq(dirMembers.userId, userId), keepsAnAdmin(orgId, userId))).run();
156
+ if (!r.changes && roleOf(orgId, userId))
157
+ throw new OnlyAdmin();
158
+ },
159
+ async setMemberRole(orgId, userId, role) {
160
+ mustOrg(orgId);
161
+ 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();
162
+ if (!r.changes) {
163
+ if (roleOf(orgId, userId))
164
+ throw new OnlyAdmin();
165
+ throw new Error('not a member');
166
+ }
167
+ },
168
+ async inviteMember(orgId, email, inviterUserId, role = 'org:member') {
169
+ mustOrg(orgId);
170
+ const address = email.trim().toLowerCase();
171
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(address))
172
+ throw new Error('not an email address');
173
+ const now = Date.now();
174
+ const inv = { id: `inv_${randomBytes(8).toString('hex')}`, orgId, email: address, role: (role === 'org:admin' ? 'org:admin' : 'org:member'), inviterId: inviterUserId, createdAt: new Date(now).toISOString(), expiresAt: new Date(now + INVITATION_DAYS * 86_400_000).toISOString() };
175
+ // a second invitation to an address replaces the first
176
+ db.transaction((tx) => { tx.delete(invitations).where(and(eq(invitations.orgId, orgId), eq(invitations.email, address))).run(); tx.insert(invitations).values(inv).run(); });
177
+ return view(inv);
178
+ },
179
+ 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().map(view); },
180
+ async revokeInvitation(orgId, invitationId) { db.delete(invitations).where(and(eq(invitations.orgId, orgId), eq(invitations.id, invitationId))).run(); },
181
+ async userIdByEmail(email) { return byEmail(email); },
182
+ async personById(userId) { const p = db.select().from(people).where(eq(people.id, userId)).get(); return p ? { id: userId, ...(p.email ? { email: p.email } : {}), ...(p.name ? { name: p.name } : {}) } : null; },
183
+ async seen(person) {
184
+ const now = new Date().toISOString();
185
+ const email = person.email ? person.email.toLowerCase() : null;
186
+ db.transaction((tx) => {
187
+ tx.insert(people).values({ id: person.subject, email, emailVerified: Boolean(person.emailVerified), name: person.name ?? null, firstSeenAt: now, lastSeenAt: now })
188
+ .onConflictDoUpdate({ target: people.id, set: { email, emailVerified: Boolean(person.emailVerified), name: person.name ?? null, lastSeenAt: now } }).run();
189
+ // an invitation lets in the person who signs in with its address, verified by the provider
190
+ if (email && person.emailVerified) {
191
+ const due = tx.select().from(invitations).where(and(eq(invitations.email, email), gt(invitations.expiresAt, now))).all();
192
+ for (const inv of due)
193
+ tx.insert(dirMembers).values({ orgId: inv.orgId, userId: person.subject, role: inv.role }).onConflictDoNothing().run();
194
+ tx.delete(invitations).where(eq(invitations.email, email)).run();
195
+ }
196
+ });
197
+ },
198
+ };
199
+ }
@@ -0,0 +1,16 @@
1
+ export type Door = {
2
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
3
+ path: string;
4
+ /** who opens it */
5
+ who: 'anyone' | 'person' | 'admin' | 'operator';
6
+ /** the object:action a token needs, or none */
7
+ scope: string | null;
8
+ summary: string;
9
+ body?: string;
10
+ answers: string;
11
+ };
12
+ export declare const DOORS: readonly Door[];
13
+ /** The table as OpenAPI 3.1. */
14
+ export declare function openapi(origin: string): Record<string, unknown>;
15
+ /** The table as the docs page. */
16
+ export declare function platformApiMarkdown(): string;
@@ -0,0 +1,123 @@
1
+ export const DOORS = [
2
+ { method: 'GET', path: '/-/health', who: 'anyone', scope: null, summary: 'Whether the platform is up, and which hosts it knows.', answers: '{ ok, hosts[] }' },
3
+ { method: 'GET', path: '/.well-known/jwks.json', who: 'anyone', scope: null, summary: 'The public keys this platform signs passes with, for the hosts that trust it.', answers: '{ keys[] }' },
4
+ { method: 'GET', path: '/-/platform', who: 'anyone', scope: null, summary: 'What the pages need before anyone signs in: the access provider people continue with (and their account page there, when it has one), whether the platform bills, whether the Help form is on, and the product site\'s address when there is one.', answers: '{ name, provider: { kind, name, account? }, billing, support, site? }' },
5
+ { method: 'GET', path: '/-/sign-in', who: 'anyone', scope: null, summary: 'Start signing in at the access provider (the authorization code with PKCE); `?next=` names a path of this platform to return to.', answers: '302 to the provider' },
6
+ { method: 'POST', path: '/-/sign-out', who: 'anyone', scope: null, summary: 'End your session here (not at the provider), from the platform\'s own page (a GET is refused with 405).', answers: '302 to the front page' },
7
+ { method: 'GET', path: '/-/status', who: 'anyone', scope: null, summary: 'The status page\'s source: each host up or down, the operator\'s notice, the clock\'s last runs.', answers: '{ ok, platform, hosts[], notice, clock, askedAt }' },
8
+ { method: 'POST', path: '/-/status/notice', who: 'operator', scope: null, summary: 'Set or clear the notice the status page shows.', body: '{ text | null }', answers: '{ notice }' },
9
+ { method: 'GET', path: '/-/openapi.json', who: 'anyone', scope: null, summary: 'This table as an OpenAPI 3.1 document.', answers: 'OpenAPI' },
10
+ { method: 'GET', path: '/-/vendors', who: 'person', scope: null, summary: 'The twins a new World may have: what the host Worlds are made on serves, as it answers.', answers: '{ host, vendors[] }' },
11
+ { method: 'GET', path: '/-/worlds', who: 'person', scope: 'worlds:read', summary: 'The worlds your orgs hold, with their addresses and twins.', answers: '{ worlds[] }' },
12
+ { method: 'POST', path: '/-/worlds', who: 'person', scope: 'worlds:write', summary: 'Provision a world into an org, on an enrolled host; where the platform bills, refused at the plan\'s limits with 402.', body: '{ org, world, vendors[] }', answers: '{ name, base, host }' },
13
+ { method: 'POST', path: '/-/worlds/sample', who: 'person', scope: 'worlds:write', summary: 'Make the org\'s sample World (`<org>/sample`, Stripe and Slack where the host serves them) and seed it through the vendors\' own APIs: a World like any other, metered and deletable; 409 when it exists.', body: '{ org }', answers: '{ name, base, host, seeded: { <vendor>: { seeded[] } | { error } } }' },
14
+ { method: 'DELETE', path: '/-/worlds/{org}/{world}', who: 'admin', scope: 'worlds:write', summary: 'Delete a world through its host.', answers: '{ deleted }' },
15
+ { method: 'GET', path: '/-/worlds/{org}/{world}/open', who: 'person', scope: 'worlds:read', summary: 'Open the world as yourself: a pass for it, signed for you and for the world\'s own origin, spent on a session there (write for a member, read for a support session or a token without worlds:write); 409 for a world whose host gives it no origin of its own. A redirect to the page with the pass in its fragment; JSON asks for the address.', answers: '303 to the world\'s page, or { url, scope, expiresIn }' },
16
+ { method: 'GET', path: '/-/worlds/{org}/{world}/previews', who: 'person', scope: 'worlds:read', summary: 'The world\'s previews: named branches made for CI (a pull request each), with where each lives and a link that opens it.', answers: '{ world, previews: [{ label, branch, expiresAt, open, live, base?, origin? }] }' },
17
+ { method: 'PUT', path: '/-/worlds/{org}/{world}/previews/{label}', who: 'person', scope: 'worlds:write', summary: 'Make a preview: a branch of the world named `label` (pr-12), made with a key the platform mints in the world for the caller (for the person; recorded against the token that asked, so logout, revoking the token or leaving the org ends the preview); ends after ttlDays (7 by default, at most 7). Answers a key to the branch for a token (never the branch\'s own token; none for a browser session). Asked again, the same preview and a fresh key, unless replace asks for a fresh one; at most 20 previews of a world at once (409).', body: '{ ttlDays?: 1-7, replace?: true }', answers: '201 { label, branch, base, origin?, open, token?, expiresAt, created: true }, or 200 with created: false' },
18
+ { method: 'DELETE', path: '/-/worlds/{org}/{world}/previews/{label}', who: 'person', scope: 'worlds:write', summary: 'Remove a preview and its branch (a pull request closed).', answers: '{ removed, branch }' },
19
+ { method: 'GET', path: '/-/worlds/{org}/{world}/previews/{label}/open', who: 'person', scope: 'worlds:read', summary: 'Open a preview as yourself: a pass for its branch, as the world\'s own open door gives one; 410 once it has ended.', answers: '303 to the preview\'s page, or { url, scope, expiresIn }' },
20
+ { method: 'POST', path: '/-/cli/device', who: 'anyone', scope: null, summary: 'Begin signing the volter command in through the browser (the device authorization grant): a code for the person to approve, and where.', body: '{ name? }', answers: '{ device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval }' },
21
+ { method: 'GET', path: '/-/cli/approve', who: 'person', scope: null, summary: 'The page where a signed-in person approves or refuses a code the volter command shows (signing in first when needed).', answers: 'a page' },
22
+ { method: 'POST', path: '/-/cli/approve', who: 'person', scope: null, summary: 'Approve or refuse a code, from the approval page itself in a signed-in browser (never with a token, never a support session).', body: 'form: code, decision (approve or deny)', answers: 'a page' },
23
+ { method: 'POST', path: '/-/cli/token', who: 'anyone', scope: null, summary: 'The volter command collects its personal token once the person approved its code (30 days, named for the machine, scoped orgs:read, worlds:read and worlds:write); authorization_pending until then.', body: '{ device_code }', answers: '{ token, name, expiresAt, person }' },
24
+ { method: 'POST', path: '/-/worlds/{org}/{world}/keys', who: 'person', scope: 'worlds:write', summary: 'Make a key in a World of your org, for an app or a job, shown once: for you, ending in 90 days unless you say otherwise, and revoked when you leave the org; made with a personal token, it ends no later than the token and is revoked with it. A browser session on the World itself makes no keys.', body: '{ name, scope?: read | write, expiresInDays?: 1-365 | null }', answers: '{ world, base, id, name, scope, expiresAt, key }' },
25
+ { method: 'GET', path: '/-/worlds/{org}/{world}/token', who: 'person', scope: 'worlds:read', summary: 'A named key for the volter command (`volter remote add`), made in the world and shown once: for a token only, never a browser or a support session; write for a token with worlds:write, else read. Listed and revoked alone in the world\'s Settings.', answers: '{ name, base, scope, token, keyId }' },
26
+ { method: 'GET', path: '/-/orgs', who: 'person', scope: 'orgs:read', summary: 'Who you are and the orgs you belong to, each with its worlds, security switches and whether it is held.', answers: '{ person, orgs[] }' },
27
+ { method: 'POST', path: '/-/orgs', who: 'person', scope: 'orgs:write', summary: 'Create an org, you its admin; where the platform has a site, its terms accepted. A name the pages use (account, help, new, …) is refused.', body: '{ name, accepted? }', answers: '{ id, slug, name }' },
28
+ { method: 'GET', path: '/-/orgs/{org}', who: 'person', scope: 'orgs:read', summary: 'The org and its worlds.', answers: '{ id, slug, name, role, worlds[] }' },
29
+ { method: 'PATCH', path: '/-/orgs/{org}', who: 'admin', scope: 'orgs:write', summary: 'Rename the org (the display name only; the address stays).', body: '{ name }', answers: '{ id, name }' },
30
+ { method: 'DELETE', path: '/-/orgs/{org}', who: 'admin', scope: 'orgs:write', summary: 'Delete the org; refused while it holds worlds unless `?everything=1`, which deletes them through their hosts first.', answers: '{ deleted }' },
31
+ { method: 'GET', path: '/-/orgs/{org}/billing', who: 'person', scope: 'billing:read', summary: 'Where the platform bills: the org\'s plan, usage this period (and by day), restriction, pending checkout, the plans on offer.', answers: '{ plan, plans[], usage, usageByDay[], restriction, pendingCheckout, paid, lastTickAt }' },
32
+ { method: 'PATCH', path: '/-/orgs/{org}/billing', who: 'admin', scope: 'billing:write', summary: 'Where the platform bills, the spend cap (Team): on, the plan\'s hours restrict after the grace; off, hours beyond the plan are billed at the metered price.', body: '{ spendCap: boolean }', answers: '{ spendCap }' },
33
+ { method: 'POST', path: '/-/orgs/{org}/checkout', who: 'admin', scope: 'billing:write', summary: 'Where the platform bills: open (or answer the open) Polar checkout for the Team plan.', answers: '{ id, url, pending? }' },
34
+ { method: 'POST', path: '/-/orgs/{org}/billing-portal', who: 'admin', scope: 'billing:write', summary: 'Where the platform bills: a session for Polar\'s customer portal: invoices, payment method, cancellation.', answers: '{ url }' },
35
+ { method: 'GET', path: '/-/orgs/{org}/members', who: 'person', scope: 'members:read', summary: 'The members and their roles, and the pending invitations.', answers: '{ members[], pending[] }' },
36
+ { method: 'POST', path: '/-/orgs/{org}/members', who: 'admin', scope: 'members:write', summary: 'Add a member the directory knows, or invite an address it does not (pending until that address signs in; mailed), as a member or (by an admin) an admin. Members may invite by email when the security page allows; adding by id is an admin\'s, and answers to the approved domains by the person\'s address. Not both at once.', body: '{ email, role? } | { sub, role? }', answers: '{ added } | { invited, pending: true, id }' },
37
+ { method: 'PATCH', path: '/-/orgs/{org}/members/{userId}', who: 'admin', scope: 'members:write', summary: 'Change a member\'s role; the org\'s only admin may not become a member (409).', body: '{ role: admin | member }', answers: '{ userId, role }' },
38
+ { method: 'DELETE', path: '/-/orgs/{org}/members/{userId}', who: 'admin', scope: 'members:write', summary: 'Remove a member (an admin), or leave the org yourself (any member); the org\'s only admin cannot be removed or leave.', answers: '{ removed }' },
39
+ { method: 'POST', path: '/-/orgs/{org}/invitations/{id}/resend', who: 'admin', scope: 'members:write', summary: 'Send a pending invitation again: a fresh one to the same address and role, for another week (its id may change). An admin, or a member where the security page lets members invite, for a member\'s invitation only; the approved domains apply.', answers: '{ invited, pending, id, role, expiresAt }' },
40
+ { method: 'DELETE', path: '/-/orgs/{org}/invitations/{id}', who: 'admin', scope: 'members:write', summary: 'Revoke a pending invitation.', answers: '{ revoked }' },
41
+ { method: 'GET', path: '/-/orgs/{org}/tokens', who: 'admin', scope: 'tokens:read', summary: 'The org\'s tokens (for CI), without their secrets.', answers: '{ tokens[] }' },
42
+ { method: 'POST', path: '/-/orgs/{org}/tokens', who: 'admin', scope: 'tokens:write', summary: 'Make an org token: bound to the org, scoped (read only by default), expiring; shown once.', body: '{ name, scopes?, expiresInDays? }', answers: '{ token, id, scopes, expiresAt }' },
43
+ { method: 'DELETE', path: '/-/orgs/{org}/tokens/{id}', who: 'admin', scope: 'tokens:write', summary: 'Revoke an org token.', answers: '{ revoked }' },
44
+ { method: 'GET', path: '/-/orgs/{org}/security', who: 'person', scope: 'orgs:read', summary: 'The org\'s security switches.', answers: '{ security }' },
45
+ { method: 'PATCH', path: '/-/orgs/{org}/security', who: 'admin', scope: 'orgs:write', summary: 'Set the switches: members may invite, personal tokens allowed, approved email domains.', body: '{ membersCanInvite?, membersCanUsePersonalTokens?, approvedEmailDomains? }', answers: '{ security }' },
46
+ { method: 'GET', path: '/-/orgs/{org}/support-access', who: 'person', scope: 'orgs:read', summary: 'Whether, and until when, support may open the org.', answers: '{ supportAccess }' },
47
+ { method: 'DELETE', path: '/-/orgs/{org}/support-access', who: 'admin', scope: 'orgs:write', summary: 'Revoke support access.', answers: '{ revoked }' },
48
+ { method: 'GET', path: '/-/orgs/{org}/labs', who: 'person', scope: 'orgs:read', summary: 'The early-access features, which are on for the org, and the early-adopter switch.', answers: '{ features[], earlyAdopter, flags }' },
49
+ { method: 'PATCH', path: '/-/orgs/{org}/labs', who: 'admin', scope: 'orgs:write', summary: 'Turn an early-access feature on or off, or turn on every new one with the early-adopter switch.', body: '{ flags?: { key: boolean }, earlyAdopter? }', answers: '{ flags, earlyAdopter }' },
50
+ { method: 'GET', path: '/-/orgs/{org}/webhooks', who: 'admin', scope: 'orgs:read', summary: 'The org\'s webhook endpoints and their state, and the event names.', answers: '{ webhooks[], events[] }' },
51
+ { method: 'POST', path: '/-/orgs/{org}/webhooks', who: 'admin', scope: 'orgs:write', summary: 'Add an endpoint: a URL and the events it wants; the secret is answered once (Standard Webhooks signing).', body: '{ url, events?: ["*" | names], description? }', answers: '{ id, url, events, secret }' },
52
+ { method: 'PATCH', path: '/-/orgs/{org}/webhooks/{id}', who: 'admin', scope: 'orgs:write', summary: 'Change an endpoint\'s events or description, or enable/disable it.', body: '{ events?, description?, enabled? }', answers: '{ id, … }' },
53
+ { method: 'DELETE', path: '/-/orgs/{org}/webhooks/{id}', who: 'admin', scope: 'orgs:write', summary: 'Remove an endpoint.', answers: '{ deleted }' },
54
+ { method: 'POST', path: '/-/orgs/{org}/webhooks/{id}/test', who: 'admin', scope: 'orgs:write', summary: 'Send a `ping` event now and answer the delivery with its attempt.', answers: '{ delivery }' },
55
+ { method: 'GET', path: '/-/orgs/{org}/webhooks/{id}/deliveries', who: 'admin', scope: 'orgs:read', summary: 'The endpoint\'s last fifty deliveries, each attempt with its status and the response\'s first 2 KB.', answers: '{ deliveries[] }' },
56
+ { method: 'GET', path: '/-/orgs/{org}/audit', who: 'admin', scope: 'activity:read', summary: 'The org\'s activity: every admin act, newest first.', answers: '{ entries[] }' },
57
+ { method: 'GET', path: '/-/orgs/{org}/audit.jsonl', who: 'admin', scope: 'activity:read', summary: 'The activity as the file kept, one act a line.', answers: 'JSON lines' },
58
+ { method: 'GET', path: '/-/orgs/{org}/export', who: 'admin', scope: 'activity:read', summary: 'What the platform knows about the org: the record, the worlds, the members, the activity.', answers: 'JSON' },
59
+ { method: 'GET', path: '/-/tokens', who: 'person', scope: 'tokens:read', summary: 'Your personal access tokens, without their secrets.', answers: '{ tokens[] }' },
60
+ { method: 'POST', path: '/-/tokens', who: 'person', scope: 'tokens:write', summary: 'Make a personal access token: scoped, expiring (30 days by default); shown once.', body: '{ name, expiresInDays?, scopes? }', answers: '{ token, id, scopes, expiresAt }' },
61
+ { method: 'DELETE', path: '/-/tokens/{id}', who: 'person', scope: 'tokens:write', summary: 'Revoke one of your tokens.', answers: '{ revoked }' },
62
+ { method: 'POST', path: '/-/support', who: 'person', scope: 'orgs:write', summary: 'Write to support; an admin may allow support to open the org for a while.', body: '{ category, severity, subject, message, org?, world?, allowAccessDays? }', answers: '{ id, at, emailed, copyTo, access }' },
63
+ { method: 'POST', path: '/-/operator/sweep-members', who: 'operator', scope: null, summary: 'Revoke, in every org\'s Worlds, the keys (and the branches they made) of people who are no longer members of that org: someone removed at the identity service directly, not through the platform. The clock runs it hourly.', answers: '{ revoked }' },
64
+ { method: 'POST', path: '/-/meter/tick', who: 'operator', scope: null, summary: 'Run the hourly billing tick now (the clock runs it on its own); 404 where the platform does not bill.', answers: '{ ticked, worlds, inserted, duplicates, restrictions }' },
65
+ { method: 'POST', path: '/-/backup', who: 'operator', scope: null, summary: 'Archive the platform\'s state to the bucket now (the clock does it nightly).', answers: '{ key, bytes }' },
66
+ { method: 'GET', path: '/-/backups', who: 'operator', scope: null, summary: 'The archives in the bucket.', answers: '{ keys[] }' },
67
+ { method: 'POST', path: '/-/restore', who: 'operator', scope: null, summary: 'Restore the platform\'s state from an archive.', body: '{ key }', answers: '{ files }' },
68
+ { method: 'GET', path: '/-/hosts', who: 'operator', scope: null, summary: 'The hosts enrolled.', answers: '{ hosts[] }' },
69
+ { method: 'POST', path: '/-/hosts', who: 'operator', scope: null, summary: 'Enroll a host by address: its admin endpoints must answer the token.', body: '{ id, base, adminToken }', answers: '{ id, base }' },
70
+ { method: 'DELETE', path: '/-/hosts/{id}', who: 'operator', scope: null, summary: 'Stop provisioning onto a host; its worlds stay where they are.', answers: '{ removed }' },
71
+ { method: 'GET', path: '/-/operator/overview', who: 'operator', scope: null, summary: 'The operator\'s page source: orgs with plan, restriction, worlds, hosts and consents; support requests; the clock.', answers: '{ orgs[], support[], hosts[], clock, notice }' },
72
+ { method: 'GET', path: '/-/operator/unclaimed', who: 'operator', scope: null, summary: 'The worlds enrolled hosts serve that no org holds (made on a host before the platform, or on the host itself).', answers: '{ worlds: [{ host, name, twins, owner? }] }' },
73
+ { method: 'POST', path: '/-/operator/claim', who: 'operator', scope: null, summary: 'Claim a world an enrolled host serves into an org whose slug it is served under: without `confirm: true` only when its host already records that org as its owner (slugs are first come), and never when the host records another. The host records the owner; its apps keep their token. Recorded in the org\'s activity.', body: '{ world, org, confirm? }', answers: '{ name, org, host }' },
74
+ { method: 'POST', path: '/-/operator/open-org', who: 'operator', scope: null, summary: 'A one-hour read-only support session for an org that has allowed it, with a category and a reason; recorded in the org\'s activity.', body: '{ org, category, reason }', answers: '{ token, until, console }' },
75
+ { method: 'PATCH', path: '/-/operator/support/{id}', who: 'operator', scope: null, summary: 'Close or reopen a support request.', body: '{ status: open | closed }', answers: '{ request }' },
76
+ ];
77
+ /** The table as OpenAPI 3.1. */
78
+ export function openapi(origin) {
79
+ const paths = {};
80
+ for (const d of DOORS) {
81
+ const params = [...d.path.matchAll(/\{(\w+)\}/g)].map((m) => ({ name: m[1], in: 'path', required: true, schema: { type: 'string' } }));
82
+ const op = {
83
+ summary: d.summary,
84
+ description: `Who: ${d.who}.${d.scope ? ` A token needs \`${d.scope}\`.` : ''} Answers ${d.answers}.`,
85
+ security: d.who === 'anyone' ? [] : d.who === 'operator' ? [{ operatorToken: [] }] : [{ session: [] }, { token: d.scope ? [d.scope] : [] }],
86
+ ...(params.length ? { parameters: params } : {}),
87
+ ...(d.body ? { requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', description: d.body } } } } } : {}),
88
+ responses: { '200': { description: d.answers }, ...(d.who !== 'anyone' ? { '401': { description: 'sign in, or the token expired' }, '403': { description: 'not yours, held by the org\'s security, or the token lacks the scope (named)' } } : {}), '429': { description: 'rate limited; Retry-After and X-RateLimit-* say when' } },
89
+ };
90
+ (paths[d.path] ??= {})[d.method.toLowerCase()] = op;
91
+ }
92
+ return {
93
+ openapi: '3.1.0',
94
+ info: { title: 'Volter World platform', version: '1', description: 'The hosted product\'s doors: orgs, worlds, members, billing, tokens, activity, support, and the operator\'s. Every twin\'s vendor API and every world\'s own endpoints are under the world\'s address, documented in the HTTP API reference.' },
95
+ servers: [{ url: origin }],
96
+ components: { securitySchemes: { session: { type: 'apiKey', in: 'cookie', name: 'volter_console_session', description: 'a signed-in browser session: the platform\'s own, started by signing in with its access provider' }, token: { type: 'http', scheme: 'bearer', description: 'a personal token (tok_p_), an org token (tok_o_) or a support session (tok_su_), with scopes object:action' }, operatorToken: { type: 'apiKey', in: 'header', name: 'x-volter-token', description: 'the platform\'s operator token' } } },
97
+ paths,
98
+ };
99
+ }
100
+ /** The table as the docs page. */
101
+ export function platformApiMarkdown() {
102
+ const groups = [
103
+ ['The platform', (d) => /^\/-\/(health|status|openapi|platform$|vendors$)/.test(d.path) || d.path.startsWith('/.well-known/')],
104
+ ['Signing in', (d) => d.path === '/-/sign-in' || d.path === '/-/sign-out'],
105
+ ['Signing the volter command in', (d) => d.path.startsWith('/-/cli/')],
106
+ ['Worlds', (d) => d.path.startsWith('/-/worlds')],
107
+ ['Orgs, members, billing, security, activity', (d) => d.path.startsWith('/-/orgs')],
108
+ ['Your tokens and support', (d) => d.path.startsWith('/-/tokens') || d.path === '/-/support'],
109
+ ['The operator\'s', (d) => d.who === 'operator' && !/^\/-\/status/.test(d.path)],
110
+ ];
111
+ const out = ['# Platform API', '', 'The hosted product\'s own endpoints, under `/-/` on the platform\'s address. A twin\'s vendor API and a world\'s own endpoints are under the world\'s address; see [HTTP API](./http-api.md). This page is generated from the endpoint table (`apps/platform/src/doors.ts`); `GET /-/openapi.json` is the same table as OpenAPI, and [platform-openapi.json](./platform-openapi.json) is that document committed, for tools.', '', 'Who opens an endpoint: **anyone**; a **person** signed in, or a token with the scope named; an **admin** of the org; the **operator** with the platform\'s token. A token without the scope is refused with 403 naming it; a held org answers 403 with the reason; everything above the rate limit answers 429 with `Retry-After`. A change made with the browser\x27s session (not a token) must come from the platform\x27s own pages (403 otherwise) with a JSON body (415 otherwise).', ''];
112
+ // every door is on the page: one no section takes is an error here, never a row quietly left out
113
+ const left = DOORS.filter((d) => !groups.some(([, pick]) => pick(d)));
114
+ if (left.length)
115
+ throw new Error(`doors in no section of the platform API page: ${left.map((d) => `${d.method} ${d.path}`).join(', ')}`);
116
+ for (const [title, pick] of groups) {
117
+ out.push(`## ${title}`, '', '| endpoint | who | scope | what it does | body → answer |', '|---|---|---|---|---|');
118
+ for (const d of DOORS.filter(pick))
119
+ out.push(`| \`${d.method} ${d.path}\` | ${d.who} | ${d.scope ? `\`${d.scope}\`` : '—'} | ${d.summary} | ${d.body ? `\`${d.body}\` → ` : ''}\`${d.answers}\` |`);
120
+ out.push('');
121
+ }
122
+ return `${out.join('\n')}\n`;
123
+ }