@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
package/src/sample.ts ADDED
@@ -0,0 +1,84 @@
1
+ // THE SAMPLE WORLD (docs/contributing/architecture.md, "A new org sees a World working"): a World the platform
2
+ // makes for an org that has none, seeded the way every World is — through the vendors' own APIs, as an app would call
3
+ // them (never a handler faking a stored write) — so each vendor's screen shows an app's work in the first minute.
4
+ //
5
+ // The story: a small shop that sells a Pro plan. Stripe holds its customers, the plan and two paid orders; Slack holds
6
+ // the workspace where the shop's app announces each sale. It runs once: a second run finds the story's first customer
7
+ // and adds nothing.
8
+
9
+ /** The vendors a sample World stands in for, in the order they are taken, those the host serves. Two, so it fits the
10
+ * smallest plan. */
11
+ export const SAMPLE_VENDORS = ['stripe', 'slack'] as const;
12
+ export const SAMPLE_WORLD = 'sample';
13
+
14
+ const CUSTOMERS = [
15
+ { name: 'Ada Lovelace', email: 'ada@example.com' },
16
+ { name: 'Grace Hopper', email: 'grace@example.com' },
17
+ { name: 'Alan Turing', email: 'alan@example.com' },
18
+ ] as const;
19
+
20
+ type Call = (vendor: string, path: string, init: { method?: string; auth: string; form?: Record<string, string>; json?: unknown }) => Promise<Record<string, unknown>>;
21
+
22
+ /** A caller for one World's vendor APIs: the World's token in `x-twins-key`, the vendor's own credential beside it. */
23
+ function caller(base: string, token: string): Call {
24
+ return async (vendor, path, init) => {
25
+ const headers: Record<string, string> = { 'x-twins-key': token, authorization: init.auth };
26
+ let body: string | undefined;
27
+ if (init.form) { headers['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(init.form).toString(); }
28
+ if (init.json !== undefined) { headers['content-type'] = 'application/json'; body = JSON.stringify(init.json); }
29
+ const res = await fetch(`${base}/${vendor}${path}`, { method: init.method ?? (body ? 'POST' : 'GET'), headers, ...(body ? { body } : {}) });
30
+ const answer = (await res.json().catch(() => ({}))) as Record<string, unknown>;
31
+ if (!res.ok) throw new Error(`${vendor} ${path} answered ${res.status}: ${JSON.stringify(answer).slice(0, 200)}`);
32
+ return answer;
33
+ };
34
+ }
35
+
36
+ const STRIPE = 'Bearer sk_test_sample';
37
+ const BOT = 'Bearer xoxb-twin-bot';
38
+
39
+ async function seedStripe(call: Call): Promise<string[]> {
40
+ const found = await call('stripe', `/v1/customers?email=${encodeURIComponent(CUSTOMERS[0].email)}`, { auth: STRIPE }) as { data?: unknown[] };
41
+ if (found.data?.length) return [];
42
+ const ids: string[] = [];
43
+ for (const c of CUSTOMERS) ids.push((await call('stripe', '/v1/customers', { auth: STRIPE, form: { name: c.name, email: c.email } })).id as string);
44
+ const product = await call('stripe', '/v1/products', { auth: STRIPE, form: { name: 'Pro plan', description: 'Everything in the shop, billed monthly' } });
45
+ await call('stripe', '/v1/prices', { auth: STRIPE, form: { product: product.id as string, unit_amount: '2900', currency: 'usd', 'recurring[interval]': 'month' } });
46
+ for (const customer of ids.slice(0, 2)) {
47
+ await call('stripe', '/v1/payment_intents', { auth: STRIPE, form: { amount: '2900', currency: 'usd', customer, payment_method: 'pm_card_visa', confirm: 'true', 'automatic_payment_methods[enabled]': 'true', 'automatic_payment_methods[allow_redirects]': 'never', description: 'Pro plan' } });
48
+ }
49
+ return ['3 customers', 'the Pro plan at $29 a month', '2 paid orders'];
50
+ }
51
+
52
+ async function seedSlack(call: Call): Promise<string[]> {
53
+ const channel = async (name: string): Promise<string> => {
54
+ const made = await call('slack', '/api/conversations.create', { auth: BOT, json: { name } }) as { ok?: boolean; error?: string; channel?: { id?: string } };
55
+ if (made.ok && made.channel?.id) return made.channel.id;
56
+ const listed = await call('slack', '/api/conversations.list', { auth: BOT, json: {} }) as { channels?: Array<{ id?: string; name?: string }> };
57
+ const id = listed.channels?.find((c) => c.name === name)?.id;
58
+ if (!id) throw new Error(`slack: #${name} was neither made nor found (${made.error ?? 'no answer'})`);
59
+ return id;
60
+ };
61
+ const general = await channel('general');
62
+ const sales = await channel('sales');
63
+ // a new channel already holds its "has joined" line (a subtype): only the app's own messages say the story ran
64
+ const history = await call('slack', '/api/conversations.history', { auth: BOT, json: { channel: sales } }) as { messages?: Array<{ subtype?: string }> };
65
+ if (history.messages?.some((m) => !m.subtype)) return [];
66
+ await call('slack', '/api/chat.postMessage', { auth: BOT, json: { channel: general, text: 'The shop is open: this workspace is where the shop\'s app says what it did.' } });
67
+ for (const c of CUSTOMERS.slice(0, 2)) await call('slack', '/api/chat.postMessage', { auth: BOT, json: { channel: sales, text: `New order: ${c.name} bought the Pro plan ($29.00)` } });
68
+ return ['#general and #sales', '3 messages from the shop\'s app'];
69
+ }
70
+
71
+ const SEEDERS: Record<string, (call: Call) => Promise<string[]>> = { stripe: seedStripe, slack: seedSlack };
72
+
73
+ /** Seed a sample World at `base` (its vendor APIs under `<base>/<vendor>`) with its token; answers what each vendor
74
+ * now holds, or why it holds nothing. A vendor that fails leaves the others seeded. */
75
+ export async function seedSample(base: string, token: string, vendors: readonly string[]): Promise<Record<string, { seeded: string[] } | { error: string }>> {
76
+ const call = caller(base.replace(/\/+$/, ''), token);
77
+ const out: Record<string, { seeded: string[] } | { error: string }> = {};
78
+ for (const v of vendors) {
79
+ const seed = SEEDERS[v];
80
+ if (!seed) continue;
81
+ try { out[v] = { seeded: await seed(call) }; } catch (e) { out[v] = { error: (e as Error).message }; }
82
+ }
83
+ return out;
84
+ }
package/src/store.ts ADDED
@@ -0,0 +1,154 @@
1
+ // THE PLATFORM'S STORE (docs/reference/platform-api.md; docs/contributing/architecture.md, "The platform's state is
2
+ // SQLite"): what the platform keeps about an org that the directory does not — the org's WORLDS (where each lives) and
3
+ // its settings — and the platform's own position (the backup, the operator's notice, support requests, the webhook
4
+ // queue). The org itself, its members and their roles are the directory's (directory.ts), keyed by its organization id.
5
+ // Billing keeps its own records (apps/billing). Tables in the platform's database (db/schema.ts), read and written
6
+ // through the methods below, each one statement or one transaction.
7
+ import { and, asc, eq, inArray } from 'drizzle-orm';
8
+ import { openDatabase, type PlatformDb } from './db/open.ts';
9
+ import { deliveries as deliveryRows, meta, orgSettings, orgWorlds, supportRequests, webhooks as webhookRows } from './db/schema.ts';
10
+ import type { Delivery, WebhookEndpoint } from './webhooks.ts';
11
+
12
+ /** A world the org holds: it LIVES on a host — the platform records where, never its log. */
13
+ export type OrgWorld = { name: string; host: string; base: string; provisionedAt: string };
14
+ /** The org's security switches (layer 6: PostHog's members_can_invite / members_can_use_personal_api_keys, Twenty's approved domains). */
15
+ export type Security = { membersCanInvite: boolean; membersCanUsePersonalTokens: boolean; approvedEmailDomains: string[] };
16
+ export const DEFAULT_SECURITY: Security = { membersCanInvite: false, membersCanUsePersonalTokens: true, approvedEmailDomains: [] };
17
+ /** Consent for support to open the org, for a while (layer 6: Supabase's allowSupportAccess, Twenty's allowImpersonation). */
18
+ export type SupportAccess = { until: string; grantedBy: string; requestId: string };
19
+ /** What the platform holds for one org: its Worlds and its settings. */
20
+ export type OrgDoc = { id: string; worlds: OrgWorld[]; /** webhook endpoints (layer 7) */ webhooks?: WebhookEndpoint[]; /** labs (layer 8): early-access features on for this org, and the early-adopter switch that turns on every new one */ flags?: Record<string, boolean>; earlyAdopter?: boolean; security?: Security; supportAccess?: SupportAccess | null; };
21
+ /** A support request as the Help form sent it (Supabase's fields), kept for the operator's page. */
22
+ export type SupportRequest = { id: string; at: string; person: { id: string; email?: string }; org: string | null; world: string | null; category: string; severity: string; subject: string; message: string; status: 'open' | 'closed' };
23
+ /** The platform's own position — the backup's last run and the operator's notice, and the support requests. */
24
+ /** A preview (docs/contributing/architecture.md, "CI runs in a World"): a named branch of one of the org's Worlds,
25
+ * made and removed by the platform for CI or a person; `label` names it within its World (`pr-12`). */
26
+ /** A pull request's preview: a branch of `world`, made with a key of its own in the parent (`key`, its id), so revoking
27
+ * that key, or the token or membership it was made for, ends the branch. */
28
+ export type Preview = { label: string; world: string; branch: string; createdAt: string; expiresAt: string | null; createdBy: string; key?: string };
29
+ export type PlatformDoc = { lastBackupAt: string | null; notice: { text: string; at: string } | null; support?: SupportRequest[] };
30
+
31
+ const NAME = /^[a-z0-9][a-z0-9-]{0,38}$/;
32
+ export function assertName(kind: string, value: string): string {
33
+ if (!NAME.test(value)) throw new Error(`${kind} must be lowercase letters, digits and dashes (1-39): got ${JSON.stringify(value)}`);
34
+ return value;
35
+ }
36
+ /** A Volter organization id (Better Auth's: letters and digits) is a name before it is a key. */
37
+ const ORG_ID = /^[A-Za-z0-9_-]{1,80}$/;
38
+ const orgIdOf = (orgId: string): string => { if (!ORG_ID.test(orgId)) throw new Error(`not an organization id: ${JSON.stringify(orgId)}`); return orgId; };
39
+ const KEEP_DELIVERIES = 2000;
40
+ const KEEP_SUPPORT = 500;
41
+
42
+ export class Store {
43
+ readonly db: PlatformDb;
44
+ /** The store over the platform's database at `<dir>/platform.db`. */
45
+ constructor(dir: string) { this.db = openDatabase(dir); }
46
+
47
+ private settings(orgId: string) { return this.db.select().from(orgSettings).where(eq(orgSettings.orgId, orgId)).get() as (typeof orgSettings.$inferSelect) | undefined; }
48
+ private patchSettings(orgId: string, patch: Partial<typeof orgSettings.$inferInsert>): void {
49
+ this.db.insert(orgSettings).values({ orgId: orgIdOf(orgId), ...patch }).onConflictDoUpdate({ target: orgSettings.orgId, set: patch }).run();
50
+ }
51
+
52
+ /** What the platform holds for an org; an org the directory holds but the platform has not seen yet holds no Worlds. */
53
+ doc(orgId: string): OrgDoc {
54
+ orgIdOf(orgId);
55
+ const worlds = (this.db.select().from(orgWorlds).where(eq(orgWorlds.orgId, orgId)).orderBy(asc(orgWorlds.provisionedAt)).all() as Array<typeof orgWorlds.$inferSelect>)
56
+ .map((w) => ({ name: w.name, host: w.host, base: w.base, provisionedAt: w.provisionedAt }));
57
+ const s = this.settings(orgId);
58
+ const hooks = this.webhooksOf(orgId);
59
+ return { id: orgId, worlds, ...(hooks.length ? { webhooks: hooks } : {}), ...(s?.flags ? { flags: s.flags } : {}), ...(s?.earlyAdopter !== null && s?.earlyAdopter !== undefined ? { earlyAdopter: s.earlyAdopter } : {}), ...(s?.security ? { security: s.security } : {}), ...(s?.supportAccess !== undefined ? { supportAccess: s.supportAccess } : {}) };
60
+ }
61
+ /** Record a World the org holds; one name once (the primary key refuses a second). */
62
+ recordWorld(orgId: string, world: OrgWorld): OrgDoc {
63
+ try { this.db.insert(orgWorlds).values({ orgId: orgIdOf(orgId), ...world }).run(); }
64
+ catch (e) { if (/UNIQUE|PRIMARY KEY/i.test(String((e as Error).message))) throw new Error(`the org already holds a world named ${world.name}`); throw e; }
65
+ return this.doc(orgId);
66
+ }
67
+ removeWorld(orgId: string, name: string): OrgDoc { this.db.delete(orgWorlds).where(and(eq(orgWorlds.orgId, orgIdOf(orgId)), eq(orgWorlds.name, name))).run(); return this.doc(orgId); }
68
+ /** Every org the platform holds a record for — the biller's roster. */
69
+ orgIds(): string[] {
70
+ const a = (this.db.selectDistinct({ id: orgWorlds.orgId }).from(orgWorlds).all() as Array<{ id: string }>).map((r) => r.id);
71
+ const b = (this.db.select({ id: orgSettings.orgId }).from(orgSettings).all() as Array<{ id: string }>).map((r) => r.id);
72
+ return [...new Set([...a, ...b])].sort();
73
+ }
74
+ setSupportAccess(orgId: string, supportAccess: SupportAccess | null): OrgDoc { this.patchSettings(orgId, { supportAccess }); return this.doc(orgId); }
75
+ supportAccessOf(orgId: string): SupportAccess | null { const a = this.settings(orgIdOf(orgId))?.supportAccess ?? null; return a && Date.parse(a.until) > Date.now() ? a : null; }
76
+ setLabs(orgId: string, patch: { flags?: Record<string, boolean>; earlyAdopter?: boolean }): OrgDoc {
77
+ this.db.transaction((tx) => {
78
+ const s = tx.select().from(orgSettings).where(eq(orgSettings.orgId, orgId)).get() as (typeof orgSettings.$inferSelect) | undefined;
79
+ const next = { ...(patch.flags ? { flags: { ...(s?.flags ?? {}), ...patch.flags } } : {}), ...(patch.earlyAdopter !== undefined ? { earlyAdopter: patch.earlyAdopter } : {}) };
80
+ tx.insert(orgSettings).values({ orgId: orgIdOf(orgId), ...next }).onConflictDoUpdate({ target: orgSettings.orgId, set: next }).run();
81
+ });
82
+ return this.doc(orgId);
83
+ }
84
+ setSecurity(orgId: string, security: Security): OrgDoc { this.patchSettings(orgId, { security }); return this.doc(orgId); }
85
+ securityOf(orgId: string): Security { return { ...DEFAULT_SECURITY, ...(this.settings(orgIdOf(orgId))?.security ?? {}) }; }
86
+
87
+ // webhooks (layer 7)
88
+ webhooksOf(orgId: string): WebhookEndpoint[] {
89
+ return (this.db.select().from(webhookRows).where(eq(webhookRows.orgId, orgId)).orderBy(asc(webhookRows.createdAt)).all() as Array<typeof webhookRows.$inferSelect>)
90
+ .map(({ orgId: _o, ...w }) => w);
91
+ }
92
+ /** The org's endpoints, as a whole: those not named go, the named are written. */
93
+ setWebhooks(orgId: string, list: WebhookEndpoint[]): OrgDoc {
94
+ this.db.transaction((tx) => {
95
+ tx.delete(webhookRows).where(eq(webhookRows.orgId, orgIdOf(orgId))).run();
96
+ if (list.length) tx.insert(webhookRows).values(list.map((w) => ({ ...w, orgId }))).run();
97
+ });
98
+ return this.doc(orgId);
99
+ }
100
+ updateWebhook(orgId: string, id: string, patch: Partial<WebhookEndpoint>): WebhookEndpoint | null {
101
+ const { id: _id, ...set } = patch;
102
+ if (Object.keys(set).length) this.db.update(webhookRows).set(set).where(and(eq(webhookRows.orgId, orgId), eq(webhookRows.id, id))).run();
103
+ return this.webhooksOf(orgId).find((w) => w.id === id) ?? null;
104
+ }
105
+
106
+ // the webhook delivery queue and its recent history (layer 7), in the order it was queued
107
+ deliveries(): Delivery[] { return (this.db.select().from(deliveryRows).orderBy(asc(deliveryRows.seq)).all() as Array<typeof deliveryRows.$inferSelect>).map(({ seq: _s, ...d }) => d); }
108
+ /** The queue as a whole (the delivery pass's result): kept in the order given. */
109
+ setDeliveries(list: Delivery[]): void {
110
+ this.db.transaction((tx) => {
111
+ const keep = list.slice(-KEEP_DELIVERIES);
112
+ tx.delete(deliveryRows).run();
113
+ if (keep.length) tx.insert(deliveryRows).values(keep.map((d, i) => ({ ...d, seq: i }))).run();
114
+ });
115
+ }
116
+ enqueueDeliveries(items: Delivery[]): void {
117
+ if (!items.length) return;
118
+ this.db.transaction((tx) => {
119
+ const last = (tx.select({ seq: deliveryRows.seq }).from(deliveryRows).orderBy(asc(deliveryRows.seq)).all() as Array<{ seq: number }>).at(-1)?.seq ?? -1;
120
+ tx.insert(deliveryRows).values(items.map((d, i) => ({ ...d, seq: last + 1 + i }))).run();
121
+ const all = (tx.select({ id: deliveryRows.id }).from(deliveryRows).orderBy(asc(deliveryRows.seq)).all() as Array<{ id: string }>);
122
+ if (all.length > KEEP_DELIVERIES) tx.delete(deliveryRows).where(inArray(deliveryRows.id, all.slice(0, all.length - KEEP_DELIVERIES).map((r) => r.id))).run();
123
+ });
124
+ }
125
+
126
+ // support requests (layer 6)
127
+ addSupportRequest(r: SupportRequest): SupportRequest[] {
128
+ this.db.transaction((tx) => {
129
+ tx.insert(supportRequests).values(r).run();
130
+ const all = (tx.select({ id: supportRequests.id }).from(supportRequests).orderBy(asc(supportRequests.at)).all() as Array<{ id: string }>);
131
+ if (all.length > KEEP_SUPPORT) tx.delete(supportRequests).where(inArray(supportRequests.id, all.slice(0, all.length - KEEP_SUPPORT).map((x) => x.id))).run();
132
+ });
133
+ return this.supportRequests();
134
+ }
135
+ private supportRequests(): SupportRequest[] { return this.db.select().from(supportRequests).orderBy(asc(supportRequests.at)).all() as SupportRequest[]; }
136
+ setSupportStatus(id: string, status: SupportRequest['status']): SupportRequest | null {
137
+ this.db.update(supportRequests).set({ status }).where(eq(supportRequests.id, id)).run();
138
+ return (this.db.select().from(supportRequests).where(eq(supportRequests.id, id)).get() as SupportRequest | undefined) ?? null;
139
+ }
140
+
141
+ // an org's previews, in its meta row (a handful per World, replaced whole)
142
+ previewsOf(orgId: string): Preview[] { return this.metaOf<Preview[]>(`previews:${orgId}`) ?? []; }
143
+ setPreviews(orgId: string, previews: Preview[]): void { this.setMeta(`previews:${orgId}`, previews); }
144
+
145
+ // the platform's own position
146
+ private metaOf<T>(key: string): T | null { return ((this.db.select().from(meta).where(eq(meta.key, key)).get() as { value: T } | undefined)?.value) ?? null; }
147
+ private setMeta(key: string, value: unknown): void { this.db.insert(meta).values({ key, value }).onConflictDoUpdate({ target: meta.key, set: { value } }).run(); }
148
+ platform(): PlatformDoc { return { lastBackupAt: this.metaOf<string>('lastBackupAt'), notice: this.metaOf<{ text: string; at: string }>('notice'), support: this.supportRequests() }; }
149
+ setPlatform(patch: Partial<PlatformDoc>): PlatformDoc {
150
+ if ('lastBackupAt' in patch) this.setMeta('lastBackupAt', patch.lastBackupAt ?? null);
151
+ if ('notice' in patch) this.setMeta('notice', patch.notice ?? null);
152
+ return this.platform();
153
+ }
154
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,94 @@
1
+ // Personal access tokens (punchlist 2.4): a person's own credential to the platform — for the CLI
2
+ // (`volter login`) and for scripts. Shown ONCE at creation; stored hashed (sha256) under the
3
+ // platform's state; revocable; named. Accepted wherever a session is (platform.ts presentedToken).
4
+ // Never a world token: a world's tokens are the host's, resolved through the platform by a member.
5
+ import { createHash, randomBytes } from 'node:crypto';
6
+ import { and, asc, eq, isNull } from 'drizzle-orm';
7
+ import { openDatabase, type PlatformDb } from './db/open.ts';
8
+ import { tokens as tokenRows } from './db/schema.ts';
9
+
10
+ export type TokenRecord = { hash: string; userId: string; name: string; createdAt: string; last4: string; lastUsedAt?: string; /** ISO, or null for a token that never expires (layer 5: Sentry's thirty-day default) */ expiresAt?: string | null;
11
+ /** `object:action` scopes (layer 6: Sentry's `org:read`, PostHog's objects × actions); `['*']` is everything the holder may do */
12
+ scopes?: string[];
13
+ /** set on an ORG token (`tok_o_…`, layer 6: Polar's organization access token, Twenty's workspace key): the org it acts as; userId is then the admin who made it */
14
+ orgId?: string;
15
+ /** set on a SUPPORT session (`tok_su_…`, layer 6): the operator's one-hour, read-only, consented view of an org — Sentry's privileged session with its reason */
16
+ support?: { reason: string; category: string };
17
+ /** the World keys made for this token (`volter remote add`), each revoked with it */
18
+ worldKeys?: Array<{ world: string; keyId: string; command?: boolean }> };
19
+ export const DEFAULT_TOKEN_DAYS = 30;
20
+ export const SCOPE_OBJECTS = ['worlds', 'orgs', 'members', 'billing', 'tokens', 'activity'] as const;
21
+ export const SCOPE_ACTIONS = ['read', 'write'] as const;
22
+ export const ALL_SCOPES = SCOPE_OBJECTS.flatMap((o) => SCOPE_ACTIONS.map((a) => `${o}:${a}`));
23
+ /** A scope list is valid when every entry is `*` or `<object>:<action>` from the catalog. */
24
+ export function validScopes(scopes: unknown): scopes is string[] {
25
+ return Array.isArray(scopes) && scopes.length > 0 && scopes.every((s) => s === '*' || (typeof s === 'string' && (ALL_SCOPES as string[]).includes(s)));
26
+ }
27
+ /** Whether a token's scopes cover a needed `object:action` — `*` covers all, `x:write` covers `x:read`. */
28
+ export function hasScope(scopes: string[] | undefined, need: string): boolean {
29
+ const list = scopes ?? ['*'];
30
+ if (list.includes('*') || list.includes(need)) return true;
31
+ const [obj, action] = need.split(':');
32
+ return action === 'read' && list.includes(`${obj}:write`);
33
+ }
34
+ const ORG_PREFIX = 'tok_o_';
35
+ const SUPPORT_PREFIX = 'tok_su_';
36
+ const PREFIX = 'tok_p_';
37
+ const hash = (token: string): string => createHash('sha256').update(token).digest('hex');
38
+
39
+ export class Tokens {
40
+ private readonly db: PlatformDb;
41
+ constructor(stateDir: string) { this.db = openDatabase(stateDir); }
42
+ private row(h: string): TokenRecord | null {
43
+ if (!/^[a-f0-9]{64}$/.test(h)) throw new Error('bad token hash');
44
+ const r = this.db.select().from(tokenRows).where(eq(tokenRows.hash, h)).get() as (typeof tokenRows.$inferSelect) | undefined;
45
+ return r ? recordOf(r) : null;
46
+ }
47
+ /** Mint a token for a person; the token itself is returned once and never stored. */
48
+ mint(userId: string, name: string, opts: { expiresInDays?: number | null; expiresInHours?: number; scopes?: string[]; orgId?: string; support?: { reason: string; category: string } } = {}): { token: string; record: TokenRecord } {
49
+ const token = `${opts.support ? SUPPORT_PREFIX : opts.orgId ? ORG_PREFIX : PREFIX}${randomBytes(24).toString('base64url')}`;
50
+ const days = opts.expiresInDays === undefined ? DEFAULT_TOKEN_DAYS : opts.expiresInDays;
51
+ const expiresAt = opts.expiresInHours !== undefined ? new Date(Date.now() + opts.expiresInHours * 3600 * 1000).toISOString() : days === null ? null : new Date(Date.now() + days * 24 * 3600 * 1000).toISOString();
52
+ const record: TokenRecord = { hash: hash(token), userId, name: name.slice(0, 80), createdAt: new Date().toISOString(), last4: token.slice(-4), expiresAt, scopes: opts.scopes ?? ['*'], ...(opts.orgId ? { orgId: opts.orgId } : {}), ...(opts.support ? { support: opts.support } : {}) };
53
+ this.db.insert(tokenRows).values({ hash: record.hash, userId, name: record.name, createdAt: record.createdAt, last4: record.last4, expiresAt, scopes: record.scopes, orgId: opts.orgId ?? null, support: opts.support ?? null }).run();
54
+ return { token, record };
55
+ }
56
+ static looksLike(token: string): boolean { return token.startsWith(PREFIX) || token.startsWith(ORG_PREFIX) || token.startsWith(SUPPORT_PREFIX); }
57
+ static isSupportToken(token: string): boolean { return token.startsWith(SUPPORT_PREFIX); }
58
+ static isOrgToken(token: string): boolean { return token.startsWith(ORG_PREFIX); }
59
+ /** The person a presented token belongs to, or null; `expired: true` for a token past its time (never touched). When
60
+ * it was last used is kept to the minute: a token in constant use is not a write per request. */
61
+ lookup(token: string): (TokenRecord & { expired?: boolean }) | null {
62
+ if (!Tokens.looksLike(token)) return null;
63
+ const record = this.row(hash(token)); if (!record) return null;
64
+ if (record.expiresAt && Date.parse(record.expiresAt) <= Date.now()) return { ...record, expired: true };
65
+ const now = new Date();
66
+ if (!record.lastUsedAt || now.getTime() - Date.parse(record.lastUsedAt) >= 60_000) { this.db.update(tokenRows).set({ lastUsedAt: now.toISOString() }).where(eq(tokenRows.hash, record.hash)).run(); return { ...record, lastUsedAt: now.toISOString() }; }
67
+ return record;
68
+ }
69
+ /** An org's tokens (layer 6), newest last. */
70
+ listOrg(orgId: string): TokenRecord[] { return (this.db.select().from(tokenRows).where(and(eq(tokenRows.orgId, orgId), isNull(tokenRows.support))).orderBy(asc(tokenRows.createdAt)).all() as Array<typeof tokenRows.$inferSelect>).map(recordOf); }
71
+ revokeOrg(orgId: string, h: string): TokenRecord | null { const record = this.row(h); if (!record || record.orgId !== orgId) return null; this.db.delete(tokenRows).where(eq(tokenRows.hash, h)).run(); return record; }
72
+ /** Note the World key made for a token (one per World: a new one replaces the last). */
73
+ /** Record a World key made for this token, revoked with it: the command's one key per World `/token` hands out
74
+ * (`command`, replacing only its own earlier one), or one more made at the key door beside the others. */
75
+ noteWorldKey(h: string, world: string, keyId: string, command = true): void {
76
+ this.db.transaction((tx) => {
77
+ const r = tx.select({ worldKeys: tokenRows.worldKeys }).from(tokenRows).where(eq(tokenRows.hash, h)).get() as { worldKeys: Array<{ world: string; keyId: string; command?: boolean }> | null } | undefined;
78
+ if (!r) return;
79
+ tx.update(tokenRows).set({ worldKeys: [...(r.worldKeys ?? []).filter((k) => k.keyId !== keyId && !(command && k.command && k.world === world)), { world, keyId, ...(command ? { command: true } : {}) }] }).where(eq(tokenRows.hash, h)).run();
80
+ });
81
+ }
82
+ list(userId: string): TokenRecord[] { return (this.db.select().from(tokenRows).where(and(eq(tokenRows.userId, userId), isNull(tokenRows.orgId))).orderBy(asc(tokenRows.createdAt)).all() as Array<typeof tokenRows.$inferSelect>).map(recordOf); }
83
+ /** Revoke one of a person's own tokens: the record it was (its World keys go with it, by the caller), or null. */
84
+ revoke(userId: string, h: string): TokenRecord | null {
85
+ // a person's own tokens only: an org's token (its maker's id beside it) is revoked through the org, by an admin now
86
+ const record = this.row(h); if (!record || record.userId !== userId || record.orgId) return null;
87
+ this.db.delete(tokenRows).where(eq(tokenRows.hash, h)).run(); return record;
88
+ }
89
+ }
90
+
91
+ /** A row as the record the platform reads (absent fields left out, as the JSON records had them). */
92
+ function recordOf(r: typeof tokenRows.$inferSelect): TokenRecord {
93
+ return { hash: r.hash, userId: r.userId, name: r.name, createdAt: r.createdAt, last4: r.last4, expiresAt: r.expiresAt, ...(r.lastUsedAt ? { lastUsedAt: r.lastUsedAt } : {}), ...(r.scopes ? { scopes: r.scopes } : {}), ...(r.orgId ? { orgId: r.orgId } : {}), ...(r.support ? { support: r.support } : {}), ...(r.worldKeys ? { worldKeys: r.worldKeys } : {}) };
94
+ }
@@ -0,0 +1,85 @@
1
+ // Webhooks out (layer 7) — the peers' shape, read in their code (HOSTED-PRODUCT-COMPLETENESS.md,
2
+ // "webhooks out"): an endpoint is a URL, a secret shown once and the events it wants (Dub, Cal.com,
3
+ // Twenty); events are `object.verb` with a `{ id, event, createdAt, data }` body (Dub); deliveries are
4
+ // signed with Standard Webhooks (Polar: webhook-id, webhook-timestamp, webhook-signature), retried
5
+ // with backoff up to ten attempts (Polar's WEBHOOK_MAX_RETRIES) and kept with their response; twenty
6
+ // consecutive failures disable the endpoint and the admins are emailed (Dub's threshold).
7
+ import { createHmac, randomBytes } from 'node:crypto';
8
+ import { lookup } from 'node:dns/promises';
9
+ import type { AuditEvent } from './audit.ts';
10
+
11
+ export type WebhookEndpoint = {
12
+ id: string; url: string; /** `whsec_` + base64 — kept so deliveries can be signed; shown to the person once */ secret: string;
13
+ events: string[]; description: string; createdAt: string;
14
+ consecutiveFailures: number; lastFailedAt: string | null; disabledAt: string | null;
15
+ };
16
+ export type Delivery = {
17
+ id: string; orgId: string; endpointId: string; eventId: string; event: string; body: string;
18
+ attempt: number; nextAt: string; /** the attempts made, newest last */ attempts: Array<{ at: string; status: number | null; ok: boolean; response: string }>;
19
+ state: 'pending' | 'delivered' | 'failed';
20
+ };
21
+
22
+ /** The catalog: the audit's events in dotted form, plus `ping` for the test button. */
23
+ export const WEBHOOK_EVENTS: Readonly<Record<string, string>> = {
24
+ WORLD_PROVISION: 'world.provisioned', WORLD_DELETE: 'world.deleted', PREVIEW_CREATE: 'preview.created', PREVIEW_DELETE: 'preview.deleted',
25
+ MEMBER_INVITE: 'member.added', INVITE_REVOKE: 'member.invite_revoked', MEMBER_REMOVE: 'member.removed', MEMBER_ROLE: 'member.role_changed',
26
+ ORG_RENAME: 'org.renamed', PLAN_CHANGE: 'plan.changed',
27
+ QUOTA_WARNED: 'quota.warned', QUOTA_RESTRICTED: 'quota.restricted', QUOTA_CLEARED: 'quota.cleared',
28
+ IMPERSONATION: 'support.opened',
29
+ };
30
+ export const WEBHOOK_EVENT_NAMES: readonly string[] = [...new Set(Object.values(WEBHOOK_EVENTS))].sort();
31
+ export const eventNameFor = (audit: AuditEvent): string | null => WEBHOOK_EVENTS[audit] ?? null;
32
+ export function validEvents(events: unknown): events is string[] { return Array.isArray(events) && events.length > 0 && events.every((e) => typeof e === 'string' && (WEBHOOK_EVENT_NAMES.includes(e) || e === '*')); }
33
+
34
+ export const MAX_ATTEMPTS = 10;
35
+ export const DISABLE_AFTER = 20;
36
+ /** Seconds until the next attempt after attempt n (1-based): 30 s, 2 min, 10 min, then 20 min (Polar's cap). */
37
+ export const backoffSeconds = (attempt: number): number => [30, 120, 600][attempt - 1] ?? 1200;
38
+
39
+ export const newSecret = (): string => `whsec_${randomBytes(24).toString('base64')}`;
40
+ export const newId = (prefix: string): string => `${prefix}_${randomBytes(8).toString('hex')}`;
41
+
42
+ /** Standard Webhooks: HMAC-SHA256 over `id.timestamp.body` with the secret's base64 decoded, sent as `v1,<base64>`. */
43
+ export function sign(secret: string, id: string, timestamp: number, body: string): string {
44
+ const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
45
+ return `v1,${createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest('base64')}`;
46
+ }
47
+ /** What a receiver does: recompute and compare (constant time). */
48
+ export function verify(secret: string, headers: { id: string; timestamp: string; signature: string }, body: string, toleranceSeconds = 300): boolean {
49
+ const ts = Number(headers.timestamp); if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false;
50
+ const expected = sign(secret, headers.id, ts, body).slice(3);
51
+ return headers.signature.split(' ').some((part) => { const [v, sig] = part.split(','); if (v !== 'v1' || !sig) return false; const a = Buffer.from(sig, 'base64'); const b = Buffer.from(expected, 'base64'); return a.length === b.length && a.equals(b); });
52
+ }
53
+
54
+ /** Whether an address is this machine's, a private network's, link-local or carrier-grade NAT: never a webhook's target. */
55
+ export function privateAddress(ip: string): boolean {
56
+ const v4 = /^(?:::ffff:)?(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(ip);
57
+ if (v4) { const [a, b] = [Number(v4[1]), Number(v4[2])]; return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224; }
58
+ const v6 = ip.toLowerCase();
59
+ return v6 === '::' || v6 === '::1' || v6.startsWith('fc') || v6.startsWith('fd') || v6.startsWith('fe8') || v6.startsWith('fe9') || v6.startsWith('fea') || v6.startsWith('feb');
60
+ }
61
+ /** Why an endpoint may not be delivered to (its name resolves to an address inside a network), or null. A platform may
62
+ * deliver inside its own network only where its operator allows it (VOLTER_WEBHOOKS_ALLOW_PRIVATE=1, or a local run). */
63
+ export async function refusedEndpoint(url: string, allowPrivate: boolean): Promise<string | null> {
64
+ if (allowPrivate) return null;
65
+ let host: string; try { host = new URL(url).hostname.replace(/^\[|\]$/g, ''); } catch { return 'not an address'; }
66
+ let addresses: string[];
67
+ try { addresses = (await lookup(host, { all: true })).map((a) => a.address); } catch { return `${host} does not resolve`; }
68
+ const inside = addresses.find(privateAddress);
69
+ return inside ? `${host} resolves to ${inside}, inside a network: a webhook goes to a public address` : null;
70
+ }
71
+
72
+ /** One attempt at one delivery; the caller keeps the record. Redirects are not followed (a public name answering with an
73
+ * inside address would carry the event there). */
74
+ export async function attempt(endpoint: WebhookEndpoint, d: Delivery, fetchImpl: typeof fetch = fetch, allowPrivate = false): Promise<{ status: number | null; ok: boolean; response: string }> {
75
+ const refused = await refusedEndpoint(endpoint.url, allowPrivate);
76
+ if (refused) return { status: null, ok: false, response: refused };
77
+ const timestamp = Math.floor(Date.now() / 1000);
78
+ const c = new AbortController(); const t = setTimeout(() => c.abort(), 10_000);
79
+ try {
80
+ const r = await fetchImpl(endpoint.url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'volter-world webhooks', 'webhook-id': d.eventId, 'webhook-timestamp': String(timestamp), 'webhook-signature': sign(endpoint.secret, d.eventId, timestamp, d.body) }, body: d.body, signal: c.signal, redirect: 'manual' });
81
+ const text = (await r.text().catch(() => '')).slice(0, 2048);
82
+ return { status: r.status, ok: r.ok, response: r.status >= 300 && r.status < 400 ? `a redirect (${r.status}), not followed` : text };
83
+ } catch (e) { return { status: null, ok: false, response: String((e as Error).message ?? e).slice(0, 2048) }; }
84
+ finally { clearTimeout(t); }
85
+ }