@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.
@@ -8,6 +8,13 @@
8
8
  "when": 1790550512216,
9
9
  "tag": "0000_init",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1790587292062,
16
+ "tag": "0001_world_access",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }
@@ -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
- async revokeInvitation(orgId, invitationId) { await call('DELETE', `/api/organizations/${seg(orgId)}/invitations/${seg(invitationId)}`); },
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\'s own open door gives one; 410 once it has ended.', answers: '303 to the preview\'s page, or { url, scope, expiresIn }' },
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 gone = new Map<string, boolean>();
205
+ const membership = new Map<string, OrgRef | null | 'unknown'>();
195
206
  for (const w of opts.store.doc(orgId).worlds) {
196
- const row = await worldRow(w.name); if (!row) continue;
197
- const res = await fetch(`${new URL(row.base).origin}/-/${row.name}/keys`, { headers: { 'x-volter-token': row.token } }).catch(() => null);
198
- const keys = res?.ok ? ((await res.json()) as { keys: Array<{ person?: string | null }> }).keys : [];
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 (!gone.has(person)) gone.set(person, (await dir.memberOf(person, orgId).catch(() => 'unknown' as const)) === null);
201
- if (gone.get(person)) { await fetch(`${new URL(row.base).origin}/-/${row.name}/keys?person=${encodeURIComponent(person)}`, { method: 'DELETE', headers: { 'x-volter-token': row.token } }).catch(() => null); revoked += 1; }
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) { const row = await worldRow(w.name); if (row) await fetch(`${new URL(row.base).origin}/-/${row.name}/keys?person=${encodeURIComponent(subject)}`, { method: 'DELETE', headers: { 'x-volter-token': row.token } }).catch(() => null); }
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
- /** The host inventory rows for the worlds an org holds (their tokens come from the host). */
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
- for (const org of await orgsOfP(person)) for (const w of opts.store.doc(org.id).worlds) wanted.set(w.name, w.host);
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
- if (!held) return json({ error: `no world ${name} in ${orgRef}` }, 404);
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 { this.db.delete(orgWorlds).where(and(eq(orgWorlds.orgId, orgIdOf(orgId)), eq(orgWorlds.name, name))).run(); return this.doc(orgId); }
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',