@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 = [
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
  ];
@@ -8,7 +8,7 @@ const dir = join(import.meta.dir, 'migrations');
8
8
  const target = join(import.meta.dir, 'migrations.ts');
9
9
  export function packed() {
10
10
  const files = readdirSync(dir).filter((f) => /^\d{4}_.+\.sql$/.test(f)).sort();
11
- const list = files.map((f) => ({ tag: f.replace(/\.sql$/, ''), statements: readFileSync(join(dir, f), 'utf8').split('--> statement-breakpoint').map((s) => s.trim()).filter(Boolean) }));
11
+ 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) }));
12
12
  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`;
13
13
  }
14
14
  if (import.meta.main) {
@@ -101,6 +101,110 @@ export declare const orgWorlds: import("drizzle-orm/sqlite-core").SQLiteTableWit
101
101
  };
102
102
  dialect: "sqlite";
103
103
  }>;
104
+ /** A World kept to some of its org (architecture, "A World may be kept to some of its org"): a row means only the
105
+ * listed members (by the directory's user id) reach it, admins always; no row, the whole org. */
106
+ export declare const worldAccess: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
107
+ name: "world_access";
108
+ schema: undefined;
109
+ columns: {
110
+ orgId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
111
+ name: "org_id";
112
+ tableName: "world_access";
113
+ dataType: "string";
114
+ columnType: "SQLiteText";
115
+ data: string;
116
+ driverParam: string;
117
+ notNull: true;
118
+ hasDefault: false;
119
+ isPrimaryKey: false;
120
+ isAutoincrement: false;
121
+ hasRuntimeDefault: false;
122
+ enumValues: [string, ...string[]];
123
+ baseColumn: never;
124
+ identity: undefined;
125
+ generated: undefined;
126
+ }, {}, {
127
+ length: number | undefined;
128
+ }>;
129
+ name: import("drizzle-orm/sqlite-core").SQLiteColumn<{
130
+ name: "name";
131
+ tableName: "world_access";
132
+ dataType: "string";
133
+ columnType: "SQLiteText";
134
+ data: string;
135
+ driverParam: string;
136
+ notNull: true;
137
+ hasDefault: false;
138
+ isPrimaryKey: false;
139
+ isAutoincrement: false;
140
+ hasRuntimeDefault: false;
141
+ enumValues: [string, ...string[]];
142
+ baseColumn: never;
143
+ identity: undefined;
144
+ generated: undefined;
145
+ }, {}, {
146
+ length: number | undefined;
147
+ }>;
148
+ members: import("drizzle-orm/sqlite-core").SQLiteColumn<{
149
+ name: "members";
150
+ tableName: "world_access";
151
+ dataType: "json";
152
+ columnType: "SQLiteTextJson";
153
+ data: string[];
154
+ driverParam: string;
155
+ notNull: true;
156
+ hasDefault: false;
157
+ isPrimaryKey: false;
158
+ isAutoincrement: false;
159
+ hasRuntimeDefault: false;
160
+ enumValues: undefined;
161
+ baseColumn: never;
162
+ identity: undefined;
163
+ generated: undefined;
164
+ }, {}, {
165
+ $type: string[];
166
+ }>;
167
+ setBy: import("drizzle-orm/sqlite-core").SQLiteColumn<{
168
+ name: "set_by";
169
+ tableName: "world_access";
170
+ dataType: "string";
171
+ columnType: "SQLiteText";
172
+ data: string;
173
+ driverParam: string;
174
+ notNull: true;
175
+ hasDefault: false;
176
+ isPrimaryKey: false;
177
+ isAutoincrement: false;
178
+ hasRuntimeDefault: false;
179
+ enumValues: [string, ...string[]];
180
+ baseColumn: never;
181
+ identity: undefined;
182
+ generated: undefined;
183
+ }, {}, {
184
+ length: number | undefined;
185
+ }>;
186
+ setAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
187
+ name: "set_at";
188
+ tableName: "world_access";
189
+ dataType: "string";
190
+ columnType: "SQLiteText";
191
+ data: string;
192
+ driverParam: string;
193
+ notNull: true;
194
+ hasDefault: false;
195
+ isPrimaryKey: false;
196
+ isAutoincrement: false;
197
+ hasRuntimeDefault: false;
198
+ enumValues: [string, ...string[]];
199
+ baseColumn: never;
200
+ identity: undefined;
201
+ generated: undefined;
202
+ }, {}, {
203
+ length: number | undefined;
204
+ }>;
205
+ };
206
+ dialect: "sqlite";
207
+ }>;
104
208
  /** An org's settings: its security switches, labs and support consent (absent: the defaults). */
105
209
  export declare const orgSettings: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
106
210
  name: "org_settings";
@@ -10,6 +10,15 @@ export const orgWorlds = sqliteTable('org_worlds', {
10
10
  base: text('base').notNull(),
11
11
  provisionedAt: text('provisioned_at').notNull(),
12
12
  }, (t) => [primaryKey({ columns: [t.orgId, t.name] })]);
13
+ /** A World kept to some of its org (architecture, "A World may be kept to some of its org"): a row means only the
14
+ * listed members (by the directory's user id) reach it, admins always; no row, the whole org. */
15
+ export const worldAccess = sqliteTable('world_access', {
16
+ orgId: text('org_id').notNull(),
17
+ name: text('name').notNull(),
18
+ members: text('members', { mode: 'json' }).$type().notNull(),
19
+ setBy: text('set_by').notNull(),
20
+ setAt: text('set_at').notNull(),
21
+ }, (t) => [primaryKey({ columns: [t.orgId, t.name] })]);
13
22
  /** An org's settings: its security switches, labs and support consent (absent: the defaults). */
14
23
  export const orgSettings = sqliteTable('org_settings', {
15
24
  orgId: text('org_id').primaryKey(),
@@ -74,7 +74,9 @@ export function volterDirectory(identity) {
74
74
  return invitationView(inv);
75
75
  },
76
76
  pendingInvitations: async (orgId) => ((await call('GET', `/api/organizations/${seg(orgId)}/invitations`)) ?? []).map(invitationView),
77
- async revokeInvitation(orgId, invitationId) { await call('DELETE', `/api/organizations/${seg(orgId)}/invitations/${seg(invitationId)}`); },
77
+ // one already used, revoked or expired is refused: a join that races a revocation lets no one in
78
+ async revokeInvitation(orgId, invitationId) { if ((await call('DELETE', `/api/organizations/${seg(orgId)}/invitations/${seg(invitationId)}`)) === null)
79
+ throw new Error('no such pending invitation'); },
78
80
  userIdByEmail,
79
81
  async personById(userId) {
80
82
  try {
package/dist/src/doors.js CHANGED
@@ -16,14 +16,17 @@ export const DOORS = [
16
16
  { 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? }] }' },
17
17
  { 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' },
18
18
  { 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 }' },
19
- { 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 }' },
19
+ { 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 }' },
20
20
  { 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 }' },
21
21
  { 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' },
22
22
  { 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' },
23
23
  { 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 }' },
24
24
  { 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 }' },
25
+ { 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] }' },
26
+ { 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 }' },
25
27
  { 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 }' },
26
- { 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[] }' },
28
+ { 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[] }' },
29
+ { 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 }' },
27
30
  { 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 }' },
28
31
  { method: 'GET', path: '/-/orgs/{org}', who: 'person', scope: 'orgs:read', summary: 'The org and its worlds.', answers: '{ id, slug, name, role, worlds[] }' },
29
32
  { 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 }' },
@@ -104,7 +107,7 @@ export function platformApiMarkdown() {
104
107
  ['Signing in', (d) => d.path === '/-/sign-in' || d.path === '/-/sign-out'],
105
108
  ['Signing the volter command in', (d) => d.path.startsWith('/-/cli/')],
106
109
  ['Worlds', (d) => d.path.startsWith('/-/worlds')],
107
- ['Orgs, members, billing, security, activity', (d) => d.path.startsWith('/-/orgs')],
110
+ ['Orgs, members, billing, security, activity', (d) => d.path.startsWith('/-/orgs') || d.path.startsWith('/-/invitations/')],
108
111
  ['Your tokens and support', (d) => d.path.startsWith('/-/tokens') || d.path === '/-/support'],
109
112
  ['The operator\'s', (d) => d.who === 'operator' && !/^\/-\/status/.test(d.path)],
110
113
  ];
@@ -192,21 +192,43 @@ export function createPlatform(opts) {
192
192
  /** A person removed from an org where the platform did not see it (at the identity service directly) keeps nothing:
193
193
  * 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
194
194
  * branches those made, revoked there. Org tokens' keys are ended with the org token. */
195
+ /** 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
196
+ * key store, under a parent key the preview's maker holds, so the parent's revocation does not reach it. */
197
+ const revokeHeldIn = async (orgId, world, person) => {
198
+ let any = false;
199
+ for (const served of [world, ...opts.store.previewsOf(orgId).filter((p) => p.world === world).map((p) => p.branch)]) {
200
+ const row = await worldRow(served);
201
+ if (!row)
202
+ continue;
203
+ 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);
204
+ any ||= Boolean(r?.ok);
205
+ }
206
+ return any;
207
+ };
195
208
  const sweepRemovedMembers = async () => {
196
209
  let revoked = 0;
197
210
  for (const orgId of opts.store.orgIds()) {
198
- const gone = new Map();
211
+ const membership = new Map();
199
212
  for (const w of opts.store.doc(orgId).worlds) {
200
- const row = await worldRow(w.name);
201
- if (!row)
202
- continue;
203
- const res = await fetch(`${new URL(row.base).origin}/-/${row.name}/keys`, { headers: { 'x-volter-token': row.token } }).catch(() => null);
204
- const keys = res?.ok ? (await res.json()).keys : [];
213
+ // the people holding keys in the World or in a live preview branch of it
214
+ const keys = [];
215
+ for (const served of [w.name, ...opts.store.previewsOf(orgId).filter((p) => p.world === w.name).map((p) => p.branch)]) {
216
+ const row = await worldRow(served);
217
+ if (!row)
218
+ continue;
219
+ const res = await fetch(`${new URL(row.base).origin}/-/${row.name}/keys`, { headers: { 'x-volter-token': row.token } }).catch(() => null);
220
+ if (res?.ok)
221
+ keys.push(...(await res.json()).keys);
222
+ }
223
+ const keptTo = opts.store.accessOf(orgId, w.name);
205
224
  for (const person of new Set(keys.map((k) => k.person).filter((p) => typeof p === 'string' && !p.startsWith('org-token:')))) {
206
- if (!gone.has(person))
207
- gone.set(person, (await dir.memberOf(person, orgId).catch(() => 'unknown')) === null);
208
- if (gone.get(person)) {
209
- await fetch(`${new URL(row.base).origin}/-/${row.name}/keys?person=${encodeURIComponent(person)}`, { method: 'DELETE', headers: { 'x-volter-token': row.token } }).catch(() => null);
225
+ if (!membership.has(person))
226
+ membership.set(person, await dir.memberOf(person, orgId).catch(() => 'unknown'));
227
+ const ref = membership.get(person);
228
+ // gone from the org, or kept off this World (not an admin, not named on it): nothing held there stays
229
+ const off = ref === null || (ref !== 'unknown' && ref !== undefined && keptTo !== null && !isAdmin(ref) && !keptTo.includes(person));
230
+ if (off) {
231
+ await revokeHeldIn(orgId, w.name, person);
210
232
  revoked += 1;
211
233
  }
212
234
  }
@@ -215,11 +237,8 @@ export function createPlatform(opts) {
215
237
  return revoked;
216
238
  };
217
239
  const revokePersonKeys = async (orgId, subject) => {
218
- for (const w of opts.store.doc(orgId).worlds) {
219
- const row = await worldRow(w.name);
220
- if (row)
221
- await fetch(`${new URL(row.base).origin}/-/${row.name}/keys?person=${encodeURIComponent(subject)}`, { method: 'DELETE', headers: { 'x-volter-token': row.token } }).catch(() => null);
222
- }
240
+ for (const w of opts.store.doc(orgId).worlds)
241
+ await revokeHeldIn(orgId, w.name, subject);
223
242
  };
224
243
  // ── billing: an attached biller answers for plans and prices; without one nothing is limited ──
225
244
  const biller = opts.billing ? opts.billing({
@@ -283,6 +302,41 @@ export function createPlatform(opts) {
283
302
  const o = await dir.orgById(person.orgToken.orgId);
284
303
  return o ? [o] : [];
285
304
  } return dir.orgsOf(person.id); };
305
+ const invitedP = async (person) => {
306
+ if (!person.email || person.orgToken || person.support || !dir.mailsInvitations)
307
+ return [];
308
+ const email = person.email.toLowerCase();
309
+ const noted = opts.store.invitedOf(email);
310
+ if (!noted.length)
311
+ return [];
312
+ const shown = [];
313
+ const gone = new Set();
314
+ for (const n of noted) {
315
+ let pending;
316
+ try {
317
+ pending = await dir.pendingInvitations(n.orgId);
318
+ }
319
+ catch {
320
+ continue;
321
+ }
322
+ const inv = pending.find((i) => i.id === n.id && i.email.toLowerCase() === email);
323
+ const org = inv && !(inv.expiresAt && Date.parse(inv.expiresAt) <= Date.now()) ? await dir.orgById(n.orgId) : null;
324
+ if (!inv || !org) {
325
+ gone.add(n.id);
326
+ continue;
327
+ }
328
+ shown.push({ id: inv.id, org: { id: org.id, slug: org.slug, name: org.name }, role: inv.role, expiresAt: inv.expiresAt });
329
+ }
330
+ // read again at the write: an invitation noted while the directory answered stays
331
+ if (gone.size)
332
+ opts.store.setInvited(email, opts.store.invitedOf(email).filter((n) => !gone.has(n.id)));
333
+ return shown;
334
+ };
335
+ const noteInvited = (orgId, inv, replaces) => {
336
+ if (!dir.mailsInvitations)
337
+ return;
338
+ 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 }]);
339
+ };
286
340
  const memberOfP = async (person, ref) => { if (person.orgToken) {
287
341
  const o = await dir.orgById(person.orgToken.orgId);
288
342
  return o && (o.id === ref || o.slug === ref) ? o : null;
@@ -309,12 +363,26 @@ export function createPlatform(opts) {
309
363
  return { person: { id: signed.userId, ...(signed.email ? { email: signed.email } : {}), ...(signed.name ? { name: signed.name } : {}) }, token };
310
364
  };
311
365
  const redirect = (location) => new Response(null, { status: 302, headers: { location, 'cache-control': 'no-store' } });
312
- /** The host inventory rows for the worlds an org holds (their tokens come from the host). */
366
+ /** Whether a person reaches a World of their org (architecture, "A World may be kept to some of its org"): an org's
367
+ * admins always; everyone in it unless the World is kept to named members. An org token acts as the org and a
368
+ * support session is the org's consented read, so neither is a member the World could be kept from. */
369
+ const mayReach = (person, org, name) => {
370
+ if (person.orgToken || person.support || isAdmin(org))
371
+ return true;
372
+ const members = opts.store.accessOf(org.id, name);
373
+ return members === null || members.includes(person.id);
374
+ };
375
+ /** The host inventory rows for the worlds a person reaches in their orgs (their tokens come from the host). */
313
376
  const rowsForPerson = async (person) => {
314
377
  const wanted = new Map(); // served name → host id
378
+ const kept = new Set(); // the ones kept to named members
315
379
  for (const org of await orgsOfP(person))
316
380
  for (const w of opts.store.doc(org.id).worlds)
317
- wanted.set(w.name, w.host);
381
+ if (mayReach(person, org, w.name)) {
382
+ wanted.set(w.name, w.host);
383
+ if (opts.store.accessOf(org.id, w.name) !== null)
384
+ kept.add(w.name);
385
+ }
318
386
  const rows = [];
319
387
  const byHost = new Map();
320
388
  for (const host of opts.hosts) {
@@ -330,7 +398,7 @@ export function createPlatform(opts) {
330
398
  const host = hostOf(hostId);
331
399
  // the world's address is the HOST's enrolled origin + its name: what the platform reached it at
332
400
  if (row && host)
333
- rows.push({ ...row, base: `${host.base}/${row.name}` });
401
+ rows.push({ ...row, base: `${host.base}/${row.name}`, ...(kept.has(name) ? { restricted: true } : {}) });
334
402
  }
335
403
  return rows.sort((a, b) => a.name.localeCompare(b.name));
336
404
  };
@@ -387,10 +455,14 @@ export function createPlatform(opts) {
387
455
  const write = method !== 'GET' && method !== 'HEAD';
388
456
  if (path === '/-/tokens' || path.startsWith('/-/tokens/'))
389
457
  return write ? 'tokens:write' : 'tokens:read';
458
+ if (/^\/-\/worlds\/[^/]+\/[^/]+\/access$/.test(path))
459
+ return write ? 'members:write' : 'members:read'; // who reaches a World is the org's membership
390
460
  if (/^\/-\/worlds\/[^/]+\/[^/]+\/(token|open|previews\/[^/]+\/open)$/.test(path))
391
461
  return 'worlds:read';
392
462
  if (path === '/-/worlds' || path.startsWith('/-/worlds/'))
393
463
  return write ? 'worlds:write' : 'worlds:read';
464
+ if (path.startsWith('/-/invitations/'))
465
+ return 'orgs:write'; // joining an org is the person's own org change
394
466
  const org = /^\/-\/orgs(?:\/[^/]+(?:\/([^/]+))?)?/.exec(path);
395
467
  if (!org)
396
468
  return null;
@@ -742,7 +814,8 @@ export function createPlatform(opts) {
742
814
  return json({ error: 'not a member' }, 403);
743
815
  const name = `${org.slug ?? org.id}/${world}`;
744
816
  const held = opts.store.doc(org.id).worlds.find((w) => w.name === name);
745
- if (!held)
817
+ // a World kept from this member answers as one that is not there: its name is not theirs to learn
818
+ if (!held || !mayReach(person, org, name))
746
819
  return json({ error: `no world ${name} in ${orgRef}` }, 404);
747
820
  const host = hostOf(held.host);
748
821
  if (!host)
@@ -813,7 +886,40 @@ export function createPlatform(opts) {
813
886
  return unauthorized(request);
814
887
  const orgs = await orgsOfP(person);
815
888
  const session = { person, token: presentedToken(request) ?? '' };
816
- 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) })) });
889
+ 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) });
890
+ }
891
+ // join an org one's address is invited to, here rather than at the identity service's page: the directory must
892
+ // name this person as the address's owner, as the platform's own directory lets an invitation in at sign-in
893
+ const joining = /^\/-\/invitations\/([^/]+)\/join$/.exec(path);
894
+ if (joining && request.method === 'POST') {
895
+ const person = (await sessionOf(request))?.person;
896
+ if (!person)
897
+ return unauthorized(request);
898
+ const inv = (await invitedP(person)).find((i) => i.id === decodeURIComponent(joining[1]));
899
+ if (!inv)
900
+ return json({ error: 'no pending invitation to your address with that id' }, 404);
901
+ if ((await dir.userIdByEmail(person.email)) !== person.id)
902
+ return json({ error: `your Volter identity does not own ${person.email}: open the invitation's mail and accept it there` }, 403);
903
+ {
904
+ const why = domainAllowed(inv.org.id, person.email);
905
+ if (why)
906
+ return json({ error: why }, 403);
907
+ } // the domains may have narrowed since
908
+ try {
909
+ // the invitation is used up first, so one an admin revokes meanwhile lets no one in; a member already is left as they are
910
+ await dir.revokeInvitation(inv.org.id, inv.id);
911
+ if (!(await dir.memberOf(person.id, inv.org.id))) {
912
+ await dir.addMember(inv.org.id, { sub: person.id });
913
+ if (inv.role === 'org:admin')
914
+ await dir.setMemberRole(inv.org.id, person.id, 'org:admin');
915
+ }
916
+ }
917
+ catch (e) {
918
+ return json({ error: e.message }, 400);
919
+ }
920
+ opts.store.setInvited(person.email, opts.store.invitedOf(person.email).filter((n) => n.id !== inv.id));
921
+ record({ event: 'MEMBER_JOIN', org: inv.org.id, actor: person, target: person.email, data: { invitation: inv.id, role: inv.role } });
922
+ return json({ joined: inv.org.slug ?? inv.org.id, role: inv.role });
817
923
  }
