slotlock 0.8.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 (86) hide show
  1. package/CHANGELOG.md +542 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-server.d.ts +25 -5
  5. package/agent-server.js +152 -7
  6. package/agent-store-backend.d.ts +6 -1
  7. package/agent-store-backend.js +1068 -68
  8. package/application-api.d.ts +28 -1
  9. package/application-api.js +789 -21
  10. package/approvals.d.ts +2 -0
  11. package/approvals.js +12 -5
  12. package/assistant.d.ts +34 -8
  13. package/assistant.js +81 -26
  14. package/availability-store.d.ts +193 -0
  15. package/availability-store.js +566 -0
  16. package/booking-rules.d.ts +103 -5
  17. package/booking-rules.js +263 -15
  18. package/config.d.ts +50 -0
  19. package/config.js +107 -0
  20. package/dashboard-assistant-ui.d.ts +1 -1
  21. package/dashboard-assistant-ui.js +109 -8
  22. package/dashboard-scope.d.ts +7 -0
  23. package/dashboard-scope.js +21 -0
  24. package/dashboard.d.ts +152 -8
  25. package/dashboard.js +3868 -399
  26. package/ddl-identity.d.ts +25 -0
  27. package/ddl-identity.js +881 -0
  28. package/ddl-scim.d.ts +5 -0
  29. package/ddl-scim.js +740 -0
  30. package/ddl-sso.d.ts +15 -0
  31. package/ddl-sso.js +1033 -0
  32. package/ddl.d.ts +44 -9
  33. package/ddl.js +2872 -47
  34. package/domains.d.ts +117 -0
  35. package/domains.js +340 -0
  36. package/event-feed.d.ts +2 -2
  37. package/event-feed.js +24 -34
  38. package/holiday-import.d.ts +38 -0
  39. package/holiday-import.js +56 -0
  40. package/holidays.d.ts +38 -0
  41. package/holidays.js +209 -0
  42. package/hours.d.ts +125 -0
  43. package/hours.js +224 -0
  44. package/index.d.ts +24 -5
  45. package/index.js +17 -3
  46. package/interop.d.ts +25 -7
  47. package/interop.js +145 -17
  48. package/invitation-email.d.ts +26 -0
  49. package/invitation-email.js +73 -0
  50. package/lifecycle.d.ts +31 -0
  51. package/lifecycle.js +115 -0
  52. package/maintenance.d.ts +26 -2
  53. package/maintenance.js +70 -2
  54. package/metrics.js +3 -0
  55. package/oidc.d.ts +135 -0
  56. package/oidc.js +452 -0
  57. package/openapi.json +20172 -9850
  58. package/package.json +2 -1
  59. package/ranges.d.ts +28 -0
  60. package/ranges.js +135 -0
  61. package/resource-search.d.ts +19 -0
  62. package/resource-search.js +153 -0
  63. package/rest-api.js +7 -0
  64. package/rules.d.ts +2 -0
  65. package/rules.js +2 -0
  66. package/scim-server.d.ts +19 -0
  67. package/scim-server.js +243 -0
  68. package/scim.d.ts +180 -0
  69. package/scim.js +416 -0
  70. package/self-host.d.ts +19 -6
  71. package/self-host.js +483 -28
  72. package/slotlock.manifest.json +1 -1
  73. package/store.d.ts +637 -23
  74. package/store.js +2529 -147
  75. package/types.d.ts +230 -0
  76. package/types.js +68 -1
  77. package/workspace-domains.d.ts +146 -0
  78. package/workspace-domains.js +249 -0
  79. package/workspace-roles.d.ts +47 -0
  80. package/workspace-roles.js +304 -0
  81. package/workspace-scim.d.ts +89 -0
  82. package/workspace-scim.js +157 -0
  83. package/workspace-sso.d.ts +131 -0
  84. package/workspace-sso.js +180 -0
  85. package/workspaces.d.ts +240 -0
  86. package/workspaces.js +433 -0
