@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
package/src/doors.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The doors' table (layer 6): every door the platform answers, declared once — the OpenAPI document
|
|
2
|
+
// (`GET /-/openapi.json`) and docs/reference/platform-api.md are this table rendered, and a drift check
|
|
3
|
+
// holds platform.ts to it (Dub renders its document from its request schemas, Twenty from its metadata,
|
|
4
|
+
// PostHog from its framework; here the table is the code that both routes and documents).
|
|
5
|
+
export type Door = {
|
|
6
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
7
|
+
path: string;
|
|
8
|
+
/** who opens it */
|
|
9
|
+
who: 'anyone' | 'person' | 'admin' | 'operator';
|
|
10
|
+
/** the object:action a token needs, or none */
|
|
11
|
+
scope: string | null;
|
|
12
|
+
summary: string;
|
|
13
|
+
body?: string;
|
|
14
|
+
answers: string;
|
|
15
|
+
};
|
|
16
|
+
export const DOORS: readonly Door[] = [
|
|
17
|
+
{ method: 'GET', path: '/-/health', who: 'anyone', scope: null, summary: 'Whether the platform is up, and which hosts it knows.', answers: '{ ok, hosts[] }' },
|
|
18
|
+
{ 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[] }' },
|
|
19
|
+
{ 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? }' },
|
|
20
|
+
{ 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' },
|
|
21
|
+
{ 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' },
|
|
22
|
+
{ 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 }' },
|
|
23
|
+
{ method: 'POST', path: '/-/status/notice', who: 'operator', scope: null, summary: 'Set or clear the notice the status page shows.', body: '{ text | null }', answers: '{ notice }' },
|
|
24
|
+
{ method: 'GET', path: '/-/openapi.json', who: 'anyone', scope: null, summary: 'This table as an OpenAPI 3.1 document.', answers: 'OpenAPI' },
|
|
25
|
+
{ 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[] }' },
|
|
26
|
+
{ method: 'GET', path: '/-/worlds', who: 'person', scope: 'worlds:read', summary: 'The worlds your orgs hold, with their addresses and twins.', answers: '{ worlds[] }' },
|
|
27
|
+
{ 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 }' },
|
|
28
|
+
{ 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 } } }' },
|
|
29
|
+
{ method: 'DELETE', path: '/-/worlds/{org}/{world}', who: 'admin', scope: 'worlds:write', summary: 'Delete a world through its host.', answers: '{ deleted }' },
|
|
30
|
+
{ 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 }' },
|
|
31
|
+
{ 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? }] }' },
|
|
32
|
+
{ 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' },
|
|
33
|
+
{ 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 }' },
|
|
34
|
+
{ 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 }' },
|
|
35
|
+
{ 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 }' },
|
|
36
|
+
{ 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' },
|
|
37
|
+
{ 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' },
|
|
38
|
+
{ 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 }' },
|
|
39
|
+
{ 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 }' },
|
|
40
|
+
{ 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 }' },
|
|
41
|
+
{ 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[] }' },
|
|
42
|
+
{ 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 }' },
|
|
43
|
+
{ method: 'GET', path: '/-/orgs/{org}', who: 'person', scope: 'orgs:read', summary: 'The org and its worlds.', answers: '{ id, slug, name, role, worlds[] }' },
|
|
44
|
+
{ 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 }' },
|
|
45
|
+
{ 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 }' },
|
|
46
|
+
{ 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 }' },
|
|
47
|
+
{ 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 }' },
|
|
48
|
+
{ 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? }' },
|
|
49
|
+
{ 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 }' },
|
|
50
|
+
{ method: 'GET', path: '/-/orgs/{org}/members', who: 'person', scope: 'members:read', summary: 'The members and their roles, and the pending invitations.', answers: '{ members[], pending[] }' },
|
|
51
|
+
{ 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 }' },
|
|
52
|
+
{ 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 }' },
|
|
53
|
+
{ 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 }' },
|
|
54
|
+
{ 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 }' },
|
|
55
|
+
{ method: 'DELETE', path: '/-/orgs/{org}/invitations/{id}', who: 'admin', scope: 'members:write', summary: 'Revoke a pending invitation.', answers: '{ revoked }' },
|
|
56
|
+
{ method: 'GET', path: '/-/orgs/{org}/tokens', who: 'admin', scope: 'tokens:read', summary: 'The org\'s tokens (for CI), without their secrets.', answers: '{ tokens[] }' },
|
|
57
|
+
{ 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 }' },
|
|
58
|
+
{ method: 'DELETE', path: '/-/orgs/{org}/tokens/{id}', who: 'admin', scope: 'tokens:write', summary: 'Revoke an org token.', answers: '{ revoked }' },
|
|
59
|
+
{ method: 'GET', path: '/-/orgs/{org}/security', who: 'person', scope: 'orgs:read', summary: 'The org\'s security switches.', answers: '{ security }' },
|
|
60
|
+
{ 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 }' },
|
|
61
|
+
{ method: 'GET', path: '/-/orgs/{org}/support-access', who: 'person', scope: 'orgs:read', summary: 'Whether, and until when, support may open the org.', answers: '{ supportAccess }' },
|
|
62
|
+
{ method: 'DELETE', path: '/-/orgs/{org}/support-access', who: 'admin', scope: 'orgs:write', summary: 'Revoke support access.', answers: '{ revoked }' },
|
|
63
|
+
{ 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 }' },
|
|
64
|
+
{ 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 }' },
|
|
65
|
+
{ 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[] }' },
|
|
66
|
+
{ 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 }' },
|
|
67
|
+
{ 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, … }' },
|
|
68
|
+
{ method: 'DELETE', path: '/-/orgs/{org}/webhooks/{id}', who: 'admin', scope: 'orgs:write', summary: 'Remove an endpoint.', answers: '{ deleted }' },
|
|
69
|
+
{ 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 }' },
|
|
70
|
+
{ 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[] }' },
|
|
71
|
+
{ method: 'GET', path: '/-/orgs/{org}/audit', who: 'admin', scope: 'activity:read', summary: 'The org\'s activity: every admin act, newest first.', answers: '{ entries[] }' },
|
|
72
|
+
{ 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' },
|
|
73
|
+
{ 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' },
|
|
74
|
+
{ method: 'GET', path: '/-/tokens', who: 'person', scope: 'tokens:read', summary: 'Your personal access tokens, without their secrets.', answers: '{ tokens[] }' },
|
|
75
|
+
{ 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 }' },
|
|
76
|
+
{ method: 'DELETE', path: '/-/tokens/{id}', who: 'person', scope: 'tokens:write', summary: 'Revoke one of your tokens.', answers: '{ revoked }' },
|
|
77
|
+
{ 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 }' },
|
|
78
|
+
{ 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 }' },
|
|
79
|
+
{ 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 }' },
|
|
80
|
+
{ 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 }' },
|
|
81
|
+
{ method: 'GET', path: '/-/backups', who: 'operator', scope: null, summary: 'The archives in the bucket.', answers: '{ keys[] }' },
|
|
82
|
+
{ method: 'POST', path: '/-/restore', who: 'operator', scope: null, summary: 'Restore the platform\'s state from an archive.', body: '{ key }', answers: '{ files }' },
|
|
83
|
+
{ method: 'GET', path: '/-/hosts', who: 'operator', scope: null, summary: 'The hosts enrolled.', answers: '{ hosts[] }' },
|
|
84
|
+
{ 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 }' },
|
|
85
|
+
{ method: 'DELETE', path: '/-/hosts/{id}', who: 'operator', scope: null, summary: 'Stop provisioning onto a host; its worlds stay where they are.', answers: '{ removed }' },
|
|
86
|
+
{ 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 }' },
|
|
87
|
+
{ 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? }] }' },
|
|
88
|
+
{ 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 }' },
|
|
89
|
+
{ 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 }' },
|
|
90
|
+
{ method: 'PATCH', path: '/-/operator/support/{id}', who: 'operator', scope: null, summary: 'Close or reopen a support request.', body: '{ status: open | closed }', answers: '{ request }' },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/** The table as OpenAPI 3.1. */
|
|
94
|
+
export function openapi(origin: string): Record<string, unknown> {
|
|
95
|
+
const paths: Record<string, Record<string, unknown>> = {};
|
|
96
|
+
for (const d of DOORS) {
|
|
97
|
+
const params = [...d.path.matchAll(/\{(\w+)\}/g)].map((m) => ({ name: m[1], in: 'path', required: true, schema: { type: 'string' } }));
|
|
98
|
+
const op: Record<string, unknown> = {
|
|
99
|
+
summary: d.summary,
|
|
100
|
+
description: `Who: ${d.who}.${d.scope ? ` A token needs \`${d.scope}\`.` : ''} Answers ${d.answers}.`,
|
|
101
|
+
security: d.who === 'anyone' ? [] : d.who === 'operator' ? [{ operatorToken: [] }] : [{ session: [] }, { token: d.scope ? [d.scope] : [] }],
|
|
102
|
+
...(params.length ? { parameters: params } : {}),
|
|
103
|
+
...(d.body ? { requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', description: d.body } } } } } : {}),
|
|
104
|
+
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' } },
|
|
105
|
+
};
|
|
106
|
+
(paths[d.path] ??= {})[d.method.toLowerCase()] = op;
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
openapi: '3.1.0',
|
|
110
|
+
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.' },
|
|
111
|
+
servers: [{ url: origin }],
|
|
112
|
+
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' } } },
|
|
113
|
+
paths,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The table as the docs page. */
|
|
118
|
+
export function platformApiMarkdown(): string {
|
|
119
|
+
const groups: Array<[string, (d: Door) => boolean]> = [
|
|
120
|
+
['The platform', (d) => /^\/-\/(health|status|openapi|platform$|vendors$)/.test(d.path) || d.path.startsWith('/.well-known/')],
|
|
121
|
+
['Signing in', (d) => d.path === '/-/sign-in' || d.path === '/-/sign-out'],
|
|
122
|
+
['Signing the volter command in', (d) => d.path.startsWith('/-/cli/')],
|
|
123
|
+
['Worlds', (d) => d.path.startsWith('/-/worlds')],
|
|
124
|
+
['Orgs, members, billing, security, activity', (d) => d.path.startsWith('/-/orgs')],
|
|
125
|
+
['Your tokens and support', (d) => d.path.startsWith('/-/tokens') || d.path === '/-/support'],
|
|
126
|
+
['The operator\'s', (d) => d.who === 'operator' && !/^\/-\/status/.test(d.path)],
|
|
127
|
+
];
|
|
128
|
+
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).', ''];
|
|
129
|
+
// every door is on the page: one no section takes is an error here, never a row quietly left out
|
|
130
|
+
const left = DOORS.filter((d) => !groups.some(([, pick]) => pick(d)));
|
|
131
|
+
if (left.length) throw new Error(`doors in no section of the platform API page: ${left.map((d) => `${d.method} ${d.path}`).join(', ')}`);
|
|
132
|
+
for (const [title, pick] of groups) {
|
|
133
|
+
out.push(`## ${title}`, '', '| endpoint | who | scope | what it does | body → answer |', '|---|---|---|---|---|');
|
|
134
|
+
for (const d of DOORS.filter(pick)) out.push(`| \`${d.method} ${d.path}\` | ${d.who} | ${d.scope ? `\`${d.scope}\`` : '—'} | ${d.summary} | ${d.body ? `\`${d.body}\` → ` : ''}\`${d.answers}\` |`);
|
|
135
|
+
out.push('');
|
|
136
|
+
}
|
|
137
|
+
return `${out.join('\n')}\n`;
|
|
138
|
+
}
|
package/src/identity.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// SIGNING IN IS THE ACCESS PROVIDER'S (docs/contributing/architecture.md, "The hosted product"; docs/reference/
|
|
2
|
+
// platform-api.md). A person signs in at the provider's issuer (the authorization code with PKCE) and the platform keeps
|
|
3
|
+
// its own session for them, keyed by the issuer's `subject`, and nothing of the credential. A platform has one provider:
|
|
4
|
+
//
|
|
5
|
+
// - `volter`: Volter Identity. `VOLTER_ISSUER` (https://id.volter.ai by default), `VOLTER_CLIENT_ID` and
|
|
6
|
+
// `VOLTER_CLIENT_SECRET`, the client the operator registered for this platform. Its directory (directory.ts
|
|
7
|
+
// volterDirectory) is the service's, reached through its product door with this client's `client_credentials`
|
|
8
|
+
// token for `<issuer>/organizations` (volter-ai/identity ADR-0002). In a World the Volter identity twin is the
|
|
9
|
+
// issuer and the drive registers the client.
|
|
10
|
+
// - `oidc`: any OpenID Connect issuer (Google, Microsoft Entra, Okta, Keycloak): `OIDC_ISSUER`, `OIDC_CLIENT_ID`,
|
|
11
|
+
// `OIDC_CLIENT_SECRET` and optionally `OIDC_NAME` (what the sign-in button says) and `OIDC_TRUST_EMAIL_DOMAINS` (the
|
|
12
|
+
// domains the issuer's organization owns and verified: an address in one counts as verified where the issuer sends
|
|
13
|
+
// no `email_verified`, as Entra does, so invitations can match it; any other address never does). It only signs people in; the
|
|
14
|
+
// platform keeps the directory itself (directory.ts localDirectory).
|
|
15
|
+
// - `github`: GitHub, whose OAuth apps are OAuth 2.0 without OpenID Connect: `GITHUB_CLIENT_ID` and
|
|
16
|
+
// `GITHUB_CLIENT_SECRET`, and for GitHub Enterprise Server `GITHUB_URL` (its web address) and `GITHUB_API_URL`
|
|
17
|
+
// (`<GITHUB_URL>/api/v3`). The person is `GET /user` and their primary verified address `GET /user/emails`, read
|
|
18
|
+
// once with the token the code bought; the subject is `github:<numeric id>`, which a rename does not change.
|
|
19
|
+
// Sign-in only; the platform keeps the directory, as for `oidc`.
|
|
20
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
21
|
+
import { eq, lte } from 'drizzle-orm';
|
|
22
|
+
import { openDatabase, type PlatformDb } from './db/open.ts';
|
|
23
|
+
import { sessions as sessionRows } from './db/schema.ts';
|
|
24
|
+
import { resolveIdentity, verifyIdentityToken, type OpenIdMetadata } from '@volter/identity';
|
|
25
|
+
import type { SignedInPerson } from './directory.ts';
|
|
26
|
+
|
|
27
|
+
/** The Volter provider's settings (each from its `VOLTER_*` name when absent). */
|
|
28
|
+
export type IdentityOptions = { issuer?: string; clientId?: string; clientSecret?: string };
|
|
29
|
+
/** The access provider a platform signs people in with. */
|
|
30
|
+
export type ProviderOptions = ({ kind: 'volter' } & IdentityOptions) | { kind: 'oidc'; issuer?: string; clientId?: string; clientSecret?: string; name?: string; /** the domains the issuer's organization owns and verified: an address in one counts as verified though the issuer sends no `email_verified` (Entra) */ trustEmailDomains?: string[] } | { kind: 'github'; clientId?: string; clientSecret?: string; /** GitHub's web address (GitHub Enterprise Server's); https://github.com by default */ web?: string; /** its REST API; https://api.github.com by default, `<web>/api/v3` for an Enterprise Server */ api?: string };
|
|
31
|
+
|
|
32
|
+
/** The platform's client at its provider: who it is there, and (Volter) a call through the product door. */
|
|
33
|
+
export type Identity = {
|
|
34
|
+
kind: 'volter' | 'oidc' | 'github';
|
|
35
|
+
/** What a person is told they continue with: "Volter", "Google", the operator's name for their issuer. */
|
|
36
|
+
name: string;
|
|
37
|
+
issuer: string;
|
|
38
|
+
clientId: string;
|
|
39
|
+
clientSecret: string;
|
|
40
|
+
/** The person's own account page at the provider, when it has one. */
|
|
41
|
+
account?: string;
|
|
42
|
+
/** The issuer's discovery document (cached by `@volter/identity`; a failure is asked again). */
|
|
43
|
+
metadata: () => Promise<OpenIdMetadata>;
|
|
44
|
+
/** A product-door call with the platform's organizations token (Volter only); 404 answers null, any other refusal throws its words. */
|
|
45
|
+
call: <T>(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: unknown) => Promise<T | null>;
|
|
46
|
+
/** GitHub's web and API addresses, for the `github` provider. */
|
|
47
|
+
github?: { web: string; api: string };
|
|
48
|
+
/** The domains the operator says the issuer's organization owns (OIDC_TRUST_EMAIL_DOMAINS): an address in one, with no `email_verified`, counts as verified. */
|
|
49
|
+
trustEmailDomains?: string[];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The provider's name from its issuer, for the issuers people know by name. */
|
|
53
|
+
const nameOf = (issuer: string): string => {
|
|
54
|
+
const host = (() => { try { return new URL(issuer).hostname; } catch { return issuer; } })();
|
|
55
|
+
if (host === 'accounts.google.com') return 'Google';
|
|
56
|
+
if (host === 'login.microsoftonline.com') return 'Microsoft';
|
|
57
|
+
if (/\.okta\.com$/.test(host)) return 'Okta';
|
|
58
|
+
return host;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function providerClient(opts: ProviderOptions): Identity {
|
|
62
|
+
if (opts.kind === 'github') return githubClient(opts);
|
|
63
|
+
const volter = opts.kind === 'volter';
|
|
64
|
+
const env = (name: string): string | undefined => process.env[`${volter ? 'VOLTER' : 'OIDC'}_${name}`];
|
|
65
|
+
const issuer = (opts.issuer || env('ISSUER') || (volter ? 'https://id.volter.ai' : '')).replace(/\/+$/, '');
|
|
66
|
+
const clientId = opts.clientId ?? env('CLIENT_ID');
|
|
67
|
+
const clientSecret = opts.clientSecret ?? env('CLIENT_SECRET');
|
|
68
|
+
if (!issuer) throw new Error('no OpenID Connect issuer: set OIDC_ISSUER (https://accounts.google.com, your Entra or Okta issuer…)');
|
|
69
|
+
if (!clientId || !clientSecret) throw new Error(volter ? 'no Volter identity client: set VOLTER_CLIENT_ID and VOLTER_CLIENT_SECRET, the client registered for this platform at the identity service (VOLTER_ISSUER)' : `no client at ${issuer}: set OIDC_CLIENT_ID and OIDC_CLIENT_SECRET, the client registered for this platform there`);
|
|
70
|
+
const metadata = async (): Promise<OpenIdMetadata> => {
|
|
71
|
+
const known = await resolveIdentity(issuer);
|
|
72
|
+
const found = known.available ? known : await resolveIdentity(issuer, { fresh: true });
|
|
73
|
+
if (!found.available) throw new Error(found.reason);
|
|
74
|
+
return found.metadata;
|
|
75
|
+
};
|
|
76
|
+
// the organizations token (RFC 6749 §4.4), kept until a minute before it expires
|
|
77
|
+
let held: { token: string; until: number } | null = null;
|
|
78
|
+
const organizationsToken = async (): Promise<string> => {
|
|
79
|
+
if (held && Date.now() < held.until) return held.token;
|
|
80
|
+
const { token_endpoint } = await metadata();
|
|
81
|
+
const res = await fetch(token_endpoint, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
84
|
+
body: new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, scope: 'organizations', resource: `${issuer}/organizations` }).toString(),
|
|
85
|
+
});
|
|
86
|
+
const body = (await res.json().catch(() => ({}))) as { access_token?: string; expires_in?: number; error?: string; error_description?: string };
|
|
87
|
+
if (!res.ok || !body.access_token) throw new Error(`the identity service refused the platform's organizations token: ${body.error_description ?? body.error ?? res.status}`);
|
|
88
|
+
held = { token: body.access_token, until: Date.now() + Math.max(0, (body.expires_in ?? 3600) - 60) * 1000 };
|
|
89
|
+
return held.token;
|
|
90
|
+
};
|
|
91
|
+
const call = async <T>(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', path: string, body?: unknown): Promise<T | null> => {
|
|
92
|
+
if (!volter) throw new Error('an OpenID Connect provider has no product door: the platform keeps the directory');
|
|
93
|
+
const res = await fetch(`${issuer}${path}`, { method, headers: { authorization: `Bearer ${await organizationsToken()}`, accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
|
94
|
+
if (res.status === 404) return null;
|
|
95
|
+
const answer = (await res.json().catch(() => ({}))) as T & { error?: string };
|
|
96
|
+
if (!res.ok) throw new Error(answer.error ?? `the identity service answered ${res.status}`);
|
|
97
|
+
return answer;
|
|
98
|
+
};
|
|
99
|
+
const name = volter ? 'Volter' : (opts.name || env('NAME') || nameOf(issuer));
|
|
100
|
+
// only the domains the operator names, never every address: a tenant's admins can set any address on a user (nOAuth)
|
|
101
|
+
const trustEmailDomains = volter ? [] : ((opts.kind === 'oidc' ? opts.trustEmailDomains : undefined) ?? (env('TRUST_EMAIL_DOMAINS') ?? '').split(',')).map((d) => d.trim().toLowerCase().replace(/^@/, '')).filter((d) => /^[a-z0-9.-]+\.[a-z]{2,}$/.test(d));
|
|
102
|
+
return { kind: opts.kind, name, issuer, clientId, clientSecret, ...(volter ? { account: `${issuer}/` } : {}), ...(trustEmailDomains.length ? { trustEmailDomains } : {}), metadata, call };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** GitHub as the provider: OAuth 2.0 at its web address, the person read from its REST API. */
|
|
106
|
+
function githubClient(opts: Extract<ProviderOptions, { kind: 'github' }>): Identity {
|
|
107
|
+
// https, or http only on this machine: the client secret, the code and the person's token cross it
|
|
108
|
+
const url = (value: string, name: string): string => { let u: URL; try { u = new URL(value); } catch { throw new Error(`${name} is not an address: ${JSON.stringify(value)}`); } const local = u.hostname === 'localhost' || u.hostname.endsWith('.localhost') || u.hostname === '127.0.0.1' || u.hostname === '[::1]'; if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) throw new Error(`${name} must be https (or http on this machine): ${JSON.stringify(value)}`); return value.replace(/\/+$/, ''); };
|
|
109
|
+
const web = url(opts.web || process.env.GITHUB_URL || 'https://github.com', 'GITHUB_URL');
|
|
110
|
+
const api = url(opts.api || process.env.GITHUB_API_URL || (web === 'https://github.com' ? 'https://api.github.com' : `${web}/api/v3`), 'GITHUB_API_URL');
|
|
111
|
+
const clientId = opts.clientId ?? process.env.GITHUB_CLIENT_ID;
|
|
112
|
+
const clientSecret = opts.clientSecret ?? process.env.GITHUB_CLIENT_SECRET;
|
|
113
|
+
if (!clientId || !clientSecret) throw new Error('no GitHub OAuth app: set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET, the OAuth app registered for this platform (its callback URL <platform>/-/sign-in/callback)');
|
|
114
|
+
const none = (): never => { throw new Error('GitHub is not an OpenID Connect issuer'); };
|
|
115
|
+
return { kind: 'github', name: 'GitHub', issuer: web, clientId, clientSecret, account: `${web}/settings/profile`, metadata: async () => none(), call: async () => none(), github: { web, api } };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The Volter provider (the settings, else the `VOLTER_*` names). */
|
|
119
|
+
export const identityClient = (opts: IdentityOptions = {}): Identity => providerClient({ kind: 'volter', ...opts });
|
|
120
|
+
|
|
121
|
+
// ── the platform's own session: a random id in an HttpOnly cookie, the record beside the tokens ──
|
|
122
|
+
export const SESSION_COOKIE = 'volter_console_session';
|
|
123
|
+
const STATE_COOKIE = 'volter_console_sign_in';
|
|
124
|
+
const SESSION_SECONDS = 8 * 60 * 60;
|
|
125
|
+
const STATE_SECONDS = 10 * 60;
|
|
126
|
+
export const CALLBACK_PATH = '/-/sign-in/callback';
|
|
127
|
+
|
|
128
|
+
export type SessionRecord = { subject: string; email?: string; name?: string; expiresAt: string };
|
|
129
|
+
|
|
130
|
+
/** The platform's sessions, by the SHA-256 of the cookie's id, in its database (in memory when it keeps no state). */
|
|
131
|
+
export class Sessions {
|
|
132
|
+
private readonly db: PlatformDb;
|
|
133
|
+
constructor(stateDir?: string) { this.db = openDatabase(stateDir); }
|
|
134
|
+
private key(sessionId: string): string { return createHash('sha256').update(sessionId).digest('hex'); }
|
|
135
|
+
start(person: { subject: string; email?: string; name?: string }): string {
|
|
136
|
+
const sessionId = randomBytes(32).toString('base64url');
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
// the expired go as new ones start: the table holds live sessions, not a history
|
|
139
|
+
this.db.delete(sessionRows).where(lte(sessionRows.expiresAt, new Date(now).toISOString())).run();
|
|
140
|
+
this.db.insert(sessionRows).values({ idHash: this.key(sessionId), subject: person.subject, email: person.email ?? null, name: person.name ?? null, expiresAt: new Date(now + SESSION_SECONDS * 1000).toISOString() }).run();
|
|
141
|
+
return sessionId;
|
|
142
|
+
}
|
|
143
|
+
read(sessionId: string): SessionRecord | null {
|
|
144
|
+
const r = this.db.select().from(sessionRows).where(eq(sessionRows.idHash, this.key(sessionId))).get() as (typeof sessionRows.$inferSelect) | undefined;
|
|
145
|
+
if (!r) return null;
|
|
146
|
+
if (Date.parse(r.expiresAt) <= Date.now()) { this.end(sessionId); return null; }
|
|
147
|
+
return { subject: r.subject, ...(r.email ? { email: r.email } : {}), ...(r.name ? { name: r.name } : {}), expiresAt: r.expiresAt };
|
|
148
|
+
}
|
|
149
|
+
end(sessionId: string): void { this.db.delete(sessionRows).where(eq(sessionRows.idHash, this.key(sessionId))).run(); }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const cookieOf = (request: Request, name: string): string | undefined => {
|
|
153
|
+
for (const part of (request.headers.get('cookie') ?? '').split(';')) { const [k, ...v] = part.trim().split('='); if (k === name) return v.join('='); }
|
|
154
|
+
return undefined;
|
|
155
|
+
};
|
|
156
|
+
// over https a cookie is `__Host-`: set only by this origin, never by a sibling page on the same site (a World origin
|
|
157
|
+
// under the same registrable domain cannot toss a session or a sign-in state of its own over the platform's)
|
|
158
|
+
const cookieName = (origin: string, base: string): string => (origin.startsWith('https:') ? `__Host-${base}` : base);
|
|
159
|
+
const cookie = (origin: string, name: string, value: string, _path: string, seconds: number): string => `${cookieName(origin, name)}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${seconds}${origin.startsWith('https:') ? '; Secure' : ''}`;
|
|
160
|
+
export const sessionIdOf = (request: Request, origin: string): string | undefined => cookieOf(request, cookieName(origin, SESSION_COOKIE));
|
|
161
|
+
|
|
162
|
+
export type Signed = { userId: string; sessionId: string; email?: string; name?: string };
|
|
163
|
+
|
|
164
|
+
/** Who a request is: the platform's session its cookie names, while it lasts. */
|
|
165
|
+
export function whoIs(sessions: Sessions, request: Request, origin: string): Signed | null {
|
|
166
|
+
const sessionId = sessionIdOf(request, origin);
|
|
167
|
+
if (!sessionId) return null;
|
|
168
|
+
const s = sessions.read(sessionId);
|
|
169
|
+
return s ? { userId: s.subject, sessionId, ...(s.email ? { email: s.email } : {}), ...(s.name ? { name: s.name } : {}) } : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// the sign-in state (the state and the PKCE verifier) rides a short-lived cookie signed with the client secret
|
|
173
|
+
const sign = (identity: Identity, payload: object): string => { const body = Buffer.from(JSON.stringify(payload)).toString('base64url'); return `${body}.${createHmac('sha256', identity.clientSecret).update(body).digest('base64url')}`; };
|
|
174
|
+
const unsign = <T>(identity: Identity, value: string | undefined): T | null => {
|
|
175
|
+
if (!value) return null;
|
|
176
|
+
const [body, mac, extra] = value.split('.');
|
|
177
|
+
if (!body || !mac || extra !== undefined) return null;
|
|
178
|
+
const want = Buffer.from(createHmac('sha256', identity.clientSecret).update(body).digest('base64url')); const got = Buffer.from(mac);
|
|
179
|
+
if (want.length !== got.length || !timingSafeEqual(want, got)) return null;
|
|
180
|
+
try { const p = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as T & { exp?: number }; return typeof p.exp === 'number' && p.exp > Date.now() / 1000 ? p : null; } catch { return null; }
|
|
181
|
+
};
|
|
182
|
+
const text = (body: string, status: number): Response => new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
|
|
183
|
+
|
|
184
|
+
/** Where a sign-in may return a person: a path of this platform (its pages, or `/-/…`), never another origin or a scheme. */
|
|
185
|
+
export const signInReturn = (next: string | null | undefined): string | undefined => (next && /^\/(?![/\\])[A-Za-z0-9/_?=&%.~-]*$/.test(next) ? next : undefined);
|
|
186
|
+
/** Start signing in: the authorization code with PKCE (S256) at the provider. */
|
|
187
|
+
export async function beginSignIn(identity: Identity, origin: string, next?: string): Promise<Response> {
|
|
188
|
+
let authorize: string;
|
|
189
|
+
if (identity.github) authorize = `${identity.github.web}/login/oauth/authorize`;
|
|
190
|
+
else { try { authorize = (await identity.metadata()).authorization_endpoint; } catch (e) { return text(`Signing in is unavailable: ${(e as Error).message}`, 503); } }
|
|
191
|
+
const state = randomBytes(16).toString('base64url');
|
|
192
|
+
const verifier = randomBytes(48).toString('base64url');
|
|
193
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
194
|
+
const target = new URL(authorize);
|
|
195
|
+
// GitHub: the profile and the addresses, nothing of the person's repositories
|
|
196
|
+
const asked: Record<string, string> = identity.github ? { scope: 'read:user user:email', allow_signup: 'true' } : { response_type: 'code', scope: 'openid profile email' };
|
|
197
|
+
target.search = new URLSearchParams({ client_id: identity.clientId, redirect_uri: `${origin}${CALLBACK_PATH}`, ...asked, state, code_challenge: challenge, code_challenge_method: 'S256' }).toString();
|
|
198
|
+
const back = signInReturn(next);
|
|
199
|
+
const pending = sign(identity, { state, verifier, exp: Math.floor(Date.now() / 1000) + STATE_SECONDS, ...(back ? { next: back } : {}) });
|
|
200
|
+
return new Response(null, { status: 302, headers: { location: target.toString(), 'cache-control': 'no-store', 'set-cookie': cookie(origin, STATE_COOKIE, pending, CALLBACK_PATH, STATE_SECONDS) } });
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The issuer's answer: the code traded with the client secret, the id token verified, the session started. */
|
|
204
|
+
export async function finishSignIn(identity: Identity, sessions: Sessions, origin: string, request: Request, landing: string, seen?: (person: SignedInPerson) => Promise<void>): Promise<Response> {
|
|
205
|
+
const url = new URL(request.url);
|
|
206
|
+
const expected = unsign<{ state: string; verifier: string; next?: string }>(identity, cookieOf(request, cookieName(origin, STATE_COOKIE)));
|
|
207
|
+
const supplied = url.searchParams.get('state') ?? '';
|
|
208
|
+
if (!expected || !supplied || supplied.length !== expected.state.length || !timingSafeEqual(Buffer.from(supplied), Buffer.from(expected.state))) return text('Sign-in refused: the sign-in expired or did not start here. Sign in again.', 401);
|
|
209
|
+
if (url.searchParams.get('error')) return text(`Sign-in did not complete: ${url.searchParams.get('error_description') ?? url.searchParams.get('error')}`, 401);
|
|
210
|
+
const code = url.searchParams.get('code');
|
|
211
|
+
if (!code) return text('Sign-in did not complete.', 401);
|
|
212
|
+
const person = identity.github ? await githubPerson(identity, identity.github, origin, code, expected.verifier) : await oidcPerson(identity, origin, code, expected.verifier);
|
|
213
|
+
if (person instanceof Response) return person;
|
|
214
|
+
// the directory knows the person before their session opens: an invitation to their address lets them in now
|
|
215
|
+
if (seen) { try { await seen(person); } catch (e) { return text(`Signing in is unavailable: ${(e as Error).message}`, 503); } }
|
|
216
|
+
const sessionId = sessions.start({ subject: person.subject, ...(person.email ? { email: person.email } : {}), ...(person.name ? { name: person.name } : {}) });
|
|
217
|
+
const headers = new Headers({ location: signInReturn(expected.next) ?? landing, 'cache-control': 'no-store' });
|
|
218
|
+
headers.append('set-cookie', cookie(origin, SESSION_COOKIE, sessionId, '/', SESSION_SECONDS));
|
|
219
|
+
headers.append('set-cookie', cookie(origin, STATE_COOKIE, '', CALLBACK_PATH, 0));
|
|
220
|
+
return new Response(null, { status: 302, headers });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** An OpenID Connect provider's answer: the code traded with the client secret, the id token verified. */
|
|
224
|
+
async function oidcPerson(identity: Identity, origin: string, code: string, verifier: string): Promise<SignedInPerson | Response> {
|
|
225
|
+
let meta: OpenIdMetadata;
|
|
226
|
+
try { meta = await identity.metadata(); } catch (e) { return text(`Signing in is unavailable: ${(e as Error).message}`, 503); }
|
|
227
|
+
const res = await fetch(meta.token_endpoint, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
230
|
+
body: new URLSearchParams({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: `${origin}${CALLBACK_PATH}`, client_id: identity.clientId, client_secret: identity.clientSecret }).toString(),
|
|
231
|
+
});
|
|
232
|
+
const tokens = (await res.json().catch(() => ({}))) as { id_token?: string; access_token?: string };
|
|
233
|
+
if (!res.ok || !tokens.id_token) return text('Sign-in refused: the authorization code was not accepted.', 401);
|
|
234
|
+
let person;
|
|
235
|
+
try { person = await verifyIdentityToken(tokens.id_token, { issuer: identity.issuer, audience: identity.clientId }); } catch { return text('Sign-in refused: the identity token did not verify.', 401); }
|
|
236
|
+
// the id token names the person; their email and name, when it does not carry them, are the userinfo's (the scopes granted them)
|
|
237
|
+
let known: { email?: string; email_verified?: boolean; name?: string; sub?: string } = {};
|
|
238
|
+
if ((!person.email || !person.name) && tokens.access_token && meta.userinfo_endpoint) {
|
|
239
|
+
const info = await fetch(meta.userinfo_endpoint, { headers: { authorization: `Bearer ${tokens.access_token}`, accept: 'application/json' } }).catch(() => null);
|
|
240
|
+
if (info?.ok) known = (await info.json().catch(() => ({}))) as typeof known;
|
|
241
|
+
if (known.sub !== person.subject) known = {};
|
|
242
|
+
}
|
|
243
|
+
const email = person.email ?? known.email; const name = person.name ?? known.name;
|
|
244
|
+
// whether the provider vouches for the address: the one that says it (the id token for its own email, else the userinfo)
|
|
245
|
+
// an issuer that says nothing about an address vouches for it only in a domain its operator named (OIDC_TRUST_EMAIL_DOMAINS)
|
|
246
|
+
const said = person.email ? person.emailVerified : known.email_verified;
|
|
247
|
+
const domain = (email ?? '').split('@')[1]?.toLowerCase() ?? '';
|
|
248
|
+
const emailVerified = said === true || (said === undefined && domain !== '' && (identity.trustEmailDomains ?? []).includes(domain));
|
|
249
|
+
return { subject: person.subject, ...(email ? { email } : {}), emailVerified, ...(name ? { name } : {}) };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** GitHub's answer: the code traded for a token (JSON asked for; GitHub's default answer is a form), then the person
|
|
253
|
+
* and their primary verified address read with it. The token is used for these two reads and kept nowhere. */
|
|
254
|
+
async function githubPerson(identity: Identity, gh: { web: string; api: string }, origin: string, code: string, verifier: string): Promise<SignedInPerson | Response> {
|
|
255
|
+
const res = await fetch(`${gh.web}/login/oauth/access_token`, {
|
|
256
|
+
method: 'POST',
|
|
257
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
258
|
+
body: new URLSearchParams({ client_id: identity.clientId, client_secret: identity.clientSecret, code, code_verifier: verifier, redirect_uri: `${origin}${CALLBACK_PATH}` }).toString(),
|
|
259
|
+
}).catch(() => null);
|
|
260
|
+
const token = (await res?.json().catch(() => ({})) ?? {}) as { access_token?: string; error?: string; error_description?: string };
|
|
261
|
+
if (!res?.ok || !token.access_token) return text(`Sign-in refused: GitHub did not accept the code${token.error_description ? ` (${token.error_description})` : ''}.`, 401);
|
|
262
|
+
const read = async <T>(path: string): Promise<T | null> => {
|
|
263
|
+
const r = await fetch(`${gh.api}${path}`, { headers: { authorization: `Bearer ${token.access_token}`, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', 'user-agent': 'volter-world-platform' } }).catch(() => null);
|
|
264
|
+
return r?.ok ? ((await r.json().catch(() => null)) as T | null) : null;
|
|
265
|
+
};
|
|
266
|
+
const user = await read<{ id?: number; login?: string; name?: string | null }>('/user');
|
|
267
|
+
if (!user || typeof user.id !== 'number') return text('Sign-in refused: GitHub did not say who signed in.', 401);
|
|
268
|
+
// the primary address only when GitHub has verified it: an unverified one could be anyone's
|
|
269
|
+
const emails = (await read<Array<{ email: string; primary: boolean; verified: boolean }>>('/user/emails')) ?? [];
|
|
270
|
+
const primary = emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified);
|
|
271
|
+
return { subject: `github:${user.id}`, ...(primary ? { email: primary.email, emailVerified: true } : {}), ...(user.name || user.login ? { name: user.name || user.login! } : {}) };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Sign out: the platform's session ends and its cookie goes. */
|
|
275
|
+
export function endSession(sessions: Sessions, origin: string, request: Request, landing: string): Response {
|
|
276
|
+
const sessionId = sessionIdOf(request, origin);
|
|
277
|
+
if (sessionId) sessions.end(sessionId);
|
|
278
|
+
return new Response(null, { status: 302, headers: { location: landing, 'cache-control': 'no-store', 'set-cookie': cookie(origin, SESSION_COOKIE, '', '/', 0) } });
|
|
279
|
+
}
|
package/src/labs.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Labs: the early-access features an org turns on (platform.ts, `/-/orgs/<org>/labs`).
|
|
2
|
+
/** Early-access features (layer 8: Twenty's lab of public flags with a title and a description, Sentry's Early Adopter switch,
|
|
3
|
+
* Cal.com's opt-in features). A new surface lands here first; an org turns it on, or turns on everything with Early adopter. */
|
|
4
|
+
export const EARLY_FEATURES = [
|
|
5
|
+
{ key: 'webhooks', name: 'Webhooks', description: 'Events the org raises — a world provisioned, a member added, the plan changed — sent to an address of yours, signed.', since: '2026-09-08' },
|
|
6
|
+
{ key: 'usage_by_day', name: 'Usage by day', description: 'A bar per day of the period, under the org\'s meters.', since: '2026-09-08' },
|
|
7
|
+
] as const;
|
|
8
|
+
export type EarlyFeatureKey = (typeof EARLY_FEATURES)[number]['key'];
|
package/src/mail.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The platform's own mail (punchlist 3.6): what the identity service and Polar do not send (an
|
|
2
|
+
// invitation is the identity service's mail, and a receipt Polar's) — a member added, a
|
|
3
|
+
// world deleted, the quota warning, the grace notice. Resend's UNMODIFIED SDK through the world's
|
|
4
|
+
// boundary: the resend twin in rehearsal (its inbox mirror shows every mail), real Resend in
|
|
5
|
+
// production with a verified sending domain (OWNER: the domain). No RESEND_API_KEY → the mail is
|
|
6
|
+
// written to the log, never invented.
|
|
7
|
+
import { Resend } from 'resend';
|
|
8
|
+
|
|
9
|
+
export type Mail = { to: string[]; subject: string; text: string };
|
|
10
|
+
export type Mailer = { send: (mail: Mail) => Promise<{ id: string | null }>; from: string };
|
|
11
|
+
|
|
12
|
+
export function mailer(opts: { apiKey?: string; from?: string; log?: (line: string) => void } = {}): Mailer {
|
|
13
|
+
const from = opts.from ?? 'Volter World <noreply@volter.world>'; // --mail-from names another sender
|
|
14
|
+
const key = opts.apiKey ?? process.env.RESEND_API_KEY;
|
|
15
|
+
const log = opts.log ?? ((line: string) => process.stdout.write(`${line}\n`));
|
|
16
|
+
if (!key) return { from, send: async (mail) => { log(`mail (no RESEND_API_KEY, not sent) to ${mail.to.join(', ')} ${mail.subject}`); return { id: null }; } };
|
|
17
|
+
const resend = new Resend(key);
|
|
18
|
+
return {
|
|
19
|
+
from,
|
|
20
|
+
send: async (mail) => {
|
|
21
|
+
const { data, error } = await resend.emails.send({ from, to: mail.to, subject: mail.subject, text: mail.text });
|
|
22
|
+
if (error) { log(`mail failed to ${mail.to.join(', ')} ${mail.subject}: ${error.message}`); return { id: null }; }
|
|
23
|
+
log(`mail sent to ${mail.to.join(', ')} ${mail.subject} ${data?.id ?? ''}`);
|
|
24
|
+
return { id: data?.id ?? null };
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
package/src/pages.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// THE PLATFORM'S PAGES as the platform serves them (docs/contributing/architecture.md, "The hosted product"): one React
|
|
2
|
+
// client (client/platform.tsx) behind one shell at the root of the platform's origin — `/`, `/<org>`, `/<org>/members`,
|
|
3
|
+
// `/account`… — its bundle and styles under `/-/`, and a card page for what is served without the client (the command's
|
|
4
|
+
// approval, a refusal). The styles are the kit's (@volter/world-console, the dashboard's package) then the platform's
|
|
5
|
+
// own, so the platform and a World's pages read as one product. No third-party script is ever served here: the site's
|
|
6
|
+
// tags stay on the site.
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { bundleClient } from '@volter/world-core';
|
|
9
|
+
import { brandAsset, kitCss, WORLD_LOGO } from '@volter/world-console';
|
|
10
|
+
import { RESERVED_ORG_NAMES } from '../client/reserved.ts';
|
|
11
|
+
|
|
12
|
+
export { RESERVED_ORG_NAMES };
|
|
13
|
+
|
|
14
|
+
/** A file URL's path as the filesystem names it: decoded, and without the leading slash before a Windows drive (`/C:/…`). */
|
|
15
|
+
const pathOf = (url: URL): string => decodeURIComponent(url.pathname).replace(/^\/([A-Za-z]:)/, '$1');
|
|
16
|
+
const CLIENT_ENTRY = () => pathOf(new URL('../client/platform.tsx', import.meta.url)); // lazy: never a top-level import.meta.url URL
|
|
17
|
+
const CLIENT_CSS = () => pathOf(new URL('../client/platform.css', import.meta.url));
|
|
18
|
+
const attr = (v: string): string => v.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
19
|
+
const HEAD = (title: string): string => `<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${attr(title)}</title><link rel="icon" type="image/svg+xml" href="${WORLD_LOGO}"><link rel="stylesheet" href="/-/brand/tokens.css"><link rel="stylesheet" href="/-/styles.css">`;
|
|
20
|
+
const SHELL_CSP = "default-src 'self'; img-src 'self' data: https://brand.volter.ai; style-src 'self' 'unsafe-inline'; font-src 'self'; script-src 'self'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
21
|
+
|
|
22
|
+
/** The catalog's vendors, most used first, for the page's vendor picker: `vendor:s` for a supported twin, `vendor:p` for a
|
|
23
|
+
* preview. Read once from the repo's generated index (generated/index.json) where it is beside the platform; elsewhere
|
|
24
|
+
* the page offers its short list and takes any name typed. */
|
|
25
|
+
let catalog: string | null = null;
|
|
26
|
+
const vendors = (): string => {
|
|
27
|
+
if (catalog !== null) return catalog;
|
|
28
|
+
try {
|
|
29
|
+
const index = JSON.parse(readFileSync(pathOf(new URL('../../../generated/index.json', import.meta.url)), 'utf8')) as { rows: Array<{ rank: number; vendor: string; exists: boolean; status?: string }> };
|
|
30
|
+
catalog = index.rows.filter((r) => r.exists && /^[a-z0-9-]+$/.test(r.vendor)).sort((a, b) => a.rank - b.rank).map((r) => `${r.vendor}:${r.status === 'Supported' ? 's' : 'p'}`).join(',');
|
|
31
|
+
} catch { catalog = ''; }
|
|
32
|
+
return catalog;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The operator's consented support view is opened with its token in the address (`/?support=tok_su_…`): only a
|
|
36
|
+
* support token's shape is carried into the page, and only as an attribute. */
|
|
37
|
+
const SUPPORT_TOKEN = /^tok_su_[A-Za-z0-9_-]{16,128}$/;
|
|
38
|
+
|
|
39
|
+
export type Pages = { handle: (request: Request) => Promise<Response | null> };
|
|
40
|
+
|
|
41
|
+
/** The platform's pages: `GET /-/app.js` (the client), `GET /-/styles.css` (the kit's, then the platform's),
|
|
42
|
+
* `GET /-/brand/*` (the brand's tokens and faces) and the shell for any other GET outside `/-/` and `/.well-known/`;
|
|
43
|
+
* null for anything else, which is a door's. */
|
|
44
|
+
export function createPages(): Pages {
|
|
45
|
+
return {
|
|
46
|
+
async handle(request) {
|
|
47
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
|
48
|
+
const url = new URL(request.url);
|
|
49
|
+
const path = url.pathname;
|
|
50
|
+
if (path === '/-/app.js') {
|
|
51
|
+
try { return new Response(await bundleClient(CLIENT_ENTRY()), { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-cache' } }); }
|
|
52
|
+
catch (error) { return new Response(String(error), { status: 500 }); }
|
|
53
|
+
}
|
|
54
|
+
if (path === '/-/styles.css') return new Response(`${kitCss()}\n${readFileSync(CLIENT_CSS(), 'utf8')}`, { headers: { 'content-type': 'text/css; charset=utf-8', 'cache-control': 'no-cache' } });
|
|
55
|
+
if (path.startsWith('/-/brand/')) return brandAsset(path.slice('/-/brand/'.length));
|
|
56
|
+
if (path.startsWith('/-/') || path === '/-' || path.startsWith('/.well-known/')) return null;
|
|
57
|
+
const support = url.searchParams.get('support') ?? '';
|
|
58
|
+
const token = SUPPORT_TOKEN.test(support) ? ` data-token="${attr(support)}"` : '';
|
|
59
|
+
return new Response(`<!doctype html>\n<html lang="en"><head>${HEAD('Volter World')}</head><body><div id="root"${token} data-vendors="${attr(vendors())}"></div><script type="module" src="/-/app.js"></script></body></html>`, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer', 'content-security-policy': SHELL_CSP } });
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A page that is one card, rendered on the server with the kit's styles: the command's approval, a refusal, a notice.
|
|
65
|
+
* `inner` is HTML the caller has escaped. `csp` replaces the default policy (no script at all). */
|
|
66
|
+
export function pageShell(title: string, inner: string, opts: { csp?: string; status?: number } = {}): Response {
|
|
67
|
+
const html = `<!doctype html>\n<html lang="en"><head>${HEAD(title)}</head><body class="portal"><main class="card"><p class="brand"><img src="${WORLD_LOGO}" alt="" width="22" height="22"> Volter World</p>${inner}</main></body></html>`;
|
|
68
|
+
return new Response(html, { status: opts.status ?? 200, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': opts.csp ?? "default-src 'self'; img-src 'self' https://brand.volter.ai; style-src 'self' 'unsafe-inline'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'" } });
|
|
69
|
+
}
|