818
924
  if (path === '/-/orgs' && request.method === 'POST') {
819
925
  const person = (await sessionOf(request))?.person;
@@ -1063,7 +1169,12 @@ export function createPlatform(opts) {
1063
1169
  const inv = (await dir.pendingInvitations(org.id)).find((i) => i.id === sub);
1064
1170
  if (!inv)
1065
1171
  return json({ error: 'no such pending invitation' }, 404);
1066
- await dir.revokeInvitation(org.id, sub);
1172
+ try {
1173
+ await dir.revokeInvitation(org.id, sub);
1174
+ }
1175
+ catch {
1176
+ return json({ error: 'no such pending invitation' }, 404);
1177
+ }
1067
1178
  record({ event: 'INVITE_REVOKE', org: org.id, actor: person, target: inv.email });
1068
1179
  return json({ revoked: inv.email });
1069
1180
  }
@@ -1081,8 +1192,14 @@ export function createPlatform(opts) {
1081
1192
  if (why)
1082
1193
  return json({ error: why }, 403);
1083
1194
  } // the domains may have narrowed since
1084
- await dir.revokeInvitation(org.id, sub);
1195
+ try {
1196
+ await dir.revokeInvitation(org.id, sub);
1197
+ }
1198
+ catch {
1199
+ return json({ error: 'no such pending invitation' }, 404);
1200
+ }
1085
1201
  const again = await dir.inviteMember(org.id, inv.email, person.id, inv.role);
1202
+ noteInvited(org.id, again, sub);
1086
1203
  if (!dir.mailsInvitations)
1087
1204
  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.`);
1088
1205
  record({ event: 'MEMBER_INVITE', org: org.id, actor: person, target: again.email, data: { pending: true, invitation: again.id, resent: true } });
@@ -1203,7 +1320,7 @@ export function createPlatform(opts) {
1203
1320
  return json({ deleted: org.id });
1204
1321
  }
1205
1322
  if (!part && request.method === 'GET')
1206
- return json({ ...org, worlds: opts.store.doc(org.id).worlds });
1323
+ 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
1207
1324
  return json({ error: `no such door: ${request.method} ${path}` }, 404);
1208
1325
  }
1209
1326
  // ── support and access (layer 6): the Help form, the consent, the operator's consented view ──
@@ -1443,6 +1560,56 @@ export function createPlatform(opts) {
1443
1560
  // 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
1444
1561
  // per pull request without a key of its own (a key would be replaced by the next job's, taking its branches); a
1445
1562
  // member opens one as themselves. A preview is an ordinary branch: its lifetime, keys and owner are a branch's.
1563
+ // WHO REACHES A WORLD (architecture, "A World may be kept to some of its org"): an admin keeps a World to named members,
1564
+ // or opens it to the whole org again; keeping it revokes, at once, the World keys held there by the members it leaves out
1565
+ const accessDoor = /^\/-\/worlds\/([^/]+)\/([^/]+)\/access$/.exec(path);
1566
+ if (accessDoor && (request.method === 'GET' || request.method === 'PUT')) {
1567
+ const session = await sessionOf(request);
1568
+ if (!session)
1569
+ return unauthorized(request);
1570
+ const org = await memberOfP(session.person, accessDoor[1]);
1571
+ if (!org)
1572
+ return json({ error: `you are not a member of org ${accessDoor[1]}` }, 403);
1573
+ const name = `${org.slug ?? org.id}/${accessDoor[2]}`;
1574
+ if (!opts.store.doc(org.id).worlds.some((w) => w.name === name) || !mayReach(session.person, org, name))
1575
+ return json({ error: `no world ${name} in your orgs` }, 404);
1576
+ if (!isAdmin(org))
1577
+ return json({ error: 'only an admin decides who reaches a World' }, 403);
1578
+ if (request.method === 'GET') {
1579
+ const members = opts.store.accessOf(org.id, name);
1580
+ return json({ world: name, restricted: members !== null, members: members ?? [] });
1581
+ }
1582
+ if (session.person.support)
1583
+ return json({ error: 'a support session changes nothing' }, 403);
1584
+ let body;
1585
+ try {
1586
+ body = (await request.json());
1587
+ }
1588
+ catch {
1589
+ return json({ error: 'access: { restricted: boolean, members?: [userId] }' }, 400);
1590
+ }
1591
+ if (typeof body.restricted !== 'boolean')
1592
+ return json({ error: 'access: { restricted: boolean, members?: [userId] }' }, 400);
1593
+ const people = await dir.membersOf(org.id);
1594
+ const asked = body.restricted ? (Array.isArray(body.members) ? body.members : []) : [];
1595
+ const unknown = asked.filter((m) => typeof m !== 'string' || !people.some((p) => p.userId === m));
1596
+ if (unknown.length)
1597
+ return json({ error: `not members of ${org.slug ?? org.id}: ${unknown.map(String).join(', ')}` }, 400);
1598
+ const members = body.restricted ? asked : null;
1599
+ opts.store.setAccess(org.id, name, members, session.person.id);
1600
+ // everyone the World now leaves out loses the keys held for them there (and the previews those keys made)
1601
+ let revoked = 0;
1602
+ if (members !== null) {
1603
+ for (const p of people) {
1604
+ if (p.role.endsWith('admin') || members.includes(p.userId))
1605
+ continue;
1606
+ if (await revokeHeldIn(org.id, name, p.userId))
1607
+ revoked += 1;
1608
+ }
1609
+ }
1610
+ record({ event: 'WORLD_ACCESS_SET', org: org.id, actor: session.person, target: name, data: { restricted: members !== null, members: members?.length ?? null } });
1611
+ return json({ world: name, restricted: members !== null, members: members ?? [], revokedFor: revoked });
1612
+ }
1446
1613
  const previewDoor = /^\/-\/worlds\/([^/]+)\/([^/]+)\/previews(?:\/([^/]+)(\/open)?)?$/.exec(path);
1447
1614
  if (previewDoor) {
1448
1615
  const session = await sessionOf(request);
@@ -1460,7 +1627,7 @@ export function createPlatform(opts) {
1460
1627
  const label = previewDoor[3];
1461
1628
  const opening = Boolean(previewDoor[4]);
1462
1629
  const held = opts.store.doc(org.id).worlds.find((w) => w.name === name);
1463
- if (!held)
1630
+ if (!held || !mayReach(session.person, org, name))
1464
1631
  return json({ error: `no world ${name} in your orgs` }, 404);
1465
1632
  const host = hostOf(held.host);
1466
1633
  if (!host)
@@ -1762,6 +1929,7 @@ export function createPlatform(opts) {
1762
1929
  // directory mails its own invitation, and the platform mails the one it keeps
1763
1930
  if (body.email && !body.sub && !(await dir.userIdByEmail(body.email))) {
1764
1931
  const inv = await dir.inviteMember(org.id, body.email, person.id, role);
1932
+ noteInvited(org.id, inv);
1765
1933
  record({ event: 'MEMBER_INVITE', org: org.id, actor: person, target: body.email, data: { pending: true, invitation: inv.id, role } });
1766
1934
  if (!dir.mailsInvitations)
1767
1935
  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.`);
