@volter/world-platform 2.0.2 → 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 +6 -6
- 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
|
@@ -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) {
|
package/dist/src/db/schema.d.ts
CHANGED
|
@@ -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";
|
package/dist/src/db/schema.js
CHANGED
|
@@ -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(),
|
package/dist/src/directory.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
];
|
package/dist/src/platform.js
CHANGED
|
@@ -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
|
|
211
|
+
const membership = new Map();
|
|
199
212
|
for (const w of opts.store.doc(orgId).worlds) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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 (!
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.`);
|
package/dist/src/store.d.ts
CHANGED
|
@@ -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) {
|
|
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(); }
|
package/dist/src/webhooks.js
CHANGED
|
@@ -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',
|