slotlock 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-store-backend.js +209 -15
  5. package/application-api.d.ts +10 -1
  6. package/application-api.js +213 -2
  7. package/availability-store.js +13 -5
  8. package/booking-rules.d.ts +11 -0
  9. package/booking-rules.js +21 -1
  10. package/config.d.ts +50 -0
  11. package/config.js +107 -0
  12. package/dashboard-scope.d.ts +7 -0
  13. package/dashboard-scope.js +21 -0
  14. package/dashboard.d.ts +97 -2
  15. package/dashboard.js +3068 -284
  16. package/ddl-identity.d.ts +25 -0
  17. package/ddl-identity.js +881 -0
  18. package/ddl-scim.d.ts +5 -0
  19. package/ddl-scim.js +740 -0
  20. package/ddl-sso.d.ts +15 -0
  21. package/ddl-sso.js +1033 -0
  22. package/ddl.d.ts +40 -9
  23. package/ddl.js +2123 -49
  24. package/domains.d.ts +117 -0
  25. package/domains.js +340 -0
  26. package/event-feed.d.ts +1 -1
  27. package/event-feed.js +6 -34
  28. package/holiday-import.d.ts +1 -1
  29. package/holiday-import.js +2 -3
  30. package/holidays.js +70 -11
  31. package/index.d.ts +12 -4
  32. package/index.js +8 -2
  33. package/invitation-email.d.ts +26 -0
  34. package/invitation-email.js +73 -0
  35. package/maintenance.d.ts +7 -2
  36. package/maintenance.js +15 -1
  37. package/oidc.d.ts +135 -0
  38. package/oidc.js +452 -0
  39. package/openapi.json +1886 -6
  40. package/package.json +2 -1
  41. package/scim-server.d.ts +19 -0
  42. package/scim-server.js +243 -0
  43. package/scim.d.ts +180 -0
  44. package/scim.js +416 -0
  45. package/self-host.d.ts +19 -6
  46. package/self-host.js +262 -7
  47. package/slotlock.manifest.json +1 -1
  48. package/store.d.ts +273 -2
  49. package/store.js +984 -72
  50. package/types.d.ts +33 -1
  51. package/types.js +68 -1
  52. package/workspace-domains.d.ts +146 -0
  53. package/workspace-domains.js +249 -0
  54. package/workspace-roles.d.ts +47 -0
  55. package/workspace-roles.js +304 -0
  56. package/workspace-scim.d.ts +89 -0
  57. package/workspace-scim.js +157 -0
  58. package/workspace-sso.d.ts +131 -0
  59. package/workspace-sso.js +180 -0
  60. package/workspaces.d.ts +240 -0
  61. package/workspaces.js +433 -0