@@ -80,6 +80,10 @@ export declare class Store {
80
80
  /** Record a World the org holds; one name once (the primary key refuses a second). */
81
81
  recordWorld(orgId: string, world: OrgWorld): OrgDoc;
82
82
  removeWorld(orgId: string, name: string): OrgDoc;
83
+ /** Who a World is kept to: the listed members (admins reach it besides), or null for the whole org. */
84
+ accessOf(orgId: string, name: string): string[] | null;
85
+ /** Keep a World to these members, or (null) open it to the whole org again. */
86
+ setAccess(orgId: string, name: string, members: string[] | null, setBy: string): void;
83
87
  /** Every org the platform holds a record for — the biller's roster. */
84
88
  orgIds(): string[];
85
89
  setSupportAccess(orgId: string, supportAccess: SupportAccess | null): OrgDoc;
@@ -103,6 +107,14 @@ export declare class Store {
103
107
  setSupportStatus(id: string, status: SupportRequest['status']): SupportRequest | null;
104
108
  previewsOf(orgId: string): Preview[];
105
109
  setPreviews(orgId: string, previews: Preview[]): void;
110
+ invitedOf(email: string): Array<{
111
+ orgId: string;
112
+ id: string;
113
+ }>;
114
+ setInvited(email: string, list: Array<{
115
+ orgId: string;
116
+ id: string;
117
+ }>): void;
106
118
  private metaOf;
107
119
  private setMeta;
108
120
  platform(): PlatformDoc;
package/dist/src/store.js 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 } from "./db/open.js";
9
- import { deliveries as deliveryRows, meta, orgSettings, orgWorlds, supportRequests, webhooks as webhookRows } from "./db/schema.js";
9
+ import { deliveries as deliveryRows, meta, orgSettings, orgWorlds, supportRequests, webhooks as webhookRows, worldAccess } from "./db/schema.js";
10
10
  export const DEFAULT_SECURITY = { membersCanInvite: false, membersCanUsePersonalTokens: true, approvedEmailDomains: [] };
11
11
  const NAME = /^[a-z0-9][a-z0-9-]{0,38}$/;
12
12
  export function assertName(kind, value) {
@@ -49,7 +49,25 @@ export class Store {
49
49
  }
50
50
  return this.doc(orgId);
51
51
  }
52
- removeWorld(orgId, name) { this.db.delete(orgWorlds).where(and(eq(orgWorlds.orgId, orgIdOf(orgId)), eq(orgWorlds.name, name))).run(); return this.doc(orgId); }
52
+ removeWorld(orgId, name) {
53
+ 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(); });
54
+ return this.doc(orgId);
55
+ }
56
+ /** Who a World is kept to: the listed members (admins reach it besides), or null for the whole org. */
57
+ accessOf(orgId, name) {
58
+ const row = this.db.select({ members: worldAccess.members }).from(worldAccess).where(and(eq(worldAccess.orgId, orgIdOf(orgId)), eq(worldAccess.name, name))).get();
59
+ return row ? row.members : null;
60
+ }
61
+ /** Keep a World to these members, or (null) open it to the whole org again. */
62
+ setAccess(orgId, name, members, setBy) {
63
+ const where = and(eq(worldAccess.orgId, orgIdOf(orgId)), eq(worldAccess.name, name));
64
+ if (members === null) {
65
+ this.db.delete(worldAccess).where(where).run();
66
+ return;
67
+ }
68
+ const row = { members: [...new Set(members)].sort(), setBy, setAt: new Date().toISOString() };
69
+ this.db.insert(worldAccess).values({ orgId: orgIdOf(orgId), name, ...row }).onConflictDoUpdate({ target: [worldAccess.orgId, worldAccess.name], set: row }).run();
70
+ }
53
71
  /** Every org the platform holds a record for — the biller's roster. */
