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,881 @@
1
+ // Teams phase 5: a team's verified domains, and joining a team by one (src/domains.ts has the DNS
2
+ // side). Reached only through the SECURITY DEFINER functions below, as the rest of the dashboard's
3
+ // records are: each locks the team's row before it changes the team's people, checks the actor's
4
+ // role itself, and answers with a refusal code rather than an error.
5
+ //
6
+ // A domain a team claims is `pending` until its TXT record is found (seven days at most), then
7
+ // `verified`. It is looked up again every day: absent from both resolvers on two checks a day apart,
8
+ // it is `lapsing` and its token is replaced, so only a record published after the absence restores
9
+ // it; fourteen days later it has `lapsed`, and another team may claim it. At most one team holds a
10
+ // domain at a time. A domain's join policy decides what a person whose verified address is at it may
11
+ // do: ask to join (`request`, the default), join as a Viewer at once (`auto`), or nothing (`off`).
12
+ /**
13
+ * Who a person is, as every dashboard table checks it: a GitHub account, a Google account, or a
14
+ * person a team's identity provider signed in (sso:<uuid>, teams phase 5.4).
15
+ */
16
+ export const SLOTLOCK_PERSON_PATTERN = '^(github:[1-9][0-9]{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})$';
17
+ const PERSON = SLOTLOCK_PERSON_PATTERN;
18
+ /** A domain as normalizeSlotlockDomain keys it: lowercase ASCII labels, two or more. */
19
+ const DOMAIN = '^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$';
20
+ /** The tables of verified domains and requests to join: their owner's only. */
21
+ export const SLOTLOCK_IDENTITY_TABLES = Object.freeze(['workspace_domains', 'workspace_join_requests', 'workspace_join_blocks']);
22
+ /** The SECURITY DEFINER functions through which the serving role reaches them. */
23
+ export const SLOTLOCK_IDENTITY_FUNCTIONS = Object.freeze([
24
+ 'slotlock.claim_domain(text, text, text, text, text)',
25
+ 'slotlock.workspace_domains_list(text)',
26
+ 'slotlock.record_domain_check(uuid, text, text, text, text, text)',
27
+ 'slotlock.domains_due(integer)',
28
+ 'slotlock.release_domain(text, text, uuid)',
29
+ 'slotlock.set_invite_domain_restriction(text, text, boolean)',
30
+ 'slotlock.set_domain_join_policy(text, text, uuid, text)',
31
+ 'slotlock.discoverable_workspaces(text, text[])',
32
+ 'slotlock.request_to_join(text, text, text[], text, integer)',
33
+ 'slotlock.workspace_join_requests_list(text)',
34
+ 'slotlock.decide_join_request(text, text, uuid, text, text, integer)',
35
+ 'slotlock.workspace_domain_members(text)',
36
+ 'slotlock.prune_workspace_domains()',
37
+ 'slotlock.workspace_invites_restricted(text)',
38
+ ]);
39
+ /** How many domains a team may claim or hold at once. */
40
+ export const SLOTLOCK_WORKSPACE_DOMAIN_LIMIT = 20;
41
+ /**
42
+ * How a person's address was verified, as a request to join records it: by a Google Workspace
43
+ * account of that domain (Google's `hd` claim), the only evidence that lets someone join at once;
44
+ * or by a Google or GitHub account that verified it once and never checks again, which only asks.
45
+ */
46
+ export const SLOTLOCK_JOIN_EVIDENCE = Object.freeze(['google_workspace', 'google', 'github']);
47
+ /**
48
+ * SQL that is true when a team holds its invitations to its verified domains and the invitation
49
+ * (to `email`, or to the GitHub account `github`) is not to one of them.
50
+ */
51
+ export const SLOTLOCK_INVITE_DOMAIN_REFUSED = (tenant, email, github) => `(coalesce((SELECT ws.invites_domain_restricted FROM slotlock.workspaces ws WHERE ws.tenant_ref = ${tenant}), false)
52
+ AND (${github} IS NOT NULL OR ${email} IS NULL OR NOT EXISTS (
53
+ SELECT 1 FROM slotlock.workspace_domains claim
54
+ WHERE claim.tenant_ref = ${tenant} AND claim.status IN ('verified', 'lapsing')
55
+ AND lower(substring(${email} FROM '@([^@]+)$')) IN (claim.domain_ascii, claim.domain_unicode))))`;
56
+ /** Requests to join a person may make a day, across every team. */
57
+ export const SLOTLOCK_JOIN_REQUEST_DAILY_LIMIT = 20;
58
+ const fn = SLOTLOCK_IDENTITY_FUNCTIONS;
59
+ /** The addresses given, as `local@ascii-domain` lowercase: the domains an address is matched on. */
60
+ const EMAIL_DOMAINS = (emails) => `(SELECT lower(substring(btrim(given) FROM '@([^@]+)$')) FROM unnest(coalesce(${emails}, ARRAY[]::text[])) given)`;
61
+ export const SLOTLOCK_IDENTITY_DDL = `
62
+ -- A team's claim on a domain, and the domain once verified. The token is public by design (it is
63
+ -- published in DNS): it is unique to the claim, so one team's record never verifies another's.
64
+ CREATE TABLE IF NOT EXISTS slotlock.workspace_domains (
65
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
66
+ tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
67
+ domain_ascii text NOT NULL,
68
+ domain_unicode text NOT NULL,
69
+ status text NOT NULL DEFAULT 'pending',
70
+ token text NOT NULL,
71
+ join_policy text NOT NULL DEFAULT 'request',
72
+ created_by text,
73
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
74
+ verified_at timestamptz,
75
+ last_checked_at timestamptz,
76
+ last_seen_at timestamptz,
77
+ missing_since timestamptz,
78
+ error_since timestamptz,
79
+ lapse_after timestamptz,
80
+ ended_at timestamptz,
81
+ CONSTRAINT slotlock_workspace_domains_valid CHECK (
82
+ char_length(domain_ascii) BETWEEN 3 AND 253
83
+ AND domain_ascii ~ '${DOMAIN}'
84
+ AND char_length(domain_unicode) BETWEEN 3 AND 253
85
+ AND domain_unicode !~ '[[:space:][:cntrl:]@/<>]'
86
+ AND status IN ('pending', 'verified', 'lapsing', 'lapsed', 'released')
87
+ AND token ~ '^[a-z2-7]{26}$'
88
+ AND join_policy IN ('off', 'request', 'auto')
89
+ AND (created_by IS NULL OR created_by ~ '${PERSON}')
90
+ AND (status NOT IN ('verified', 'lapsing') OR verified_at IS NOT NULL)
91
+ AND (status = 'lapsing') = (lapse_after IS NOT NULL)
92
+ AND (status IN ('lapsed', 'released')) = (ended_at IS NOT NULL)
93
+ )
94
+ );
95
+ -- One team holds a domain at a time; a team claims each once.
96
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_workspace_domains_held_key
97
+ ON slotlock.workspace_domains (domain_ascii) WHERE status IN ('verified', 'lapsing');
98
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_workspace_domains_claim_key
99
+ ON slotlock.workspace_domains (tenant_ref, domain_ascii) WHERE status IN ('pending', 'verified', 'lapsing');
100
+ CREATE INDEX IF NOT EXISTS slotlock_workspace_domains_due_idx
101
+ ON slotlock.workspace_domains (last_checked_at NULLS FIRST) WHERE status IN ('pending', 'verified', 'lapsing');
102
+ ALTER TABLE slotlock.workspace_domains ENABLE ROW LEVEL SECURITY;
103
+ REVOKE ALL ON slotlock.workspace_domains FROM PUBLIC;
104
+
105
+ -- Invitations only to addresses at the team's verified domains, when its Owner says so.
106
+ ALTER TABLE slotlock.workspaces ADD COLUMN IF NOT EXISTS invites_domain_restricted boolean NOT NULL DEFAULT false;
107
+
108
+ -- A membership a domain gave (asked for and approved, or joined at once), and through which domain;
109
+ -- and the address a membership came by (a domain's, or an invitation's), to remember a removal by.
110
+ ALTER TABLE slotlock.workspace_memberships ADD COLUMN IF NOT EXISTS joined_via_domain uuid
111
+ REFERENCES slotlock.workspace_domains (id) ON DELETE SET NULL;
112
+ ALTER TABLE slotlock.workspace_memberships ADD COLUMN IF NOT EXISTS joined_email text
113
+ CONSTRAINT slotlock_workspace_memberships_joined_email_valid
114
+ CHECK (joined_email IS NULL OR (char_length(joined_email) BETWEEN 3 AND 254 AND joined_email = lower(joined_email)));
115
+ DO $slotlock_memberships_sources$
116
+ BEGIN
117
+ IF NOT EXISTS (
118
+ SELECT 1 FROM pg_constraint
119
+ WHERE conname = 'slotlock_workspace_memberships_valid' AND conrelid = 'slotlock.workspace_memberships'::regclass
120
+ AND pg_get_constraintdef(oid) LIKE '%''scim''%'
121
+ ) THEN
122
+ ALTER TABLE slotlock.workspace_memberships DROP CONSTRAINT IF EXISTS slotlock_workspace_memberships_valid;
123
+ ALTER TABLE slotlock.workspace_memberships ADD CONSTRAINT slotlock_workspace_memberships_valid CHECK (
124
+ tenant_ref ~ '^(github-org:[1-9][0-9]{0,19}|team:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$'
125
+ AND person_ref ~ '${PERSON}'
126
+ AND role IN ('owner', 'admin', 'developer', 'member', 'viewer', 'billing')
127
+ AND status IN ('active', 'inactive')
128
+ AND source IN ('github_org', 'invitation', 'created', 'domain', 'sso', 'scim')
129
+ AND (status = 'active') = (deactivated_at IS NULL)
130
+ );
131
+ END IF;
132
+ END
133
+ $slotlock_memberships_sources$;
134
+
135
+ -- A person's request to join a team, by an address they hold verified at one of its domains. It
136
+ -- waits seven days for an Owner or Admin, who picks the role (Viewer, Member or Developer).
137
+ CREATE TABLE IF NOT EXISTS slotlock.workspace_join_requests (
138
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
139
+ tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
140
+ domain_id uuid NOT NULL REFERENCES slotlock.workspace_domains (id) ON DELETE CASCADE,
141
+ person_ref text NOT NULL,
142
+ email text NOT NULL,
143
+ status text NOT NULL DEFAULT 'pending',
144
+ role text,
145
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
146
+ expires_at timestamptz NOT NULL,
147
+ decided_by text,
148
+ decided_at timestamptz,
149
+ CONSTRAINT slotlock_workspace_join_requests_valid CHECK (
150
+ person_ref ~ '${PERSON}'
151
+ AND char_length(email) BETWEEN 3 AND 254
152
+ AND email ~ '^[^@[:space:][:cntrl:]<>]+@[^@[:space:][:cntrl:]<>]+$'
153
+ AND status IN ('pending', 'approved', 'declined', 'expired')
154
+ AND (role IS NULL OR role IN ('developer', 'member', 'viewer'))
155
+ AND (status = 'approved') = (role IS NOT NULL)
156
+ AND (decided_by IS NULL OR decided_by ~ '${PERSON}')
157
+ AND expires_at > created_at
158
+ )
159
+ );
160
+ ALTER TABLE slotlock.workspace_join_requests ADD COLUMN IF NOT EXISTS evidence text NOT NULL DEFAULT 'github'
161
+ CONSTRAINT slotlock_workspace_join_requests_evidence_valid CHECK (evidence IN ('google_workspace', 'google', 'github'));
162
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_workspace_join_requests_pending_key
163
+ ON slotlock.workspace_join_requests (tenant_ref, person_ref) WHERE status = 'pending';
164
+ CREATE INDEX IF NOT EXISTS slotlock_workspace_join_requests_person_idx
165
+ ON slotlock.workspace_join_requests (person_ref, created_at);
166
+ ALTER TABLE slotlock.workspace_join_requests ENABLE ROW LEVEL SECURITY;
167
+ REVOKE ALL ON slotlock.workspace_join_requests FROM PUBLIC;
168
+
169
+ -- Addresses a team removed or declined, by their SHA-256 only: whoever holds one asks again rather
170
+ -- than joining at once, under any account and after erasing the one they had. A team's record of its
171
+ -- own decision: it goes with the team, or a year after it was made, or when a request is approved.
172
+ CREATE TABLE IF NOT EXISTS slotlock.workspace_join_blocks (
173
+ tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
174
+ email_sha256 bytea NOT NULL,
175
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
176
+ CONSTRAINT slotlock_workspace_join_blocks_pkey PRIMARY KEY (tenant_ref, email_sha256),
177
+ CONSTRAINT slotlock_workspace_join_blocks_valid CHECK (octet_length(email_sha256) = 32)
178
+ );
179
+ ALTER TABLE slotlock.workspace_join_blocks ENABLE ROW LEVEL SECURITY;
180
+ REVOKE ALL ON slotlock.workspace_join_blocks FROM PUBLIC;
181
+
182
+ -- Claim a domain for a team, by its Owner, with a new token. A claim already pending past its seven
183
+ -- days is claimed afresh (a new token); one the team claimed or holds already is refused.
184
+ -- The caller has normalised and checked the domain (public suffixes, consumer mail); the shape is
185
+ -- checked again here.
186
+ CREATE OR REPLACE FUNCTION slotlock.claim_domain(
187
+ requested_tenant text, requested_actor text, requested_ascii text, requested_unicode text, requested_token text)
188
+ RETURNS TABLE (claimed_id uuid, refusal text)
189
+ LANGUAGE plpgsql
190
+ VOLATILE
191
+ SECURITY DEFINER
192
+ SET search_path = pg_catalog, slotlock, pg_temp
193
+ AS $slotlock_claim_domain$
194
+ DECLARE
195
+ actor_role text;
196
+ existing slotlock.workspace_domains%ROWTYPE;
197
+ created uuid;
198
+ BEGIN
199
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
200
+ IF NOT FOUND THEN
201
+ RETURN QUERY SELECT NULL::uuid, 'not_found'::text;
202
+ RETURN;
203
+ END IF;
204
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
205
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
206
+ IF actor_role IS DISTINCT FROM 'owner' THEN
207
+ RETURN QUERY SELECT NULL::uuid, 'not_allowed'::text;
208
+ RETURN;
209
+ END IF;
210
+ IF requested_ascii IS NULL OR char_length(requested_ascii) NOT BETWEEN 3 AND 253 OR requested_ascii !~ '${DOMAIN}'
211
+ OR requested_unicode IS NULL OR char_length(requested_unicode) NOT BETWEEN 3 AND 253
212
+ OR requested_unicode ~ '[[:space:][:cntrl:]@/<>]' OR requested_token IS NULL OR requested_token !~ '^[a-z2-7]{26}$' THEN
213
+ RETURN QUERY SELECT NULL::uuid, 'invalid_domain'::text;
214
+ RETURN;
215
+ END IF;
216
+ -- Whether another team holds it is not said here, where anyone with a team could ask of any
217
+ -- domain: only to a team that publishes its own record (record_domain_check's domain_taken).
218
+ SELECT * INTO existing FROM slotlock.workspace_domains claim
219
+ WHERE claim.tenant_ref = requested_tenant AND claim.domain_ascii = requested_ascii
220
+ AND claim.status IN ('pending', 'verified', 'lapsing')
221
+ FOR UPDATE;
222
+ IF FOUND THEN
223
+ IF existing.status = 'pending' AND existing.created_at <= statement_timestamp() - interval '7 days' THEN
224
+ UPDATE slotlock.workspace_domains claim
225
+ SET token = requested_token, domain_unicode = requested_unicode, created_by = requested_actor,
226
+ created_at = statement_timestamp(), last_checked_at = NULL, error_since = NULL
227
+ WHERE claim.id = existing.id;
228
+ RETURN QUERY SELECT existing.id, NULL::text;
229
+ RETURN;
230
+ END IF;
231
+ RETURN QUERY SELECT existing.id, 'already_claimed'::text;
232
+ RETURN;
233
+ END IF;
234
+ IF (SELECT count(*) FROM slotlock.workspace_domains claim
235
+ WHERE claim.tenant_ref = requested_tenant AND claim.status IN ('pending', 'verified', 'lapsing'))
236
+ >= ${SLOTLOCK_WORKSPACE_DOMAIN_LIMIT} THEN
237
+ RETURN QUERY SELECT NULL::uuid, 'domain_limit'::text;
238
+ RETURN;
239
+ END IF;
240
+ INSERT INTO slotlock.workspace_domains (tenant_ref, domain_ascii, domain_unicode, token, created_by)
241
+ VALUES (requested_tenant, requested_ascii, requested_unicode, requested_token, requested_actor)
242
+ RETURNING id INTO created;
243
+ RETURN QUERY SELECT created, NULL::text;
244
+ END
245
+ $slotlock_claim_domain$;
246
+ REVOKE ALL ON FUNCTION ${fn[0]} FROM PUBLIC;
247
+
248
+ -- A team's domains as its Identity page shows them: claims pending (or past their seven days),
249
+ -- verified and lapsing ones. The token is public: it is the record the team publishes.
250
+ CREATE OR REPLACE FUNCTION slotlock.workspace_domains_list(requested_tenant text)
251
+ RETURNS TABLE (domain_id uuid, domain_ascii text, domain_unicode text, status text, token text,
252
+ join_policy text, created_at timestamptz, verified_at timestamptz,
253
+ last_checked_at timestamptz, missing_since timestamptz, error_since timestamptz,
254
+ lapse_after timestamptz, expired boolean)
255
+ LANGUAGE sql
256
+ STABLE
257
+ SECURITY DEFINER
258
+ SET search_path = pg_catalog, slotlock, pg_temp
259
+ AS $slotlock_workspace_domains_list$
260
+ SELECT claim.id, claim.domain_ascii, claim.domain_unicode, claim.status, claim.token, claim.join_policy,
261
+ claim.created_at, claim.verified_at, claim.last_checked_at, claim.missing_since, claim.error_since,
262
+ claim.lapse_after,
263
+ claim.status = 'pending' AND claim.created_at <= statement_timestamp() - interval '7 days'
264
+ FROM slotlock.workspace_domains claim
265
+ WHERE claim.tenant_ref = requested_tenant AND claim.status IN ('pending', 'verified', 'lapsing')
266
+ ORDER BY claim.domain_ascii;
267
+ $slotlock_workspace_domains_list$;
268
+ REVOKE ALL ON FUNCTION ${fn[1]} FROM PUBLIC;
269
+
270
+ -- What a lookup of a claim's record found, applied: by the daily check (no actor), or by an Owner's
271
+ -- "Verify now" (at most once a minute). requested_checked_token is the token the lookup asked for:
272
+ -- if the claim's has changed since, nothing changes. requested_primary is the first resolver's
273
+ -- verdict, requested_secondary the second's (null with one resolver). requested_next_token replaces
274
+ -- the token when the domain starts to lapse. Returns what happened, and the claim's team and domain
275
+ -- for the audit log.
276
+ CREATE OR REPLACE FUNCTION slotlock.record_domain_check(
277
+ requested_domain uuid, requested_actor text, requested_checked_token text, requested_primary text,
278
+ requested_secondary text, requested_next_token text)
279
+ RETURNS TABLE (outcome text, team_ref text, domain_name text)
280
+ LANGUAGE plpgsql
281
+ VOLATILE
282
+ SECURITY DEFINER
283
+ SET search_path = pg_catalog, slotlock, pg_temp
284
+ AS $slotlock_record_domain_check$
285
+ DECLARE
286
+ claim slotlock.workspace_domains%ROWTYPE;
287
+ actor_role text;
288
+ both_absent boolean;
289
+ BEGIN
290
+ IF requested_primary IS NULL OR requested_primary NOT IN ('present', 'absent', 'error')
291
+ OR (requested_secondary IS NOT NULL AND requested_secondary NOT IN ('present', 'absent', 'error')) THEN
292
+ RAISE EXCEPTION 'Slotlock domains: a verdict is present, absent or error' USING ERRCODE = '22023';
293
+ END IF;
294
+ SELECT * INTO claim FROM slotlock.workspace_domains found WHERE found.id = requested_domain FOR UPDATE;
295
+ IF NOT FOUND OR claim.status NOT IN ('pending', 'verified', 'lapsing') THEN
296
+ RETURN QUERY SELECT 'not_found'::text, NULL::text, NULL::text;
297
+ RETURN;
298
+ END IF;
299
+ IF requested_actor IS NOT NULL THEN
300
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
301
+ WHERE member.tenant_ref = claim.tenant_ref AND member.person_ref = requested_actor AND member.status = 'active';
302
+ IF actor_role IS DISTINCT FROM 'owner' THEN
303
+ RETURN QUERY SELECT 'not_allowed'::text, NULL::text, NULL::text;
304
+ RETURN;
305
+ END IF;
306
+ IF claim.last_checked_at > statement_timestamp() - interval '1 minute' THEN
307
+ RETURN QUERY SELECT 'too_soon'::text, claim.tenant_ref, claim.domain_ascii;
308
+ RETURN;
309
+ END IF;
310
+ END IF;
311
+ IF claim.status = 'pending' AND claim.created_at <= statement_timestamp() - interval '7 days' THEN
312
+ RETURN QUERY SELECT 'expired'::text, claim.tenant_ref, claim.domain_ascii;
313
+ RETURN;
314
+ END IF;
315
+ IF requested_checked_token IS DISTINCT FROM claim.token THEN
316
+ RETURN QUERY SELECT 'unchanged'::text, claim.tenant_ref, claim.domain_ascii;
317
+ RETURN;
318
+ END IF;
319
+ UPDATE slotlock.workspace_domains found SET last_checked_at = statement_timestamp() WHERE found.id = claim.id;
320
+ both_absent := requested_primary = 'absent' AND coalesce(requested_secondary, 'absent') = 'absent';
321
+
322
+ IF claim.status = 'pending' THEN
323
+ IF requested_primary = 'present' THEN
324
+ BEGIN
325
+ UPDATE slotlock.workspace_domains found
326
+ SET status = 'verified', verified_at = statement_timestamp(), last_seen_at = statement_timestamp(),
327
+ missing_since = NULL, error_since = NULL
328
+ WHERE found.id = claim.id;
329
+ EXCEPTION WHEN unique_violation THEN
330
+ -- Another team verified it first: the claim stays pending, and says only that it is taken.
331
+ RETURN QUERY SELECT 'domain_taken'::text, claim.tenant_ref, claim.domain_ascii;
332
+ RETURN;
333
+ END;
334
+ RETURN QUERY SELECT 'verified'::text, claim.tenant_ref, claim.domain_ascii;
335
+ RETURN;
336
+ END IF;
337
+ UPDATE slotlock.workspace_domains found
338
+ SET error_since = CASE WHEN requested_primary = 'error' THEN coalesce(found.error_since, statement_timestamp()) END
339
+ WHERE found.id = claim.id;
340
+ RETURN QUERY SELECT CASE WHEN requested_primary = 'error' THEN 'error' ELSE 'absent' END, claim.tenant_ref, claim.domain_ascii;
341
+ RETURN;
342
+ END IF;
343
+
344
+ IF claim.status = 'verified' THEN
345
+ IF requested_primary = 'present' THEN
346
+ UPDATE slotlock.workspace_domains found
347
+ SET last_seen_at = statement_timestamp(), missing_since = NULL, error_since = NULL
348
+ WHERE found.id = claim.id;
349
+ RETURN QUERY SELECT 'seen'::text, claim.tenant_ref, claim.domain_ascii;
350
+ RETURN;
351
+ END IF;
352
+ IF both_absent THEN
353
+ IF claim.missing_since IS NULL THEN
354
+ UPDATE slotlock.workspace_domains found SET missing_since = statement_timestamp(), error_since = NULL
355
+ WHERE found.id = claim.id;
356
+ RETURN QUERY SELECT 'missing'::text, claim.tenant_ref, claim.domain_ascii;
357
+ RETURN;
358
+ END IF;
359
+ IF claim.missing_since <= statement_timestamp() - interval '1 day' THEN
360
+ IF requested_next_token IS NULL OR requested_next_token !~ '^[a-z2-7]{26}$' OR requested_next_token = claim.token THEN
361
+ RAISE EXCEPTION 'Slotlock domains: a lapsing domain needs a new token' USING ERRCODE = '22023';
362
+ END IF;
363
+ -- Gone a day: lapsing, with a new token. The old one is never accepted again, so a record
364
+ -- copied from DNS history by whoever holds the domain next restores nothing.
365
+ UPDATE slotlock.workspace_domains found
366
+ SET status = 'lapsing', token = requested_next_token, lapse_after = statement_timestamp() + interval '14 days',
367
+ error_since = NULL
368
+ WHERE found.id = claim.id;
369
+ RETURN QUERY SELECT 'lapsing'::text, claim.tenant_ref, claim.domain_ascii;
370
+ RETURN;
371
+ END IF;
372
+ RETURN QUERY SELECT 'missing'::text, claim.tenant_ref, claim.domain_ascii;
373
+ RETURN;
374
+ END IF;
375
+ IF requested_primary = 'error' OR requested_secondary = 'error' THEN
376
+ -- Fourteen days of nothing but errors (a zone deleted, its delegation dangling): lapsing, with
377
+ -- a new token, as if the record had gone, so whoever takes the delegation over cannot revive it.
378
+ IF claim.error_since IS NOT NULL AND claim.error_since <= statement_timestamp() - interval '14 days'
379
+ AND requested_primary <> 'present' AND coalesce(requested_secondary, 'error') <> 'present' THEN
380
+ IF requested_next_token IS NULL OR requested_next_token !~ '^[a-z2-7]{26}$' OR requested_next_token = claim.token THEN
381
+ RAISE EXCEPTION 'Slotlock domains: a lapsing domain needs a new token' USING ERRCODE = '22023';
382
+ END IF;
383
+ UPDATE slotlock.workspace_domains found
384
+ SET status = 'lapsing', token = requested_next_token, lapse_after = statement_timestamp() + interval '14 days'
385
+ WHERE found.id = claim.id;
386
+ RETURN QUERY SELECT 'lapsing'::text, claim.tenant_ref, claim.domain_ascii;
387
+ RETURN;
388
+ END IF;
389
+ UPDATE slotlock.workspace_domains found SET error_since = coalesce(found.error_since, statement_timestamp())
390
+ WHERE found.id = claim.id;
391
+ RETURN QUERY SELECT 'error'::text, claim.tenant_ref, claim.domain_ascii;
392
+ RETURN;
393
+ END IF;
394
+ -- One resolver has it and the other not: nothing changes.
395
+ RETURN QUERY SELECT 'unchanged'::text, claim.tenant_ref, claim.domain_ascii;
396
+ RETURN;
397
+ END IF;
398
+
399
+ -- Lapsing: the new token's record restores it; fourteen days without, it has lapsed.
400
+ IF requested_primary = 'present' THEN
401
+ UPDATE slotlock.workspace_domains found
402
+ SET status = 'verified', lapse_after = NULL, missing_since = NULL, error_since = NULL,
403
+ last_seen_at = statement_timestamp()
404
+ WHERE found.id = claim.id;
405
+ RETURN QUERY SELECT 'restored'::text, claim.tenant_ref, claim.domain_ascii;
406
+ RETURN;
407
+ END IF;
408
+ -- Past its fourteen days it lapses only on what lapsed it: both resolvers without the record, or
409
+ -- fourteen days of nothing but errors. One resolver failing, or one still finding it, waits.
410
+ IF requested_primary = 'error' OR requested_secondary = 'error' THEN
411
+ UPDATE slotlock.workspace_domains found SET error_since = coalesce(found.error_since, statement_timestamp())
412
+ WHERE found.id = claim.id;
413
+ ELSE
414
+ UPDATE slotlock.workspace_domains found SET error_since = NULL WHERE found.id = claim.id;
415
+ END IF;
416
+ IF claim.lapse_after <= statement_timestamp()
417
+ AND (both_absent
418
+ OR (requested_primary <> 'present' AND coalesce(requested_secondary, 'error') <> 'present'
419
+ AND claim.error_since IS NOT NULL AND claim.error_since <= statement_timestamp() - interval '14 days')) THEN
420
+ UPDATE slotlock.workspace_domains found
421
+ SET status = 'lapsed', lapse_after = NULL, error_since = NULL, ended_at = statement_timestamp()
422
+ WHERE found.id = claim.id;
423
+ UPDATE slotlock.workspace_join_requests pending SET status = 'expired'
424
+ WHERE pending.domain_id = claim.id AND pending.status = 'pending';
425
+ RETURN QUERY SELECT 'lapsed'::text, claim.tenant_ref, claim.domain_ascii;
426
+ RETURN;
427
+ END IF;
428
+ RETURN QUERY SELECT CASE WHEN requested_primary = 'error' THEN 'error' ELSE 'unchanged' END, claim.tenant_ref, claim.domain_ascii;
429
+ END
430
+ $slotlock_record_domain_check$;
431
+ REVOKE ALL ON FUNCTION ${fn[2]} FROM PUBLIC;
432
+
433
+ -- The claims to look up now, oldest check first: pending ones hourly for their first three days
434
+ -- (after that, only "Verify now"), verified and lapsing ones daily, and lapsing ones past their
435
+ -- fourteen days at once. Teams being deleted are left alone.
436
+ CREATE OR REPLACE FUNCTION slotlock.domains_due(requested_limit integer)
437
+ RETURNS TABLE (domain_id uuid, team_ref text, domain_ascii text, token text, status text, missing_since timestamptz)
438
+ LANGUAGE sql
439
+ STABLE
440
+ SECURITY DEFINER
441
+ SET search_path = pg_catalog, slotlock, pg_temp
442
+ AS $slotlock_domains_due$
443
+ SELECT claim.id, claim.tenant_ref, claim.domain_ascii, claim.token, claim.status, claim.missing_since
444
+ FROM slotlock.workspace_domains claim
445
+ JOIN slotlock.workspaces ws ON ws.tenant_ref = claim.tenant_ref AND ws.status = 'active'
446
+ WHERE (claim.status = 'pending' AND claim.created_at > statement_timestamp() - interval '72 hours'
447
+ AND (claim.last_checked_at IS NULL OR claim.last_checked_at <= statement_timestamp() - interval '50 minutes'))
448
+ OR (claim.status IN ('verified', 'lapsing')
449
+ AND (claim.last_checked_at IS NULL OR claim.last_checked_at <= statement_timestamp() - interval '1 day'))
450
+ OR (claim.status = 'lapsing' AND claim.lapse_after <= statement_timestamp())
451
+ ORDER BY claim.last_checked_at NULLS FIRST
452
+ LIMIT greatest(1, least(coalesce(requested_limit, 50), 500));
453
+ $slotlock_domains_due$;
454
+ REVOKE ALL ON FUNCTION ${fn[3]} FROM PUBLIC;
455
+
456
+ -- Give up a domain (or withdraw a claim), by the team's Owner. Not the last verified domain while
457
+ -- invitations are held to them: the Owner turns that off first.
458
+ CREATE OR REPLACE FUNCTION slotlock.release_domain(requested_tenant text, requested_actor text, requested_domain uuid)
459
+ RETURNS text
460
+ LANGUAGE plpgsql
461
+ VOLATILE
462
+ SECURITY DEFINER
463
+ SET search_path = pg_catalog, slotlock, pg_temp
464
+ AS $slotlock_release_domain$
465
+ DECLARE
466
+ actor_role text;
467
+ restricted boolean;
468
+ claim slotlock.workspace_domains%ROWTYPE;
469
+ BEGIN
470
+ SELECT ws.invites_domain_restricted INTO restricted FROM slotlock.workspaces ws
471
+ WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
472
+ IF NOT FOUND THEN
473
+ RETURN 'not_found';
474
+ END IF;
475
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
476
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
477
+ IF actor_role IS DISTINCT FROM 'owner' THEN
478
+ RETURN 'not_allowed';
479
+ END IF;
480
+ SELECT * INTO claim FROM slotlock.workspace_domains found
481
+ WHERE found.id = requested_domain AND found.tenant_ref = requested_tenant
482
+ AND found.status IN ('pending', 'verified', 'lapsing')
483
+ FOR UPDATE;
484
+ IF NOT FOUND THEN
485
+ RETURN 'not_found';
486
+ END IF;
487
+ IF restricted AND claim.status IN ('verified', 'lapsing') AND NOT EXISTS (
488
+ SELECT 1 FROM slotlock.workspace_domains other
489
+ WHERE other.tenant_ref = requested_tenant AND other.id <> claim.id AND other.status IN ('verified', 'lapsing')
490
+ ) THEN
491
+ RETURN 'restricted';
492
+ END IF;
493
+ UPDATE slotlock.workspace_domains found
494
+ SET status = 'released', lapse_after = NULL, ended_at = statement_timestamp()
495
+ WHERE found.id = claim.id;
496
+ UPDATE slotlock.workspace_join_requests pending SET status = 'expired'
497
+ WHERE pending.domain_id = claim.id AND pending.status = 'pending';
498
+ RETURN NULL;
499
+ END
500
+ $slotlock_release_domain$;
501
+ REVOKE ALL ON FUNCTION ${fn[4]} FROM PUBLIC;
502
+
503
+ -- Hold invitations to addresses at the team's verified domains, or stop: by its Owner, and only
504
+ -- with a verified domain to hold them to. Turning it on withdraws the open invitations it refuses.
505
+ CREATE OR REPLACE FUNCTION slotlock.set_invite_domain_restriction(requested_tenant text, requested_actor text, requested_on boolean)
506
+ RETURNS text
507
+ LANGUAGE plpgsql
508
+ VOLATILE
509
+ SECURITY DEFINER
510
+ SET search_path = pg_catalog, slotlock, pg_temp
511
+ AS $slotlock_set_invite_domain_restriction$
512
+ DECLARE
513
+ actor_role text;
514
+ BEGIN
515
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
516
+ IF NOT FOUND THEN
517
+ RETURN 'not_found';
518
+ END IF;
519
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
520
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
521
+ IF actor_role IS DISTINCT FROM 'owner' THEN
522
+ RETURN 'not_allowed';
523
+ END IF;
524
+ IF requested_on AND NOT EXISTS (
525
+ SELECT 1 FROM slotlock.workspace_domains claim
526
+ WHERE claim.tenant_ref = requested_tenant AND claim.status = 'verified'
527
+ ) THEN
528
+ RETURN 'no_verified_domain';
529
+ END IF;
530
+ UPDATE slotlock.workspaces ws SET invites_domain_restricted = coalesce(requested_on, false)
531
+ WHERE ws.tenant_ref = requested_tenant;
532
+ -- Held from now on, invitations already open to anyone else are withdrawn too.
533
+ IF requested_on THEN
534
+ UPDATE slotlock.workspace_invitations invitation SET revoked_at = statement_timestamp()
535
+ WHERE invitation.tenant_ref = requested_tenant AND invitation.accepted_at IS NULL AND invitation.revoked_at IS NULL
536
+ AND ${SLOTLOCK_INVITE_DOMAIN_REFUSED('requested_tenant', 'invitation.email', 'invitation.github_ref')};
537
+ END IF;
538
+ RETURN NULL;
539
+ END
540
+ $slotlock_set_invite_domain_restriction$;
541
+ REVOKE ALL ON FUNCTION ${fn[5]} FROM PUBLIC;
542
+
543
+ -- What a domain lets the people at it do: by the team's Owner. Turning it off withdraws the
544
+ -- requests waiting through it.
545
+ CREATE OR REPLACE FUNCTION slotlock.set_domain_join_policy(
546
+ requested_tenant text, requested_actor text, requested_domain uuid, requested_policy text)
547
+ RETURNS text
548
+ LANGUAGE plpgsql
549
+ VOLATILE
550
+ SECURITY DEFINER
551
+ SET search_path = pg_catalog, slotlock, pg_temp
552
+ AS $slotlock_set_domain_join_policy$
553
+ DECLARE
554
+ actor_role text;
555
+ BEGIN
556
+ IF requested_policy IS NULL OR requested_policy NOT IN ('off', 'request', 'auto') THEN
557
+ RAISE EXCEPTION 'Slotlock domains: a join policy is off, request or auto' USING ERRCODE = '22023';
558
+ END IF;
559
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
560
+ IF NOT FOUND THEN
561
+ RETURN 'not_found';
562
+ END IF;
563
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
564
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
565
+ IF actor_role IS DISTINCT FROM 'owner' THEN
566
+ RETURN 'not_allowed';
567
+ END IF;
568
+ UPDATE slotlock.workspace_domains claim SET join_policy = requested_policy
569
+ WHERE claim.id = requested_domain AND claim.tenant_ref = requested_tenant
570
+ AND claim.status IN ('pending', 'verified', 'lapsing');
571
+ IF NOT FOUND THEN
572
+ RETURN 'not_found';
573
+ END IF;
574
+ IF requested_policy = 'off' THEN
575
+ UPDATE slotlock.workspace_join_requests pending SET status = 'expired'
576
+ WHERE pending.domain_id = requested_domain AND pending.status = 'pending';
577
+ END IF;
578
+ RETURN NULL;
579
+ END
580
+ $slotlock_set_domain_join_policy$;
581
+ REVOKE ALL ON FUNCTION ${fn[6]} FROM PUBLIC;
582
+
583
+ -- The teams a person may ask to join (or join): those with a verified domain, open to joining, that
584
+ -- one of their verified addresses is at, exactly. Name and handle only, and whether they asked.
585
+ CREATE OR REPLACE FUNCTION slotlock.discoverable_workspaces(requested_person text, requested_emails text[])
586
+ RETURNS TABLE (team_ref text, handle text, name text, domain text, domain_key text, join_policy text, requested boolean)
587
+ LANGUAGE sql
588
+ STABLE
589
+ SECURITY DEFINER
590
+ SET search_path = pg_catalog, slotlock, pg_temp
591
+ AS $slotlock_discoverable_workspaces$
592
+ SELECT ws.tenant_ref, ws.handle, ws.name, claim.domain_unicode, claim.domain_ascii, claim.join_policy,
593
+ EXISTS (SELECT 1 FROM slotlock.workspace_join_requests asked
594
+ WHERE asked.tenant_ref = ws.tenant_ref AND asked.person_ref = requested_person
595
+ AND asked.status = 'pending' AND asked.expires_at > statement_timestamp())
596
+ FROM slotlock.workspace_domains claim
597
+ JOIN slotlock.workspaces ws ON ws.tenant_ref = claim.tenant_ref AND ws.status = 'active'
598
+ WHERE requested_person ~ '${PERSON}'
599
+ AND claim.status = 'verified' AND claim.join_policy IN ('request', 'auto')
600
+ AND claim.domain_ascii IN ${EMAIL_DOMAINS('requested_emails')}
601
+ AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
602
+ WHERE member.tenant_ref = ws.tenant_ref AND member.person_ref = requested_person
603
+ AND member.status = 'active')
604
+ ORDER BY ws.name, ws.handle
605
+ LIMIT 20;
606
+ $slotlock_discoverable_workspaces$;
607
+ REVOKE ALL ON FUNCTION ${fn[7]} FROM PUBLIC;
608
+
609
+ -- Ask to join a team by a verified address at one of its domains: joined at once as a Viewer where
610
+ -- the domain says so, the address was verified by an account of that domain (a Google Workspace
611
+ -- account: evidence google_workspace), the person was never a member and the address was never
612
+ -- removed or declined (someone removed asks again, under any account); else a request that waits
613
+ -- seven days. Every case the person may not see answers not_found.
614
+ CREATE OR REPLACE FUNCTION slotlock.request_to_join(
615
+ requested_tenant text, requested_person text, requested_emails text[], requested_evidence text,
616
+ requested_member_cap integer)
617
+ RETURNS TABLE (outcome text, refusal text)
618
+ LANGUAGE plpgsql
619
+ VOLATILE
620
+ SECURITY DEFINER
621
+ SET search_path = pg_catalog, slotlock, pg_temp
622
+ AS $slotlock_request_to_join$
623
+ DECLARE
624
+ matched slotlock.workspace_domains%ROWTYPE;
625
+ matched_email text;
626
+ occupied integer;
627
+ BEGIN
628
+ IF requested_person IS NULL OR requested_person !~ '${PERSON}' THEN
629
+ RETURN QUERY SELECT NULL::text, 'not_found'::text;
630
+ RETURN;
631
+ END IF;
632
+ IF requested_evidence IS NULL OR requested_evidence NOT IN ('google_workspace', 'google', 'github') THEN
633
+ RAISE EXCEPTION 'Slotlock teams: evidence is google_workspace, google or github' USING ERRCODE = '22023';
634
+ END IF;
635
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
636
+ IF NOT FOUND THEN
637
+ RETURN QUERY SELECT NULL::text, 'not_found'::text;
638
+ RETURN;
639
+ END IF;
640
+ SELECT * INTO matched FROM slotlock.workspace_domains claim
641
+ WHERE claim.tenant_ref = requested_tenant AND claim.status = 'verified' AND claim.join_policy IN ('request', 'auto')
642
+ AND claim.domain_ascii IN ${EMAIL_DOMAINS('requested_emails')}
643
+ ORDER BY claim.join_policy = 'auto' DESC, claim.domain_ascii
644
+ LIMIT 1;
645
+ IF NOT FOUND THEN
646
+ RETURN QUERY SELECT NULL::text, 'not_found'::text;
647
+ RETURN;
648
+ END IF;
649
+ SELECT lower(btrim(given)) INTO matched_email FROM unnest(requested_emails) given
650
+ WHERE lower(substring(btrim(given) FROM '@([^@]+)$')) = matched.domain_ascii
651
+ LIMIT 1;
652
+ IF EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
653
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person AND member.status = 'active') THEN
654
+ RETURN QUERY SELECT NULL::text, 'already_member'::text;
655
+ RETURN;
656
+ END IF;
657
+ UPDATE slotlock.workspace_join_requests stale SET status = 'expired'
658
+ WHERE stale.tenant_ref = requested_tenant AND stale.person_ref = requested_person
659
+ AND stale.status = 'pending' AND stale.expires_at <= statement_timestamp();
660
+ IF matched.join_policy = 'auto' AND requested_evidence = 'google_workspace'
661
+ AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
662
+ WHERE member.tenant_ref = requested_tenant
663
+ AND (member.person_ref = requested_person OR member.joined_email = matched_email))
664
+ AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_join_blocks blocked
665
+ WHERE blocked.tenant_ref = requested_tenant
666
+ AND blocked.email_sha256 = sha256(convert_to(matched_email, 'UTF8'))) THEN
667
+ IF requested_member_cap IS NOT NULL THEN
668
+ SELECT (SELECT count(*) FROM slotlock.workspace_memberships member
669
+ WHERE member.tenant_ref = requested_tenant AND member.status = 'active')
670
+ + (SELECT count(*) FROM slotlock.workspace_invitations pending
671
+ WHERE pending.tenant_ref = requested_tenant AND pending.accepted_at IS NULL
672
+ AND pending.revoked_at IS NULL AND pending.expires_at > statement_timestamp())
673
+ INTO occupied;
674
+ IF occupied >= requested_member_cap THEN
675
+ RETURN QUERY SELECT NULL::text, 'member_cap'::text;
676
+ RETURN;
677
+ END IF;
678
+ END IF;
679
+ INSERT INTO slotlock.workspace_memberships (tenant_ref, person_ref, role, source, joined_via_domain, joined_email)
680
+ VALUES (requested_tenant, requested_person, 'viewer', 'domain', matched.id, matched_email);
681
+ UPDATE slotlock.workspace_join_requests asked SET status = 'expired'
682
+ WHERE asked.tenant_ref = requested_tenant AND asked.person_ref = requested_person AND asked.status = 'pending';
683
+ RETURN QUERY SELECT 'joined'::text, NULL::text;
684
+ RETURN;
685
+ END IF;
686
+ IF EXISTS (SELECT 1 FROM slotlock.workspace_join_requests asked
687
+ WHERE asked.tenant_ref = requested_tenant AND asked.person_ref = requested_person AND asked.status = 'pending') THEN
688
+ RETURN QUERY SELECT NULL::text, 'pending'::text;
689
+ RETURN;
690
+ END IF;
691
+ IF (SELECT count(*) FROM slotlock.workspace_join_requests asked
692
+ WHERE asked.person_ref = requested_person AND asked.created_at > statement_timestamp() - interval '1 day')
693
+ >= ${SLOTLOCK_JOIN_REQUEST_DAILY_LIMIT} THEN
694
+ RETURN QUERY SELECT NULL::text, 'request_limit'::text;
695
+ RETURN;
696
+ END IF;
697
+ INSERT INTO slotlock.workspace_join_requests (tenant_ref, domain_id, person_ref, email, evidence, expires_at)
698
+ VALUES (requested_tenant, matched.id, requested_person, matched_email, requested_evidence,
699
+ statement_timestamp() + interval '7 days');
700
+ RETURN QUERY SELECT 'requested'::text, NULL::text;
701
+ END
702
+ $slotlock_request_to_join$;
703
+ REVOKE ALL ON FUNCTION ${fn[8]} FROM PUBLIC;
704
+
705
+ -- The requests waiting on a team: who asked (as they are shown), by which address, through which domain.
706
+ CREATE OR REPLACE FUNCTION slotlock.workspace_join_requests_list(requested_tenant text)
707
+ RETURNS TABLE (request_id uuid, person_ref text, display text, email text, domain text,
708
+ created_at timestamptz, expires_at timestamptz, evidence text)
709
+ LANGUAGE sql
710
+ STABLE
711
+ SECURITY DEFINER
712
+ SET search_path = pg_catalog, slotlock, pg_temp
713
+ AS $slotlock_workspace_join_requests_list$
714
+ SELECT asked.id, asked.person_ref, person.display, asked.email, claim.domain_unicode, asked.created_at, asked.expires_at,
715
+ asked.evidence
716
+ FROM slotlock.workspace_join_requests asked
717
+ JOIN slotlock.workspace_domains claim ON claim.id = asked.domain_id
718
+ LEFT JOIN slotlock.dashboard_people person ON person.person_ref = asked.person_ref
719
+ WHERE asked.tenant_ref = requested_tenant AND asked.status = 'pending' AND asked.expires_at > statement_timestamp()
720
+ ORDER BY asked.created_at
721
+ LIMIT 200;
722
+ $slotlock_workspace_join_requests_list$;
723
+ REVOKE ALL ON FUNCTION ${fn[9]} FROM PUBLIC;
724
+
725
+ -- Approve a request (as Viewer, Member or Developer, never more) or decline it: by an Owner or an
726
+ -- Admin who may give the role, while the domain it came through is still verified.
727
+ CREATE OR REPLACE FUNCTION slotlock.decide_join_request(
728
+ requested_tenant text, requested_actor text, requested_request uuid, requested_decision text,
729
+ requested_role text, requested_member_cap integer)
730
+ RETURNS TABLE (refusal text, joined_person text)
731
+ LANGUAGE plpgsql
732
+ VOLATILE
733
+ SECURITY DEFINER
734
+ SET search_path = pg_catalog, slotlock, pg_temp
735
+ AS $slotlock_decide_join_request$
736
+ DECLARE
737
+ actor_role text;
738
+ asked slotlock.workspace_join_requests%ROWTYPE;
739
+ occupied integer;
740
+ BEGIN
741
+ IF requested_decision IS NULL OR requested_decision NOT IN ('approve', 'decline') THEN
742
+ RAISE EXCEPTION 'Slotlock teams: a decision is approve or decline' USING ERRCODE = '22023';
743
+ END IF;
744
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
745
+ IF NOT FOUND THEN
746
+ RETURN QUERY SELECT 'not_found'::text, NULL::text;
747
+ RETURN;
748
+ END IF;
749
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
750
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
751
+ IF actor_role IS NULL OR actor_role NOT IN ('owner', 'admin') THEN
752
+ RETURN QUERY SELECT 'not_allowed'::text, NULL::text;
753
+ RETURN;
754
+ END IF;
755
+ SELECT * INTO asked FROM slotlock.workspace_join_requests found
756
+ WHERE found.id = requested_request AND found.tenant_ref = requested_tenant AND found.status = 'pending'
757
+ FOR UPDATE;
758
+ IF NOT FOUND THEN
759
+ RETURN QUERY SELECT 'not_found'::text, NULL::text;
760
+ RETURN;
761
+ END IF;
762
+ IF asked.expires_at <= statement_timestamp() THEN
763
+ UPDATE slotlock.workspace_join_requests found SET status = 'expired' WHERE found.id = asked.id;
764
+ RETURN QUERY SELECT 'expired'::text, asked.person_ref;
765
+ RETURN;
766
+ END IF;
767
+ IF requested_decision = 'decline' THEN
768
+ UPDATE slotlock.workspace_join_requests found
769
+ SET status = 'declined', decided_by = requested_actor, decided_at = statement_timestamp()
770
+ WHERE found.id = asked.id;
771
+ INSERT INTO slotlock.workspace_join_blocks (tenant_ref, email_sha256)
772
+ VALUES (requested_tenant, sha256(convert_to(asked.email, 'UTF8')))
773
+ ON CONFLICT ON CONSTRAINT slotlock_workspace_join_blocks_pkey DO NOTHING;
774
+ RETURN QUERY SELECT NULL::text, asked.person_ref;
775
+ RETURN;
776
+ END IF;
777
+ IF requested_role IS NULL OR requested_role NOT IN ('viewer', 'member', 'developer') THEN
778
+ RETURN QUERY SELECT 'role_not_allowed'::text, asked.person_ref;
779
+ RETURN;
780
+ END IF;
781
+ IF NOT slotlock.workspace_may_grant(actor_role, requested_role) THEN
782
+ RETURN QUERY SELECT 'not_allowed'::text, asked.person_ref;
783
+ RETURN;
784
+ END IF;
785
+ IF NOT EXISTS (SELECT 1 FROM slotlock.workspace_domains claim
786
+ WHERE claim.id = asked.domain_id AND claim.status = 'verified' AND claim.join_policy <> 'off') THEN
787
+ RETURN QUERY SELECT 'domain_not_verified'::text, asked.person_ref;
788
+ RETURN;
789
+ END IF;
790
+ IF EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
791
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = asked.person_ref AND member.status = 'active') THEN
792
+ UPDATE slotlock.workspace_join_requests found SET status = 'expired' WHERE found.id = asked.id;
793
+ RETURN QUERY SELECT 'already_member'::text, asked.person_ref;
794
+ RETURN;
795
+ END IF;
796
+ IF requested_member_cap IS NOT NULL THEN
797
+ SELECT (SELECT count(*) FROM slotlock.workspace_memberships member
798
+ WHERE member.tenant_ref = requested_tenant AND member.status = 'active')
799
+ + (SELECT count(*) FROM slotlock.workspace_invitations pending
800
+ WHERE pending.tenant_ref = requested_tenant AND pending.accepted_at IS NULL
801
+ AND pending.revoked_at IS NULL AND pending.expires_at > statement_timestamp())
802
+ INTO occupied;
803
+ IF occupied >= requested_member_cap THEN
804
+ RETURN QUERY SELECT 'member_cap'::text, asked.person_ref;
805
+ RETURN;
806
+ END IF;
807
+ END IF;
808
+ INSERT INTO slotlock.workspace_memberships (tenant_ref, person_ref, role, source, joined_via_domain, joined_email)
809
+ VALUES (requested_tenant, asked.person_ref, requested_role, 'domain', asked.domain_id, asked.email)
810
+ ON CONFLICT ON CONSTRAINT slotlock_workspace_memberships_pkey DO UPDATE
811
+ SET role = excluded.role, status = 'active', source = 'domain', joined_via_domain = excluded.joined_via_domain, deactivated_by = NULL,
812
+ joined_email = excluded.joined_email, deactivated_at = NULL, updated_at = statement_timestamp();
813
+ -- Let in by a person who decided: the address is no longer one the team turned away.
814
+ DELETE FROM slotlock.workspace_join_blocks blocked
815
+ WHERE blocked.tenant_ref = requested_tenant AND blocked.email_sha256 = sha256(convert_to(asked.email, 'UTF8'));
816
+ UPDATE slotlock.workspace_join_requests found
817
+ SET status = 'approved', role = requested_role, decided_by = requested_actor, decided_at = statement_timestamp()
818
+ WHERE found.id = asked.id;
819
+ RETURN QUERY SELECT NULL::text, asked.person_ref;
820
+ END
821
+ $slotlock_decide_join_request$;
822
+ REVOKE ALL ON FUNCTION ${fn[10]} FROM PUBLIC;
823
+
824
+ -- Members a domain let in, with that domain and its standing now: a lapsed domain's people stay,
825
+ -- shown as joined through a domain the team no longer holds.
826
+ CREATE OR REPLACE FUNCTION slotlock.workspace_domain_members(requested_tenant text)
827
+ RETURNS TABLE (person_ref text, domain text, domain_status text)
828
+ LANGUAGE sql
829
+ STABLE
830
+ SECURITY DEFINER
831
+ SET search_path = pg_catalog, slotlock, pg_temp
832
+ AS $slotlock_workspace_domain_members$
833
+ SELECT member.person_ref, claim.domain_unicode, claim.status
834
+ FROM slotlock.workspace_memberships member
835
+ JOIN slotlock.workspace_domains claim ON claim.id = member.joined_via_domain
836
+ WHERE member.tenant_ref = requested_tenant AND member.status = 'active' AND member.source = 'domain';
837
+ $slotlock_workspace_domain_members$;
838
+ REVOKE ALL ON FUNCTION ${fn[11]} FROM PUBLIC;
839
+
840
+ -- Requests decided or expired 30 days ago, with the address they came by, deleted; claims that ended
841
+ -- (lapsed, released, or pending past their seven days) 90 days ago deleted; removals and refusals
842
+ -- remembered a year. How many went.
843
+ CREATE OR REPLACE FUNCTION slotlock.prune_workspace_domains()
844
+ RETURNS integer
845
+ LANGUAGE plpgsql
846
+ VOLATILE
847
+ SECURITY DEFINER
848
+ SET search_path = pg_catalog, slotlock, pg_temp
849
+ AS $slotlock_prune_workspace_domains$
850
+ DECLARE
851
+ requests integer;
852
+ claims integer;
853
+ blocks integer;
854
+ BEGIN
855
+ DELETE FROM slotlock.workspace_join_requests asked
856
+ WHERE (asked.status <> 'pending' AND coalesce(asked.decided_at, asked.expires_at) < statement_timestamp() - interval '30 days')
857
+ OR (asked.status = 'pending' AND asked.expires_at < statement_timestamp() - interval '30 days');
858
+ GET DIAGNOSTICS requests = ROW_COUNT;
859
+ DELETE FROM slotlock.workspace_domains claim
860
+ WHERE (claim.status IN ('lapsed', 'released') AND claim.ended_at < statement_timestamp() - interval '90 days')
861
+ OR (claim.status = 'pending' AND claim.created_at < statement_timestamp() - interval '97 days');
862
+ GET DIAGNOSTICS claims = ROW_COUNT;
863
+ DELETE FROM slotlock.workspace_join_blocks blocked WHERE blocked.created_at < statement_timestamp() - interval '365 days';
864
+ GET DIAGNOSTICS blocks = ROW_COUNT;
865
+ RETURN requests + claims + blocks;
866
+ END
867
+ $slotlock_prune_workspace_domains$;
868
+ REVOKE ALL ON FUNCTION ${fn[12]} FROM PUBLIC;
869
+
870
+ -- Whether a team's invitations are held to its verified domains.
871
+ CREATE OR REPLACE FUNCTION slotlock.workspace_invites_restricted(requested_tenant text)
872
+ RETURNS boolean
873
+ LANGUAGE sql
874
+ STABLE
875
+ SECURITY DEFINER
876
+ SET search_path = pg_catalog, slotlock, pg_temp
877
+ AS $slotlock_workspace_invites_restricted$
878
+ SELECT coalesce((SELECT ws.invites_domain_restricted FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant), false);
879
+ $slotlock_workspace_invites_restricted$;
880
+ REVOKE ALL ON FUNCTION ${fn[13]} FROM PUBLIC;
881
+ `;