@volter/world-platform 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +29 -0
- package/client/platform.css +228 -0
- package/client/platform.tsx +1049 -0
- package/client/reserved.ts +3 -0
- package/dist/client/platform.bundle.js +237 -0
- package/dist/client/platform.css +228 -0
- package/dist/client/platform.tsx +1049 -0
- package/dist/client/reserved.d.ts +1 -0
- package/dist/client/reserved.js +3 -0
- package/dist/client/reserved.ts +3 -0
- package/dist/src/audit.d.ts +24 -0
- package/dist/src/audit.js +23 -0
- package/dist/src/backup.d.ts +37 -0
- package/dist/src/backup.js +94 -0
- package/dist/src/biller.d.ts +59 -0
- package/dist/src/biller.js +1 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +112 -0
- package/dist/src/db/migrations/0000_init.sql +143 -0
- package/dist/src/db/migrations/meta/0000_snapshot.json +877 -0
- package/dist/src/db/migrations/meta/_journal.json +13 -0
- package/dist/src/db/migrations.d.ts +4 -0
- package/dist/src/db/migrations.js +33 -0
- package/dist/src/db/open.d.ts +11 -0
- package/dist/src/db/open.js +114 -0
- package/dist/src/db/pack-migrations.d.ts +1 -0
- package/dist/src/db/pack-migrations.js +33 -0
- package/dist/src/db/schema.d.ts +1803 -0
- package/dist/src/db/schema.js +123 -0
- package/dist/src/directory.d.ts +75 -0
- package/dist/src/directory.js +199 -0
- package/dist/src/doors.d.ts +16 -0
- package/dist/src/doors.js +123 -0
- package/dist/src/identity.d.ts +88 -0
- package/dist/src/identity.js +314 -0
- package/dist/src/labs.d.ts +14 -0
- package/dist/src/labs.js +7 -0
- package/dist/src/mail.d.ts +16 -0
- package/dist/src/mail.js +27 -0
- package/dist/src/pages.d.ts +15 -0
- package/dist/src/pages.js +73 -0
- package/dist/src/platform.d.ts +77 -0
- package/dist/src/platform.js +1845 -0
- package/dist/src/sample.d.ts +11 -0
- package/dist/src/sample.js +93 -0
- package/dist/src/store.d.ts +110 -0
- package/dist/src/store.js +142 -0
- package/dist/src/tokens.d.ts +69 -0
- package/dist/src/tokens.js +96 -0
- package/dist/src/webhooks.d.ts +60 -0
- package/dist/src/webhooks.js +92 -0
- package/package.json +78 -0
- package/src/audit.ts +26 -0
- package/src/backup.ts +73 -0
- package/src/biller.ts +45 -0
- package/src/cli.ts +99 -0
- package/src/db/migrations/0000_init.sql +143 -0
- package/src/db/migrations/meta/0000_snapshot.json +877 -0
- package/src/db/migrations/meta/_journal.json +13 -0
- package/src/db/migrations.ts +33 -0
- package/src/db/open.ts +92 -0
- package/src/db/pack-migrations.ts +19 -0
- package/src/db/schema.ts +137 -0
- package/src/directory.ts +216 -0
- package/src/doors.ts +138 -0
- package/src/identity.ts +279 -0
- package/src/labs.ts +8 -0
- package/src/mail.ts +27 -0
- package/src/pages.ts +69 -0
- package/src/platform.ts +1183 -0
- package/src/sample.ts +84 -0
- package/src/store.ts +154 -0
- package/src/tokens.ts +94 -0
- package/src/webhooks.ts +85 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** The vendors a sample World stands in for, in the order they are taken, those the host serves. Two, so it fits the
|
|
2
|
+
* smallest plan. */
|
|
3
|
+
export declare const SAMPLE_VENDORS: readonly ["stripe", "slack"];
|
|
4
|
+
export declare const SAMPLE_WORLD = "sample";
|
|
5
|
+
/** Seed a sample World at `base` (its vendor APIs under `<base>/<vendor>`) with its token; answers what each vendor
|
|
6
|
+
* now holds, or why it holds nothing. A vendor that fails leaves the others seeded. */
|
|
7
|
+
export declare function seedSample(base: string, token: string, vendors: readonly string[]): Promise<Record<string, {
|
|
8
|
+
seeded: string[];
|
|
9
|
+
} | {
|
|
10
|
+
error: string;
|
|
11
|
+
}>>;
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
/** The vendors a sample World stands in for, in the order they are taken, those the host serves. Two, so it fits the
|
|
9
|
+
* smallest plan. */
|
|
10
|
+
export const SAMPLE_VENDORS = ['stripe', 'slack'];
|
|
11
|
+
export const SAMPLE_WORLD = 'sample';
|
|
12
|
+
const CUSTOMERS = [
|
|
13
|
+
{ name: 'Ada Lovelace', email: 'ada@example.com' },
|
|
14
|
+
{ name: 'Grace Hopper', email: 'grace@example.com' },
|
|
15
|
+
{ name: 'Alan Turing', email: 'alan@example.com' },
|
|
16
|
+
];
|
|
17
|
+
/** A caller for one World's vendor APIs: the World's token in `x-twins-key`, the vendor's own credential beside it. */
|
|
18
|
+
function caller(base, token) {
|
|
19
|
+
return async (vendor, path, init) => {
|
|
20
|
+
const headers = { 'x-twins-key': token, authorization: init.auth };
|
|
21
|
+
let body;
|
|
22
|
+
if (init.form) {
|
|
23
|
+
headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
24
|
+
body = new URLSearchParams(init.form).toString();
|
|
25
|
+
}
|
|
26
|
+
if (init.json !== undefined) {
|
|
27
|
+
headers['content-type'] = 'application/json';
|
|
28
|
+
body = JSON.stringify(init.json);
|
|
29
|
+
}
|
|
30
|
+
const res = await fetch(`${base}/${vendor}${path}`, { method: init.method ?? (body ? 'POST' : 'GET'), headers, ...(body ? { body } : {}) });
|
|
31
|
+
const answer = (await res.json().catch(() => ({})));
|
|
32
|
+
if (!res.ok)
|
|
33
|
+
throw new Error(`${vendor} ${path} answered ${res.status}: ${JSON.stringify(answer).slice(0, 200)}`);
|
|
34
|
+
return answer;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const STRIPE = 'Bearer sk_test_sample';
|
|
38
|
+
const BOT = 'Bearer xoxb-twin-bot';
|
|
39
|
+
async function seedStripe(call) {
|
|
40
|
+
const found = await call('stripe', `/v1/customers?email=${encodeURIComponent(CUSTOMERS[0].email)}`, { auth: STRIPE });
|
|
41
|
+
if (found.data?.length)
|
|
42
|
+
return [];
|
|
43
|
+
const ids = [];
|
|
44
|
+
for (const c of CUSTOMERS)
|
|
45
|
+
ids.push((await call('stripe', '/v1/customers', { auth: STRIPE, form: { name: c.name, email: c.email } })).id);
|
|
46
|
+
const product = await call('stripe', '/v1/products', { auth: STRIPE, form: { name: 'Pro plan', description: 'Everything in the shop, billed monthly' } });
|
|
47
|
+
await call('stripe', '/v1/prices', { auth: STRIPE, form: { product: product.id, unit_amount: '2900', currency: 'usd', 'recurring[interval]': 'month' } });
|
|
48
|
+
for (const customer of ids.slice(0, 2)) {
|
|
49
|
+
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' } });
|
|
50
|
+
}
|
|
51
|
+
return ['3 customers', 'the Pro plan at $29 a month', '2 paid orders'];
|
|
52
|
+
}
|
|
53
|
+
async function seedSlack(call) {
|
|
54
|
+
const channel = async (name) => {
|
|
55
|
+
const made = await call('slack', '/api/conversations.create', { auth: BOT, json: { name } });
|
|
56
|
+
if (made.ok && made.channel?.id)
|
|
57
|
+
return made.channel.id;
|
|
58
|
+
const listed = await call('slack', '/api/conversations.list', { auth: BOT, json: {} });
|
|
59
|
+
const id = listed.channels?.find((c) => c.name === name)?.id;
|
|
60
|
+
if (!id)
|
|
61
|
+
throw new Error(`slack: #${name} was neither made nor found (${made.error ?? 'no answer'})`);
|
|
62
|
+
return id;
|
|
63
|
+
};
|
|
64
|
+
const general = await channel('general');
|
|
65
|
+
const sales = await channel('sales');
|
|
66
|
+
// a new channel already holds its "has joined" line (a subtype): only the app's own messages say the story ran
|
|
67
|
+
const history = await call('slack', '/api/conversations.history', { auth: BOT, json: { channel: sales } });
|
|
68
|
+
if (history.messages?.some((m) => !m.subtype))
|
|
69
|
+
return [];
|
|
70
|
+
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.' } });
|
|
71
|
+
for (const c of CUSTOMERS.slice(0, 2))
|
|
72
|
+
await call('slack', '/api/chat.postMessage', { auth: BOT, json: { channel: sales, text: `New order: ${c.name} bought the Pro plan ($29.00)` } });
|
|
73
|
+
return ['#general and #sales', '3 messages from the shop\'s app'];
|
|
74
|
+
}
|
|
75
|
+
const SEEDERS = { stripe: seedStripe, slack: seedSlack };
|
|
76
|
+
/** Seed a sample World at `base` (its vendor APIs under `<base>/<vendor>`) with its token; answers what each vendor
|
|
77
|
+
* now holds, or why it holds nothing. A vendor that fails leaves the others seeded. */
|
|
78
|
+
export async function seedSample(base, token, vendors) {
|
|
79
|
+
const call = caller(base.replace(/\/+$/, ''), token);
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const v of vendors) {
|
|
82
|
+
const seed = SEEDERS[v];
|
|
83
|
+
if (!seed)
|
|
84
|
+
continue;
|
|
85
|
+
try {
|
|
86
|
+
out[v] = { seeded: await seed(call) };
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
out[v] = { error: e.message };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { type PlatformDb } from './db/open.js';
|
|
2
|
+
import type { Delivery, WebhookEndpoint } from './webhooks.js';
|
|
3
|
+
/** A world the org holds: it LIVES on a host — the platform records where, never its log. */
|
|
4
|
+
export type OrgWorld = {
|
|
5
|
+
name: string;
|
|
6
|
+
host: string;
|
|
7
|
+
base: string;
|
|
8
|
+
provisionedAt: string;
|
|
9
|
+
};
|
|
10
|
+
/** The org's security switches (layer 6: PostHog's members_can_invite / members_can_use_personal_api_keys, Twenty's approved domains). */
|
|
11
|
+
export type Security = {
|
|
12
|
+
membersCanInvite: boolean;
|
|
13
|
+
membersCanUsePersonalTokens: boolean;
|
|
14
|
+
approvedEmailDomains: string[];
|
|
15
|
+
};
|
|
16
|
+
export declare const DEFAULT_SECURITY: Security;
|
|
17
|
+
/** Consent for support to open the org, for a while (layer 6: Supabase's allowSupportAccess, Twenty's allowImpersonation). */
|
|
18
|
+
export type SupportAccess = {
|
|
19
|
+
until: string;
|
|
20
|
+
grantedBy: string;
|
|
21
|
+
requestId: string;
|
|
22
|
+
};
|
|
23
|
+
/** What the platform holds for one org: its Worlds and its settings. */
|
|
24
|
+
export type OrgDoc = {
|
|
25
|
+
id: string;
|
|
26
|
+
worlds: OrgWorld[]; /** webhook endpoints (layer 7) */
|
|
27
|
+
webhooks?: WebhookEndpoint[]; /** labs (layer 8): early-access features on for this org, and the early-adopter switch that turns on every new one */
|
|
28
|
+
flags?: Record<string, boolean>;
|
|
29
|
+
earlyAdopter?: boolean;
|
|
30
|
+
security?: Security;
|
|
31
|
+
supportAccess?: SupportAccess | null;
|
|
32
|
+
};
|
|
33
|
+
/** A support request as the Help form sent it (Supabase's fields), kept for the operator's page. */
|
|
34
|
+
export type SupportRequest = {
|
|
35
|
+
id: string;
|
|
36
|
+
at: string;
|
|
37
|
+
person: {
|
|
38
|
+
id: string;
|
|
39
|
+
email?: string;
|
|
40
|
+
};
|
|
41
|
+
org: string | null;
|
|
42
|
+
world: string | null;
|
|
43
|
+
category: string;
|
|
44
|
+
severity: string;
|
|
45
|
+
subject: string;
|
|
46
|
+
message: string;
|
|
47
|
+
status: 'open' | 'closed';
|
|
48
|
+
};
|
|
49
|
+
/** The platform's own position — the backup's last run and the operator's notice, and the support requests. */
|
|
50
|
+
/** A preview (docs/contributing/architecture.md, "CI runs in a World"): a named branch of one of the org's Worlds,
|
|
51
|
+
* made and removed by the platform for CI or a person; `label` names it within its World (`pr-12`). */
|
|
52
|
+
/** A pull request's preview: a branch of `world`, made with a key of its own in the parent (`key`, its id), so revoking
|
|
53
|
+
* that key, or the token or membership it was made for, ends the branch. */
|
|
54
|
+
export type Preview = {
|
|
55
|
+
label: string;
|
|
56
|
+
world: string;
|
|
57
|
+
branch: string;
|
|
58
|
+
createdAt: string;
|
|
59
|
+
expiresAt: string | null;
|
|
60
|
+
createdBy: string;
|
|
61
|
+
key?: string;
|
|
62
|
+
};
|
|
63
|
+
export type PlatformDoc = {
|
|
64
|
+
lastBackupAt: string | null;
|
|
65
|
+
notice: {
|
|
66
|
+
text: string;
|
|
67
|
+
at: string;
|
|
68
|
+
} | null;
|
|
69
|
+
support?: SupportRequest[];
|
|
70
|
+
};
|
|
71
|
+
export declare function assertName(kind: string, value: string): string;
|
|
72
|
+
export declare class Store {
|
|
73
|
+
readonly db: PlatformDb;
|
|
74
|
+
/** The store over the platform's database at `<dir>/platform.db`. */
|
|
75
|
+
constructor(dir: string);
|
|
76
|
+
private settings;
|
|
77
|
+
private patchSettings;
|
|
78
|
+
/** What the platform holds for an org; an org the directory holds but the platform has not seen yet holds no Worlds. */
|
|
79
|
+
doc(orgId: string): OrgDoc;
|
|
80
|
+
/** Record a World the org holds; one name once (the primary key refuses a second). */
|
|
81
|
+
recordWorld(orgId: string, world: OrgWorld): OrgDoc;
|
|
82
|
+
removeWorld(orgId: string, name: string): OrgDoc;
|
|
83
|
+
/** Every org the platform holds a record for — the biller's roster. */
|
|
84
|
+
orgIds(): string[];
|
|
85
|
+
setSupportAccess(orgId: string, supportAccess: SupportAccess | null): OrgDoc;
|
|
86
|
+
supportAccessOf(orgId: string): SupportAccess | null;
|
|
87
|
+
setLabs(orgId: string, patch: {
|
|
88
|
+
flags?: Record<string, boolean>;
|
|
89
|
+
earlyAdopter?: boolean;
|
|
90
|
+
}): OrgDoc;
|
|
91
|
+
setSecurity(orgId: string, security: Security): OrgDoc;
|
|
92
|
+
securityOf(orgId: string): Security;
|
|
93
|
+
webhooksOf(orgId: string): WebhookEndpoint[];
|
|
94
|
+
/** The org's endpoints, as a whole: those not named go, the named are written. */
|
|
95
|
+
setWebhooks(orgId: string, list: WebhookEndpoint[]): OrgDoc;
|
|
96
|
+
updateWebhook(orgId: string, id: string, patch: Partial<WebhookEndpoint>): WebhookEndpoint | null;
|
|
97
|
+
deliveries(): Delivery[];
|
|
98
|
+
/** The queue as a whole (the delivery pass's result): kept in the order given. */
|
|
99
|
+
setDeliveries(list: Delivery[]): void;
|
|
100
|
+
enqueueDeliveries(items: Delivery[]): void;
|
|
101
|
+
addSupportRequest(r: SupportRequest): SupportRequest[];
|
|
102
|
+
private supportRequests;
|
|
103
|
+
setSupportStatus(id: string, status: SupportRequest['status']): SupportRequest | null;
|
|
104
|
+
previewsOf(orgId: string): Preview[];
|
|
105
|
+
setPreviews(orgId: string, previews: Preview[]): void;
|
|
106
|
+
private metaOf;
|
|
107
|
+
private setMeta;
|
|
108
|
+
platform(): PlatformDoc;
|
|
109
|
+
setPlatform(patch: Partial<PlatformDoc>): PlatformDoc;
|
|
110
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
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 } from "./db/open.js";
|
|
9
|
+
import { deliveries as deliveryRows, meta, orgSettings, orgWorlds, supportRequests, webhooks as webhookRows } from "./db/schema.js";
|
|
10
|
+
export const DEFAULT_SECURITY = { membersCanInvite: false, membersCanUsePersonalTokens: true, approvedEmailDomains: [] };
|
|
11
|
+
const NAME = /^[a-z0-9][a-z0-9-]{0,38}$/;
|
|
12
|
+
export function assertName(kind, value) {
|
|
13
|
+
if (!NAME.test(value))
|
|
14
|
+
throw new Error(`${kind} must be lowercase letters, digits and dashes (1-39): got ${JSON.stringify(value)}`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
/** A Volter organization id (Better Auth's: letters and digits) is a name before it is a key. */
|
|
18
|
+
const ORG_ID = /^[A-Za-z0-9_-]{1,80}$/;
|
|
19
|
+
const orgIdOf = (orgId) => { if (!ORG_ID.test(orgId))
|
|
20
|
+
throw new Error(`not an organization id: ${JSON.stringify(orgId)}`); return orgId; };
|
|
21
|
+
const KEEP_DELIVERIES = 2000;
|
|
22
|
+
const KEEP_SUPPORT = 500;
|
|
23
|
+
export class Store {
|
|
24
|
+
db;
|
|
25
|
+
/** The store over the platform's database at `<dir>/platform.db`. */
|
|
26
|
+
constructor(dir) { this.db = openDatabase(dir); }
|
|
27
|
+
settings(orgId) { return this.db.select().from(orgSettings).where(eq(orgSettings.orgId, orgId)).get(); }
|
|
28
|
+
patchSettings(orgId, patch) {
|
|
29
|
+
this.db.insert(orgSettings).values({ orgId: orgIdOf(orgId), ...patch }).onConflictDoUpdate({ target: orgSettings.orgId, set: patch }).run();
|
|
30
|
+
}
|
|
31
|
+
/** What the platform holds for an org; an org the directory holds but the platform has not seen yet holds no Worlds. */
|
|
32
|
+
doc(orgId) {
|
|
33
|
+
orgIdOf(orgId);
|
|
34
|
+
const worlds = this.db.select().from(orgWorlds).where(eq(orgWorlds.orgId, orgId)).orderBy(asc(orgWorlds.provisionedAt)).all()
|
|
35
|
+
.map((w) => ({ name: w.name, host: w.host, base: w.base, provisionedAt: w.provisionedAt }));
|
|
36
|
+
const s = this.settings(orgId);
|
|
37
|
+
const hooks = this.webhooksOf(orgId);
|
|
38
|
+
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 } : {}) };
|
|
39
|
+
}
|
|
40
|
+
/** Record a World the org holds; one name once (the primary key refuses a second). */
|
|
41
|
+
recordWorld(orgId, world) {
|
|
42
|
+
try {
|
|
43
|
+
this.db.insert(orgWorlds).values({ orgId: orgIdOf(orgId), ...world }).run();
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (/UNIQUE|PRIMARY KEY/i.test(String(e.message)))
|
|
47
|
+
throw new Error(`the org already holds a world named ${world.name}`);
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
return this.doc(orgId);
|
|
51
|
+
}
|
|
52
|
+
removeWorld(orgId, name) { this.db.delete(orgWorlds).where(and(eq(orgWorlds.orgId, orgIdOf(orgId)), eq(orgWorlds.name, name))).run(); return this.doc(orgId); }
|
|
53
|
+
/** Every org the platform holds a record for — the biller's roster. */
|
|
54
|
+
orgIds() {
|
|
55
|
+
const a = this.db.selectDistinct({ id: orgWorlds.orgId }).from(orgWorlds).all().map((r) => r.id);
|
|
56
|
+
const b = this.db.select({ id: orgSettings.orgId }).from(orgSettings).all().map((r) => r.id);
|
|
57
|
+
return [...new Set([...a, ...b])].sort();
|
|
58
|
+
}
|
|
59
|
+
setSupportAccess(orgId, supportAccess) { this.patchSettings(orgId, { supportAccess }); return this.doc(orgId); }
|
|
60
|
+
supportAccessOf(orgId) { const a = this.settings(orgIdOf(orgId))?.supportAccess ?? null; return a && Date.parse(a.until) > Date.now() ? a : null; }
|
|
61
|
+
setLabs(orgId, patch) {
|
|
62
|
+
this.db.transaction((tx) => {
|
|
63
|
+
const s = tx.select().from(orgSettings).where(eq(orgSettings.orgId, orgId)).get();
|
|
64
|
+
const next = { ...(patch.flags ? { flags: { ...(s?.flags ?? {}), ...patch.flags } } : {}), ...(patch.earlyAdopter !== undefined ? { earlyAdopter: patch.earlyAdopter } : {}) };
|
|
65
|
+
tx.insert(orgSettings).values({ orgId: orgIdOf(orgId), ...next }).onConflictDoUpdate({ target: orgSettings.orgId, set: next }).run();
|
|
66
|
+
});
|
|
67
|
+
return this.doc(orgId);
|
|
68
|
+
}
|
|
69
|
+
setSecurity(orgId, security) { this.patchSettings(orgId, { security }); return this.doc(orgId); }
|
|
70
|
+
securityOf(orgId) { return { ...DEFAULT_SECURITY, ...(this.settings(orgIdOf(orgId))?.security ?? {}) }; }
|
|
71
|
+
// webhooks (layer 7)
|
|
72
|
+
webhooksOf(orgId) {
|
|
73
|
+
return this.db.select().from(webhookRows).where(eq(webhookRows.orgId, orgId)).orderBy(asc(webhookRows.createdAt)).all()
|
|
74
|
+
.map(({ orgId: _o, ...w }) => w);
|
|
75
|
+
}
|
|
76
|
+
/** The org's endpoints, as a whole: those not named go, the named are written. */
|
|
77
|
+
setWebhooks(orgId, list) {
|
|
78
|
+
this.db.transaction((tx) => {
|
|
79
|
+
tx.delete(webhookRows).where(eq(webhookRows.orgId, orgIdOf(orgId))).run();
|
|
80
|
+
if (list.length)
|
|
81
|
+
tx.insert(webhookRows).values(list.map((w) => ({ ...w, orgId }))).run();
|
|
82
|
+
});
|
|
83
|
+
return this.doc(orgId);
|
|
84
|
+
}
|
|
85
|
+
updateWebhook(orgId, id, patch) {
|
|
86
|
+
const { id: _id, ...set } = patch;
|
|
87
|
+
if (Object.keys(set).length)
|
|
88
|
+
this.db.update(webhookRows).set(set).where(and(eq(webhookRows.orgId, orgId), eq(webhookRows.id, id))).run();
|
|
89
|
+
return this.webhooksOf(orgId).find((w) => w.id === id) ?? null;
|
|
90
|
+
}
|
|
91
|
+
// the webhook delivery queue and its recent history (layer 7), in the order it was queued
|
|
92
|
+
deliveries() { return this.db.select().from(deliveryRows).orderBy(asc(deliveryRows.seq)).all().map(({ seq: _s, ...d }) => d); }
|
|
93
|
+
/** The queue as a whole (the delivery pass's result): kept in the order given. */
|
|
94
|
+
setDeliveries(list) {
|
|
95
|
+
this.db.transaction((tx) => {
|
|
96
|
+
const keep = list.slice(-KEEP_DELIVERIES);
|
|
97
|
+
tx.delete(deliveryRows).run();
|
|
98
|
+
if (keep.length)
|
|
99
|
+
tx.insert(deliveryRows).values(keep.map((d, i) => ({ ...d, seq: i }))).run();
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
enqueueDeliveries(items) {
|
|
103
|
+
if (!items.length)
|
|
104
|
+
return;
|
|
105
|
+
this.db.transaction((tx) => {
|
|
106
|
+
const last = tx.select({ seq: deliveryRows.seq }).from(deliveryRows).orderBy(asc(deliveryRows.seq)).all().at(-1)?.seq ?? -1;
|
|
107
|
+
tx.insert(deliveryRows).values(items.map((d, i) => ({ ...d, seq: last + 1 + i }))).run();
|
|
108
|
+
const all = tx.select({ id: deliveryRows.id }).from(deliveryRows).orderBy(asc(deliveryRows.seq)).all();
|
|
109
|
+
if (all.length > KEEP_DELIVERIES)
|
|
110
|
+
tx.delete(deliveryRows).where(inArray(deliveryRows.id, all.slice(0, all.length - KEEP_DELIVERIES).map((r) => r.id))).run();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
// support requests (layer 6)
|
|
114
|
+
addSupportRequest(r) {
|
|
115
|
+
this.db.transaction((tx) => {
|
|
116
|
+
tx.insert(supportRequests).values(r).run();
|
|
117
|
+
const all = tx.select({ id: supportRequests.id }).from(supportRequests).orderBy(asc(supportRequests.at)).all();
|
|
118
|
+
if (all.length > KEEP_SUPPORT)
|
|
119
|
+
tx.delete(supportRequests).where(inArray(supportRequests.id, all.slice(0, all.length - KEEP_SUPPORT).map((x) => x.id))).run();
|
|
120
|
+
});
|
|
121
|
+
return this.supportRequests();
|
|
122
|
+
}
|
|
123
|
+
supportRequests() { return this.db.select().from(supportRequests).orderBy(asc(supportRequests.at)).all(); }
|
|
124
|
+
setSupportStatus(id, status) {
|
|
125
|
+
this.db.update(supportRequests).set({ status }).where(eq(supportRequests.id, id)).run();
|
|
126
|
+
return this.db.select().from(supportRequests).where(eq(supportRequests.id, id)).get() ?? null;
|
|
127
|
+
}
|
|
128
|
+
// an org's previews, in its meta row (a handful per World, replaced whole)
|
|
129
|
+
previewsOf(orgId) { return this.metaOf(`previews:${orgId}`) ?? []; }
|
|
130
|
+
setPreviews(orgId, previews) { this.setMeta(`previews:${orgId}`, previews); }
|
|
131
|
+
// the platform's own position
|
|
132
|
+
metaOf(key) { return (this.db.select().from(meta).where(eq(meta.key, key)).get()?.value) ?? null; }
|
|
133
|
+
setMeta(key, value) { this.db.insert(meta).values({ key, value }).onConflictDoUpdate({ target: meta.key, set: { value } }).run(); }
|
|
134
|
+
platform() { return { lastBackupAt: this.metaOf('lastBackupAt'), notice: this.metaOf('notice'), support: this.supportRequests() }; }
|
|
135
|
+
setPlatform(patch) {
|
|
136
|
+
if ('lastBackupAt' in patch)
|
|
137
|
+
this.setMeta('lastBackupAt', patch.lastBackupAt ?? null);
|
|
138
|
+
if ('notice' in patch)
|
|
139
|
+
this.setMeta('notice', patch.notice ?? null);
|
|
140
|
+
return this.platform();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type TokenRecord = {
|
|
2
|
+
hash: string;
|
|
3
|
+
userId: string;
|
|
4
|
+
name: string;
|
|
5
|
+
createdAt: string;
|
|
6
|
+
last4: string;
|
|
7
|
+
lastUsedAt?: string; /** ISO, or null for a token that never expires (layer 5: Sentry's thirty-day default) */
|
|
8
|
+
expiresAt?: string | null;
|
|
9
|
+
/** `object:action` scopes (layer 6: Sentry's `org:read`, PostHog's objects × actions); `['*']` is everything the holder may do */
|
|
10
|
+
scopes?: string[];
|
|
11
|
+
/** 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 */
|
|
12
|
+
orgId?: string;
|
|
13
|
+
/** 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 */
|
|
14
|
+
support?: {
|
|
15
|
+
reason: string;
|
|
16
|
+
category: string;
|
|
17
|
+
};
|
|
18
|
+
/** the World keys made for this token (`volter remote add`), each revoked with it */
|
|
19
|
+
worldKeys?: Array<{
|
|
20
|
+
world: string;
|
|
21
|
+
keyId: string;
|
|
22
|
+
command?: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
};
|
|
25
|
+
export declare const DEFAULT_TOKEN_DAYS = 30;
|
|
26
|
+
export declare const SCOPE_OBJECTS: readonly ["worlds", "orgs", "members", "billing", "tokens", "activity"];
|
|
27
|
+
export declare const SCOPE_ACTIONS: readonly ["read", "write"];
|
|
28
|
+
export declare const ALL_SCOPES: string[];
|
|
29
|
+
/** A scope list is valid when every entry is `*` or `<object>:<action>` from the catalog. */
|
|
30
|
+
export declare function validScopes(scopes: unknown): scopes is string[];
|
|
31
|
+
/** Whether a token's scopes cover a needed `object:action` — `*` covers all, `x:write` covers `x:read`. */
|
|
32
|
+
export declare function hasScope(scopes: string[] | undefined, need: string): boolean;
|
|
33
|
+
export declare class Tokens {
|
|
34
|
+
private readonly db;
|
|
35
|
+
constructor(stateDir: string);
|
|
36
|
+
private row;
|
|
37
|
+
/** Mint a token for a person; the token itself is returned once and never stored. */
|
|
38
|
+
mint(userId: string, name: string, opts?: {
|
|
39
|
+
expiresInDays?: number | null;
|
|
40
|
+
expiresInHours?: number;
|
|
41
|
+
scopes?: string[];
|
|
42
|
+
orgId?: string;
|
|
43
|
+
support?: {
|
|
44
|
+
reason: string;
|
|
45
|
+
category: string;
|
|
46
|
+
};
|
|
47
|
+
}): {
|
|
48
|
+
token: string;
|
|
49
|
+
record: TokenRecord;
|
|
50
|
+
};
|
|
51
|
+
static looksLike(token: string): boolean;
|
|
52
|
+
static isSupportToken(token: string): boolean;
|
|
53
|
+
static isOrgToken(token: string): boolean;
|
|
54
|
+
/** The person a presented token belongs to, or null; `expired: true` for a token past its time (never touched). When
|
|
55
|
+
* it was last used is kept to the minute: a token in constant use is not a write per request. */
|
|
56
|
+
lookup(token: string): (TokenRecord & {
|
|
57
|
+
expired?: boolean;
|
|
58
|
+
}) | null;
|
|
59
|
+
/** An org's tokens (layer 6), newest last. */
|
|
60
|
+
listOrg(orgId: string): TokenRecord[];
|
|
61
|
+
revokeOrg(orgId: string, h: string): TokenRecord | null;
|
|
62
|
+
/** Note the World key made for a token (one per World: a new one replaces the last). */
|
|
63
|
+
/** Record a World key made for this token, revoked with it: the command's one key per World `/token` hands out
|
|
64
|
+
* (`command`, replacing only its own earlier one), or one more made at the key door beside the others. */
|
|
65
|
+
noteWorldKey(h: string, world: string, keyId: string, command?: boolean): void;
|
|
66
|
+
list(userId: string): TokenRecord[];
|
|
67
|
+
/** Revoke one of a person's own tokens: the record it was (its World keys go with it, by the caller), or null. */
|
|
68
|
+
revoke(userId: string, h: string): TokenRecord | null;
|
|
69
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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 } from "./db/open.js";
|
|
8
|
+
import { tokens as tokenRows } from "./db/schema.js";
|
|
9
|
+
export const DEFAULT_TOKEN_DAYS = 30;
|
|
10
|
+
export const SCOPE_OBJECTS = ['worlds', 'orgs', 'members', 'billing', 'tokens', 'activity'];
|
|
11
|
+
export const SCOPE_ACTIONS = ['read', 'write'];
|
|
12
|
+
export const ALL_SCOPES = SCOPE_OBJECTS.flatMap((o) => SCOPE_ACTIONS.map((a) => `${o}:${a}`));
|
|
13
|
+
/** A scope list is valid when every entry is `*` or `<object>:<action>` from the catalog. */
|
|
14
|
+
export function validScopes(scopes) {
|
|
15
|
+
return Array.isArray(scopes) && scopes.length > 0 && scopes.every((s) => s === '*' || (typeof s === 'string' && ALL_SCOPES.includes(s)));
|
|
16
|
+
}
|
|
17
|
+
/** Whether a token's scopes cover a needed `object:action` — `*` covers all, `x:write` covers `x:read`. */
|
|
18
|
+
export function hasScope(scopes, need) {
|
|
19
|
+
const list = scopes ?? ['*'];
|
|
20
|
+
if (list.includes('*') || list.includes(need))
|
|
21
|
+
return true;
|
|
22
|
+
const [obj, action] = need.split(':');
|
|
23
|
+
return action === 'read' && list.includes(`${obj}:write`);
|
|
24
|
+
}
|
|
25
|
+
const ORG_PREFIX = 'tok_o_';
|
|
26
|
+
const SUPPORT_PREFIX = 'tok_su_';
|
|
27
|
+
const PREFIX = 'tok_p_';
|
|
28
|
+
const hash = (token) => createHash('sha256').update(token).digest('hex');
|
|
29
|
+
export class Tokens {
|
|
30
|
+
db;
|
|
31
|
+
constructor(stateDir) { this.db = openDatabase(stateDir); }
|
|
32
|
+
row(h) {
|
|
33
|
+
if (!/^[a-f0-9]{64}$/.test(h))
|
|
34
|
+
throw new Error('bad token hash');
|
|
35
|
+
const r = this.db.select().from(tokenRows).where(eq(tokenRows.hash, h)).get();
|
|
36
|
+
return r ? recordOf(r) : null;
|
|
37
|
+
}
|
|
38
|
+
/** Mint a token for a person; the token itself is returned once and never stored. */
|
|
39
|
+
mint(userId, name, opts = {}) {
|
|
40
|
+
const token = `${opts.support ? SUPPORT_PREFIX : opts.orgId ? ORG_PREFIX : PREFIX}${randomBytes(24).toString('base64url')}`;
|
|
41
|
+
const days = opts.expiresInDays === undefined ? DEFAULT_TOKEN_DAYS : opts.expiresInDays;
|
|
42
|
+
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();
|
|
43
|
+
const record = { 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 } : {}) };
|
|
44
|
+
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();
|
|
45
|
+
return { token, record };
|
|
46
|
+
}
|
|
47
|
+
static looksLike(token) { return token.startsWith(PREFIX) || token.startsWith(ORG_PREFIX) || token.startsWith(SUPPORT_PREFIX); }
|
|
48
|
+
static isSupportToken(token) { return token.startsWith(SUPPORT_PREFIX); }
|
|
49
|
+
static isOrgToken(token) { return token.startsWith(ORG_PREFIX); }
|
|
50
|
+
/** The person a presented token belongs to, or null; `expired: true` for a token past its time (never touched). When
|
|
51
|
+
* it was last used is kept to the minute: a token in constant use is not a write per request. */
|
|
52
|
+
lookup(token) {
|
|
53
|
+
if (!Tokens.looksLike(token))
|
|
54
|
+
return null;
|
|
55
|
+
const record = this.row(hash(token));
|
|
56
|
+
if (!record)
|
|
57
|
+
return null;
|
|
58
|
+
if (record.expiresAt && Date.parse(record.expiresAt) <= Date.now())
|
|
59
|
+
return { ...record, expired: true };
|
|
60
|
+
const now = new Date();
|
|
61
|
+
if (!record.lastUsedAt || now.getTime() - Date.parse(record.lastUsedAt) >= 60_000) {
|
|
62
|
+
this.db.update(tokenRows).set({ lastUsedAt: now.toISOString() }).where(eq(tokenRows.hash, record.hash)).run();
|
|
63
|
+
return { ...record, lastUsedAt: now.toISOString() };
|
|
64
|
+
}
|
|
65
|
+
return record;
|
|
66
|
+
}
|
|
67
|
+
/** An org's tokens (layer 6), newest last. */
|
|
68
|
+
listOrg(orgId) { return this.db.select().from(tokenRows).where(and(eq(tokenRows.orgId, orgId), isNull(tokenRows.support))).orderBy(asc(tokenRows.createdAt)).all().map(recordOf); }
|
|
69
|
+
revokeOrg(orgId, h) { const record = this.row(h); if (!record || record.orgId !== orgId)
|
|
70
|
+
return null; this.db.delete(tokenRows).where(eq(tokenRows.hash, h)).run(); return record; }
|
|
71
|
+
/** Note the World key made for a token (one per World: a new one replaces the last). */
|
|
72
|
+
/** Record a World key made for this token, revoked with it: the command's one key per World `/token` hands out
|
|
73
|
+
* (`command`, replacing only its own earlier one), or one more made at the key door beside the others. */
|
|
74
|
+
noteWorldKey(h, world, keyId, command = true) {
|
|
75
|
+
this.db.transaction((tx) => {
|
|
76
|
+
const r = tx.select({ worldKeys: tokenRows.worldKeys }).from(tokenRows).where(eq(tokenRows.hash, h)).get();
|
|
77
|
+
if (!r)
|
|
78
|
+
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) { return this.db.select().from(tokenRows).where(and(eq(tokenRows.userId, userId), isNull(tokenRows.orgId))).orderBy(asc(tokenRows.createdAt)).all().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, h) {
|
|
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);
|
|
87
|
+
if (!record || record.userId !== userId || record.orgId)
|
|
88
|
+
return null;
|
|
89
|
+
this.db.delete(tokenRows).where(eq(tokenRows.hash, h)).run();
|
|
90
|
+
return record;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** A row as the record the platform reads (absent fields left out, as the JSON records had them). */
|
|
94
|
+
function recordOf(r) {
|
|
95
|
+
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 } : {}) };
|
|
96
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AuditEvent } from './audit.js';
|
|
2
|
+
export type WebhookEndpoint = {
|
|
3
|
+
id: string;
|
|
4
|
+
url: string; /** `whsec_` + base64 — kept so deliveries can be signed; shown to the person once */
|
|
5
|
+
secret: string;
|
|
6
|
+
events: string[];
|
|
7
|
+
description: string;
|
|
8
|
+
createdAt: string;
|
|
9
|
+
consecutiveFailures: number;
|
|
10
|
+
lastFailedAt: string | null;
|
|
11
|
+
disabledAt: string | null;
|
|
12
|
+
};
|
|
13
|
+
export type Delivery = {
|
|
14
|
+
id: string;
|
|
15
|
+
orgId: string;
|
|
16
|
+
endpointId: string;
|
|
17
|
+
eventId: string;
|
|
18
|
+
event: string;
|
|
19
|
+
body: string;
|
|
20
|
+
attempt: number;
|
|
21
|
+
nextAt: string; /** the attempts made, newest last */
|
|
22
|
+
attempts: Array<{
|
|
23
|
+
at: string;
|
|
24
|
+
status: number | null;
|
|
25
|
+
ok: boolean;
|
|
26
|
+
response: string;
|
|
27
|
+
}>;
|
|
28
|
+
state: 'pending' | 'delivered' | 'failed';
|
|
29
|
+
};
|
|
30
|
+
/** The catalog: the audit's events in dotted form, plus `ping` for the test button. */
|
|
31
|
+
export declare const WEBHOOK_EVENTS: Readonly<Record<string, string>>;
|
|
32
|
+
export declare const WEBHOOK_EVENT_NAMES: readonly string[];
|
|
33
|
+
export declare const eventNameFor: (audit: AuditEvent) => string | null;
|
|
34
|
+
export declare function validEvents(events: unknown): events is string[];
|
|
35
|
+
export declare const MAX_ATTEMPTS = 10;
|
|
36
|
+
export declare const DISABLE_AFTER = 20;
|
|
37
|
+
/** Seconds until the next attempt after attempt n (1-based): 30 s, 2 min, 10 min, then 20 min (Polar's cap). */
|
|
38
|
+
export declare const backoffSeconds: (attempt: number) => number;
|
|
39
|
+
export declare const newSecret: () => string;
|
|
40
|
+
export declare const newId: (prefix: string) => string;
|
|
41
|
+
/** Standard Webhooks: HMAC-SHA256 over `id.timestamp.body` with the secret's base64 decoded, sent as `v1,<base64>`. */
|
|
42
|
+
export declare function sign(secret: string, id: string, timestamp: number, body: string): string;
|
|
43
|
+
/** What a receiver does: recompute and compare (constant time). */
|
|
44
|
+
export declare function verify(secret: string, headers: {
|
|
45
|
+
id: string;
|
|
46
|
+
timestamp: string;
|
|
47
|
+
signature: string;
|
|
48
|
+
}, body: string, toleranceSeconds?: number): boolean;
|
|
49
|
+
/** Whether an address is this machine's, a private network's, link-local or carrier-grade NAT: never a webhook's target. */
|
|
50
|
+
export declare function privateAddress(ip: string): boolean;
|
|
51
|
+
/** Why an endpoint may not be delivered to (its name resolves to an address inside a network), or null. A platform may
|
|
52
|
+
* deliver inside its own network only where its operator allows it (VOLTER_WEBHOOKS_ALLOW_PRIVATE=1, or a local run). */
|
|
53
|
+
export declare function refusedEndpoint(url: string, allowPrivate: boolean): Promise<string | null>;
|
|
54
|
+
/** One attempt at one delivery; the caller keeps the record. Redirects are not followed (a public name answering with an
|
|
55
|
+
* inside address would carry the event there). */
|
|
56
|
+
export declare function attempt(endpoint: WebhookEndpoint, d: Delivery, fetchImpl?: typeof fetch, allowPrivate?: boolean): Promise<{
|
|
57
|
+
status: number | null;
|
|
58
|
+
ok: boolean;
|
|
59
|
+
response: string;
|
|
60
|
+
}>;
|