@volter/world-platform 2.0.3 → 2.0.4
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/client/platform.css +9 -0
- package/client/platform.tsx +98 -18
- package/dist/client/platform.bundle.js +83 -83
- package/dist/client/platform.css +9 -0
- package/dist/client/platform.tsx +98 -18
- package/dist/src/audit.d.ts +1 -1
- package/dist/src/audit.js +1 -1
- package/dist/src/db/migrations/0001_world_access.sql +8 -0
- package/dist/src/db/migrations/meta/0001_snapshot.json +930 -0
- package/dist/src/db/migrations/meta/_journal.json +7 -0
- package/dist/src/db/migrations.js +6 -0
- package/dist/src/db/pack-migrations.js +1 -1
- package/dist/src/db/schema.d.ts +104 -0
- package/dist/src/db/schema.js +9 -0
- package/dist/src/directory.js +3 -1
- package/dist/src/doors.js +6 -3
- package/dist/src/platform.js +192 -24
- package/dist/src/store.d.ts +12 -0
- package/dist/src/store.js +24 -2
- package/dist/src/webhooks.js +2 -2
- package/package.json +4 -4
- package/src/audit.ts +1 -1
- package/src/db/migrations/0001_world_access.sql +8 -0
- package/src/db/migrations/meta/0001_snapshot.json +930 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations.ts +6 -0
- package/src/db/pack-migrations.ts +1 -1
- package/src/db/schema.ts +10 -0
- package/src/directory.ts +2 -1
- package/src/doors.ts +6 -3
- package/src/platform.ts +127 -17
- package/src/store.ts +21 -2
- package/src/webhooks.ts +2 -2
package/src/db/migrations.ts
CHANGED
|
@@ -29,5 +29,11 @@ export const MIGRATIONS: ReadonlyArray<{ tag: string; statements: readonly strin
|
|
|
29
29
|
"CREATE TABLE `webhooks` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`org_id` text NOT NULL,\n\t`url` text NOT NULL,\n\t`secret` text NOT NULL,\n\t`events` text NOT NULL,\n\t`description` text NOT NULL,\n\t`created_at` text NOT NULL,\n\t`consecutive_failures` integer NOT NULL,\n\t`last_failed_at` text,\n\t`disabled_at` text\n);",
|
|
30
30
|
"CREATE INDEX `webhooks_org` ON `webhooks` (`org_id`);"
|
|
31
31
|
]
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"tag": "0001_world_access",
|
|
35
|
+
"statements": [
|
|
36
|
+
"CREATE TABLE `world_access` (\n\t`org_id` text NOT NULL,\n\t`name` text NOT NULL,\n\t`members` text NOT NULL,\n\t`set_by` text NOT NULL,\n\t`set_at` text NOT NULL,\n\tPRIMARY KEY(`org_id`, `name`)\n);"
|
|
37
|
+
]
|
|
32
38
|
}
|
|
33
39
|
];
|
|
@@ -9,7 +9,7 @@ const dir = join(import.meta.dir, 'migrations');
|
|
|
9
9
|
const target = join(import.meta.dir, 'migrations.ts');
|
|
10
10
|
export function packed(): string {
|
|
11
11
|
const files = readdirSync(dir).filter((f) => /^\d{4}_.+\.sql$/.test(f)).sort();
|
|
12
|
-
const list = files.map((f) => ({ tag: f.replace(/\.sql$/, ''), statements: readFileSync(join(dir, f), 'utf8').split('--> statement-breakpoint').map((s) => s.trim()).filter(Boolean) }));
|
|
12
|
+
const list = files.map((f) => ({ tag: f.replace(/\.sql$/, ''), statements: readFileSync(join(dir, f), 'utf8').replace(/\r\n/g, '\n').split('--> statement-breakpoint').map((s) => s.trim()).filter(Boolean) }));
|
|
13
13
|
return `// GENERATED by \`bun src/db/pack-migrations.ts --write\` from ./migrations/*.sql (drizzle-kit's): do not edit.\nexport const MIGRATIONS: ReadonlyArray<{ tag: string; statements: readonly string[] }> = ${JSON.stringify(list, null, 2)};\n`;
|
|
14
14
|
}
|
|
15
15
|
if (import.meta.main) {
|
package/src/db/schema.ts
CHANGED
|
@@ -12,6 +12,16 @@ export const orgWorlds = sqliteTable('org_worlds', {
|
|
|
12
12
|
provisionedAt: text('provisioned_at').notNull(),
|
|
13
13
|
}, (t) => [primaryKey({ columns: [t.orgId, t.name] })]);
|
|
14
14
|
|
|
15
|
+
/** A World kept to some of its org (architecture, "A World may be kept to some of its org"): a row means only the
|
|
16
|
+
* listed members (by the directory's user id) reach it, admins always; no row, the whole org. */
|
|
17
|
+
export const worldAccess = sqliteTable('world_access', {
|
|
18
|
+
orgId: text('org_id').notNull(),
|
|
19
|
+
name: text('name').notNull(),
|
|
20
|
+
members: text('members', { mode: 'json' }).$type<string[]>().notNull(),
|
|
21
|
+
setBy: text('set_by').notNull(),
|
|
22
|
+
setAt: text('set_at').notNull(),
|
|
23
|
+
}, (t) => [primaryKey({ columns: [t.orgId, t.name] })]);
|
|
24
|
+
|
|
15
25
|
/** An org's settings: its security switches, labs and support consent (absent: the defaults). */
|
|
16
26
|
export const orgSettings = sqliteTable('org_settings', {
|
|
17
27
|
orgId: text('org_id').primaryKey(),
|
package/src/directory.ts
CHANGED
|
@@ -106,7 +106,8 @@ export function volterDirectory(identity: Identity): Directory {
|
|
|
106
106
|
return invitationView(inv);
|
|
107
107
|
},
|
|
108
108
|
pendingInvitations: async (orgId) => ((await call<InvitationRow[]>('GET', `/api/organizations/${seg(orgId)}/invitations`)) ?? []).map(invitationView),
|
|
109
|
-
|
|
109
|
+
// one already used, revoked or expired is refused: a join that races a revocation lets no one in
|
|
110
|
+
async revokeInvitation(orgId, invitationId) { if ((await call('DELETE', `/api/organizations/${seg(orgId)}/invitations/${seg(invitationId)}`)) === null) throw new Error('no such pending invitation'); },
|
|
110
111
|
userIdByEmail,
|
|
111
112
|
async personById(userId) {
|
|
112
113
|
try {
|
package/src/doors.ts
CHANGED
|
@@ -31,14 +31,17 @@ export const DOORS: readonly Door[] = [
|
|
|
31
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
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
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
|
|
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 opening the world gives one; 410 once it has ended.', answers: '303 to the preview\'s page, or { url, scope, expiresIn }' },
|
|
35
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
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
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
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
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}/access', who: 'admin', scope: 'members:read', summary: 'Who reaches the world: the whole org, or only the listed members (admins always). A world kept from the caller answers 404.', answers: '{ world, restricted, members: [userId] }' },
|
|
41
|
+
{ method: 'PUT', path: '/-/worlds/{org}/{world}/access', who: 'admin', scope: 'members:write', summary: 'Keep the world to named members, or open it to the whole org again. Keeping it revokes, at once, the world keys held there by the members it leaves out (and the previews those keys made). Webhook: world.access_changed.', body: '{ restricted: boolean, members?: [userId] }', answers: '{ world, restricted, members, revokedFor }' },
|
|
40
42
|
{ 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[] }' },
|
|
43
|
+
{ 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, and the invitations to your address still pending, where the provider keeps them.', answers: '{ person, orgs[], invitations[] }' },
|
|
44
|
+
{ method: 'POST', path: '/-/invitations/{id}/join', who: 'person', scope: 'orgs:write', summary: 'Join the org a pending invitation to your address names, in its role, when your identity owns that address.', answers: '{ joined, role }' },
|
|
42
45
|
{ 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
46
|
{ method: 'GET', path: '/-/orgs/{org}', who: 'person', scope: 'orgs:read', summary: 'The org and its worlds.', answers: '{ id, slug, name, role, worlds[] }' },
|
|
44
47
|
{ 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 }' },
|
|
@@ -121,7 +124,7 @@ export function platformApiMarkdown(): string {
|
|
|
121
124
|
['Signing in', (d) => d.path === '/-/sign-in' || d.path === '/-/sign-out'],
|
|
122
125
|
['Signing the volter command in', (d) => d.path.startsWith('/-/cli/')],
|
|
123
126
|
['Worlds', (d) => d.path.startsWith('/-/worlds')],
|
|
124
|
-
['Orgs, members, billing, security, activity', (d) => d.path.startsWith('/-/orgs')],
|
|
127
|
+
['Orgs, members, billing, security, activity', (d) => d.path.startsWith('/-/orgs') || d.path.startsWith('/-/invitations/')],
|
|
125
128
|
['Your tokens and support', (d) => d.path.startsWith('/-/tokens') || d.path === '/-/support'],
|
|
126
129
|
['The operator\'s', (d) => d.who === 'operator' && !/^\/-\/status/.test(d.path)],
|
|
127
130
|
];
|
package/src/platform.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { EARLY_FEATURES } from './labs.ts';
|
|
|
17
17
|
import type { Biller, BillerFactory } from './biller.ts';
|
|
18
18
|
import { createPages, pageShell, RESERVED_ORG_NAMES } from './pages.ts';
|
|
19
19
|
import { beginSignIn, CALLBACK_PATH, endSession, finishSignIn, providerClient, Sessions, sessionIdOf, whoIs, type IdentityOptions, type ProviderOptions } from './identity.ts';
|
|
20
|
-
import { isAdmin, localDirectory, OnlyAdmin, volterDirectory, type Directory, type OrgRef } from './directory.ts';
|
|
20
|
+
import { isAdmin, localDirectory, OnlyAdmin, volterDirectory, type Directory, type OrgRef, type PendingInvitation } from './directory.ts';
|
|
21
21
|
import { eq } from 'drizzle-orm';
|
|
22
22
|
import { billerRecords } from './db/schema.ts';
|
|
23
23
|
import { assertName, Store, type OrgWorld, type Preview, type Security, type SupportRequest } from './store.ts';
|
|
@@ -188,24 +188,43 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
188
188
|
/** A person removed from an org where the platform did not see it (at the identity service directly) keeps nothing:
|
|
189
189
|
* every person a key in the org's Worlds is for (or was made by) who is no longer a member has their keys, and the
|
|
190
190
|
* branches those made, revoked there. Org tokens' keys are ended with the org token. */
|
|
191
|
+
/** A person's keys in a World and in each live preview branch of it: a preview key minted for them lives in the branch's own
|
|
192
|
+
* key store, under a parent key the preview's maker holds, so the parent's revocation does not reach it. */
|
|
193
|
+
const revokeHeldIn = async (orgId: string, world: string, person: string): Promise<boolean> => {
|
|
194
|
+
let any = false;
|
|
195
|
+
for (const served of [world, ...opts.store.previewsOf(orgId).filter((p) => p.world === world).map((p) => p.branch)]) {
|
|
196
|
+
const row = await worldRow(served); if (!row) continue;
|
|
197
|
+
const r = await fetch(`${new URL(row.base).origin}/-/${row.name}/keys?person=${encodeURIComponent(person)}`, { method: 'DELETE', headers: { 'x-volter-token': row.token } }).catch(() => null);
|
|
198
|
+
any ||= Boolean(r?.ok);
|
|
199
|
+
}
|
|
200
|
+
return any;
|
|
201
|
+
};
|
|
191
202
|
const sweepRemovedMembers = async (): Promise<number> => {
|
|
192
203
|
let revoked = 0;
|
|
193
204
|
for (const orgId of opts.store.orgIds()) {
|
|
194
|
-
const
|
|
205
|
+
const membership = new Map<string, OrgRef | null | 'unknown'>();
|
|
195
206
|
for (const w of opts.store.doc(orgId).worlds) {
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const
|
|
207
|
+
// the people holding keys in the World or in a live preview branch of it
|
|
208
|
+
const keys: Array<{ person?: string | null }> = [];
|
|
209
|
+
for (const served of [w.name, ...opts.store.previewsOf(orgId).filter((p) => p.world === w.name).map((p) => p.branch)]) {
|
|
210
|
+
const row = await worldRow(served); if (!row) continue;
|
|
211
|
+
const res = await fetch(`${new URL(row.base).origin}/-/${row.name}/keys`, { headers: { 'x-volter-token': row.token } }).catch(() => null);
|
|
212
|
+
if (res?.ok) keys.push(...((await res.json()) as { keys: Array<{ person?: string | null }> }).keys);
|
|
213
|
+
}
|
|
214
|
+
const keptTo = opts.store.accessOf(orgId, w.name);
|
|
199
215
|
for (const person of new Set(keys.map((k) => k.person).filter((p): p is string => typeof p === 'string' && !p.startsWith('org-token:')))) {
|
|
200
|
-
if (!
|
|
201
|
-
|
|
216
|
+
if (!membership.has(person)) membership.set(person, await dir.memberOf(person, orgId).catch(() => 'unknown' as const));
|
|
217
|
+
const ref = membership.get(person);
|
|
218
|
+
// gone from the org, or kept off this World (not an admin, not named on it): nothing held there stays
|
|
219
|
+
const off = ref === null || (ref !== 'unknown' && ref !== undefined && keptTo !== null && !isAdmin(ref) && !keptTo.includes(person));
|
|
220
|
+
if (off) { await revokeHeldIn(orgId, w.name, person); revoked += 1; }
|
|
202
221
|
}
|
|
203
222
|
}
|
|
204
223
|
}
|
|
205
224
|
return revoked;
|
|
206
225
|
};
|
|
207
226
|
const revokePersonKeys = async (orgId: string, subject: string): Promise<void> => {
|
|
208
|
-
for (const w of opts.store.doc(orgId).worlds)
|
|
227
|
+
for (const w of opts.store.doc(orgId).worlds) await revokeHeldIn(orgId, w.name, subject);
|
|
209
228
|
};
|
|
210
229
|
// ── billing: an attached biller answers for plans and prices; without one nothing is limited ──
|
|
211
230
|
const biller: Biller | null = opts.billing ? opts.billing({
|
|
@@ -254,6 +273,29 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
254
273
|
type Person = { id: string; email?: string; name?: string; /** an org token acting as its org (layer 6): the org, and the token's name */ orgToken?: { orgId: string; name: string }; /** a support session (layer 6): read-only, consented, until when, and why */ support?: { orgId: string; until: string; reason: string; category: string } };
|
|
255
274
|
// an org token is a member of exactly one org and of nothing else; a person's orgs are the identity service's
|
|
256
275
|
const orgsOfP = async (person: Person): Promise<OrgRef[]> => { if (person.orgToken) { const o = await dir.orgById(person.orgToken.orgId); return o ? [o] : []; } return dir.orgsOf(person.id); };
|
|
276
|
+
// an invited person who signed in here instead of through the mail: the invitations the platform sent to their address
|
|
277
|
+
// that the directory still holds (a directory that lets invitations in at sign-in keeps none to show); the ones it no
|
|
278
|
+
// longer holds are forgotten, and an unreachable directory keeps them for the next look
|
|
279
|
+
type Invited = { id: string; org: { id: string; slug: string | null; name: string }; role: string; expiresAt: string | null };
|
|
280
|
+
const invitedP = async (person: Person): Promise<Invited[]> => {
|
|
281
|
+
if (!person.email || person.orgToken || person.support || !dir.mailsInvitations) return [];
|
|
282
|
+
const email = person.email.toLowerCase(); const noted = opts.store.invitedOf(email); if (!noted.length) return [];
|
|
283
|
+
const shown: Invited[] = []; const gone = new Set<string>();
|
|
284
|
+
for (const n of noted) {
|
|
285
|
+
let pending: PendingInvitation[]; try { pending = await dir.pendingInvitations(n.orgId); } catch { continue; }
|
|
286
|
+
const inv = pending.find((i) => i.id === n.id && i.email.toLowerCase() === email);
|
|
287
|
+
const org = inv && !(inv.expiresAt && Date.parse(inv.expiresAt) <= Date.now()) ? await dir.orgById(n.orgId) : null;
|
|
288
|
+
if (!inv || !org) { gone.add(n.id); continue; }
|
|
289
|
+
shown.push({ id: inv.id, org: { id: org.id, slug: org.slug, name: org.name }, role: inv.role, expiresAt: inv.expiresAt });
|
|
290
|
+
}
|
|
291
|
+
// read again at the write: an invitation noted while the directory answered stays
|
|
292
|
+
if (gone.size) opts.store.setInvited(email, opts.store.invitedOf(email).filter((n) => !gone.has(n.id)));
|
|
293
|
+
return shown;
|
|
294
|
+
};
|
|
295
|
+
const noteInvited = (orgId: string, inv: PendingInvitation, replaces?: string): void => {
|
|
296
|
+
if (!dir.mailsInvitations) return;
|
|
297
|
+
opts.store.setInvited(inv.email, [...opts.store.invitedOf(inv.email).filter((n) => !(n.orgId === orgId && (n.id === inv.id || n.id === replaces))), { orgId, id: inv.id }]);
|
|
298
|
+
};
|
|
257
299
|
const memberOfP = async (person: Person, ref: string): Promise<OrgRef | null> => { if (person.orgToken) { const o = await dir.orgById(person.orgToken.orgId); return o && (o.id === ref || o.slug === ref) ? o : null; } return dir.memberOf(person.id, ref); };
|
|
258
300
|
/** The CLI's pending sign-ins, by device code (the /-/cli doors). */
|
|
259
301
|
type DeviceGrant = { userCode: string; name: string; expires: number; state: 'pending' | 'approved' | 'denied'; person?: Person };
|
|
@@ -273,10 +315,19 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
273
315
|
};
|
|
274
316
|
const redirect = (location: string): Response => new Response(null, { status: 302, headers: { location, 'cache-control': 'no-store' } });
|
|
275
317
|
|
|
276
|
-
/**
|
|
318
|
+
/** Whether a person reaches a World of their org (architecture, "A World may be kept to some of its org"): an org's
|
|
319
|
+
* admins always; everyone in it unless the World is kept to named members. An org token acts as the org and a
|
|
320
|
+
* support session is the org's consented read, so neither is a member the World could be kept from. */
|
|
321
|
+
const mayReach = (person: Person, org: OrgRef, name: string): boolean => {
|
|
322
|
+
if (person.orgToken || person.support || isAdmin(org)) return true;
|
|
323
|
+
const members = opts.store.accessOf(org.id, name);
|
|
324
|
+
return members === null || members.includes(person.id);
|
|
325
|
+
};
|
|
326
|
+
/** The host inventory rows for the worlds a person reaches in their orgs (their tokens come from the host). */
|
|
277
327
|
const rowsForPerson = async (person: Person): Promise<WorldRow[]> => {
|
|
278
328
|
const wanted = new Map<string, string>(); // served name → host id
|
|
279
|
-
|
|
329
|
+
const kept = new Set<string>(); // the ones kept to named members
|
|
330
|
+
for (const org of await orgsOfP(person)) for (const w of opts.store.doc(org.id).worlds) if (mayReach(person, org, w.name)) { wanted.set(w.name, w.host); if (opts.store.accessOf(org.id, w.name) !== null) kept.add(w.name); }
|
|
280
331
|
const rows: WorldRow[] = [];
|
|
281
332
|
const byHost = new Map<string, WorldRow[]>();
|
|
282
333
|
for (const host of opts.hosts) {
|
|
@@ -289,7 +340,7 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
289
340
|
const row = byHost.get(hostId)?.find((r) => r.name === name);
|
|
290
341
|
const host = hostOf(hostId);
|
|
291
342
|
// the world's address is the HOST's enrolled origin + its name: what the platform reached it at
|
|
292
|
-
if (row && host) rows.push({ ...row, base: `${host.base}/${row.name}
|
|
343
|
+
if (row && host) rows.push({ ...row, base: `${host.base}/${row.name}`, ...(kept.has(name) ? { restricted: true } : {}) });
|
|
293
344
|
}
|
|
294
345
|
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
295
346
|
};
|
|
@@ -324,8 +375,10 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
324
375
|
const scopeFor = (method: string, path: string): string | null => {
|
|
325
376
|
const write = method !== 'GET' && method !== 'HEAD';
|
|
326
377
|
if (path === '/-/tokens' || path.startsWith('/-/tokens/')) return write ? 'tokens:write' : 'tokens:read';
|
|
378
|
+
if (/^\/-\/worlds\/[^/]+\/[^/]+\/access$/.test(path)) return write ? 'members:write' : 'members:read'; // who reaches a World is the org's membership
|
|
327
379
|
if (/^\/-\/worlds\/[^/]+\/[^/]+\/(token|open|previews\/[^/]+\/open)$/.test(path)) return 'worlds:read';
|
|
328
380
|
if (path === '/-/worlds' || path.startsWith('/-/worlds/')) return write ? 'worlds:write' : 'worlds:read';
|
|
381
|
+
if (path.startsWith('/-/invitations/')) return 'orgs:write'; // joining an org is the person's own org change
|
|
329
382
|
const org = /^\/-\/orgs(?:\/[^/]+(?:\/([^/]+))?)?/.exec(path); if (!org) return null;
|
|
330
383
|
const part = org[1];
|
|
331
384
|
if (part === 'members' || part === 'invitations') return write ? 'members:write' : 'members:read';
|
|
@@ -545,7 +598,8 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
545
598
|
const org = orgRef ? await memberOfP(person, orgRef) : null;
|
|
546
599
|
if (!org) return json({ error: 'not a member' }, 403);
|
|
547
600
|
const name = `${org.slug ?? org.id}/${world}`; const held = opts.store.doc(org.id).worlds.find((w) => w.name === name);
|
|
548
|
-
|
|
601
|
+
// a World kept from this member answers as one that is not there: its name is not theirs to learn
|
|
602
|
+
if (!held || !mayReach(person, org, name)) return json({ error: `no world ${name} in ${orgRef}` }, 404);
|
|
549
603
|
const host = hostOf(held.host);
|
|
550
604
|
if (!host) return json({ error: `the host ${held.host} that serves ${name} is not enrolled` }, 502);
|
|
551
605
|
const gone = await fetch(`${host.base}/-/worlds/${name}`, { method: 'DELETE', headers: { 'x-volter-token': host.adminToken } });
|
|
@@ -590,7 +644,29 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
590
644
|
if (!person) return unauthorized(request);
|
|
591
645
|
const orgs = await orgsOfP(person);
|
|
592
646
|
const session = { person, token: presentedToken(request) ?? '' };
|
|
593
|
-
return json({ person, orgs: orgs.map((o) => ({ ...o, worlds: opts.store.doc(o.id).worlds, security: opts.store.securityOf(o.id), held: heldBy(o.id, session), flags: flagsOf(o.id) })) });
|
|
647
|
+
return json({ person, orgs: orgs.map((o) => ({ ...o, worlds: opts.store.doc(o.id).worlds.filter((w) => mayReach(person, o, w.name)), security: opts.store.securityOf(o.id), held: heldBy(o.id, session), flags: flagsOf(o.id) })), invitations: await invitedP(person) });
|
|
648
|
+
}
|
|
649
|
+
// join an org one's address is invited to, here rather than at the identity service's page: the directory must
|
|
650
|
+
// name this person as the address's owner, as the platform's own directory lets an invitation in at sign-in
|
|
651
|
+
const joining = /^\/-\/invitations\/([^/]+)\/join$/.exec(path);
|
|
652
|
+
if (joining && request.method === 'POST') {
|
|
653
|
+
const person = (await sessionOf(request))?.person;
|
|
654
|
+
if (!person) return unauthorized(request);
|
|
655
|
+
const inv = (await invitedP(person)).find((i) => i.id === decodeURIComponent(joining[1]!));
|
|
656
|
+
if (!inv) return json({ error: 'no pending invitation to your address with that id' }, 404);
|
|
657
|
+
if ((await dir.userIdByEmail(person.email!)) !== person.id) return json({ error: `your Volter identity does not own ${person.email}: open the invitation's mail and accept it there` }, 403);
|
|
658
|
+
{ const why = domainAllowed(inv.org.id, person.email!); if (why) return json({ error: why }, 403); } // the domains may have narrowed since
|
|
659
|
+
try {
|
|
660
|
+
// the invitation is used up first, so one an admin revokes meanwhile lets no one in; a member already is left as they are
|
|
661
|
+
await dir.revokeInvitation(inv.org.id, inv.id);
|
|
662
|
+
if (!(await dir.memberOf(person.id, inv.org.id))) {
|
|
663
|
+
await dir.addMember(inv.org.id, { sub: person.id });
|
|
664
|
+
if (inv.role === 'org:admin') await dir.setMemberRole(inv.org.id, person.id, 'org:admin');
|
|
665
|
+
}
|
|
666
|
+
} catch (e) { return json({ error: (e as Error).message }, 400); }
|
|
667
|
+
opts.store.setInvited(person.email!, opts.store.invitedOf(person.email!).filter((n) => n.id !== inv.id));
|
|
668
|
+
record({ event: 'MEMBER_JOIN', org: inv.org.id, actor: person, target: person.email!, data: { invitation: inv.id, role: inv.role } });
|
|
669
|
+
return json({ joined: inv.org.slug ?? inv.org.id, role: inv.role });
|
|
594
670
|
}
|
|
595
671
|
if (path === '/-/orgs' && request.method === 'POST') {
|
|
596
672
|
const person = (await sessionOf(request))?.person;
|
|
@@ -718,7 +794,7 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
718
794
|
if (part === 'invitations' && sub && request.method === 'DELETE') {
|
|
719
795
|
if (!isAdmin(org)) return json({ error: 'only an admin of the org revokes invitations' }, 403);
|
|
720
796
|
const inv = (await dir.pendingInvitations(org.id)).find((i) => i.id === sub); if (!inv) return json({ error: 'no such pending invitation' }, 404);
|
|
721
|
-
await dir.revokeInvitation(org.id, sub); record({ event: 'INVITE_REVOKE', org: org.id, actor: person, target: inv.email });
|
|
797
|
+
try { await dir.revokeInvitation(org.id, sub); } catch { return json({ error: 'no such pending invitation' }, 404); } record({ event: 'INVITE_REVOKE', org: org.id, actor: person, target: inv.email });
|
|
722
798
|
return json({ revoked: inv.email });
|
|
723
799
|
}
|
|
724
800
|
// resend a pending invitation: a fresh one to the same address and role (the directory's id may change), mailed again
|
|
@@ -727,8 +803,9 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
727
803
|
const inv = (await dir.pendingInvitations(org.id)).find((i) => i.id === sub); if (!inv) return json({ error: 'no such pending invitation' }, 404);
|
|
728
804
|
if (inv.role.endsWith('admin') && !isAdmin(org)) return json({ error: 'only an admin resends an invitation to be an admin' }, 403);
|
|
729
805
|
{ const why = domainAllowed(org.id, inv.email); if (why) return json({ error: why }, 403); } // the domains may have narrowed since
|
|
730
|
-
await dir.revokeInvitation(org.id, sub);
|
|
806
|
+
try { await dir.revokeInvitation(org.id, sub); } catch { return json({ error: 'no such pending invitation' }, 404); }
|
|
731
807
|
const again = await dir.inviteMember(org.id, inv.email, person.id, inv.role);
|
|
808
|
+
noteInvited(org.id, again, sub);
|
|
732
809
|
if (!dir.mailsInvitations) await mail([again.email], `A reminder: you are invited to ${orgLabel(org.id, org.slug)} on Volter World`, `You are invited to the org ${orgLabel(org.id, org.slug)}. Sign in at ${opts.origin}/ with ${identity.name}, as ${again.email}, before ${(again.expiresAt ?? '').slice(0, 10)} to join it.`);
|
|
733
810
|
record({ event: 'MEMBER_INVITE', org: org.id, actor: person, target: again.email, data: { pending: true, invitation: again.id, resent: true } });
|
|
734
811
|
return json({ invited: again.email, pending: true, id: again.id, role: again.role, expiresAt: again.expiresAt }, 201);
|
|
@@ -779,7 +856,7 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
779
856
|
}
|
|
780
857
|
await dir.deleteOrg(org.id); record({ event: 'ORG_DELETE', org: org.id, actor: person, target: org.slug ?? org.name }); return json({ deleted: org.id });
|
|
781
858
|
}
|
|
782
|
-
if (!part && request.method === 'GET') return json({ ...org, worlds: opts.store.doc(org.id).worlds });
|
|
859
|
+
if (!part && request.method === 'GET') return json({ ...org, worlds: opts.store.doc(org.id).worlds.filter((w) => mayReach(person, org, w.name)) }); // a kept World is listed only to its members
|
|
783
860
|
return json({ error: `no such door: ${request.method} ${path}` }, 404);
|
|
784
861
|
}
|
|
785
862
|
// ── support and access (layer 6): the Help form, the consent, the operator's consented view ──
|
|
@@ -904,6 +981,38 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
904
981
|
// branch of one of the org's Worlds, made and removed here with the World's own token, so CI's org token makes one
|
|
905
982
|
// per pull request without a key of its own (a key would be replaced by the next job's, taking its branches); a
|
|
906
983
|
// member opens one as themselves. A preview is an ordinary branch: its lifetime, keys and owner are a branch's.
|
|
984
|
+
// WHO REACHES A WORLD (architecture, "A World may be kept to some of its org"): an admin keeps a World to named members,
|
|
985
|
+
// or opens it to the whole org again; keeping it revokes, at once, the World keys held there by the members it leaves out
|
|
986
|
+
const accessDoor = /^\/-\/worlds\/([^/]+)\/([^/]+)\/access$/.exec(path);
|
|
987
|
+
if (accessDoor && (request.method === 'GET' || request.method === 'PUT')) {
|
|
988
|
+
const session = await sessionOf(request);
|
|
989
|
+
if (!session) return unauthorized(request);
|
|
990
|
+
const org = await memberOfP(session.person, accessDoor[1]!);
|
|
991
|
+
if (!org) return json({ error: `you are not a member of org ${accessDoor[1]}` }, 403);
|
|
992
|
+
const name = `${org.slug ?? org.id}/${accessDoor[2]}`;
|
|
993
|
+
if (!opts.store.doc(org.id).worlds.some((w) => w.name === name) || !mayReach(session.person, org, name)) return json({ error: `no world ${name} in your orgs` }, 404);
|
|
994
|
+
if (!isAdmin(org)) return json({ error: 'only an admin decides who reaches a World' }, 403);
|
|
995
|
+
if (request.method === 'GET') { const members = opts.store.accessOf(org.id, name); return json({ world: name, restricted: members !== null, members: members ?? [] }); }
|
|
996
|
+
if (session.person.support) return json({ error: 'a support session changes nothing' }, 403);
|
|
997
|
+
let body: { restricted?: unknown; members?: unknown }; try { body = (await request.json()) as typeof body; } catch { return json({ error: 'access: { restricted: boolean, members?: [userId] }' }, 400); }
|
|
998
|
+
if (typeof body.restricted !== 'boolean') return json({ error: 'access: { restricted: boolean, members?: [userId] }' }, 400);
|
|
999
|
+
const people = await dir.membersOf(org.id);
|
|
1000
|
+
const asked = body.restricted ? (Array.isArray(body.members) ? body.members : []) : [];
|
|
1001
|
+
const unknown = asked.filter((m) => typeof m !== 'string' || !people.some((p) => p.userId === m));
|
|
1002
|
+
if (unknown.length) return json({ error: `not members of ${org.slug ?? org.id}: ${unknown.map(String).join(', ')}` }, 400);
|
|
1003
|
+
const members = body.restricted ? (asked as string[]) : null;
|
|
1004
|
+
opts.store.setAccess(org.id, name, members, session.person.id);
|
|
1005
|
+
// everyone the World now leaves out loses the keys held for them there (and the previews those keys made)
|
|
1006
|
+
let revoked = 0;
|
|
1007
|
+
if (members !== null) {
|
|
1008
|
+
for (const p of people) {
|
|
1009
|
+
if (p.role.endsWith('admin') || members.includes(p.userId)) continue;
|
|
1010
|
+
if (await revokeHeldIn(org.id, name, p.userId)) revoked += 1;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
record({ event: 'WORLD_ACCESS_SET', org: org.id, actor: session.person, target: name, data: { restricted: members !== null, members: members?.length ?? null } });
|
|
1014
|
+
return json({ world: name, restricted: members !== null, members: members ?? [], revokedFor: revoked });
|
|
1015
|
+
}
|
|
907
1016
|
const previewDoor = /^\/-\/worlds\/([^/]+)\/([^/]+)\/previews(?:\/([^/]+)(\/open)?)?$/.exec(path);
|
|
908
1017
|
if (previewDoor) {
|
|
909
1018
|
const session = await sessionOf(request);
|
|
@@ -914,7 +1023,7 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
914
1023
|
const name = `${org.slug ?? org.id}/${previewDoor[2]}`;
|
|
915
1024
|
const label = previewDoor[3]; const opening = Boolean(previewDoor[4]);
|
|
916
1025
|
const held = opts.store.doc(org.id).worlds.find((w) => w.name === name);
|
|
917
|
-
if (!held) return json({ error: `no world ${name} in your orgs` }, 404);
|
|
1026
|
+
if (!held || !mayReach(session.person, org, name)) return json({ error: `no world ${name} in your orgs` }, 404);
|
|
918
1027
|
const host = hostOf(held.host);
|
|
919
1028
|
if (!host) return json({ error: `the host ${held.host} that serves ${name} is not enrolled` }, 502);
|
|
920
1029
|
const inventory = async (): Promise<WorldRow[]> => { const r = await fetch(`${host.base}/-/worlds`, { headers: { 'x-volter-token': host.adminToken } }).catch(() => null); return r?.ok ? ((await r.json()) as { worlds: WorldRow[] }).worlds : []; };
|
|
@@ -1113,6 +1222,7 @@ export function createPlatform(opts: PlatformOptions): { fetch: (request: Reques
|
|
|
1113
1222
|
// directory mails its own invitation, and the platform mails the one it keeps
|
|
1114
1223
|
if (body.email && !body.sub && !(await dir.userIdByEmail(body.email))) {
|
|
1115
1224
|
const inv = await dir.inviteMember(org.id, body.email, person.id, role);
|
|
1225
|
+
noteInvited(org.id, inv);
|
|
1116
1226
|
record({ event: 'MEMBER_INVITE', org: org.id, actor: person, target: body.email, data: { pending: true, invitation: inv.id, role } });
|
|
1117
1227
|
if (!dir.mailsInvitations) await mail([inv.email], `${person.email ?? person.name ?? 'Someone'} invited you to ${orgLabel(org.id, org.slug)} on Volter World`, `You are invited to the org ${orgLabel(org.id, org.slug)}. Sign in at ${opts.origin}/ with ${identity.name}, as ${inv.email}, before ${(inv.expiresAt ?? '').slice(0, 10)} to join it.`);
|
|
1118
1228
|
return json({ invited: body.email, pending: true, id: inv.id, expiresAt: inv.expiresAt }, 201);
|
package/src/store.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// through the methods below, each one statement or one transaction.
|
|
7
7
|
import { and, asc, eq, inArray } from 'drizzle-orm';
|
|
8
8
|
import { openDatabase, type PlatformDb } from './db/open.ts';
|
|
9
|
-
import { deliveries as deliveryRows, meta, orgSettings, orgWorlds, supportRequests, webhooks as webhookRows } from './db/schema.ts';
|
|
9
|
+
import { deliveries as deliveryRows, meta, orgSettings, orgWorlds, supportRequests, webhooks as webhookRows, worldAccess } from './db/schema.ts';
|
|
10
10
|
import type { Delivery, WebhookEndpoint } from './webhooks.ts';
|
|
11
11
|
|
|
12
12
|
/** A world the org holds: it LIVES on a host — the platform records where, never its log. */
|
|
@@ -64,7 +64,22 @@ export class Store {
|
|
|
64
64
|
catch (e) { if (/UNIQUE|PRIMARY KEY/i.test(String((e as Error).message))) throw new Error(`the org already holds a world named ${world.name}`); throw e; }
|
|
65
65
|
return this.doc(orgId);
|
|
66
66
|
}
|
|
67
|
-
removeWorld(orgId: string, name: string): OrgDoc {
|
|
67
|
+
removeWorld(orgId: string, name: string): OrgDoc {
|
|
68
|
+
this.db.transaction((tx) => { tx.delete(orgWorlds).where(and(eq(orgWorlds.orgId, orgIdOf(orgId)), eq(orgWorlds.name, name))).run(); tx.delete(worldAccess).where(and(eq(worldAccess.orgId, orgIdOf(orgId)), eq(worldAccess.name, name))).run(); });
|
|
69
|
+
return this.doc(orgId);
|
|
70
|
+
}
|
|
71
|
+
/** Who a World is kept to: the listed members (admins reach it besides), or null for the whole org. */
|
|
72
|
+
accessOf(orgId: string, name: string): string[] | null {
|
|
73
|
+
const row = this.db.select({ members: worldAccess.members }).from(worldAccess).where(and(eq(worldAccess.orgId, orgIdOf(orgId)), eq(worldAccess.name, name))).get() as { members: string[] } | undefined;
|
|
74
|
+
return row ? row.members : null;
|
|
75
|
+
}
|
|
76
|
+
/** Keep a World to these members, or (null) open it to the whole org again. */
|
|
77
|
+
setAccess(orgId: string, name: string, members: string[] | null, setBy: string): void {
|
|
78
|
+
const where = and(eq(worldAccess.orgId, orgIdOf(orgId)), eq(worldAccess.name, name));
|
|
79
|
+
if (members === null) { this.db.delete(worldAccess).where(where).run(); return; }
|
|
80
|
+
const row = { members: [...new Set(members)].sort(), setBy, setAt: new Date().toISOString() };
|
|
81
|
+
this.db.insert(worldAccess).values({ orgId: orgIdOf(orgId), name, ...row }).onConflictDoUpdate({ target: [worldAccess.orgId, worldAccess.name], set: row }).run();
|
|
82
|
+
}
|
|
68
83
|
/** Every org the platform holds a record for — the biller's roster. */
|
|
69
84
|
orgIds(): string[] {
|
|
70
85
|
const a = (this.db.selectDistinct({ id: orgWorlds.orgId }).from(orgWorlds).all() as Array<{ id: string }>).map((r) => r.id);
|
|
@@ -141,6 +156,10 @@ export class Store {
|
|
|
141
156
|
// an org's previews, in its meta row (a handful per World, replaced whole)
|
|
142
157
|
previewsOf(orgId: string): Preview[] { return this.metaOf<Preview[]>(`previews:${orgId}`) ?? []; }
|
|
143
158
|
setPreviews(orgId: string, previews: Preview[]): void { this.setMeta(`previews:${orgId}`, previews); }
|
|
159
|
+
// the invitations the platform sent through a directory that lists them only by org (Volter's), by address: what
|
|
160
|
+
// an invited person who signs in here, instead of through the mail, is shown
|
|
161
|
+
invitedOf(email: string): Array<{ orgId: string; id: string }> { return this.metaOf<Array<{ orgId: string; id: string }>>(`invited:${email.toLowerCase()}`) ?? []; }
|
|
162
|
+
setInvited(email: string, list: Array<{ orgId: string; id: string }>): void { this.setMeta(`invited:${email.toLowerCase()}`, list); }
|
|
144
163
|
|
|
145
164
|
// the platform's own position
|
|
146
165
|
private metaOf<T>(key: string): T | null { return ((this.db.select().from(meta).where(eq(meta.key, key)).get() as { value: T } | undefined)?.value) ?? null; }
|
package/src/webhooks.ts
CHANGED
|
@@ -21,8 +21,8 @@ export type Delivery = {
|
|
|
21
21
|
|
|
22
22
|
/** The catalog: the audit's events in dotted form, plus `ping` for the test button. */
|
|
23
23
|
export const WEBHOOK_EVENTS: Readonly<Record<string, string>> = {
|
|
24
|
-
WORLD_PROVISION: 'world.provisioned', WORLD_DELETE: 'world.deleted', PREVIEW_CREATE: 'preview.created', PREVIEW_DELETE: 'preview.deleted',
|
|
25
|
-
MEMBER_INVITE: 'member.added', INVITE_REVOKE: 'member.invite_revoked', MEMBER_REMOVE: 'member.removed', MEMBER_ROLE: 'member.role_changed',
|
|
24
|
+
WORLD_PROVISION: 'world.provisioned', WORLD_DELETE: 'world.deleted', WORLD_ACCESS_SET: 'world.access_changed', PREVIEW_CREATE: 'preview.created', PREVIEW_DELETE: 'preview.deleted',
|
|
25
|
+
MEMBER_INVITE: 'member.added', MEMBER_JOIN: 'member.joined', INVITE_REVOKE: 'member.invite_revoked', MEMBER_REMOVE: 'member.removed', MEMBER_ROLE: 'member.role_changed',
|
|
26
26
|
ORG_RENAME: 'org.renamed', PLAN_CHANGE: 'plan.changed',
|
|
27
27
|
QUOTA_WARNED: 'quota.warned', QUOTA_RESTRICTED: 'quota.restricted', QUOTA_CLEARED: 'quota.cleared',
|
|
28
28
|
IMPERSONATION: 'support.opened',
|