@@ -0,0 +1,240 @@
1
+ import type { SlotlockSql } from './store.js';
2
+ import { type SlotlockWorkspaceDomains } from './workspace-domains.js';
3
+ import { type SlotlockWorkspaceSso } from './workspace-sso.js';
4
+ import { type SlotlockWorkspaceScim } from './workspace-scim.js';
5
+ import { type SlotlockWorkspaceRole } from './workspace-roles.js';
6
+ import { type SlotlockResourceScope } from './resource-profile.js';
7
+ /** A GitHub organization the person is an active owner (`admin`) or member of, as GitHub lists it. */
8
+ export interface SlotlockGitHubMembership {
9
+ id: string;
10
+ role: 'admin' | 'member';
11
+ }
12
+ /** A person's standing in one workspace now. */
13
+ export interface SlotlockWorkspaceStanding {
14
+ /** Their role, or null when they do not belong to it (any longer). */
15
+ role: SlotlockWorkspaceRole | null;
16
+ /**
17
+ * The epoch their sessions must carry, or null when the person has no record (never signed in
18
+ * with this store, or erased): then no session passes. "Sign out everywhere" draws a new one.
19
+ */
20
+ sessionEpoch: number | null;
21
+ /**
22
+ * Why there is no role though the person is a member: the team requires single sign-on and this
23
+ * session holds no recent proof (`sso_required`), or the session was signed in through another
24
+ * team's provider alone (`sso_scope`). Null otherwise.
25
+ */
26
+ refusal?: 'sso_required' | 'sso_scope' | null;
27
+ /** With sso_required: whether this person (an Owner who signs in with GitHub or Google) may bypass. */
28
+ mayBypass?: boolean;
29
+ }
30
+ /** What the dashboard reads and records of people and their memberships. */
31
+ export interface SlotlockDashboardWorkspaces {
32
+ /**
33
+ * Record a sign-in, returning the epoch to seal into the session. Signing in with GitHub, pass
34
+ * the organizations GitHub lists the person in: they become memberships (owners Owner, members
35
+ * Developer), and one GitHub no longer lists ends.
36
+ */
37
+ signedIn(person: string, githubOrganizations?: readonly SlotlockGitHubMembership[]): Promise<number>;
38
+ /**
39
+ * The person's role in a tenant (its test environment is its live tenant's) and their epoch, for
40
+ * this session (its id, as the cookie holds it, or null where there is none): a team that requires
41
+ * single sign-on gives a session without a recent proof no role, and a session scoped to a team's
42
+ * provider has none elsewhere. Required, so no caller with a session leaves its scope unchecked.
43
+ */
44
+ standing(tenantRef: string, person: string, sid: string | null): Promise<SlotlockWorkspaceStanding>;
45
+ /**
46
+ * The locations and groups the person is limited to in the tenant (RFC 0004 D1), or null for all
47
+ * of it. An Owner is never limited.
48
+ */
49
+ scope?(tenantRef: string, person: string): Promise<SlotlockResourceScope | null>;
50
+ /** End every session of the person: a new epoch is drawn, and those sealed under the old are refused. */
51
+ signOutEverywhere(person: string): Promise<number>;
52
+ /** Team workspaces, their members and invitations: only with the database (createSlotlockDashboardWorkspaces). */
53
+ teams?: SlotlockWorkspaceTeams;
54
+ /** Each session a person signed in with, to list and sign out one by one: only with the database. */
55
+ sessions?: SlotlockDashboardSessions;
56
+ /** Teams' verified domains and requests to join through them (workspace-domains.ts): only with the database. */
57
+ domains?: SlotlockWorkspaceDomains;
58
+ /** Teams' single sign-on (workspace-sso.ts): only with the database. */
59
+ sso?: SlotlockWorkspaceSso;
60
+ /** Teams' SCIM provisioning (workspace-scim.ts): only with the database. */
61
+ scim?: SlotlockWorkspaceScim;
62
+ }
63
+ /** A session a person signed in with, as their Account page lists it. */
64
+ export interface SlotlockDashboardSession {
65
+ /** The SHA-256 of the session's id, in hex: names it without revealing it. */
66
+ id: string;
67
+ startedAt: Date;
68
+ lastSeenAt: Date;
69
+ expiresAt: Date;
70
+ userAgent: string | null;
71
+ ipAddress: string | null;
72
+ }
73
+ /** Sessions by person (slotlock.dashboard_sessions): only a digest of each session's id is kept. */
74
+ export interface SlotlockDashboardSessions {
75
+ started(person: string, sid: string, expiresAt: Date, from: {
76
+ userAgent?: string;
77
+ ipAddress?: string;
78
+ }): Promise<void>;
79
+ /** Whether the session may go on: false once signed out from another device. Notes it seen. */
80
+ seen(person: string, sid: string): Promise<boolean>;
81
+ list(person: string): Promise<SlotlockDashboardSession[]>;
82
+ /** Sign out one session by its id (SlotlockDashboardSession.id), or every one with null. */
83
+ end(person: string, id: string | null): Promise<boolean>;
84
+ /** The id a session is listed under. */
85
+ idOf(sid: string): string;
86
+ }
87
+ /** A team workspace (team:<uuid>) as a person sees it in their list. */
88
+ export interface SlotlockTeamWorkspace {
89
+ tenantRef: string;
90
+ handle: string;
91
+ name: string;
92
+ role: SlotlockWorkspaceRole;
93
+ }
94
+ /** An active member of a workspace. */
95
+ export interface SlotlockWorkspaceMember {
96
+ person: string;
97
+ role: SlotlockWorkspaceRole;
98
+ source: 'github_org' | 'invitation' | 'created';
99
+ /** Their GitHub login or verified Google email as they last signed in; null before they have. */
100
+ display: string | null;
101
+ joinedAt: Date;
102
+ }
103
+ /** An invitation not accepted or revoked: pending, or expired in the last 30 days. */
104
+ export interface SlotlockWorkspaceInvitation {
105
+ id: string;
106
+ email: string | null;
107
+ githubLogin: string | null;
108
+ role: SlotlockWorkspaceRole;
109
+ inviterDisplay: string;
110
+ sentAt: Date;
111
+ expiresAt: Date;
112
+ }
113
+ /** What the holder of an invitation's link sees before joining. */
114
+ export interface SlotlockInvitationPreview {
115
+ id: string;
116
+ tenantRef: string;
117
+ workspaceName: string;
118
+ workspaceHandle: string;
119
+ inviterDisplay: string;
120
+ role: SlotlockWorkspaceRole;
121
+ email: string | null;
122
+ githubRef: string | null;
123
+ githubLogin: string | null;
124
+ state: 'pending' | 'accepted' | 'revoked' | 'expired';
125
+ }
126
+ /** Why a team change was refused. */
127
+ export type SlotlockTeamRefusal = 'invalid_handle' | 'invalid_name' | 'handle_taken' | 'team_limit' | 'not_found' | 'not_allowed' | 'already_member' | 'invitation_limit'
128
+ /** The address was sent SLOTLOCK_INVITATIONS_PER_ADDRESS invitations in the last day. */
129
+ | 'recipient_limit' | 'member_cap' | 'revoked' | 'accepted' | 'expired' | 'not_for_you' | 'last_owner' | 'sso_owner' | 'confirm' | 'subscription_live' | 'domain_restricted'
130
+ /** An Owner reaches the whole workspace: never limited. */
131
+ | 'owner';
132
+ /**
133
+ * Team workspaces through their SECURITY DEFINER functions: each checks the actor's role and
134
+ * ceiling, keeps an Owner, and serialises changes to one workspace's people.
135
+ */
136
+ export interface SlotlockWorkspaceTeams {
137
+ /** How the person is shown to others: their GitHub login or verified Google email. */
138
+ setDisplay(person: string, display: string | null): Promise<void>;
139
+ /** A new team workspace with the person its Owner; `cap` free team workspaces a person may create. */
140
+ create(person: string, name: string, handle: string, cap: number | null): Promise<{
141
+ tenantRef: string;
142
+ } | {
143
+ refusal: SlotlockTeamRefusal;
144
+ }>;
145
+ info(tenantRef: string): Promise<{
146
+ handle: string;
147
+ name: string;
148
+ } | null>;
149
+ /** The team workspaces the person is an active member of. */
150
+ workspacesOf(person: string): Promise<SlotlockTeamWorkspace[]>;
151
+ members(tenantRef: string): Promise<SlotlockWorkspaceMember[]>;
152
+ invitations(tenantRef: string): Promise<SlotlockWorkspaceInvitation[]>;
153
+ invite(params: {
154
+ tenantRef: string;
155
+ inviter: string;
156
+ to: {
157
+ email: string;
158
+ } | {
159
+ github: string;
160
+ login: string;
161
+ };
162
+ role: SlotlockWorkspaceRole;
163
+ /** SHA-256 of the token; the token itself is never given. */
164
+ tokenSha256: Buffer;
165
+ days: number;
166
+ dailyLimit: number | null;
167
+ memberCap: number | null;
168
+ }): Promise<{
169
+ id: string;
170
+ } | {
171
+ refusal: SlotlockTeamRefusal;
172
+ }>;
173
+ preview(tokenSha256: Buffer): Promise<SlotlockInvitationPreview | null>;
174
+ /** Accept by the token's digest or, one found for the person, by id. `emails` are those the person holds verified. */
175
+ accept(params: {
176
+ tokenSha256?: Buffer;
177
+ id?: string;
178
+ person: string;
179
+ emails: readonly string[];
180
+ memberCap: number | null;
181
+ }): Promise<{
182
+ tenantRef: string;
183
+ } | {
184
+ refusal: SlotlockTeamRefusal;
185
+ }>;
186
+ revoke(tenantRef: string, actor: string, id: string): Promise<boolean>;
187
+ /** Send again with a new token (the old stops working), held to the limits a new one is. */
188
+ resend(tenantRef: string, actor: string, id: string, tokenSha256: Buffer, days: number, dailyLimit: number | null, memberCap: number | null): Promise<SlotlockTeamRefusal | null>;
189
+ changeRole(tenantRef: string, actor: string, person: string, role: SlotlockWorkspaceRole): Promise<SlotlockTeamRefusal | null>;
190
+ /**
191
+ * Limit a member to locations and groups, or give them the whole workspace again (null): by an
192
+ * Owner or Admin not limited themselves, of a member not an Owner (`owner`) whose role they may
193
+ * grant.
194
+ */
195
+ setScope(tenantRef: string, actor: string, person: string, scope: SlotlockResourceScope | null): Promise<SlotlockTeamRefusal | null>;
196
+ /** Each member limited to part of the workspace, by person, with what they are limited to. */
197
+ scopes(tenantRef: string): Promise<ReadonlyMap<string, SlotlockResourceScope>>;
198
+ /** Remove a member; the actor removing themselves leaves. */
199
+ remove(tenantRef: string, actor: string, person: string): Promise<SlotlockTeamRefusal | null>;
200
+ /** Pending invitations meant for the person (their GitHub account, or an email they hold verified). */
201
+ pendingFor(person: string, emails: readonly string[]): Promise<{
202
+ id: string;
203
+ tenantRef: string;
204
+ workspaceName: string;
205
+ workspaceHandle: string;
206
+ inviterDisplay: string;
207
+ role: SlotlockWorkspaceRole;
208
+ }[]>;
209
+ /** Whether a team workspace exists and serves its keys. */
210
+ isActive(tenantRef: string): Promise<boolean>;
211
+ /** Delete invitations finished more than 30 days ago; how many went. */
212
+ prune(): Promise<number>;
213
+ /** Delete the team in seven days, by an Owner who typed its handle; its keys stop at once. */
214
+ scheduleDeletion(tenantRef: string, actor: string, handle: string): Promise<SlotlockTeamRefusal | null>;
215
+ /** Keep a team being deleted, by any of its Owners. */
216
+ cancelDeletion(tenantRef: string, actor: string): Promise<SlotlockTeamRefusal | null>;
217
+ /** When a team being deleted goes, or null. */
218
+ deleteAfter(tenantRef: string): Promise<Date | null>;
219
+ }
220
+ /** Through slotlock.dashboard_sign_in, dashboard_standing and dashboard_sign_out_everywhere. */
221
+ export declare function createSlotlockDashboardWorkspaces(sql: SlotlockSql): SlotlockDashboardWorkspaces;
222
+ /**
223
+ * Whether a team workspace serves its keys, for `serve`: read through slotlock.workspace_active and
224
+ * kept for `ttlMs` (30 seconds), so deleting one stops its keys on every server within that.
225
+ */
226
+ export declare function createSlotlockWorkspaceCheck(sql: SlotlockSql, options?: {
227
+ ttlMs?: number;
228
+ now?: () => number;
229
+ maxEntries?: number;
230
+ }): (tenantRef: string) => Promise<boolean>;
231
+ /**
232
+ * The same in memory, for tests and for a library deployment with a single server. `setRole` and
233
+ * `remove` stand in for what the Members page will do.
234
+ */
235
+ export declare function createSlotlockMemoryDashboardWorkspaces(): SlotlockDashboardWorkspaces & {
236
+ setRole(tenantRef: string, person: string, role: SlotlockWorkspaceRole, source?: 'github_org' | 'invitation' | 'created'): void;
237
+ remove(tenantRef: string, person: string): void;
238
+ /** Limit a member to locations and groups, or (null) give them all of the workspace again. */
239
+ limit(tenantRef: string, person: string, scope: SlotlockResourceScope | null): void;
240
+ };
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
+ }