package/workspaces.js ADDED
@@ -0,0 +1,433 @@
1
+ // Who belongs to which workspace, and as what (workspace-roles.ts), as the dashboard asks on every
2
+ // request: from slotlock.workspace_memberships and slotlock.dashboard_people, through their
3
+ // functions, never from the session cookie. A person's own workspace has no row: they are its
4
+ // Owner. GitHub organizations' members are recorded at each sign-in from what GitHub lists.
5
+ import { createHash } from 'node:crypto';
6
+ import { createSlotlockWorkspaceDomains } from './workspace-domains.js';
7
+ import { createSlotlockWorkspaceSso } from './workspace-sso.js';
8
+ import { createSlotlockWorkspaceScim } from './workspace-scim.js';
9
+ import { isSlotlockWorkspaceRole } from './workspace-roles.js';
10
+ import { isSlotlockResourceScope } from './resource-profile.js';
11
+ /**
12
+ * A person as the dashboard knows them: `github:<id>`, `google:<subject>`, or `sso:<uuid>` (one a
13
+ * team's identity provider signed in, ddl-sso.ts), never an email.
14
+ */
15
+ const PERSON = /^(github:[1-9]\d{0,19}|google:[A-Za-z0-9._-]{1,255}|sso:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/;
16
+ const ORGANIZATION_ID = /^[1-9]\d{0,19}$/;
17
+ function refuse(message) {
18
+ throw Object.assign(new Error(`Slotlock workspaces: ${message}`), { code: 'invalid_person' });
19
+ }
20
+ function checkMemberships(organizations) {
21
+ if (organizations === undefined)
22
+ return;
23
+ if (organizations.length > 100)
24
+ refuse('at most 100 organizations');
25
+ for (const org of organizations) {
26
+ if (!ORGANIZATION_ID.test(org.id) || (org.role !== 'admin' && org.role !== 'member')) {
27
+ refuse('an organization needs its id and the role admin or member');
28
+ }
29
+ }
30
+ }
31
+ /** Through slotlock.dashboard_sign_in, dashboard_standing and dashboard_sign_out_everywhere. */
32
+ export function createSlotlockDashboardWorkspaces(sql) {
33
+ return {
34
+ async signedIn(person, githubOrganizations) {
35
+ if (!PERSON.test(person))
36
+ refuse('needs github:<id> or google:<subject>');
37
+ if (githubOrganizations !== undefined && !person.startsWith('github:'))
38
+ refuse('only a GitHub person has organizations');
39
+ checkMemberships(githubOrganizations);
40
+ const listed = githubOrganizations === undefined
41
+ ? null
42
+ : JSON.stringify(githubOrganizations.map((org) => ({ id: org.id, role: org.role })));
43
+ const [row] = await sql `
44
+ SELECT slotlock.dashboard_sign_in(${person}::text, ${listed}::text) AS epoch`;
45
+ return Number(row?.epoch ?? 0);
46
+ },
47
+ async scope(tenantRef, person) {
48
+ if (!PERSON.test(person))
49
+ return null;
50
+ const [row] = await sql `
51
+ SELECT slotlock.member_scope(${tenantRef}::text, ${person}::text) AS scope`;
52
+ return scopeOf(row?.scope);
53
+ },
54
+ async standing(tenantRef, person, sid) {
55
+ if (!PERSON.test(person))
56
+ return { role: null, sessionEpoch: null };
57
+ const digest = sid === null ? null : sessionDigest(sid);
58
+ const [row] = await sql `
59
+ SELECT role, session_epoch, refusal, may_bypass
60
+ FROM slotlock.dashboard_standing(${tenantRef}::text, ${person}::text, ${digest}::bytea)`;
61
+ const role = row?.role ?? null;
62
+ const epoch = row?.session_epoch;
63
+ const refusal = row?.refusal === 'sso_required' || row?.refusal === 'sso_scope' ? row.refusal : null;
64
+ return {
65
+ role: isSlotlockWorkspaceRole(role) ? role : null,
66
+ sessionEpoch: epoch === null || epoch === undefined ? null : Number(epoch),
67
+ refusal,
68
+ mayBypass: row?.may_bypass === true,
69
+ };
70
+ },
71
+ async signOutEverywhere(person) {
72
+ if (!PERSON.test(person))
73
+ refuse('needs github:<id> or google:<subject>');
74
+ const [row] = await sql `
75
+ SELECT slotlock.dashboard_sign_out_everywhere(${person}::text) AS epoch`;
76
+ return Number(row?.epoch ?? 0);
77
+ },
78
+ teams: createTeams(sql),
79
+ sessions: createSessions(sql),
80
+ domains: createSlotlockWorkspaceDomains(sql),
81
+ sso: createSlotlockWorkspaceSso(sql),
82
+ scim: createSlotlockWorkspaceScim(sql),
83
+ };
84
+ }
85
+ const sessionDigest = (sid) => createHash('sha256').update(sid, 'utf8').digest();
86
+ const SESSION_ID = /^[0-9a-f]{64}$/;
87
+ function createSessions(sql) {
88
+ return {
89
+ async started(person, sid, expiresAt, from) {
90
+ if (!PERSON.test(person))
91
+ refuse('needs github:<id> or google:<subject>');
92
+ await sql `SELECT slotlock.dashboard_session_started(${person}::text, ${sessionDigest(sid)}::bytea,
93
+ ${expiresAt}::timestamptz, ${from.userAgent?.slice(0, 256) ?? null}::text, ${from.ipAddress?.slice(0, 64) ?? null}::text)`;
94
+ },
95
+ async seen(person, sid) {
96
+ if (!PERSON.test(person))
97
+ return false;
98
+ const [row] = await sql `
99
+ SELECT slotlock.dashboard_session_seen(${person}::text, ${sessionDigest(sid)}::bytea) AS alive`;
100
+ return row?.alive === true;
101
+ },
102
+ async list(person) {
103
+ if (!PERSON.test(person))
104
+ return [];
105
+ const rows = await sql `SELECT * FROM slotlock.dashboard_sessions_of(${person}::text)`;
106
+ return rows.map((row) => ({
107
+ id: row.sid_sha256.toString('hex'),
108
+ startedAt: row.started_at,
109
+ lastSeenAt: row.last_seen_at,
110
+ expiresAt: row.expires_at,
111
+ userAgent: row.user_agent,
112
+ ipAddress: row.ip_address,
113
+ }));
114
+ },
115
+ async end(person, id) {
116
+ if (!PERSON.test(person) || (id !== null && !SESSION_ID.test(id)))
117
+ return false;
118
+ const [row] = await sql `
119
+ SELECT slotlock.dashboard_session_end(${person}::text, ${id === null ? null : Buffer.from(id, 'hex')}::bytea) AS ended`;
120
+ return row?.ended === true;
121
+ },
122
+ idOf: (sid) => sessionDigest(sid).toString('hex'),
123
+ };
124
+ }
125
+ const TEAM = /^team:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
126
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
127
+ /**
128
+ * A member's scope as kept, or null for none. One that is not a scope never reads as none: that
129
+ * would give the whole workspace to someone limited, so it is refused (the database's check keeps
130
+ * such a value out).
131
+ */
132
+ function scopeOf(value) {
133
+ if (value === null || value === undefined)
134
+ return null;
135
+ if (!isSlotlockResourceScope(value))
136
+ throw new Error('Slotlock read a member scope that is not one');
137
+ return value;
138
+ }
139
+ const refusalOf = (value) => value === null || value === undefined ? null : value;
140
+ const roleOf = (value) => {
141
+ if (!isSlotlockWorkspaceRole(value))
142
+ throw new Error(`Slotlock workspaces: unknown role ${value}`);
143
+ return value;
144
+ };
145
+ const digest = (token) => {
146
+ if (token.length !== 32)
147
+ refuse('a token digest is 32 bytes');
148
+ return token;
149
+ };
150
+ function createTeams(sql) {
151
+ return {
152
+ async setDisplay(person, display) {
153
+ if (!PERSON.test(person))
154
+ return;
155
+ const shown = display === null ? null : display.slice(0, 254);
156
+ await sql `SELECT slotlock.dashboard_display(${person}::text, ${shown}::text)`;
157
+ },
158
+ async create(person, name, handle, cap) {
159
+ if (!PERSON.test(person))
160
+ refuse('needs github:<id> or google:<subject>');
161
+ const [row] = await sql `
162
+ SELECT tenant_ref, refusal FROM slotlock.create_team(${person}::text, ${name}::text, ${handle}::text, ${cap}::integer)`;
163
+ return row?.tenant_ref ? { tenantRef: row.tenant_ref } : { refusal: refusalOf(row?.refusal) ?? 'not_allowed' };
164
+ },
165
+ async info(tenantRef) {
166
+ const [row] = await sql `
167
+ SELECT handle, name FROM slotlock.workspace_info(${tenantRef}::text)`;
168
+ return row ? { handle: row.handle, name: row.name } : null;
169
+ },
170
+ async workspacesOf(person) {
171
+ if (!PERSON.test(person))
172
+ return [];
173
+ const rows = await sql `
174
+ SELECT tenant_ref, handle, name, role FROM slotlock.person_workspaces(${person}::text)`;
175
+ return rows.map((row) => ({ tenantRef: row.tenant_ref, handle: row.handle, name: row.name, role: roleOf(row.role) }));
176
+ },
177
+ async members(tenantRef) {
178
+ if (!TEAM.test(tenantRef.replace(/~test$/, '')))
179
+ return [];
180
+ const rows = await sql `
181
+ SELECT person_ref, role, source, display, joined_at FROM slotlock.workspace_members(${tenantRef}::text)`;
182
+ return rows.map((row) => ({ person: row.person_ref, role: roleOf(row.role), source: row.source, display: row.display, joinedAt: row.joined_at }));
183
+ },
184
+ async invitations(tenantRef) {
185
+ if (!TEAM.test(tenantRef.replace(/~test$/, '')))
186
+ return [];
187
+ const rows = await sql `SELECT id, email, github_login, role, inviter_display, sent_at, expires_at
188
+ FROM slotlock.workspace_invitations_list(${tenantRef}::text)`;
189
+ return rows.map((row) => ({
190
+ id: row.id,
191
+ email: row.email,
192
+ githubLogin: row.github_login,
193
+ role: roleOf(row.role),
194
+ inviterDisplay: row.inviter_display,
195
+ sentAt: row.sent_at,
196
+ expiresAt: row.expires_at,
197
+ }));
198
+ },
199
+ async invite(params) {
200
+ if (!TEAM.test(params.tenantRef) || !PERSON.test(params.inviter))
201
+ refuse('needs a team workspace and the inviter');
202
+ const email = 'email' in params.to ? params.to.email : null;
203
+ const github = 'github' in params.to ? params.to.github : null;
204
+ const login = 'github' in params.to ? params.to.login : null;
205
+ const [row] = await sql `
206
+ SELECT invitation_id, refusal FROM slotlock.invite_member(
207
+ ${params.tenantRef}::text, ${params.inviter}::text, ${email}::text, ${github}::text, ${login}::text,
208
+ ${params.role}::text, ${digest(params.tokenSha256)}::bytea, ${params.days}::integer,
209
+ ${params.dailyLimit}::integer, ${params.memberCap}::integer)`;
210
+ return row?.invitation_id ? { id: row.invitation_id } : { refusal: refusalOf(row?.refusal) ?? 'not_allowed' };
211
+ },
212
+ async preview(tokenSha256) {
213
+ const [row] = await sql `SELECT * FROM slotlock.invitation_preview(${digest(tokenSha256)}::bytea)`;
214
+ if (!row)
215
+ return null;
216
+ return {
217
+ id: row.invitation_id,
218
+ tenantRef: row.tenant_ref,
219
+ workspaceName: row.workspace_name,
220
+ workspaceHandle: row.workspace_handle,
221
+ inviterDisplay: row.inviter_display,
222
+ role: roleOf(row.role),
223
+ email: row.email,
224
+ githubRef: row.github_ref,
225
+ githubLogin: row.github_login,
226
+ state: row.state,
227
+ };
228
+ },
229
+ async accept(params) {
230
+ if (!PERSON.test(params.person))
231
+ refuse('needs github:<id> or google:<subject>');
232
+ if ((params.tokenSha256 === undefined) === (params.id === undefined))
233
+ refuse('accept by the token or by id');
234
+ if (params.id !== undefined && !UUID.test(params.id))
235
+ return { refusal: 'not_found' };
236
+ const token = params.tokenSha256 === undefined ? null : digest(params.tokenSha256);
237
+ const [row] = await sql `
238
+ SELECT tenant_ref, refusal FROM slotlock.accept_invitation(
239
+ ${token}::bytea, ${params.id ?? null}::uuid, ${params.person}::text,
240
+ ${[...params.emails].slice(0, 20)}::text[], ${params.memberCap}::integer)`;
241
+ return row?.tenant_ref ? { tenantRef: row.tenant_ref } : { refusal: refusalOf(row?.refusal) ?? 'not_found' };
242
+ },
243
+ async revoke(tenantRef, actor, id) {
244
+ if (!UUID.test(id))
245
+ return false;
246
+ const [row] = await sql `
247
+ SELECT slotlock.revoke_invitation(${tenantRef}::text, ${actor}::text, ${id}::uuid) AS revoked`;
248
+ return row?.revoked === true;
249
+ },
250
+ async resend(tenantRef, actor, id, tokenSha256, days, dailyLimit, memberCap) {
251
+ if (!UUID.test(id))
252
+ return 'not_found';
253
+ const [row] = await sql `
254
+ SELECT slotlock.resend_invitation(${tenantRef}::text, ${actor}::text, ${id}::uuid,
255
+ ${digest(tokenSha256)}::bytea, ${days}::integer, ${dailyLimit}::integer, ${memberCap}::integer) AS refusal`;
256
+ return refusalOf(row?.refusal);
257
+ },
258
+ async changeRole(tenantRef, actor, person, role) {
259
+ const [row] = await sql `
260
+ SELECT slotlock.change_member_role(${tenantRef}::text, ${actor}::text, ${person}::text, ${role}::text) AS refusal`;
261
+ return refusalOf(row?.refusal);
262
+ },
263
+ async scopes(tenantRef) {
264
+ if (!TEAM.test(tenantRef.replace(/~test$/, '')))
265
+ return new Map();
266
+ const rows = await sql `
267
+ SELECT person_ref, resource_scope FROM slotlock.member_scopes(${tenantRef}::text)`;
268
+ return new Map(rows.map((row) => [row.person_ref, scopeOf(row.resource_scope)]));
269
+ },
270
+ async setScope(tenantRef, actor, person, scope) {
271
+ if (scope !== null && !isSlotlockResourceScope(scope))
272
+ refuse('a scope names locations or groups');
273
+ const kept = scope === null ? null : { locations: [...scope.locations], groups: [...scope.groups] };
274
+ const [row] = await sql `
275
+ SELECT slotlock.set_member_scope(${tenantRef}::text, ${actor}::text, ${person}::text,
276
+ ${kept === null ? null : sql.json(kept)}::jsonb) AS refusal`;
277
+ return refusalOf(row?.refusal);
278
+ },
279
+ async remove(tenantRef, actor, person) {
280
+ const [row] = await sql `
281
+ SELECT slotlock.remove_member(${tenantRef}::text, ${actor}::text, ${person}::text) AS refusal`;
282
+ return refusalOf(row?.refusal);
283
+ },
284
+ async pendingFor(person, emails) {
285
+ if (!PERSON.test(person))
286
+ return [];
287
+ const rows = await sql `SELECT * FROM slotlock.pending_invitations_for(${person}::text, ${[...emails].slice(0, 20)}::text[])`;
288
+ return rows.map((row) => ({
289
+ id: row.invitation_id,
290
+ tenantRef: row.tenant_ref,
291
+ workspaceName: row.workspace_name,
292
+ workspaceHandle: row.workspace_handle,
293
+ inviterDisplay: row.inviter_display,
294
+ role: roleOf(row.role),
295
+ }));
296
+ },
297
+ async isActive(tenantRef) {
298
+ if (!TEAM.test(tenantRef.replace(/~test$/, '')))
299
+ return false;
300
+ const [row] = await sql `SELECT slotlock.workspace_active(${tenantRef}::text) AS active`;
301
+ return row?.active === true;
302
+ },
303
+ async prune() {
304
+ const [row] = await sql `SELECT slotlock.prune_workspace_invitations() AS pruned`;
305
+ return Number(row?.pruned ?? 0);
306
+ },
307
+ async scheduleDeletion(tenantRef, actor, handle) {
308
+ const [row] = await sql `
309
+ SELECT slotlock.schedule_team_deletion(${tenantRef}::text, ${actor}::text, ${handle}::text) AS refusal`;
310
+ return refusalOf(row?.refusal);
311
+ },
312
+ async cancelDeletion(tenantRef, actor) {
313
+ const [row] = await sql `
314
+ SELECT slotlock.cancel_team_deletion(${tenantRef}::text, ${actor}::text) AS refusal`;
315
+ return refusalOf(row?.refusal);
316
+ },
317
+ async deleteAfter(tenantRef) {
318
+ if (!TEAM.test(tenantRef.replace(/~test$/, '')))
319
+ return null;
320
+ const [row] = await sql `SELECT slotlock.workspace_delete_after(${tenantRef}::text) AS after`;
321
+ return row?.after ?? null;
322
+ },
323
+ };
324
+ }
325
+ /**
326
+ * Whether a team workspace serves its keys, for `serve`: read through slotlock.workspace_active and
327
+ * kept for `ttlMs` (30 seconds), so deleting one stops its keys on every server within that.
328
+ */
329
+ export function createSlotlockWorkspaceCheck(sql, options = {}) {
330
+ const ttlMs = options.ttlMs ?? 30_000;
331
+ const now = options.now ?? Date.now;
332
+ const maxEntries = options.maxEntries ?? 10_000;
333
+ const known = new Map();
334
+ const teams = createTeams(sql);
335
+ return (tenantRef) => {
336
+ const live = tenantRef.replace(/~test$/, '');
337
+ if (!TEAM.test(live))
338
+ return Promise.resolve(false);
339
+ const cached = known.get(live);
340
+ if (cached && now() - cached.at < ttlMs)
341
+ return cached.active;
342
+ const active = teams.isActive(live).then((found) => found, (error) => {
343
+ known.delete(live);
344
+ throw error;
345
+ });
346
+ known.delete(live);
347
+ known.set(live, { at: now(), active });
348
+ if (known.size > maxEntries)
349
+ known.delete(known.keys().next().value);
350
+ return active;
351
+ };
352
+ }
353
+ /**
354
+ * The same in memory, for tests and for a library deployment with a single server. `setRole` and
355
+ * `remove` stand in for what the Members page will do.
356
+ */
357
+ export function createSlotlockMemoryDashboardWorkspaces() {
358
+ const live = (tenantRef) => tenantRef.replace(/~test$/, '');
359
+ const memberships = new Map();
360
+ const epochs = new Map();
361
+ /** A new epoch, never the one it replaces. */
362
+ const draw = (old) => {
363
+ let next = old ?? 0;
364
+ while (next === old || next === 0)
365
+ next = 1 + Math.floor(Math.random() * 2_147_483_646);
366
+ return next;
367
+ };
368
+ const key = (tenantRef, person) => `${live(tenantRef)}\n${person}`;
369
+ return {
370
+ async signedIn(person, githubOrganizations) {
371
+ if (!PERSON.test(person))
372
+ refuse('needs github:<id> or google:<subject>');
373
+ checkMemberships(githubOrganizations);
374
+ if (githubOrganizations !== undefined) {
375
+ const listed = new Set(githubOrganizations.map((org) => `github-org:${org.id}`));
376
+ for (const org of githubOrganizations) {
377
+ const found = memberships.get(key(`github-org:${org.id}`, person));
378
+ if (found && found.source !== 'github_org')
379
+ continue;
380
+ memberships.set(key(`github-org:${org.id}`, person), {
381
+ role: org.role === 'admin' ? 'owner' : 'developer',
382
+ source: 'github_org',
383
+ active: true,
384
+ });
385
+ }
386
+ for (const [at, membership] of memberships) {
387
+ const [tenant, who] = at.split('\n');
388
+ if (who === person && membership.source === 'github_org' && !listed.has(tenant))
389
+ membership.active = false;
390
+ }
391
+ }
392
+ if (!epochs.has(person))
393
+ epochs.set(person, draw());
394
+ return epochs.get(person);
395
+ },
396
+ async scope(tenantRef, person) {
397
+ const found = memberships.get(key(tenantRef, person));
398
+ return found?.active && found.role !== 'owner' ? (found.scope ?? null) : null;
399
+ },
400
+ async standing(tenantRef, person) {
401
+ const sessionEpoch = epochs.get(person) ?? null;
402
+ if (live(tenantRef) === person)
403
+ return { role: 'owner', sessionEpoch };
404
+ const found = memberships.get(key(tenantRef, person));
405
+ return { role: found?.active ? found.role : null, sessionEpoch };
406
+ },
407
+ async signOutEverywhere(person) {
408
+ const next = draw(epochs.get(person));
409
+ epochs.set(person, next);
410
+ return next;
411
+ },
412
+ setRole(tenantRef, person, role, source = 'created') {
413
+ memberships.set(key(tenantRef, person), { role, source, active: true });
414
+ },
415
+ limit(tenantRef, person, scope) {
416
+ const found = memberships.get(key(tenantRef, person));
417
+ if (!found)
418
+ throw new Error('Slotlock limits only a member');
419
+ if (scope === null)
420
+ delete found.scope;
421
+ else
422
+ found.scope = { locations: [...scope.locations], groups: [...scope.groups] };
423
+ },
424
+ remove(tenantRef, person) {
425
+ // A removal made in Slotlock is Slotlock's: signing in again never undoes it.
426
+ const found = memberships.get(key(tenantRef, person));
427
+ if (found) {
428
+ found.active = false;
429
+ found.source = 'created';
430
+ }
431
+ },
432
+ };
433
+ }