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
@@ -0,0 +1,304 @@
1
+ // Who may do what in a workspace: six fixed roles, each a set of capabilities, and every dashboard
2
+ // route named by the capability it needs. The dashboard reads a person's role from the database on
3
+ // every request (workspaces.ts) and checks it here before any handler runs, so a role changed or a
4
+ // membership ended applies at the next request, and a route missing from the table is refused.
5
+ //
6
+ // The roles follow what API products settled on (Stripe, Vercel): a Developer holds credentials but
7
+ // not people or money; Billing and Viewer hold the least. Owner and Admin differ by money and by
8
+ // the workspace itself. A capability marked fresh also needs a sign-in in the last 15 minutes.
9
+ /** The roles, most powerful first. */
10
+ export const SLOTLOCK_WORKSPACE_ROLES = Object.freeze([
11
+ 'owner',
12
+ 'admin',
13
+ 'developer',
14
+ 'member',
15
+ 'viewer',
16
+ 'billing',
17
+ ]);
18
+ /** Each role as the dashboard names it. */
19
+ export const SLOTLOCK_WORKSPACE_ROLE_NAMES = Object.freeze({
20
+ owner: 'Owner',
21
+ admin: 'Admin',
22
+ developer: 'Developer',
23
+ member: 'Member',
24
+ viewer: 'Viewer',
25
+ billing: 'Billing',
26
+ });
27
+ export const SLOTLOCK_WORKSPACE_CAPABILITIES = Object.freeze([
28
+ /** Switch workspace or environment, sign out, see one's own account. Every role. */
29
+ 'account.use',
30
+ /** Read the workspace: overview, resources, bookings, contacts, history, key and webhook lists. */
31
+ 'workspace.read',
32
+ /** The plan, its limits and this month's use. */
33
+ 'usage.read',
34
+ /** Chat with the assistant; what it proposes still waits for a person to approve. */
35
+ 'assistant.chat',
36
+ /** Run the booking desk: approvals, the waitlist, hours, closures and holiday calendars. */
37
+ 'operations.run',
38
+ /** Resources, their booking rules, pools, locations and groups. */
39
+ 'resources.manage',
40
+ /** The test environment's seed, reset and fast-forward. */
41
+ 'sandbox.manage',
42
+ /** Read-only, read-write or resource-limited keys; rotate, revoke and limit one's own. */
43
+ 'keys.manage_own',
44
+ /** Full-access keys (the admin scope: contacts, webhooks, history, erasure). Fresh. */
45
+ 'keys.full',
46
+ /** Rotate, revoke or limit a key someone else created. */
47
+ 'keys.manage_any',
48
+ /** Add, delete and enable webhooks, which stream every change to an outside address. Fresh. */
49
+ 'webhooks.manage',
50
+ /** Forget a contact: an erasure request. */
51
+ 'contacts.forget',
52
+ /** Connect, test or disconnect the assistant's model and its key. Fresh. */
53
+ 'assistant.settings',
54
+ /** Invite, change roles and remove people, up to one's own powers. */
55
+ 'members.manage',
56
+ /** Read the audit log. */
57
+ 'audit.read',
58
+ /**
59
+ * Export the workspace's data, and find, export or erase what it keeps about one person (RFC 0004
60
+ * D3). Fresh.
61
+ */
62
+ 'privacy.manage',
63
+ /** Change the plan and open billing. Fresh. */
64
+ 'billing.manage',
65
+ /** Grant Owner or Billing, transfer, delete the workspace, link a GitHub organization. Fresh. */
66
+ 'workspace.own',
67
+ /** The team's identity: its verified domains and who may join through them. Owner only. Fresh. */
68
+ 'identity.manage',
69
+ ]);
70
+ /** Capabilities that also need a sign-in in the last 15 minutes (as GitHub's sudo mode). */
71
+ export const SLOTLOCK_FRESH_CAPABILITIES = new Set([
72
+ 'keys.full',
73
+ 'webhooks.manage',
74
+ 'assistant.settings',
75
+ 'billing.manage',
76
+ 'workspace.own',
77
+ 'privacy.manage',
78
+ 'identity.manage',
79
+ ]);
80
+ const everyCapability = SLOTLOCK_WORKSPACE_CAPABILITIES;
81
+ const without = (...left) => everyCapability.filter((capability) => !left.includes(capability));
82
+ /** What each role may do. */
83
+ export const SLOTLOCK_ROLE_CAPABILITIES = Object.freeze({
84
+ owner: new Set(everyCapability),
85
+ admin: new Set(without('billing.manage', 'workspace.own', 'identity.manage')),
86
+ developer: new Set([
87
+ 'account.use',
88
+ 'workspace.read',
89
+ 'usage.read',
90
+ 'assistant.chat',
91
+ 'operations.run',
92
+ 'resources.manage',
93
+ 'sandbox.manage',
94
+ 'keys.manage_own',
95
+ ]),
96
+ member: new Set(['account.use', 'workspace.read', 'usage.read', 'assistant.chat', 'operations.run']),
97
+ viewer: new Set(['account.use', 'workspace.read', 'usage.read', 'assistant.chat']),
98
+ billing: new Set(['account.use', 'usage.read', 'billing.manage']),
99
+ });
100
+ export function isSlotlockWorkspaceRole(value) {
101
+ return typeof value === 'string' && SLOTLOCK_WORKSPACE_ROLES.includes(value);
102
+ }
103
+ /** Whether a role may use a capability. */
104
+ export function slotlockRoleCan(role, capability) {
105
+ return role !== null && role !== undefined && SLOTLOCK_ROLE_CAPABILITIES[role].has(capability);
106
+ }
107
+ /**
108
+ * Whether `granter` may give `role` to someone: never a role with a capability the granter lacks,
109
+ * so an Admin invites up to Admin, never Owner or Billing, and only those who manage members grant
110
+ * at all.
111
+ */
112
+ export function slotlockRoleMayGrant(granter, role) {
113
+ if (!slotlockRoleCan(granter, 'members.manage'))
114
+ return false;
115
+ for (const capability of SLOTLOCK_ROLE_CAPABILITIES[role]) {
116
+ if (!slotlockRoleCan(granter, capability))
117
+ return false;
118
+ }
119
+ return true;
120
+ }
121
+ /** The API key scopes a role may give a key it creates: admin only with `keys.full`. */
122
+ export function slotlockRoleKeyScopes(role) {
123
+ if (slotlockRoleCan(role, 'keys.full'))
124
+ return ['read', 'write', 'admin'];
125
+ if (slotlockRoleCan(role, 'keys.manage_own'))
126
+ return ['read', 'write'];
127
+ return [];
128
+ }
129
+ /**
130
+ * Each dashboard POST route, by the capability it needs before its handler runs. A handler may ask
131
+ * for more where the form decides (a Full-access key asks `keys.full`; another person's key asks
132
+ * `keys.manage_any`). A route not listed is refused.
133
+ */
134
+ export const SLOTLOCK_DASHBOARD_POST_CAPABILITIES = Object.freeze({
135
+ '/sign-out': 'account.use',
136
+ '/sign-out-everywhere': 'account.use',
137
+ '/sessions/end': 'account.use',
138
+ '/organization': 'account.use',
139
+ '/environment': 'account.use',
140
+ '/teams/enable': 'account.use',
141
+ '/teams/disable': 'account.use',
142
+ // Anyone signed in may create a team workspace, join one they were invited to, or leave one.
143
+ '/teams/create': 'account.use',
144
+ // Deleting a team, or keeping one being deleted: its Owners only.
145
+ '/teams/delete': 'workspace.own',
146
+ '/teams/restore': 'workspace.own',
147
+ '/invite/accept': 'account.use',
148
+ '/members/leave': 'account.use',
149
+ // Inviting or changing someone also needs the power the role given carries (dashboard.ts
150
+ // capabilitiesOf): an Owner `workspace.own`, a Billing manager `billing.manage`.
151
+ '/members/invite': 'members.manage',
152
+ '/members/resend': 'members.manage',
153
+ '/members/revoke': 'members.manage',
154
+ '/members/role': 'members.manage',
155
+ // Limiting a member to locations and groups (RFC 0004 D1), or giving them all of it again.
156
+ '/members/scope': 'members.manage',
157
+ '/members/remove': 'members.manage',
158
+ // Approving or declining a request to join through a verified domain.
159
+ '/members/requests': 'members.manage',
160
+ // Asking to join a team through a verified address: anyone signed in, for a team they are not in.
161
+ '/join/request': 'account.use',
162
+ // A team's domains, and holding invitations to them: its Owners only.
163
+ '/identity/domains': 'identity.manage',
164
+ '/identity/domains/verify': 'identity.manage',
165
+ '/identity/domains/remove': 'identity.manage',
166
+ '/identity/domains/policy': 'identity.manage',
167
+ '/identity/invites': 'identity.manage',
168
+ // A team's single sign-on: its Owners only.
169
+ '/identity/sso': 'identity.manage',
170
+ '/identity/sso/test': 'identity.manage',
171
+ '/identity/sso/status': 'identity.manage',
172
+ '/identity/sso/delete': 'identity.manage',
173
+ '/identity/sso/provisioning': 'identity.manage',
174
+ '/identity/sso/mapping': 'identity.manage',
175
+ '/identity/sso/require': 'identity.manage',
176
+ '/identity/scim/enable': 'identity.manage',
177
+ '/identity/scim/disable': 'identity.manage',
178
+ '/identity/scim/token': 'identity.manage',
179
+ '/identity/scim/token/revoke': 'identity.manage',
180
+ '/identity/scim/group': 'identity.manage',
181
+ // Signing in through one's team's provider (to link it or prove oneself), or an Owner's bypass:
182
+ // from wherever one is, for a team the handler and the database check one belongs to.
183
+ '/sso/proof': 'account.use',
184
+ '/sso/bypass': 'account.use',
185
+ '/keys': 'keys.manage_own',
186
+ '/keys/rotate': 'keys.manage_own',
187
+ '/keys/revoke': 'keys.manage_own',
188
+ '/keys/limits': 'keys.manage_own',
189
+ '/resources': 'resources.manage',
190
+ '/resources/rules': 'resources.manage',
191
+ '/resources/pool': 'resources.manage',
192
+ '/resources/profile': 'resources.manage',
193
+ '/resources/place': 'resources.manage',
194
+ '/resources/parent': 'resources.manage',
195
+ '/locations': 'resources.manage',
196
+ '/locations/delete': 'resources.manage',
197
+ '/groups': 'resources.manage',
198
+ '/groups/delete': 'resources.manage',
199
+ '/webhooks': 'webhooks.manage',
200
+ '/webhooks/delete': 'webhooks.manage',
201
+ '/webhooks/enable': 'webhooks.manage',
202
+ '/waitlist/leave': 'operations.run',
203
+ '/approvals/decide': 'operations.run',
204
+ '/hours': 'operations.run',
205
+ '/closures': 'operations.run',
206
+ '/closures/delete': 'operations.run',
207
+ '/closures/import': 'operations.run',
208
+ '/closures/calendars/delete': 'operations.run',
209
+ '/sandbox/seed': 'sandbox.manage',
210
+ '/sandbox/reset': 'sandbox.manage',
211
+ '/sandbox/fast-forward': 'sandbox.manage',
212
+ '/contacts/forget': 'contacts.forget',
213
+ '/assistant': 'assistant.chat',
214
+ '/assistant/stream': 'assistant.chat',
215
+ '/assistant/reset': 'assistant.chat',
216
+ '/assistant/rename': 'assistant.chat',
217
+ '/assistant/delete': 'assistant.chat',
218
+ // Approving runs the change the assistant proposed: as the desk would.
219
+ '/assistant/approve': 'operations.run',
220
+ '/assistant/dismiss': 'assistant.chat',
221
+ '/settings/assistant': 'assistant.settings',
222
+ '/settings/assistant/test': 'assistant.settings',
223
+ '/settings/assistant/models': 'assistant.settings',
224
+ '/settings/assistant/remove': 'assistant.settings',
225
+ '/plan/upgrade': 'billing.manage',
226
+ '/plan/manage': 'billing.manage',
227
+ '/settings/privacy/export': 'privacy.manage',
228
+ '/settings/privacy/export/delete': 'privacy.manage',
229
+ '/settings/privacy/person': 'privacy.manage',
230
+ '/settings/privacy/person/export': 'privacy.manage',
231
+ '/settings/privacy/person/erase': 'privacy.manage',
232
+ });
233
+ /** Each dashboard page (GET), by the capability it needs. A page not listed is refused. */
234
+ export const SLOTLOCK_DASHBOARD_PAGE_CAPABILITIES = Object.freeze({
235
+ '/': 'workspace.read',
236
+ '/keys': 'workspace.read',
237
+ '/resources': 'workspace.read',
238
+ '/resources/:id': 'workspace.read',
239
+ '/webhooks': 'workspace.read',
240
+ '/waitlist': 'workspace.read',
241
+ '/approvals': 'workspace.read',
242
+ '/hours': 'workspace.read',
243
+ '/closures': 'workspace.read',
244
+ '/contacts': 'workspace.read',
245
+ '/locations': 'workspace.read',
246
+ '/assistant': 'assistant.chat',
247
+ '/assistant/chats': 'assistant.chat',
248
+ '/plan': 'usage.read',
249
+ '/teams': 'account.use',
250
+ '/settings': 'workspace.read',
251
+ '/settings/endpoints': 'workspace.read',
252
+ '/settings/hours': 'workspace.read',
253
+ '/settings/account': 'account.use',
254
+ '/settings/members': 'workspace.read',
255
+ '/settings/audit': 'audit.read',
256
+ '/settings/audit/export': 'audit.read',
257
+ '/settings/team': 'workspace.own',
258
+ '/settings/privacy': 'privacy.manage',
259
+ '/settings/privacy/download': 'privacy.manage',
260
+ '/settings/identity': 'identity.manage',
261
+ });
262
+ /**
263
+ * A member limited to some locations and groups (RFC 0004 D1) keeps at most these of their role's
264
+ * capabilities: they read and run the desk for those resources, and chat with an assistant that
265
+ * reaches only them. Nothing that reaches the whole workspace (keys, webhooks, people, money, its
266
+ * hours and closures, its data whole or a person's) is theirs.
267
+ */
268
+ export const SLOTLOCK_SCOPED_CAPABILITIES = new Set([
269
+ 'account.use',
270
+ 'workspace.read',
271
+ 'assistant.chat',
272
+ 'operations.run',
273
+ ]);
274
+ /**
275
+ * The pages a limited member reaches: what the store reads for them is held to their resources by
276
+ * the database (resource_scope_v1), and pages whose content is the whole workspace's (keys,
277
+ * webhooks, contacts, hours, closures, locations, usage, the audit log) are not among them.
278
+ */
279
+ export const SLOTLOCK_SCOPED_DASHBOARD_PAGES = new Set([
280
+ '/',
281
+ '/resources',
282
+ '/resources/:id',
283
+ '/waitlist',
284
+ '/approvals',
285
+ '/assistant',
286
+ '/assistant/chats',
287
+ '/teams',
288
+ '/settings',
289
+ '/settings/account',
290
+ '/settings/members',
291
+ ]);
292
+ /** The forms a limited member may send: their own account's, and the desk's for their resources. */
293
+ export const SLOTLOCK_SCOPED_DASHBOARD_POSTS = new Set([
294
+ ...Object.entries(SLOTLOCK_DASHBOARD_POST_CAPABILITIES)
295
+ .filter(([, capability]) => capability === 'account.use' || capability === 'assistant.chat')
296
+ .map(([route]) => route),
297
+ '/waitlist/leave',
298
+ '/approvals/decide',
299
+ '/assistant/approve',
300
+ ]);
301
+ /** Whether a role, limited (`scoped`) or not, may use a capability. */
302
+ export function slotlockMemberCan(role, scoped, capability) {
303
+ return slotlockRoleCan(role, capability) && (!scoped || SLOTLOCK_SCOPED_CAPABILITIES.has(capability));
304
+ }
@@ -0,0 +1,89 @@
1
+ import type { SlotlockScimGroupAttributes, SlotlockScimUserAttributes } from './scim.js';
2
+ import type { SlotlockSql } from './store.js';
3
+ /** A SCIM token: `slk_scim_` and 256 random bits in hex. */
4
+ export declare const SLOTLOCK_SCIM_TOKEN: RegExp;
5
+ export type SlotlockScimRefusal = 'not_found' | 'not_allowed' | 'not_enabled' | 'no_verified_domain' | 'key_policy' | 'token_limit' | 'bad_token' | 'wrong_workspace' | 'domain_not_verified' | 'uniqueness' | 'owner_protected' | 'invalid_member' | 'owner_not_mappable' | 'role_not_allowed';
6
+ /** A directory user as kept, with its id and times. */
7
+ export interface SlotlockScimUser extends SlotlockScimUserAttributes {
8
+ id: string;
9
+ createdAt: Date;
10
+ updatedAt: Date;
11
+ }
12
+ export interface SlotlockScimGroup extends SlotlockScimGroupAttributes {
13
+ id: string;
14
+ createdAt: Date;
15
+ updatedAt: Date;
16
+ /** The role an Owner mapped the group to (reserved names map themselves), or null. Not part of SCIM. */
17
+ role: string | null;
18
+ }
19
+ /** A filter as the endpoint passes it: one attribute (username, externalid, id, email / displayname). */
20
+ export type SlotlockScimLookup = {
21
+ attribute: string;
22
+ value: string;
23
+ } | null;
24
+ export interface SlotlockWorkspaceScim {
25
+ enable(tenantRef: string, actor: string, keyPolicy: 'keep' | 'revoke'): Promise<SlotlockScimRefusal | null>;
26
+ disable(tenantRef: string, actor: string): Promise<SlotlockScimRefusal | null>;
27
+ /** A new token, shown once: the raw value, or why not. */
28
+ createToken(tenantRef: string, actor: string): Promise<{
29
+ token: string;
30
+ prefix: string;
31
+ } | {
32
+ refusal: SlotlockScimRefusal;
33
+ }>;
34
+ revokeToken(tenantRef: string, actor: string, id: string): Promise<SlotlockScimRefusal | null>;
35
+ state(tenantRef: string): Promise<{
36
+ enabled: boolean;
37
+ keyPolicy: 'keep' | 'revoke' | null;
38
+ tokens: {
39
+ id: string;
40
+ prefix: string;
41
+ createdAt: Date;
42
+ lastUsedAt: Date | null;
43
+ }[];
44
+ }>;
45
+ /** A request's token, for the team its address names. */
46
+ authenticate(token: string, teamId: string): Promise<{
47
+ tenantRef: string;
48
+ prefix: string;
49
+ keyPolicy: 'keep' | 'revoke';
50
+ } | {
51
+ refusal: 'bad_token' | 'wrong_workspace';
52
+ tenantRef?: string;
53
+ prefix?: string;
54
+ }>;
55
+ putUser(tenantRef: string, id: string | null, newId: string, user: SlotlockScimUserAttributes): Promise<{
56
+ id: string;
57
+ membership: 'none' | 'deactivated' | 'activated';
58
+ person: string | null;
59
+ } | {
60
+ refusal: SlotlockScimRefusal;
61
+ person?: string | null;
62
+ }>;
63
+ users(tenantRef: string, lookup: SlotlockScimLookup, startIndex: number, count: number, id?: string): Promise<{
64
+ users: SlotlockScimUser[];
65
+ total: number;
66
+ }>;
67
+ deleteUser(tenantRef: string, id: string): Promise<{
68
+ refusal: SlotlockScimRefusal | null;
69
+ person: string | null;
70
+ }>;
71
+ putGroup(tenantRef: string, id: string | null, newId: string, group: SlotlockScimGroupAttributes): Promise<{
72
+ id: string;
73
+ } | {
74
+ refusal: SlotlockScimRefusal;
75
+ }>;
76
+ groups(tenantRef: string, lookup: SlotlockScimLookup, startIndex: number, count: number, id?: string): Promise<{
77
+ groups: SlotlockScimGroup[];
78
+ total: number;
79
+ }>;
80
+ deleteGroup(tenantRef: string, id: string): Promise<SlotlockScimRefusal | null>;
81
+ mapGroup(tenantRef: string, actor: string, id: string, role: string | null): Promise<SlotlockScimRefusal | null>;
82
+ /**
83
+ * A sign-in holding addresses the domain vouches for: the directory users at them bind to the
84
+ * person. `freeCap` is the member cap of a team on the free plan, where plans apply, else null.
85
+ */
86
+ bind(person: string, emails: readonly string[], freeCap?: number | null): Promise<string[]>;
87
+ }
88
+ /** Through the functions of ddl-scim.ts. */
89
+ export declare function createSlotlockWorkspaceScim(sql: SlotlockSql): SlotlockWorkspaceScim;
@@ -0,0 +1,157 @@
1
+ // A team's directory (SCIM 2.0) as the dashboard and its SCIM endpoint reach it (teams phase
2
+ // 5.6–5.7): through the SECURITY DEFINER functions of ddl-scim.ts. Tokens are made here and shown
3
+ // once; only their SHA-256 reaches the database.
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+ const TEAM = /^team:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ /** A SCIM token: `slk_scim_` and 256 random bits in hex. */
8
+ export const SLOTLOCK_SCIM_TOKEN = /^slk_scim_[0-9a-f]{64}$/;
9
+ const digest = (token) => createHash('sha256').update(token, 'utf8').digest();
10
+ const refusalOf = (value) => value === null || value === undefined ? null : value;
11
+ /** Through the functions of ddl-scim.ts. */
12
+ export function createSlotlockWorkspaceScim(sql) {
13
+ return {
14
+ async enable(tenantRef, actor, keyPolicy) {
15
+ const [row] = await sql `SELECT slotlock.enable_scim(${tenantRef}::text, ${actor}::text, ${keyPolicy}::text) AS refusal`;
16
+ return refusalOf(row?.refusal);
17
+ },
18
+ async disable(tenantRef, actor) {
19
+ const [row] = await sql `SELECT slotlock.disable_scim(${tenantRef}::text, ${actor}::text) AS refusal`;
20
+ return refusalOf(row?.refusal);
21
+ },
22
+ async createToken(tenantRef, actor) {
23
+ const token = `slk_scim_${randomBytes(32).toString('hex')}`;
24
+ const prefix = token.slice(0, 'slk_scim_'.length + 4);
25
+ const [row] = await sql `
26
+ SELECT slotlock.create_scim_token(${tenantRef}::text, ${actor}::text, ${digest(token)}::bytea, ${prefix}::text) AS refusal`;
27
+ return row?.refusal ? { refusal: refusalOf(row.refusal) ?? 'not_allowed' } : { token, prefix };
28
+ },
29
+ async revokeToken(tenantRef, actor, id) {
30
+ if (!UUID.test(id))
31
+ return 'not_found';
32
+ const [row] = await sql `SELECT slotlock.revoke_scim_token(${tenantRef}::text, ${actor}::text, ${id}::uuid) AS refusal`;
33
+ return refusalOf(row?.refusal);
34
+ },
35
+ async state(tenantRef) {
36
+ const rows = await sql `
37
+ SELECT * FROM slotlock.scim_state(${tenantRef}::text)`;
38
+ return {
39
+ enabled: rows[0]?.enabled === true,
40
+ keyPolicy: rows[0]?.key_policy ?? null,
41
+ tokens: rows.flatMap((row) => row.token_id && row.prefix && row.created_at ? [{ id: row.token_id, prefix: row.prefix, createdAt: row.created_at, lastUsedAt: row.last_used_at }] : []),
42
+ };
43
+ },
44
+ async authenticate(token, teamId) {
45
+ if (!SLOTLOCK_SCIM_TOKEN.test(token) || !UUID.test(teamId))
46
+ return { refusal: 'bad_token' };
47
+ const [row] = await sql `
48
+ SELECT * FROM slotlock.scim_authenticate(${digest(token)}::bytea, ${`team:${teamId.toLowerCase()}`}::text)`;
49
+ if (!row || row.refusal === 'bad_token' || (row.refusal === null && (!row.team_ref || !row.key_policy)))
50
+ return { refusal: 'bad_token' };
51
+ if (row.refusal === 'wrong_workspace') {
52
+ return { refusal: 'wrong_workspace', ...(row.team_ref ? { tenantRef: row.team_ref } : {}), ...(row.token_prefix ? { prefix: row.token_prefix } : {}) };
53
+ }
54
+ return { tenantRef: row.team_ref, prefix: row.token_prefix, keyPolicy: row.key_policy };
55
+ },
56
+ async putUser(tenantRef, id, newId, user) {
57
+ if (!TEAM.test(tenantRef) || (id !== null && !UUID.test(id)) || !UUID.test(newId))
58
+ return { refusal: 'not_found' };
59
+ const [row] = await sql `
60
+ SELECT * FROM slotlock.scim_put_user(${tenantRef}::text, ${id}::uuid, ${newId}::uuid, ${user.userName}::text,
61
+ ${user.externalId}::text, ${user.active}::boolean, ${user.givenName}::text, ${user.familyName}::text,
62
+ ${user.displayName}::text, ${user.workEmail}::text)`;
63
+ if (!row || row.refusal)
64
+ return { refusal: refusalOf(row?.refusal) ?? 'not_found', person: row?.person ?? null };
65
+ return {
66
+ id: row.user_id,
67
+ membership: row.membership === 'deactivated' || row.membership === 'activated' ? row.membership : 'none',
68
+ person: row.person,
69
+ };
70
+ },
71
+ async users(tenantRef, lookup, startIndex, count, id) {
72
+ if (!TEAM.test(tenantRef) || (id !== undefined && !UUID.test(id)))
73
+ return { users: [], total: 0 };
74
+ const rows = await sql `SELECT * FROM slotlock.scim_users_list(${tenantRef}::text, ${lookup?.attribute ?? null}::text, ${lookup?.value ?? null}::text,
75
+ ${startIndex}::integer, ${count}::integer, ${id ?? null}::uuid)`;
76
+ // A page past the end (or count=0) has no rows to carry the total: one row from the start does.
77
+ const total = rows[0]?.total ??
78
+ (startIndex > 1 || count === 0
79
+ ? ((await sql `SELECT total FROM slotlock.scim_users_list(${tenantRef}::text, ${lookup?.attribute ?? null}::text,
80
+ ${lookup?.value ?? null}::text, 1, 1, ${id ?? null}::uuid)`)[0]?.total ?? 0)
81
+ : 0);
82
+ return {
83
+ total: Number(total),
84
+ users: rows.map((row) => ({
85
+ id: row.user_id,
86
+ userName: row.user_name,
87
+ externalId: row.external_id,
88
+ active: row.active,
89
+ givenName: row.given_name,
90
+ familyName: row.family_name,
91
+ displayName: row.display_name,
92
+ workEmail: row.work_email,
93
+ createdAt: row.created_at,
94
+ updatedAt: row.updated_at,
95
+ })),
96
+ };
97
+ },
98
+ async deleteUser(tenantRef, id) {
99
+ if (!UUID.test(id))
100
+ return { refusal: 'not_found', person: null };
101
+ const [row] = await sql `SELECT * FROM slotlock.scim_delete_user(${tenantRef}::text, ${id}::uuid)`;
102
+ return { refusal: refusalOf(row?.refusal), person: row?.person ?? null };
103
+ },
104
+ async putGroup(tenantRef, id, newId, group) {
105
+ if (!TEAM.test(tenantRef) || (id !== null && !UUID.test(id)) || !UUID.test(newId))
106
+ return { refusal: 'not_found' };
107
+ if (!group.members.every((member) => UUID.test(member)))
108
+ return { refusal: 'invalid_member' };
109
+ const [row] = await sql `
110
+ SELECT * FROM slotlock.scim_put_group(${tenantRef}::text, ${id}::uuid, ${newId}::uuid, ${group.displayName}::text,
111
+ ${group.externalId}::text, ${group.members}::uuid[])`;
112
+ return row?.group_id && !row.refusal ? { id: row.group_id } : { refusal: refusalOf(row?.refusal) ?? 'not_found' };
113
+ },
114
+ async groups(tenantRef, lookup, startIndex, count, id) {
115
+ if (!TEAM.test(tenantRef) || (id !== undefined && !UUID.test(id)))
116
+ return { groups: [], total: 0 };
117
+ const rows = await sql `SELECT * FROM slotlock.scim_groups_list(${tenantRef}::text, ${lookup?.attribute ?? null}::text, ${lookup?.value ?? null}::text,
118
+ ${startIndex}::integer, ${count}::integer, ${id ?? null}::uuid)`;
119
+ const total = rows[0]?.total ??
120
+ (startIndex > 1 || count === 0
121
+ ? ((await sql `SELECT total FROM slotlock.scim_groups_list(${tenantRef}::text, ${lookup?.attribute ?? null}::text,
122
+ ${lookup?.value ?? null}::text, 1, 1, ${id ?? null}::uuid)`)[0]?.total ?? 0)
123
+ : 0);
124
+ return {
125
+ total: Number(total),
126
+ groups: rows.map((row) => ({
127
+ id: row.group_id,
128
+ displayName: row.display_name,
129
+ externalId: row.external_id,
130
+ members: row.members,
131
+ createdAt: row.created_at,
132
+ updatedAt: row.updated_at,
133
+ role: row.mapped_role,
134
+ })),
135
+ };
136
+ },
137
+ async deleteGroup(tenantRef, id) {
138
+ if (!UUID.test(id))
139
+ return 'not_found';
140
+ const [row] = await sql `SELECT slotlock.scim_delete_group(${tenantRef}::text, ${id}::uuid) AS refusal`;
141
+ return refusalOf(row?.refusal);
142
+ },
143
+ async mapGroup(tenantRef, actor, id, role) {
144
+ if (!UUID.test(id))
145
+ return 'not_found';
146
+ const [row] = await sql `
147
+ SELECT slotlock.map_scim_group_role(${tenantRef}::text, ${actor}::text, ${id}::uuid, ${role}::text) AS refusal`;
148
+ return refusalOf(row?.refusal);
149
+ },
150
+ async bind(person, emails, freeCap) {
151
+ if (emails.length === 0)
152
+ return [];
153
+ const rows = await sql `SELECT team_ref FROM slotlock.scim_bind(${person}::text, ${[...emails].slice(0, 10)}::text[], ${freeCap ?? null}::integer)`;
154
+ return rows.map((row) => row.team_ref);
155
+ },
156
+ };
157
+ }