54
72
  orgIds() {
55
73
  const a = this.db.selectDistinct({ id: orgWorlds.orgId }).from(orgWorlds).all().map((r) => r.id);
@@ -128,6 +146,10 @@ export class Store {
128
146
  // an org's previews, in its meta row (a handful per World, replaced whole)
129
147
  previewsOf(orgId) { return this.metaOf(`previews:${orgId}`) ?? []; }
130
148
  setPreviews(orgId, previews) { this.setMeta(`previews:${orgId}`, previews); }
149
+ // the invitations the platform sent through a directory that lists them only by org (Volter's), by address: what
150
+ // an invited person who signs in here, instead of through the mail, is shown
151
+ invitedOf(email) { return this.metaOf(`invited:${email.toLowerCase()}`) ?? []; }
152
+ setInvited(email, list) { this.setMeta(`invited:${email.toLowerCase()}`, list); }
131
153
  // the platform's own position
132
154
  metaOf(key) { return (this.db.select().from(meta).where(eq(meta.key, key)).get()?.value) ?? null; }
133
155
  setMeta(key, value) { this.db.insert(meta).values({ key, value }).onConflictDoUpdate({ target: meta.key, set: { value } }).run(); }
@@ -8,8 +8,8 @@ import { createHmac, randomBytes } from 'node:crypto';
8
8
  import { lookup } from 'node:dns/promises';
9
9
  /** The catalog: the audit's events in dotted form, plus `ping` for the test button. */
10
10
  export const WEBHOOK_EVENTS = {
11
- WORLD_PROVISION: 'world.provisioned', WORLD_DELETE: 'world.deleted', PREVIEW_CREATE: 'preview.created', PREVIEW_DELETE: 'preview.deleted',
12
- MEMBER_INVITE: 'member.added', INVITE_REVOKE: 'member.invite_revoked', MEMBER_REMOVE: 'member.removed', MEMBER_ROLE: 'member.role_changed',
11
+ WORLD_PROVISION: 'world.provisioned', WORLD_DELETE: 'world.deleted', WORLD_ACCESS_SET: 'world.access_changed', PREVIEW_CREATE: 'preview.created', PREVIEW_DELETE: 'preview.deleted',
12
+ MEMBER_INVITE: 'member.added', MEMBER_JOIN: 'member.joined', INVITE_REVOKE: 'member.invite_revoked', MEMBER_REMOVE: 'member.removed', MEMBER_ROLE: 'member.role_changed',
13
13
  ORG_RENAME: 'org.renamed', PLAN_CHANGE: 'plan.changed',
14
14
  QUOTA_WARNED: 'quota.warned', QUOTA_RESTRICTED: 'quota.restricted', QUOTA_CLEARED: 'quota.cleared',
15
15
  IMPERSONATION: 'support.opened',