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
package/ddl.js CHANGED
@@ -10,6 +10,9 @@
10
10
  // transaction: under autocommit the pin ends with its own statement.
11
11
  import { SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_MAX_KEY_SCOPE_REFS, SLOTLOCK_MAX_COMPOSITE_PARTS, SLOTLOCK_RECONCILE_TOLERANCE_PERCENT, SLOTLOCK_MAX_POOL_SETTINGS, SLOTLOCK_POOL_ORDERS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, } from './resource-profile.js';
12
12
  import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_WEBHOOK_FILTER_REFS, SLOTLOCK_WEBHOOK_FILTER_RESOURCES, legacyCoverageSourceDigestSql, } from './event-feed.js';
13
+ import { SLOTLOCK_IDENTITY_DDL, SLOTLOCK_IDENTITY_FUNCTIONS, SLOTLOCK_IDENTITY_TABLES, SLOTLOCK_INVITE_DOMAIN_REFUSED, } from './ddl-identity.js';
14
+ import { SLOTLOCK_SSO_DDL, SLOTLOCK_SSO_FUNCTIONS, SLOTLOCK_SSO_TABLES } from './ddl-sso.js';
15
+ import { SLOTLOCK_SCIM_DDL, SLOTLOCK_SCIM_FUNCTIONS, SLOTLOCK_SCIM_TABLES } from './ddl-scim.js';
13
16
  import { SLOTLOCK_MAX_AVAILABILITY_RULES } from './weekly-hours.js';
14
17
  const CALLER_SEARCH_PATH_SETTING = 'slotlock.caller_search_path';
15
18
  const DEPLOYMENT_SEARCH_PATH_PIN = `SELECT pg_catalog.set_config('${CALLER_SEARCH_PATH_SETTING}', pg_catalog.current_setting('search_path'), true);
@@ -69,9 +72,10 @@ export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into sc
69
72
  * The schema version this release creates, recorded in `slotlock.schema_version`. Up to 0.2
70
73
  * recorded nothing (0.1 was 1, 0.2 was 2); 0.3 to 0.5 recorded 3. 0.6 is 4, so a 0.5 release
71
74
  * refuses it rather than restoring the trigger that names a coverage source by its guessable
72
- * digest.
75
+ * digest. 0.10 is 5, so a 0.9 release refuses it rather than putting back an erase_tenant that
76
+ * leaves team workspaces, their members and invitations behind.
73
77
  */
74
- export const SLOTLOCK_SCHEMA_VERSION = 4;
78
+ export const SLOTLOCK_SCHEMA_VERSION = 5;
75
79
  /** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
76
80
  export const SLOTLOCK_SCHEMA_NEWER_REFUSAL = 'Slotlock will not migrate a schema a newer release migrated';
77
81
  /** The oldest PostgreSQL the DDL runs on (`server_version_num`): it uses pg_input_is_valid. */
@@ -218,7 +222,21 @@ export const SLOTLOCK_API_KEY_FUNCTIONS = Object.freeze([
218
222
  * The table behind the dashboard's shared records (dashboard.ts). No role but its owner holds a
219
223
  * right on it; it is reached only through SLOTLOCK_DASHBOARD_FUNCTIONS.
220
224
  */
221
- export const SLOTLOCK_DASHBOARD_TABLES = Object.freeze(['dashboard_tokens', 'dashboard_organizations']);
225
+ export const SLOTLOCK_DASHBOARD_TABLES = Object.freeze([
226
+ 'dashboard_tokens',
227
+ 'dashboard_organizations',
228
+ 'dashboard_people',
229
+ 'workspace_memberships',
230
+ 'workspaces',
231
+ 'workspace_invitations',
232
+ 'dashboard_sessions',
233
+ // Verified domains and requests to join (ddl-identity.ts).
234
+ ...SLOTLOCK_IDENTITY_TABLES,
235
+ // Single sign-on (ddl-sso.ts).
236
+ ...SLOTLOCK_SSO_TABLES,
237
+ // SCIM provisioning (ddl-scim.ts).
238
+ ...SLOTLOCK_SCIM_TABLES,
239
+ ]);
222
240
  /** The SECURITY DEFINER functions through which the serving role reaches the dashboard's records. */
223
241
  export const SLOTLOCK_DASHBOARD_FUNCTIONS = Object.freeze([
224
242
  'slotlock.use_dashboard_token(text, bytea, timestamptz, bytea)',
@@ -226,8 +244,68 @@ export const SLOTLOCK_DASHBOARD_FUNCTIONS = Object.freeze([
226
244
  'slotlock.enabled_organizations(text)',
227
245
  'slotlock.enable_organization(text, text, text)',
228
246
  'slotlock.disable_organization(text, text)',
247
+ 'slotlock.dashboard_sign_in(text, text)',
248
+ 'slotlock.dashboard_standing(text, text, bytea)',
249
+ 'slotlock.dashboard_sign_out_everywhere(text)',
250
+ 'slotlock.prune_audit_events(text)',
251
+ 'slotlock.dashboard_display(text, text)',
252
+ 'slotlock.create_team(text, text, text, integer)',
253
+ 'slotlock.workspace_info(text)',
254
+ 'slotlock.person_workspaces(text)',
255
+ 'slotlock.workspace_members(text)',
256
+ 'slotlock.workspace_invitations_list(text)',
257
+ 'slotlock.invite_member(text, text, text, text, text, text, bytea, integer, integer, integer)',
258
+ 'slotlock.invitation_preview(bytea)',
259
+ 'slotlock.accept_invitation(bytea, uuid, text, text[], integer)',
260
+ 'slotlock.revoke_invitation(text, text, uuid)',
261
+ 'slotlock.resend_invitation(text, text, uuid, bytea, integer, integer, integer)',
262
+ 'slotlock.change_member_role(text, text, text, text)',
263
+ 'slotlock.remove_member(text, text, text)',
264
+ 'slotlock.pending_invitations_for(text, text[])',
265
+ 'slotlock.workspace_active(text)',
266
+ 'slotlock.prune_workspace_invitations()',
267
+ 'slotlock.dashboard_session_started(text, bytea, timestamptz, text, text)',
268
+ 'slotlock.dashboard_session_seen(text, bytea)',
269
+ 'slotlock.dashboard_sessions_of(text)',
270
+ 'slotlock.dashboard_session_end(text, bytea)',
271
+ 'slotlock.prune_dashboard_sessions()',
272
+ 'slotlock.schedule_team_deletion(text, text, text)',
273
+ 'slotlock.cancel_team_deletion(text, text)',
274
+ 'slotlock.erase_deleted_teams()',
275
+ 'slotlock.workspace_delete_after(text)',
276
+ 'slotlock.member_scope(text, text)',
277
+ 'slotlock.set_member_scope(text, text, text, jsonb)',
278
+ 'slotlock.member_scopes(text)',
279
+ ...SLOTLOCK_IDENTITY_FUNCTIONS,
280
+ ...SLOTLOCK_SSO_FUNCTIONS,
281
+ ...SLOTLOCK_SCIM_FUNCTIONS,
229
282
  ]);
283
+ /**
284
+ * A workspace's roles (workspace-roles.ts), as workspace_memberships keeps them; and how long the
285
+ * audit log keeps an event.
286
+ */
287
+ export const SLOTLOCK_WORKSPACE_ROLE_VALUES = Object.freeze(['owner', 'admin', 'developer', 'member', 'viewer', 'billing']);
288
+ export const SLOTLOCK_AUDIT_RETENTION_DAYS = 180;
289
+ /**
290
+ * Days the change history (slotlock.outbox: the feed, and each booking's history) keeps a change on
291
+ * each hosted plan (RFC 0004 D2). A tenant with no plan keeps the store's `retentionDays` (default
292
+ * 30); one with a plan keeps its plan's days, or the store's if longer. A cancelled plan keeps Free's.
293
+ * The workspace audit log keeps SLOTLOCK_AUDIT_RETENTION_DAYS, or the plan's days if longer.
294
+ */
295
+ export const SLOTLOCK_CHANGE_RETENTION_BY_PLAN = Object.freeze({ free: 30, developer: 90, team: 400 });
296
+ /** The plan's change retention in days for the tenant `tenant` (an SQL expression), null without a plan. */
297
+ const planChangeRetentionDaysSql = (tenant) => `(SELECT CASE WHEN given.status = 'canceled' THEN ${SLOTLOCK_CHANGE_RETENTION_BY_PLAN.free}
298
+ ELSE CASE given.plan ${Object.entries(SLOTLOCK_CHANGE_RETENTION_BY_PLAN)
299
+ .map(([plan, days]) => `WHEN '${plan}' THEN ${days}`)
300
+ .join(' ')} END END
301
+ FROM slotlock.tenant_plan(${tenant}) given LIMIT 1)`;
230
302
  /** Live form records one person may leave in the dashboard's records (12 hours of forms). */
303
+ /**
304
+ * The invitations one address is sent in a day, from every team together, where invitations are
305
+ * limited (a deployment with plans): with sign-up open, many accounts each within their own daily
306
+ * limit could otherwise fill one inbox.
307
+ */
308
+ export const SLOTLOCK_INVITATIONS_PER_ADDRESS = 5;
231
309
  export const SLOTLOCK_DASHBOARD_FORM_LIMIT = 1_000;
232
310
  /** The table that records which release migrated the schema (SLOTLOCK_SCHEMA_VERSION). */
233
311
  export const SLOTLOCK_SCHEMA_TABLES = Object.freeze(['schema_version']);
@@ -277,6 +355,8 @@ export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
277
355
  'slotlock.note_usage_threshold(text, date, text, integer, bigint, bigint)',
278
356
  'slotlock.tenant_plan_quotas(text)',
279
357
  'slotlock.reset_test_tenant(text)',
358
+ 'slotlock.prune_changes(text, integer, integer)',
359
+ 'slotlock.erase_subject_changes(text, text, text, text)',
280
360
  ]);
281
361
  /** The functions usage metering runs (SLOTLOCK_USAGE=on): serve checks it may, before it starts. */
282
362
  export const SLOTLOCK_USAGE_FUNCTIONS = Object.freeze([
@@ -292,6 +372,10 @@ export const SLOTLOCK_QUOTA_FUNCTIONS = Object.freeze([
292
372
  ]);
293
373
  /** What a plan's monthly quotas count (quotas.ts): operations run and bookings made. */
294
374
  export const SLOTLOCK_QUOTA_METRICS = Object.freeze(['operations', 'bookings']);
375
+ /** The most conversations a member keeps with the dashboard assistant: past it, the least recent goes. */
376
+ export const SLOTLOCK_ASSISTANT_MAX_CHATS = 100;
377
+ /** How a conversation with the assistant is named: 16 URL-safe characters. */
378
+ export const SLOTLOCK_ASSISTANT_CHAT_ID = /^[A-Za-z0-9_-]{16}$/;
295
379
  /** The shares of a quota, in percent, at which a tenant is told once a month (usage.threshold_reached). */
296
380
  export const SLOTLOCK_QUOTA_NOTICE_PERCENTS = Object.freeze([80, 100]);
297
381
  /** The most bookings past the roll-up cursor one quota read counts: more than any plan allows. */
@@ -361,6 +445,13 @@ export const SLOTLOCK_DEFINER_FUNCTIONS = Object.freeze([
361
445
  ...SLOTLOCK_SCHEMA_FUNCTIONS,
362
446
  ...SLOTLOCK_TENANT_ADMIN_FUNCTIONS,
363
447
  ]);
448
+ /**
449
+ * Tenant tables the serving role may only add to and read (no UPDATE or DELETE): the audit log,
450
+ * whose rows go only through slotlock.prune_audit_events and erasure, and the change history,
451
+ * whose rows go only through slotlock.prune_changes and erasure, and whose one edit is taking a
452
+ * person out of it (slotlock.erase_subject_changes).
453
+ */
454
+ export const SLOTLOCK_APPEND_ONLY_TABLES = Object.freeze(['outbox', 'audit_events']);
364
455
  /** Tables no role but their owner reads or writes, reached only through SLOTLOCK_DEFINER_FUNCTIONS. */
365
456
  export const SLOTLOCK_DEFINER_TABLES = Object.freeze([
366
457
  ...SLOTLOCK_API_KEY_TABLES,
@@ -1048,52 +1139,1403 @@ LANGUAGE sql
1048
1139
  STABLE
1049
1140
  SECURITY DEFINER
1050
1141
  SET search_path = pg_catalog, slotlock, pg_temp
1051
- AS $slotlock_enabled_organizations$
1052
- SELECT org.org_id FROM slotlock.dashboard_organizations org
1053
- WHERE org.enabled
1054
- AND org.org_id = ANY (pg_catalog.string_to_array(requested_ids, ','));
1055
- $slotlock_enabled_organizations$;
1056
- REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[2]} FROM PUBLIC;
1142
+ AS $slotlock_enabled_organizations$
1143
+ SELECT org.org_id FROM slotlock.dashboard_organizations org
1144
+ WHERE org.enabled
1145
+ AND org.org_id = ANY (pg_catalog.string_to_array(requested_ids, ','));
1146
+ $slotlock_enabled_organizations$;
1147
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[2]} FROM PUBLIC;
1148
+
1149
+ -- Turn Slotlock on for an organization; true when it was not on. Who may (an owner of it, read from
1150
+ -- GitHub at sign-in) is the dashboard's to check.
1151
+ CREATE OR REPLACE FUNCTION slotlock.enable_organization(requested_org text, requested_login text, requested_by text)
1152
+ RETURNS boolean
1153
+ LANGUAGE plpgsql
1154
+ VOLATILE
1155
+ SECURITY DEFINER
1156
+ SET search_path = pg_catalog, slotlock, pg_temp
1157
+ AS $slotlock_enable_organization$
1158
+ DECLARE
1159
+ was boolean;
1160
+ BEGIN
1161
+ SELECT org.enabled INTO was FROM slotlock.dashboard_organizations org
1162
+ WHERE org.org_id = requested_org FOR UPDATE;
1163
+ INSERT INTO slotlock.dashboard_organizations AS org (org_id, login, enabled, changed_by)
1164
+ VALUES (requested_org, requested_login, true, requested_by)
1165
+ ON CONFLICT (org_id) DO UPDATE
1166
+ SET login = EXCLUDED.login, enabled = true,
1167
+ changed_by = CASE WHEN org.enabled THEN org.changed_by ELSE EXCLUDED.changed_by END,
1168
+ changed_at = CASE WHEN org.enabled THEN org.changed_at ELSE statement_timestamp() END;
1169
+ RETURN was IS NOT TRUE;
1170
+ END
1171
+ $slotlock_enable_organization$;
1172
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[3]} FROM PUBLIC;
1173
+
1174
+ -- Turn it off; true when it was on.
1175
+ CREATE OR REPLACE FUNCTION slotlock.disable_organization(requested_org text, requested_by text)
1176
+ RETURNS boolean
1177
+ LANGUAGE sql
1178
+ VOLATILE
1179
+ SECURITY DEFINER
1180
+ SET search_path = pg_catalog, slotlock, pg_temp
1181
+ AS $slotlock_disable_organization$
1182
+ UPDATE slotlock.dashboard_organizations org
1183
+ SET enabled = false, changed_by = requested_by, changed_at = statement_timestamp()
1184
+ WHERE org.org_id = requested_org AND org.enabled
1185
+ RETURNING true;
1186
+ $slotlock_disable_organization$;
1187
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[4]} FROM PUBLIC;
1188
+
1189
+ -- The people who sign in to the dashboard, by their identity (github:<id> or google:<subject>,
1190
+ -- never an email): when they last signed in, and the epoch their sessions carry. The epoch is
1191
+ -- drawn at random: "Sign out everywhere" draws another, and a session sealed under any other is
1192
+ -- refused (ASVS 5.0 7.4.1's per-user revocation). A person without a row (erased) has no epoch,
1193
+ -- so none of their sessions passes, and a row made again draws afresh. Owner-only; reached
1194
+ -- through the functions below.
1195
+ CREATE TABLE IF NOT EXISTS slotlock.dashboard_people (
1196
+ person_ref text PRIMARY KEY,
1197
+ session_epoch integer NOT NULL DEFAULT (1 + floor(random() * 2147483646))::integer,
1198
+ last_sign_in_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1199
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1200
+ CONSTRAINT slotlock_dashboard_people_valid CHECK (
1201
+ person_ref ~ '^(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})$'
1202
+ AND session_epoch > 0
1203
+ )
1204
+ );
1205
+ ALTER TABLE slotlock.dashboard_people ENABLE ROW LEVEL SECURITY;
1206
+ REVOKE ALL ON slotlock.dashboard_people FROM PUBLIC;
1207
+
1208
+ -- Who belongs to a shared workspace, and as what (workspace-roles.ts). A person's own workspace
1209
+ -- (their github:<id> or google:<subject> tenant) has no row: they are its Owner. The live tenant
1210
+ -- holds the rows; its test environment (~test) has the same members. Inactive rows are kept so
1211
+ -- someone added back gets their role again. Owner-only: the serving role reads and writes roles only
1212
+ -- through the functions below, which check what they are given; it cannot write the table itself.
1213
+ -- A change made in Slotlock (a role given, a member removed) sets source to 'created' or
1214
+ -- 'invitation', and sign-in's mirroring of GitHub never touches such a row again.
1215
+ CREATE TABLE IF NOT EXISTS slotlock.workspace_memberships (
1216
+ tenant_ref text NOT NULL,
1217
+ person_ref text NOT NULL,
1218
+ role text NOT NULL,
1219
+ status text NOT NULL DEFAULT 'active',
1220
+ source text NOT NULL,
1221
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1222
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1223
+ deactivated_at timestamptz,
1224
+ CONSTRAINT slotlock_workspace_memberships_pkey PRIMARY KEY (tenant_ref, person_ref),
1225
+ CONSTRAINT slotlock_workspace_memberships_valid CHECK (
1226
+ 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})$'
1227
+ AND person_ref ~ '^(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})$'
1228
+ AND role IN ('owner', 'admin', 'developer', 'member', 'viewer', 'billing')
1229
+ AND status IN ('active', 'inactive')
1230
+ AND source IN ('github_org', 'invitation', 'created', 'domain', 'sso', 'scim')
1231
+ AND (status = 'active') = (deactivated_at IS NULL)
1232
+ )
1233
+ );
1234
+ CREATE INDEX IF NOT EXISTS slotlock_workspace_memberships_person_idx
1235
+ ON slotlock.workspace_memberships (person_ref) WHERE status = 'active';
1236
+ ALTER TABLE slotlock.workspace_memberships ENABLE ROW LEVEL SECURITY;
1237
+ REVOKE ALL ON slotlock.workspace_memberships FROM PUBLIC;
1238
+
1239
+ -- A sign-in: the person is recorded (their epoch returned, to seal into the session) and, signing
1240
+ -- in with GitHub, the organizations GitHub lists them in, as JSON [{"id":"123","role":"admin"}],
1241
+ -- become their memberships there: GitHub's owners Owner, its members Developer. An organization
1242
+ -- GitHub no longer lists ends the membership it gave. A role given in Slotlock (source other than
1243
+ -- github_org) is never changed here.
1244
+ CREATE OR REPLACE FUNCTION slotlock.dashboard_sign_in(requested_person text, requested_github_orgs text)
1245
+ RETURNS integer
1246
+ LANGUAGE plpgsql
1247
+ VOLATILE
1248
+ SECURITY DEFINER
1249
+ SET search_path = pg_catalog, slotlock, pg_temp
1250
+ AS $slotlock_dashboard_sign_in$
1251
+ DECLARE
1252
+ listed jsonb;
1253
+ epoch integer;
1254
+ BEGIN
1255
+ IF requested_person IS NULL
1256
+ OR requested_person !~ '^(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})$' THEN
1257
+ RAISE EXCEPTION 'Slotlock dashboard: invalid person' USING ERRCODE = '22023';
1258
+ END IF;
1259
+ -- A person a team's provider made exists only while an identity of theirs does: one erased while
1260
+ -- their sign-in was under way (ddl-sso.ts) is not made again. The identity is locked, so an
1261
+ -- erasure waits for this, or this sees it.
1262
+ IF requested_person ~ '^sso:' THEN
1263
+ PERFORM 1 FROM slotlock.sso_identities identity WHERE identity.person_ref = requested_person FOR SHARE;
1264
+ IF NOT FOUND THEN
1265
+ RAISE EXCEPTION 'Slotlock dashboard: that person is no longer here' USING ERRCODE = 'P0002';
1266
+ END IF;
1267
+ END IF;
1268
+ INSERT INTO slotlock.dashboard_people AS person (person_ref) VALUES (requested_person)
1269
+ ON CONFLICT (person_ref) DO UPDATE SET last_sign_in_at = statement_timestamp()
1270
+ RETURNING person.session_epoch INTO epoch;
1271
+ IF requested_github_orgs IS NULL THEN
1272
+ RETURN epoch;
1273
+ END IF;
1274
+ IF requested_person !~ '^github:' OR octet_length(requested_github_orgs) > 8192 THEN
1275
+ RAISE EXCEPTION 'Slotlock dashboard: invalid organizations' USING ERRCODE = '22023';
1276
+ END IF;
1277
+ listed := requested_github_orgs::jsonb;
1278
+ IF jsonb_typeof(listed) <> 'array' OR jsonb_array_length(listed) > 100 OR EXISTS (
1279
+ SELECT 1 FROM jsonb_array_elements(listed) org
1280
+ WHERE jsonb_typeof(org) <> 'object'
1281
+ OR coalesce(org ->> 'id', '') !~ '^[1-9][0-9]{0,19}$'
1282
+ OR coalesce(org ->> 'role', '') NOT IN ('admin', 'member')
1283
+ ) THEN
1284
+ RAISE EXCEPTION 'Slotlock dashboard: invalid organizations' USING ERRCODE = '22023';
1285
+ END IF;
1286
+ INSERT INTO slotlock.workspace_memberships AS member (tenant_ref, person_ref, role, status, source)
1287
+ SELECT DISTINCT ON (org ->> 'id') 'github-org:' || (org ->> 'id'), requested_person,
1288
+ CASE org ->> 'role' WHEN 'admin' THEN 'owner' ELSE 'developer' END, 'active', 'github_org'
1289
+ FROM jsonb_array_elements(listed) org
1290
+ ORDER BY org ->> 'id', org ->> 'role'
1291
+ ON CONFLICT (tenant_ref, person_ref) DO UPDATE
1292
+ SET role = EXCLUDED.role, status = 'active', deactivated_at = NULL,
1293
+ updated_at = statement_timestamp()
1294
+ WHERE member.source = 'github_org';
1295
+ UPDATE slotlock.workspace_memberships member
1296
+ SET status = 'inactive', deactivated_at = statement_timestamp(), updated_at = statement_timestamp()
1297
+ WHERE member.person_ref = requested_person
1298
+ AND member.source = 'github_org'
1299
+ AND member.status = 'active'
1300
+ AND NOT EXISTS (
1301
+ SELECT 1 FROM jsonb_array_elements(listed) org
1302
+ WHERE member.tenant_ref = 'github-org:' || (org ->> 'id'));
1303
+ RETURN epoch;
1304
+ END
1305
+ $slotlock_dashboard_sign_in$;
1306
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[5]} FROM PUBLIC;
1307
+
1308
+ -- A person's standing in a workspace now is slotlock.dashboard_standing(tenant, person, session),
1309
+ -- in ddl-sso.ts: it reads the single sign-on tables, so it is made after them. The form before
1310
+ -- single sign-on, without the session, is dropped, so every caller passes the session.
1311
+ DROP FUNCTION IF EXISTS slotlock.dashboard_standing(text, text);
1312
+
1313
+ -- Sign a person out everywhere: a new epoch is drawn (never the old one), and every session sealed
1314
+ -- under the old is refused from now on. Returns the new epoch.
1315
+ CREATE OR REPLACE FUNCTION slotlock.dashboard_sign_out_everywhere(requested_person text)
1316
+ RETURNS integer
1317
+ LANGUAGE plpgsql
1318
+ VOLATILE
1319
+ SECURITY DEFINER
1320
+ SET search_path = pg_catalog, slotlock, pg_temp
1321
+ AS $slotlock_dashboard_sign_out_everywhere$
1322
+ DECLARE
1323
+ epoch integer;
1324
+ BEGIN
1325
+ IF requested_person IS NULL
1326
+ OR requested_person !~ '^(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})$' THEN
1327
+ RAISE EXCEPTION 'Slotlock dashboard: invalid person' USING ERRCODE = '22023';
1328
+ END IF;
1329
+ INSERT INTO slotlock.dashboard_people AS person (person_ref) VALUES (requested_person)
1330
+ ON CONFLICT (person_ref) DO UPDATE
1331
+ SET session_epoch = (
1332
+ SELECT CASE WHEN drawn.epoch = person.session_epoch THEN drawn.epoch % 2147483646 + 1 ELSE drawn.epoch END
1333
+ FROM (SELECT (1 + floor(random() * 2147483646))::integer AS epoch) drawn)
1334
+ RETURNING person.session_epoch INTO epoch;
1335
+ RETURN epoch;
1336
+ END
1337
+ $slotlock_dashboard_sign_out_everywhere$;
1338
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[7]} FROM PUBLIC;
1339
+
1340
+ -- Team workspaces (team:<uuid>): a workspace Slotlock owns, not a person's and not a GitHub
1341
+ -- organization's, so its owners can change. Owner-only; reached through the functions below.
1342
+ CREATE TABLE IF NOT EXISTS slotlock.workspaces (
1343
+ tenant_ref text PRIMARY KEY,
1344
+ handle text NOT NULL,
1345
+ name text NOT NULL,
1346
+ status text NOT NULL DEFAULT 'active',
1347
+ -- Who created it, for the free team workspaces a person may create; cleared when they are erased.
1348
+ created_by text,
1349
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1350
+ CONSTRAINT slotlock_workspaces_handle_key UNIQUE (handle),
1351
+ CONSTRAINT slotlock_workspaces_valid CHECK (
1352
+ tenant_ref ~ '^team:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
1353
+ AND handle ~ '^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$'
1354
+ AND char_length(name) BETWEEN 1 AND 80
1355
+ AND name !~ '[[:cntrl:]]'
1356
+ AND status IN ('active')
1357
+ AND (created_by IS NULL OR created_by ~ '^(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})$')
1358
+ )
1359
+ );
1360
+ ALTER TABLE slotlock.workspaces ENABLE ROW LEVEL SECURITY;
1361
+ REVOKE ALL ON slotlock.workspaces FROM PUBLIC;
1362
+
1363
+ -- How a person is shown to the others in their workspaces: their GitHub login or the Google email
1364
+ -- Google verified, as they last signed in. Never an identifier: people are their github:/google: ref.
1365
+ ALTER TABLE slotlock.dashboard_people ADD COLUMN IF NOT EXISTS display text;
1366
+ DO $slotlock_people_display$
1367
+ BEGIN
1368
+ IF NOT EXISTS (
1369
+ SELECT 1 FROM pg_constraint
1370
+ WHERE conname = 'slotlock_dashboard_people_display_valid'
1371
+ AND conrelid = 'slotlock.dashboard_people'::regclass
1372
+ ) THEN
1373
+ ALTER TABLE slotlock.dashboard_people ADD CONSTRAINT slotlock_dashboard_people_display_valid
1374
+ CHECK (display IS NULL OR (char_length(display) BETWEEN 1 AND 254 AND display !~ '[[:cntrl:]<>]'));
1375
+ END IF;
1376
+ END
1377
+ $slotlock_people_display$;
1378
+
1379
+ -- Invitations to a team workspace: to an email address or a GitHub account, with a role, by a
1380
+ -- member entitled to give it. Only a SHA-256 digest of the token is kept (the token is 256 random
1381
+ -- bits, shown or sent once). An invitation is pending until accepted, revoked or past expires_at;
1382
+ -- accepting needs the signed-in person to be the GitHub account invited, or to hold the invited
1383
+ -- email verified. Owner-only; reached through the functions below.
1384
+ CREATE TABLE IF NOT EXISTS slotlock.workspace_invitations (
1385
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
1386
+ tenant_ref text NOT NULL REFERENCES slotlock.workspaces (tenant_ref) ON DELETE CASCADE,
1387
+ token_sha256 bytea NOT NULL,
1388
+ email text,
1389
+ github_ref text,
1390
+ github_login text,
1391
+ role text NOT NULL,
1392
+ inviter_ref text NOT NULL,
1393
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1394
+ sent_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1395
+ expires_at timestamptz NOT NULL,
1396
+ accepted_at timestamptz,
1397
+ accepted_by text,
1398
+ revoked_at timestamptz,
1399
+ CONSTRAINT slotlock_workspace_invitations_token_key UNIQUE (token_sha256),
1400
+ CONSTRAINT slotlock_workspace_invitations_valid CHECK (
1401
+ octet_length(token_sha256) = 32
1402
+ AND (email IS NULL) <> (github_ref IS NULL)
1403
+ AND (email IS NULL OR (char_length(email) BETWEEN 3 AND 254 AND email = lower(email)
1404
+ AND email ~ '^[^@[:space:][:cntrl:]<>]+@[^@[:space:][:cntrl:]<>]+$'))
1405
+ AND (github_ref IS NULL OR github_ref ~ '^github:[1-9][0-9]{0,19}$')
1406
+ AND (github_login IS NULL OR github_login ~ '^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$')
1407
+ AND role IN ('owner', 'admin', 'developer', 'member', 'viewer', 'billing')
1408
+ AND inviter_ref ~ '^(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})$'
1409
+ AND (accepted_by IS NULL OR accepted_by ~ '^(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})$')
1410
+ AND expires_at > created_at
1411
+ )
1412
+ );
1413
+ CREATE INDEX IF NOT EXISTS slotlock_workspace_invitations_tenant_idx
1414
+ ON slotlock.workspace_invitations (tenant_ref, sent_at DESC);
1415
+ CREATE INDEX IF NOT EXISTS slotlock_workspace_invitations_github_idx
1416
+ ON slotlock.workspace_invitations (github_ref) WHERE accepted_at IS NULL AND revoked_at IS NULL;
1417
+ CREATE INDEX IF NOT EXISTS slotlock_workspace_invitations_email_idx
1418
+ ON slotlock.workspace_invitations (email) WHERE accepted_at IS NULL AND revoked_at IS NULL;
1419
+ CREATE INDEX IF NOT EXISTS slotlock_workspace_invitations_sent_email_idx
1420
+ ON slotlock.workspace_invitations (email, sent_at) WHERE email IS NOT NULL;
1421
+ ALTER TABLE slotlock.workspace_invitations ENABLE ROW LEVEL SECURITY;
1422
+ REVOKE ALL ON slotlock.workspace_invitations FROM PUBLIC;
1423
+
1424
+ -- Whether a role may give another (workspace-roles.ts slotlockRoleMayGrant): an Owner any role, an
1425
+ -- Admin up to Admin (never Owner or Billing), anyone else none.
1426
+ CREATE OR REPLACE FUNCTION slotlock.workspace_may_grant(granter text, granted text)
1427
+ RETURNS boolean
1428
+ LANGUAGE sql
1429
+ IMMUTABLE
1430
+ SET search_path = pg_catalog, slotlock, pg_temp
1431
+ AS $slotlock_workspace_may_grant$
1432
+ SELECT CASE granter
1433
+ WHEN 'owner' THEN granted IN ('owner', 'admin', 'developer', 'member', 'viewer', 'billing')
1434
+ WHEN 'admin' THEN granted IN ('admin', 'developer', 'member', 'viewer')
1435
+ ELSE false
1436
+ END;
1437
+ $slotlock_workspace_may_grant$;
1438
+ REVOKE ALL ON FUNCTION slotlock.workspace_may_grant(text, text) FROM PUBLIC;
1439
+
1440
+ -- A person's display (their GitHub login, or the email Google verified), at sign-in.
1441
+ CREATE OR REPLACE FUNCTION slotlock.dashboard_display(requested_person text, requested_display text)
1442
+ RETURNS void
1443
+ LANGUAGE plpgsql
1444
+ VOLATILE
1445
+ SECURITY DEFINER
1446
+ SET search_path = pg_catalog, slotlock, pg_temp
1447
+ AS $slotlock_dashboard_display$
1448
+ BEGIN
1449
+ UPDATE slotlock.dashboard_people person
1450
+ SET display = NULLIF(requested_display, '')
1451
+ WHERE person.person_ref = requested_person;
1452
+ END
1453
+ $slotlock_dashboard_display$;
1454
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[9]} FROM PUBLIC;
1455
+
1456
+ -- A new team workspace, its creator its Owner. With requested_cap, a person may have created at
1457
+ -- most that many active team workspaces that are not on a paid plan.
1458
+ CREATE OR REPLACE FUNCTION slotlock.create_team(
1459
+ requested_person text, requested_name text, requested_handle text, requested_cap integer)
1460
+ RETURNS TABLE (tenant_ref text, refusal text)
1461
+ LANGUAGE plpgsql
1462
+ VOLATILE
1463
+ SECURITY DEFINER
1464
+ SET search_path = pg_catalog, slotlock, pg_temp
1465
+ AS $slotlock_create_team$
1466
+ DECLARE
1467
+ created text := 'team:' || gen_random_uuid()::text;
1468
+ free_count integer;
1469
+ BEGIN
1470
+ IF requested_person !~ '^(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})$' THEN
1471
+ RAISE EXCEPTION 'Slotlock teams: invalid person' USING ERRCODE = '22023';
1472
+ END IF;
1473
+ -- A person a team's identity provider made (sso:) creates no team: their organization holds them.
1474
+ IF requested_person ~ '^sso:' THEN
1475
+ RETURN QUERY SELECT NULL::text, 'not_allowed'::text;
1476
+ RETURN;
1477
+ END IF;
1478
+ IF requested_handle IS NULL OR requested_handle !~ '^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$' THEN
1479
+ RETURN QUERY SELECT NULL::text, 'invalid_handle'::text;
1480
+ RETURN;
1481
+ END IF;
1482
+ IF requested_name IS NULL OR char_length(btrim(requested_name)) NOT BETWEEN 1 AND 80
1483
+ OR requested_name ~ '[[:cntrl:]]' THEN
1484
+ RETURN QUERY SELECT NULL::text, 'invalid_name'::text;
1485
+ RETURN;
1486
+ END IF;
1487
+ -- One person's creations at a time, so two at once cannot both pass the cap.
1488
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:create-team:' || requested_person, 0));
1489
+ IF requested_cap IS NOT NULL THEN
1490
+ SELECT count(*) INTO free_count FROM slotlock.workspaces ws
1491
+ WHERE ws.created_by = requested_person AND ws.status = 'active'
1492
+ AND NOT EXISTS (SELECT 1 FROM slotlock.tenant_plans paid
1493
+ WHERE paid.tenant_ref = ws.tenant_ref AND paid.plan <> 'free' AND paid.status <> 'canceled');
1494
+ IF free_count >= requested_cap THEN
1495
+ RETURN QUERY SELECT NULL::text, 'team_limit'::text;
1496
+ RETURN;
1497
+ END IF;
1498
+ END IF;
1499
+ IF EXISTS (SELECT 1 FROM slotlock.workspaces ws WHERE ws.handle = requested_handle) THEN
1500
+ RETURN QUERY SELECT NULL::text, 'handle_taken'::text;
1501
+ RETURN;
1502
+ END IF;
1503
+ INSERT INTO slotlock.workspaces (tenant_ref, handle, name, created_by)
1504
+ VALUES (created, requested_handle, btrim(requested_name), requested_person);
1505
+ INSERT INTO slotlock.workspace_memberships (tenant_ref, person_ref, role, source)
1506
+ VALUES (created, requested_person, 'owner', 'created');
1507
+ RETURN QUERY SELECT created, NULL::text;
1508
+ EXCEPTION WHEN unique_violation THEN
1509
+ RETURN QUERY SELECT NULL::text, 'handle_taken'::text;
1510
+ END
1511
+ $slotlock_create_team$;
1512
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[10]} FROM PUBLIC;
1513
+
1514
+ -- A team workspace's handle, name and status (its test environment's are its own).
1515
+ CREATE OR REPLACE FUNCTION slotlock.workspace_info(requested_tenant text)
1516
+ RETURNS TABLE (handle text, name text, status text)
1517
+ LANGUAGE plpgsql
1518
+ STABLE
1519
+ SECURITY DEFINER
1520
+ SET search_path = pg_catalog, slotlock, pg_temp
1521
+ AS $slotlock_workspace_info$
1522
+ BEGIN
1523
+ RETURN QUERY SELECT ws.handle, ws.name, ws.status FROM slotlock.workspaces ws
1524
+ WHERE ws.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', '');
1525
+ END
1526
+ $slotlock_workspace_info$;
1527
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[11]} FROM PUBLIC;
1528
+
1529
+ -- The team workspaces a person is an active member of, with their role, by name.
1530
+ CREATE OR REPLACE FUNCTION slotlock.person_workspaces(requested_person text)
1531
+ RETURNS TABLE (tenant_ref text, handle text, name text, role text)
1532
+ LANGUAGE plpgsql
1533
+ STABLE
1534
+ SECURITY DEFINER
1535
+ SET search_path = pg_catalog, slotlock, pg_temp
1536
+ AS $slotlock_person_workspaces$
1537
+ BEGIN
1538
+ RETURN QUERY SELECT ws.tenant_ref, ws.handle, ws.name, member.role
1539
+ FROM slotlock.workspace_memberships member
1540
+ JOIN slotlock.workspaces ws ON ws.tenant_ref = member.tenant_ref AND ws.status IN ('active', 'deleting')
1541
+ WHERE member.person_ref = requested_person AND member.status = 'active'
1542
+ ORDER BY lower(ws.name), ws.handle
1543
+ LIMIT 100;
1544
+ END
1545
+ $slotlock_person_workspaces$;
1546
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[12]} FROM PUBLIC;
1547
+
1548
+ -- A workspace's active members, Owners first, with how each is shown.
1549
+ CREATE OR REPLACE FUNCTION slotlock.workspace_members(requested_tenant text)
1550
+ RETURNS TABLE (person_ref text, role text, source text, display text, joined_at timestamptz)
1551
+ LANGUAGE plpgsql
1552
+ STABLE
1553
+ SECURITY DEFINER
1554
+ SET search_path = pg_catalog, slotlock, pg_temp
1555
+ AS $slotlock_workspace_members$
1556
+ BEGIN
1557
+ RETURN QUERY SELECT member.person_ref, member.role, member.source, person.display, member.created_at
1558
+ FROM slotlock.workspace_memberships member
1559
+ LEFT JOIN slotlock.dashboard_people person ON person.person_ref = member.person_ref
1560
+ WHERE member.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', '')
1561
+ AND member.status = 'active'
1562
+ ORDER BY array_position(ARRAY['owner', 'admin', 'developer', 'member', 'viewer', 'billing'], member.role),
1563
+ lower(coalesce(person.display, member.person_ref))
1564
+ LIMIT 1000;
1565
+ END
1566
+ $slotlock_workspace_members$;
1567
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[13]} FROM PUBLIC;
1568
+
1569
+ -- A workspace's invitations not accepted or revoked: pending ones, and those expired in the last
1570
+ -- 30 days (to send again), newest first.
1571
+ CREATE OR REPLACE FUNCTION slotlock.workspace_invitations_list(requested_tenant text)
1572
+ RETURNS TABLE (id uuid, email text, github_login text, role text, inviter_display text,
1573
+ sent_at timestamptz, expires_at timestamptz)
1574
+ LANGUAGE plpgsql
1575
+ STABLE
1576
+ SECURITY DEFINER
1577
+ SET search_path = pg_catalog, slotlock, pg_temp
1578
+ AS $slotlock_workspace_invitations_list$
1579
+ BEGIN
1580
+ RETURN QUERY SELECT invitation.id, invitation.email, invitation.github_login, invitation.role,
1581
+ coalesce(inviter.display, invitation.inviter_ref), invitation.sent_at, invitation.expires_at
1582
+ FROM slotlock.workspace_invitations invitation
1583
+ LEFT JOIN slotlock.dashboard_people inviter ON inviter.person_ref = invitation.inviter_ref
1584
+ WHERE invitation.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', '')
1585
+ AND invitation.accepted_at IS NULL AND invitation.revoked_at IS NULL
1586
+ AND invitation.expires_at > statement_timestamp() - interval '30 days'
1587
+ ORDER BY invitation.sent_at DESC
1588
+ LIMIT 500;
1589
+ END
1590
+ $slotlock_workspace_invitations_list$;
1591
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[14]} FROM PUBLIC;
1592
+
1593
+ -- Invite to a team workspace: by email or GitHub account, with a role the inviter may give, one
1594
+ -- pending invitation per address (a new one replaces it), at most requested_daily_limit sent a day,
1595
+ -- and with requested_member_cap, no more people than it counting pending invitations. The token
1596
+ -- is given as its SHA-256 digest only.
1597
+ CREATE OR REPLACE FUNCTION slotlock.invite_member(
1598
+ requested_tenant text, requested_inviter text, requested_email text, requested_github text,
1599
+ requested_github_login text, requested_role text, requested_token bytea, requested_days integer,
1600
+ requested_daily_limit integer, requested_member_cap integer)
1601
+ RETURNS TABLE (invitation_id uuid, refusal text)
1602
+ LANGUAGE plpgsql
1603
+ VOLATILE
1604
+ SECURITY DEFINER
1605
+ SET search_path = pg_catalog, slotlock, pg_temp
1606
+ AS $slotlock_invite_member$
1607
+ DECLARE
1608
+ inviter_role text;
1609
+ email_normalized text := NULLIF(lower(btrim(requested_email)), '');
1610
+ occupied integer;
1611
+ created uuid;
1612
+ BEGIN
1613
+ IF requested_days IS NULL OR requested_days NOT BETWEEN 1 AND 30 THEN
1614
+ RAISE EXCEPTION 'Slotlock teams: invitations last 1-30 days' USING ERRCODE = '22023';
1615
+ END IF;
1616
+ -- The workspace's invitations one at a time: the cap and the daily limit see each other.
1617
+ PERFORM 1 FROM slotlock.workspaces ws
1618
+ WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
1619
+ IF NOT FOUND THEN
1620
+ RETURN QUERY SELECT NULL::uuid, 'not_found'::text;
1621
+ RETURN;
1622
+ END IF;
1623
+ SELECT member.role INTO inviter_role FROM slotlock.workspace_memberships member
1624
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_inviter AND member.status = 'active';
1625
+ IF inviter_role IS NULL OR NOT slotlock.workspace_may_grant(inviter_role, requested_role) THEN
1626
+ RETURN QUERY SELECT NULL::uuid, 'not_allowed'::text;
1627
+ RETURN;
1628
+ END IF;
1629
+ IF (email_normalized IS NULL) = (requested_github IS NULL) THEN
1630
+ RAISE EXCEPTION 'Slotlock teams: invite an email or a GitHub account' USING ERRCODE = '22023';
1631
+ END IF;
1632
+ -- Held to the team's verified domains (ddl-identity.ts): only addresses at one, and so no GitHub
1633
+ -- account, which carries none.
1634
+ IF ${SLOTLOCK_INVITE_DOMAIN_REFUSED('requested_tenant', 'email_normalized', 'requested_github')} THEN
1635
+ RETURN QUERY SELECT NULL::uuid, 'domain_restricted'::text;
1636
+ RETURN;
1637
+ END IF;
1638
+ IF requested_github IS NOT NULL AND EXISTS (
1639
+ SELECT 1 FROM slotlock.workspace_memberships member
1640
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_github AND member.status = 'active'
1641
+ ) THEN
1642
+ RETURN QUERY SELECT NULL::uuid, 'already_member'::text;
1643
+ RETURN;
1644
+ END IF;
1645
+ -- The team's invitations a day, and the inviter's across every team they are in.
1646
+ IF requested_daily_limit IS NOT NULL AND (
1647
+ (SELECT count(*) FROM slotlock.workspace_invitations sent
1648
+ WHERE sent.tenant_ref = requested_tenant AND sent.sent_at > statement_timestamp() - interval '1 day')
1649
+ >= requested_daily_limit
1650
+ OR (SELECT count(*) FROM slotlock.workspace_invitations sent
1651
+ WHERE sent.inviter_ref = requested_inviter AND sent.sent_at > statement_timestamp() - interval '1 day')
1652
+ >= requested_daily_limit
1653
+ ) THEN
1654
+ RETURN QUERY SELECT NULL::uuid, 'invitation_limit'::text;
1655
+ RETURN;
1656
+ END IF;
1657
+ -- One address's invitations a day, from every team together (SLOTLOCK_INVITATIONS_PER_ADDRESS).
1658
+ IF requested_daily_limit IS NOT NULL AND email_normalized IS NOT NULL AND
1659
+ (SELECT count(*) FROM slotlock.workspace_invitations sent
1660
+ WHERE sent.email = email_normalized AND sent.sent_at > statement_timestamp() - interval '1 day')
1661
+ >= ${SLOTLOCK_INVITATIONS_PER_ADDRESS} THEN
1662
+ RETURN QUERY SELECT NULL::uuid, 'recipient_limit'::text;
1663
+ RETURN;
1664
+ END IF;
1665
+ -- A pending invitation to the same address is replaced, so it is not counted against the cap.
1666
+ IF requested_member_cap IS NOT NULL THEN
1667
+ SELECT (SELECT count(*) FROM slotlock.workspace_memberships member
1668
+ WHERE member.tenant_ref = requested_tenant AND member.status = 'active')
1669
+ + (SELECT count(*) FROM slotlock.workspace_invitations pending
1670
+ WHERE pending.tenant_ref = requested_tenant AND pending.accepted_at IS NULL
1671
+ AND pending.revoked_at IS NULL AND pending.expires_at > statement_timestamp()
1672
+ AND NOT coalesce(pending.email = email_normalized OR pending.github_ref = requested_github, false))
1673
+ INTO occupied;
1674
+ IF occupied >= requested_member_cap THEN
1675
+ RETURN QUERY SELECT NULL::uuid, 'member_cap'::text;
1676
+ RETURN;
1677
+ END IF;
1678
+ END IF;
1679
+ UPDATE slotlock.workspace_invitations old
1680
+ SET revoked_at = statement_timestamp()
1681
+ WHERE old.tenant_ref = requested_tenant AND old.accepted_at IS NULL AND old.revoked_at IS NULL
1682
+ AND (old.email = email_normalized OR old.github_ref = requested_github);
1683
+ INSERT INTO slotlock.workspace_invitations
1684
+ (tenant_ref, token_sha256, email, github_ref, github_login, role, inviter_ref, expires_at)
1685
+ VALUES (requested_tenant, requested_token, email_normalized, requested_github,
1686
+ CASE WHEN requested_github IS NULL THEN NULL ELSE requested_github_login END,
1687
+ requested_role, requested_inviter,
1688
+ statement_timestamp() + make_interval(days => requested_days))
1689
+ RETURNING id INTO created;
1690
+ RETURN QUERY SELECT created, NULL::text;
1691
+ END
1692
+ $slotlock_invite_member$;
1693
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[15]} FROM PUBLIC;
1694
+
1695
+ -- What an invitation's holder may see before joining: the workspace, who invited them, the role,
1696
+ -- whom it is for, and its state. Nothing else of the workspace.
1697
+ CREATE OR REPLACE FUNCTION slotlock.invitation_preview(requested_token bytea)
1698
+ RETURNS TABLE (invitation_id uuid, tenant_ref text, workspace_name text, workspace_handle text,
1699
+ inviter_display text, role text, email text, github_ref text, github_login text, state text)
1700
+ LANGUAGE plpgsql
1701
+ STABLE
1702
+ SECURITY DEFINER
1703
+ SET search_path = pg_catalog, slotlock, pg_temp
1704
+ AS $slotlock_invitation_preview$
1705
+ BEGIN
1706
+ RETURN QUERY SELECT invitation.id, invitation.tenant_ref, ws.name, ws.handle,
1707
+ coalesce(inviter.display, 'A member'), invitation.role, invitation.email, invitation.github_ref,
1708
+ invitation.github_login,
1709
+ CASE WHEN invitation.revoked_at IS NOT NULL THEN 'revoked'
1710
+ WHEN invitation.accepted_at IS NOT NULL THEN 'accepted'
1711
+ WHEN invitation.expires_at <= statement_timestamp() THEN 'expired'
1712
+ ELSE 'pending' END
1713
+ FROM slotlock.workspace_invitations invitation
1714
+ JOIN slotlock.workspaces ws ON ws.tenant_ref = invitation.tenant_ref
1715
+ LEFT JOIN slotlock.dashboard_people inviter ON inviter.person_ref = invitation.inviter_ref
1716
+ WHERE invitation.token_sha256 = requested_token;
1717
+ END
1718
+ $slotlock_invitation_preview$;
1719
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[16]} FROM PUBLIC;
1720
+
1721
+ -- Accept an invitation, by its token's digest or (one found for the person) its id: in one
1722
+ -- transaction, only while pending, only by the GitHub account invited or a person holding the
1723
+ -- invited email verified, only while the inviter may still give the role, and within
1724
+ -- requested_member_cap. A member already keeps their role.
1725
+ CREATE OR REPLACE FUNCTION slotlock.accept_invitation(
1726
+ requested_token bytea, requested_id uuid, requested_person text, requested_emails text[],
1727
+ requested_member_cap integer)
1728
+ RETURNS TABLE (tenant_ref text, refusal text)
1729
+ LANGUAGE plpgsql
1730
+ VOLATILE
1731
+ SECURITY DEFINER
1732
+ SET search_path = pg_catalog, slotlock, pg_temp
1733
+ AS $slotlock_accept_invitation$
1734
+ DECLARE
1735
+ invitation slotlock.workspace_invitations%ROWTYPE;
1736
+ found_id uuid;
1737
+ found_tenant text;
1738
+ inviter_role text;
1739
+ members integer;
1740
+ BEGIN
1741
+ IF requested_person !~ '^(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})$' THEN
1742
+ RAISE EXCEPTION 'Slotlock teams: invalid person' USING ERRCODE = '22023';
1743
+ END IF;
1744
+ SELECT found.id, found.tenant_ref INTO found_id, found_tenant FROM slotlock.workspace_invitations found
1745
+ WHERE (requested_token IS NOT NULL AND found.token_sha256 = requested_token)
1746
+ OR (requested_token IS NULL AND found.id = requested_id);
1747
+ IF found_id IS NULL THEN
1748
+ RETURN QUERY SELECT NULL::text, 'not_found'::text;
1749
+ RETURN;
1750
+ END IF;
1751
+ -- The workspace first, then the invitation: the order every change to it takes, so none deadlock.
1752
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = found_tenant AND ws.status = 'active' FOR UPDATE;
1753
+ IF NOT FOUND THEN
1754
+ RETURN QUERY SELECT NULL::text, 'not_found'::text;
1755
+ RETURN;
1756
+ END IF;
1757
+ SELECT * INTO invitation FROM slotlock.workspace_invitations found WHERE found.id = found_id FOR UPDATE;
1758
+ -- Sent again while this waited: the old token no longer opens it.
1759
+ IF NOT FOUND OR (requested_token IS NOT NULL AND invitation.token_sha256 <> requested_token) THEN
1760
+ RETURN QUERY SELECT NULL::text, 'not_found'::text;
1761
+ RETURN;
1762
+ END IF;
1763
+ -- An Owner signs in with GitHub or Google too, for when the team's provider is down (ddl-sso.ts).
1764
+ IF invitation.role = 'owner' AND requested_person ~ '^sso:' THEN
1765
+ RETURN QUERY SELECT NULL::text, 'not_for_you'::text;
1766
+ RETURN;
1767
+ END IF;
1768
+ IF invitation.revoked_at IS NOT NULL THEN
1769
+ RETURN QUERY SELECT NULL::text, 'revoked'::text;
1770
+ RETURN;
1771
+ ELSIF invitation.accepted_at IS NOT NULL THEN
1772
+ RETURN QUERY SELECT NULL::text, 'accepted'::text;
1773
+ RETURN;
1774
+ ELSIF invitation.expires_at <= statement_timestamp() THEN
1775
+ RETURN QUERY SELECT NULL::text, 'expired'::text;
1776
+ RETURN;
1777
+ END IF;
1778
+ IF NOT (
1779
+ (invitation.github_ref IS NOT NULL AND invitation.github_ref = requested_person)
1780
+ OR (invitation.email IS NOT NULL AND invitation.email = ANY (
1781
+ SELECT lower(btrim(given)) FROM unnest(coalesce(requested_emails, ARRAY[]::text[])) given))
1782
+ ) THEN
1783
+ RETURN QUERY SELECT NULL::text, 'not_for_you'::text;
1784
+ RETURN;
1785
+ END IF;
1786
+ SELECT member.role INTO inviter_role FROM slotlock.workspace_memberships member
1787
+ WHERE member.tenant_ref = invitation.tenant_ref AND member.person_ref = invitation.inviter_ref
1788
+ AND member.status = 'active';
1789
+ IF inviter_role IS NULL OR NOT slotlock.workspace_may_grant(inviter_role, invitation.role) THEN
1790
+ UPDATE slotlock.workspace_invitations SET revoked_at = statement_timestamp() WHERE id = invitation.id;
1791
+ RETURN QUERY SELECT NULL::text, 'revoked'::text;
1792
+ RETURN;
1793
+ END IF;
1794
+ IF EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
1795
+ WHERE member.tenant_ref = invitation.tenant_ref AND member.person_ref = requested_person
1796
+ AND member.status = 'active') THEN
1797
+ UPDATE slotlock.workspace_invitations
1798
+ SET accepted_at = statement_timestamp(), accepted_by = requested_person WHERE id = invitation.id;
1799
+ RETURN QUERY SELECT invitation.tenant_ref, NULL::text;
1800
+ RETURN;
1801
+ END IF;
1802
+ IF requested_member_cap IS NOT NULL THEN
1803
+ SELECT count(*) INTO members FROM slotlock.workspace_memberships member
1804
+ WHERE member.tenant_ref = invitation.tenant_ref AND member.status = 'active';
1805
+ IF members >= requested_member_cap THEN
1806
+ RETURN QUERY SELECT NULL::text, 'member_cap'::text;
1807
+ RETURN;
1808
+ END IF;
1809
+ END IF;
1810
+ INSERT INTO slotlock.workspace_memberships AS member (tenant_ref, person_ref, role, source, joined_email)
1811
+ VALUES (invitation.tenant_ref, requested_person, invitation.role, 'invitation', invitation.email)
1812
+ ON CONFLICT ON CONSTRAINT slotlock_workspace_memberships_pkey DO UPDATE
1813
+ SET role = EXCLUDED.role, status = 'active', source = 'invitation', deactivated_at = NULL, deactivated_by = NULL,
1814
+ joined_email = coalesce(EXCLUDED.joined_email, member.joined_email), joined_via_domain = NULL,
1815
+ role_managed_by = 'manual', updated_at = statement_timestamp();
1816
+ UPDATE slotlock.workspace_invitations
1817
+ SET accepted_at = statement_timestamp(), accepted_by = requested_person WHERE id = invitation.id;
1818
+ RETURN QUERY SELECT invitation.tenant_ref, NULL::text;
1819
+ END
1820
+ $slotlock_accept_invitation$;
1821
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[17]} FROM PUBLIC;
1822
+
1823
+ -- Revoke a pending invitation, by an Owner or Admin of its workspace.
1824
+ CREATE OR REPLACE FUNCTION slotlock.revoke_invitation(requested_tenant text, requested_actor text, requested_id uuid)
1825
+ RETURNS boolean
1826
+ LANGUAGE plpgsql
1827
+ VOLATILE
1828
+ SECURITY DEFINER
1829
+ SET search_path = pg_catalog, slotlock, pg_temp
1830
+ AS $slotlock_revoke_invitation$
1831
+ DECLARE
1832
+ actor_role text;
1833
+ changed integer;
1834
+ BEGIN
1835
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
1836
+ IF NOT FOUND THEN
1837
+ RETURN false;
1838
+ END IF;
1839
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
1840
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
1841
+ -- Only an invitation whose role the actor could give: an Admin never withdraws an Owner's.
1842
+ UPDATE slotlock.workspace_invitations invitation SET revoked_at = statement_timestamp()
1843
+ WHERE invitation.id = requested_id AND invitation.tenant_ref = requested_tenant
1844
+ AND invitation.accepted_at IS NULL AND invitation.revoked_at IS NULL
1845
+ AND actor_role IS NOT NULL AND slotlock.workspace_may_grant(actor_role, invitation.role);
1846
+ GET DIAGNOSTICS changed = ROW_COUNT;
1847
+ RETURN changed > 0;
1848
+ END
1849
+ $slotlock_revoke_invitation$;
1850
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[18]} FROM PUBLIC;
1851
+
1852
+ -- Send an invitation again, with a new token and a new expiry: by someone who may give its role,
1853
+ -- within the daily limit and the address's. As inviting the address again does, it withdraws the
1854
+ -- old invitation and adds a new one, so each re-send counts as a send: re-sending one row in place
1855
+ -- made a new link, and an email, the daily limit never saw.
1856
+ CREATE OR REPLACE FUNCTION slotlock.resend_invitation(
1857
+ requested_tenant text, requested_actor text, requested_id uuid, requested_token bytea,
1858
+ requested_days integer, requested_daily_limit integer, requested_member_cap integer)
1859
+ RETURNS text
1860
+ LANGUAGE plpgsql
1861
+ VOLATILE
1862
+ SECURITY DEFINER
1863
+ SET search_path = pg_catalog, slotlock, pg_temp
1864
+ AS $slotlock_resend_invitation$
1865
+ DECLARE
1866
+ actor_role text;
1867
+ invitation_role text;
1868
+ invitation_email text;
1869
+ invitation_github text;
1870
+ BEGIN
1871
+ IF requested_days IS NULL OR requested_days NOT BETWEEN 1 AND 30 THEN
1872
+ RAISE EXCEPTION 'Slotlock teams: invitations last 1-30 days' USING ERRCODE = '22023';
1873
+ END IF;
1874
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
1875
+ IF NOT FOUND THEN
1876
+ RETURN 'not_found';
1877
+ END IF;
1878
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
1879
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
1880
+ SELECT invitation.role, invitation.email, invitation.github_ref INTO invitation_role, invitation_email, invitation_github
1881
+ FROM slotlock.workspace_invitations invitation
1882
+ WHERE invitation.id = requested_id AND invitation.tenant_ref = requested_tenant
1883
+ AND invitation.accepted_at IS NULL AND invitation.revoked_at IS NULL
1884
+ FOR UPDATE;
1885
+ IF invitation_role IS NULL THEN
1886
+ RETURN 'not_found';
1887
+ END IF;
1888
+ IF actor_role IS NULL OR NOT slotlock.workspace_may_grant(actor_role, invitation_role) THEN
1889
+ RETURN 'not_allowed';
1890
+ END IF;
1891
+ -- Sent again only where it could be sent now (ddl-identity.ts).
1892
+ IF ${SLOTLOCK_INVITE_DOMAIN_REFUSED('requested_tenant', 'invitation_email', 'invitation_github')} THEN
1893
+ RETURN 'domain_restricted';
1894
+ END IF;
1895
+ IF requested_daily_limit IS NOT NULL AND (
1896
+ (SELECT count(*) FROM slotlock.workspace_invitations sent
1897
+ WHERE sent.tenant_ref = requested_tenant AND sent.sent_at > statement_timestamp() - interval '1 day')
1898
+ >= requested_daily_limit
1899
+ OR (SELECT count(*) FROM slotlock.workspace_invitations sent
1900
+ WHERE sent.inviter_ref = requested_actor AND sent.sent_at > statement_timestamp() - interval '1 day')
1901
+ >= requested_daily_limit
1902
+ ) THEN
1903
+ RETURN 'invitation_limit';
1904
+ END IF;
1905
+ IF requested_daily_limit IS NOT NULL AND invitation_email IS NOT NULL AND
1906
+ (SELECT count(*) FROM slotlock.workspace_invitations sent
1907
+ WHERE sent.email = invitation_email AND sent.sent_at > statement_timestamp() - interval '1 day')
1908
+ >= ${SLOTLOCK_INVITATIONS_PER_ADDRESS} THEN
1909
+ RETURN 'recipient_limit';
1910
+ END IF;
1911
+ -- Sent again, it is open again: held to the cap with the members and the other open invitations.
1912
+ IF requested_member_cap IS NOT NULL AND
1913
+ (SELECT count(*) FROM slotlock.workspace_memberships member
1914
+ WHERE member.tenant_ref = requested_tenant AND member.status = 'active')
1915
+ + (SELECT count(*) FROM slotlock.workspace_invitations pending
1916
+ WHERE pending.tenant_ref = requested_tenant AND pending.id <> requested_id
1917
+ AND pending.accepted_at IS NULL AND pending.revoked_at IS NULL
1918
+ AND pending.expires_at > statement_timestamp())
1919
+ >= requested_member_cap THEN
1920
+ RETURN 'member_cap';
1921
+ END IF;
1922
+ UPDATE slotlock.workspace_invitations invitation SET revoked_at = statement_timestamp()
1923
+ WHERE invitation.id = requested_id;
1924
+ INSERT INTO slotlock.workspace_invitations
1925
+ (tenant_ref, token_sha256, email, github_ref, github_login, role, inviter_ref, expires_at)
1926
+ SELECT old.tenant_ref, requested_token, old.email, old.github_ref, old.github_login, old.role,
1927
+ requested_actor, statement_timestamp() + make_interval(days => requested_days)
1928
+ FROM slotlock.workspace_invitations old
1929
+ WHERE old.id = requested_id;
1930
+ RETURN NULL;
1931
+ END
1932
+ $slotlock_resend_invitation$;
1933
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[19]} FROM PUBLIC;
1934
+
1935
+ -- Change a member's role, by someone who may give both their current role and the new one; a
1936
+ -- workspace always keeps an Owner. A role given in Slotlock is never changed by a GitHub sign-in.
1937
+ CREATE OR REPLACE FUNCTION slotlock.change_member_role(
1938
+ requested_tenant text, requested_actor text, requested_person text, requested_role text)
1939
+ RETURNS text
1940
+ LANGUAGE plpgsql
1941
+ VOLATILE
1942
+ SECURITY DEFINER
1943
+ SET search_path = pg_catalog, slotlock, pg_temp
1944
+ AS $slotlock_change_member_role$
1945
+ DECLARE
1946
+ actor_role text;
1947
+ current_role_of text;
1948
+ BEGIN
1949
+ IF requested_role NOT IN ('owner', 'admin', 'developer', 'member', 'viewer', 'billing') THEN
1950
+ RAISE EXCEPTION 'Slotlock teams: invalid role' USING ERRCODE = '22023';
1951
+ END IF;
1952
+ -- The workspace's owners one change at a time, so two Owners demoting each other leave one.
1953
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
1954
+ IF NOT FOUND THEN
1955
+ RETURN 'not_found';
1956
+ END IF;
1957
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
1958
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
1959
+ SELECT member.role INTO current_role_of FROM slotlock.workspace_memberships member
1960
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person AND member.status = 'active';
1961
+ IF current_role_of IS NULL THEN
1962
+ RETURN 'not_found';
1963
+ END IF;
1964
+ IF actor_role IS NULL OR NOT slotlock.workspace_may_grant(actor_role, current_role_of)
1965
+ OR NOT slotlock.workspace_may_grant(actor_role, requested_role) THEN
1966
+ RETURN 'not_allowed';
1967
+ END IF;
1968
+ -- Every Owner can sign in with GitHub or Google, for when the team's provider is down: so the
1969
+ -- Owner a team always keeps is one who may bypass single sign-on (ddl-sso.ts).
1970
+ IF requested_role = 'owner' AND requested_person ~ '^sso:' THEN
1971
+ RETURN 'sso_owner';
1972
+ END IF;
1973
+ IF current_role_of = 'owner' AND requested_role <> 'owner' AND NOT EXISTS (
1974
+ SELECT 1 FROM slotlock.workspace_memberships other
1975
+ WHERE other.tenant_ref = requested_tenant AND other.status = 'active' AND other.role = 'owner'
1976
+ AND other.person_ref <> requested_person
1977
+ ) THEN
1978
+ RETURN 'last_owner';
1979
+ END IF;
1980
+ -- A role set by hand stays: the team's provider no longer recomputes it at sign-in.
1981
+ UPDATE slotlock.workspace_memberships member
1982
+ SET role = requested_role, source = CASE WHEN member.source = 'github_org' THEN 'created' ELSE member.source END,
1983
+ role_managed_by = 'manual', updated_at = statement_timestamp()
1984
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person;
1985
+ RETURN NULL;
1986
+ END
1987
+ $slotlock_change_member_role$;
1988
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[20]} FROM PUBLIC;
1989
+
1990
+ -- A member limited to part of the workspace (RFC 0004 D1): the locations and groups whose resources
1991
+ -- they reach, as an API key's resource scope (RFC 0003 G6). Null is the whole workspace; an Owner is
1992
+ -- never limited.
1993
+ ALTER TABLE slotlock.workspace_memberships ADD COLUMN IF NOT EXISTS resource_scope jsonb;
1994
+ DO $slotlock_membership_scope$
1995
+ BEGIN
1996
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_workspace_memberships_scope_check') THEN
1997
+ -- As a key's scope: 1-20 locations and 1-20 groups by reference, at least one in all. A CASE,
1998
+ -- not AND, so no array function sees what is not an array, and false (never null) otherwise.
1999
+ ALTER TABLE slotlock.workspace_memberships ADD CONSTRAINT slotlock_workspace_memberships_scope_check CHECK (
2000
+ resource_scope IS NULL OR CASE
2001
+ WHEN jsonb_typeof(resource_scope) = 'object'
2002
+ AND jsonb_typeof(resource_scope -> 'locations') IS NOT DISTINCT FROM 'array'
2003
+ AND jsonb_typeof(resource_scope -> 'groups') IS NOT DISTINCT FROM 'array'
2004
+ THEN jsonb_array_length(resource_scope -> 'locations') <= 20
2005
+ AND jsonb_array_length(resource_scope -> 'groups') <= 20
2006
+ AND jsonb_array_length(resource_scope -> 'locations') + jsonb_array_length(resource_scope -> 'groups') >= 1
2007
+ AND NOT jsonb_path_exists(resource_scope,
2008
+ '$.locations[*] ? (@.type() != "string" || !(@ like_regex "^[a-z0-9][a-z0-9._-]{0,99}$"))')
2009
+ AND NOT jsonb_path_exists(resource_scope,
2010
+ '$.groups[*] ? (@.type() != "string" || !(@ like_regex "^[a-z0-9][a-z0-9._-]{0,99}$"))')
2011
+ ELSE false
2012
+ END
2013
+ );
2014
+ END IF;
2015
+ END
2016
+ $slotlock_membership_scope$;
2017
+
2018
+ -- An Owner reaches the whole workspace: made one by any path (a role change, an invitation, a
2019
+ -- GitHub organization's sign-in), their limit is cleared, so it does not return if they stop being
2020
+ -- one.
2021
+ CREATE OR REPLACE FUNCTION slotlock.workspace_owner_unlimited()
2022
+ RETURNS trigger
2023
+ LANGUAGE plpgsql
2024
+ SET search_path = pg_catalog, slotlock, pg_temp
2025
+ AS $slotlock_workspace_owner_unlimited$
2026
+ BEGIN
2027
+ IF NEW.role = 'owner' THEN
2028
+ NEW.resource_scope := NULL;
2029
+ END IF;
2030
+ RETURN NEW;
2031
+ END
2032
+ $slotlock_workspace_owner_unlimited$;
2033
+ CREATE OR REPLACE TRIGGER slotlock_workspace_owner_unlimited
2034
+ BEFORE INSERT OR UPDATE OF role, resource_scope ON slotlock.workspace_memberships
2035
+ FOR EACH ROW EXECUTE FUNCTION slotlock.workspace_owner_unlimited();
2036
+ -- Owners limited before this trigger: none should be, but a database that has one is cleared.
2037
+ UPDATE slotlock.workspace_memberships SET resource_scope = NULL
2038
+ WHERE role = 'owner' AND resource_scope IS NOT NULL;
2039
+
2040
+ -- The part of a workspace (its test environment is its live tenant's) a person is limited to, or
2041
+ -- null for all of it. An Owner reaches all of it.
2042
+ CREATE OR REPLACE FUNCTION slotlock.member_scope(requested_tenant text, requested_person text)
2043
+ RETURNS jsonb
2044
+ LANGUAGE sql
2045
+ STABLE
2046
+ SECURITY DEFINER
2047
+ SET search_path = pg_catalog, slotlock, pg_temp
2048
+ AS $slotlock_member_scope$
2049
+ SELECT CASE WHEN member.role = 'owner' THEN NULL ELSE member.resource_scope END
2050
+ FROM slotlock.workspace_memberships member
2051
+ WHERE member.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', '')
2052
+ AND member.person_ref = requested_person AND member.status = 'active';
2053
+ $slotlock_member_scope$;
2054
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[34]} FROM PUBLIC;
2055
+
2056
+ -- Every member of a workspace limited to part of it, with what they are limited to (Members).
2057
+ CREATE OR REPLACE FUNCTION slotlock.member_scopes(requested_tenant text)
2058
+ RETURNS TABLE (person_ref text, resource_scope jsonb)
2059
+ LANGUAGE sql
2060
+ STABLE
2061
+ SECURITY DEFINER
2062
+ SET search_path = pg_catalog, slotlock, pg_temp
2063
+ AS $slotlock_member_scopes$
2064
+ SELECT member.person_ref, member.resource_scope
2065
+ FROM slotlock.workspace_memberships member
2066
+ WHERE member.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', '')
2067
+ AND member.status = 'active' AND member.role <> 'owner' AND member.resource_scope IS NOT NULL
2068
+ LIMIT 1000;
2069
+ $slotlock_member_scopes$;
2070
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[36]} FROM PUBLIC;
2071
+
2072
+ -- Limit a member to locations and groups, or give them the whole workspace again (null): by an
2073
+ -- Owner or Admin who is not limited themselves, of a member who is not an Owner and whose role they
2074
+ -- may grant. Null on success; else not_found, not_allowed or owner.
2075
+ CREATE OR REPLACE FUNCTION slotlock.set_member_scope(
2076
+ requested_tenant text, requested_actor text, requested_person text, requested_scope jsonb)
2077
+ RETURNS text
2078
+ LANGUAGE plpgsql
2079
+ VOLATILE
2080
+ SECURITY DEFINER
2081
+ SET search_path = pg_catalog, slotlock, pg_temp
2082
+ AS $slotlock_set_member_scope$
2083
+ DECLARE
2084
+ actor_role text;
2085
+ actor_scope jsonb;
2086
+ member_role text;
2087
+ BEGIN
2088
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
2089
+ IF NOT FOUND THEN
2090
+ RETURN 'not_found';
2091
+ END IF;
2092
+ SELECT member.role, member.resource_scope INTO actor_role, actor_scope FROM slotlock.workspace_memberships member
2093
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
2094
+ SELECT member.role INTO member_role FROM slotlock.workspace_memberships member
2095
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person AND member.status = 'active';
2096
+ IF member_role IS NULL THEN
2097
+ RETURN 'not_found';
2098
+ END IF;
2099
+ IF member_role = 'owner' THEN
2100
+ RETURN 'owner';
2101
+ END IF;
2102
+ IF actor_role IS NULL OR actor_role NOT IN ('owner', 'admin') OR actor_scope IS NOT NULL
2103
+ OR NOT slotlock.workspace_may_grant(actor_role, member_role) THEN
2104
+ RETURN 'not_allowed';
2105
+ END IF;
2106
+ UPDATE slotlock.workspace_memberships member
2107
+ SET resource_scope = requested_scope, updated_at = statement_timestamp()
2108
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person;
2109
+ RETURN NULL;
2110
+ END
2111
+ $slotlock_set_member_scope$;
2112
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[35]} FROM PUBLIC;
2113
+
2114
+ -- Remove a member (or, the actor themselves, leave): by someone who may give their role; a
2115
+ -- workspace always keeps an Owner. Their pending invitations go with them. The membership is kept
2116
+ -- inactive, so inviting them back restores what an invitation gives.
2117
+ CREATE OR REPLACE FUNCTION slotlock.remove_member(requested_tenant text, requested_actor text, requested_person text)
2118
+ RETURNS text
2119
+ LANGUAGE plpgsql
2120
+ VOLATILE
2121
+ SECURITY DEFINER
2122
+ SET search_path = pg_catalog, slotlock, pg_temp
2123
+ AS $slotlock_remove_member$
2124
+ DECLARE
2125
+ actor_role text;
2126
+ removed_role text;
2127
+ BEGIN
2128
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
2129
+ IF NOT FOUND THEN
2130
+ RETURN 'not_found';
2131
+ END IF;
2132
+ SELECT member.role INTO actor_role FROM slotlock.workspace_memberships member
2133
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor AND member.status = 'active';
2134
+ SELECT member.role INTO removed_role FROM slotlock.workspace_memberships member
2135
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person AND member.status = 'active';
2136
+ IF removed_role IS NULL OR actor_role IS NULL THEN
2137
+ RETURN 'not_found';
2138
+ END IF;
2139
+ IF requested_actor <> requested_person AND NOT slotlock.workspace_may_grant(actor_role, removed_role) THEN
2140
+ RETURN 'not_allowed';
2141
+ END IF;
2142
+ IF removed_role = 'owner' AND NOT EXISTS (
2143
+ SELECT 1 FROM slotlock.workspace_memberships other
2144
+ WHERE other.tenant_ref = requested_tenant AND other.status = 'active' AND other.role = 'owner'
2145
+ AND other.person_ref <> requested_person
2146
+ ) THEN
2147
+ RETURN 'last_owner';
2148
+ END IF;
2149
+ -- Removed by someone else, the address they came by joins at once no more (ddl-identity.ts),
2150
+ -- nor do those their sign-ins through the team's provider carried (ddl-sso.ts).
2151
+ IF requested_actor <> requested_person THEN
2152
+ INSERT INTO slotlock.workspace_join_blocks (tenant_ref, email_sha256)
2153
+ SELECT requested_tenant, sha256(convert_to(address.email, 'UTF8'))
2154
+ FROM (SELECT member.joined_email AS email FROM slotlock.workspace_memberships member
2155
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person
2156
+ UNION
2157
+ SELECT identity.email_last FROM slotlock.sso_identities identity
2158
+ JOIN slotlock.sso_connections linked ON linked.id = identity.connection_id AND linked.tenant_ref = requested_tenant
2159
+ WHERE identity.person_ref = requested_person) address
2160
+ WHERE address.email IS NOT NULL
2161
+ ON CONFLICT ON CONSTRAINT slotlock_workspace_join_blocks_pkey DO NOTHING;
2162
+ END IF;
2163
+ UPDATE slotlock.workspace_memberships member
2164
+ SET status = 'inactive', source = 'created', deactivated_at = statement_timestamp(),
2165
+ deactivated_by = CASE WHEN requested_actor = requested_person THEN 'self' ELSE 'team' END,
2166
+ updated_at = statement_timestamp()
2167
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person;
2168
+ -- Their pending invitations go with them: those they sent, and those sent to them (their account,
2169
+ -- or an address they joined with or signed in through the team's provider with), which would
2170
+ -- otherwise let them back in.
2171
+ UPDATE slotlock.workspace_invitations invitation SET revoked_at = statement_timestamp()
2172
+ WHERE invitation.tenant_ref = requested_tenant AND invitation.accepted_at IS NULL AND invitation.revoked_at IS NULL
2173
+ AND (invitation.inviter_ref = requested_person
2174
+ OR (requested_actor <> requested_person AND (
2175
+ invitation.github_ref = requested_person
2176
+ OR invitation.email IN (
2177
+ SELECT member.joined_email FROM slotlock.workspace_memberships member
2178
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_person
2179
+ UNION
2180
+ SELECT identity.email_last FROM slotlock.sso_identities identity
2181
+ JOIN slotlock.sso_connections linked ON linked.id = identity.connection_id AND linked.tenant_ref = requested_tenant
2182
+ WHERE identity.person_ref = requested_person))));
2183
+ RETURN NULL;
2184
+ END
2185
+ $slotlock_remove_member$;
2186
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[21]} FROM PUBLIC;
2187
+
2188
+ -- The pending invitations meant for a person: to their GitHub account, or to an email they hold
2189
+ -- verified. For the dashboard to offer "Join" without the link.
2190
+ CREATE OR REPLACE FUNCTION slotlock.pending_invitations_for(requested_person text, requested_emails text[])
2191
+ RETURNS TABLE (invitation_id uuid, tenant_ref text, workspace_name text, workspace_handle text,
2192
+ inviter_display text, role text)
2193
+ LANGUAGE plpgsql
2194
+ STABLE
2195
+ SECURITY DEFINER
2196
+ SET search_path = pg_catalog, slotlock, pg_temp
2197
+ AS $slotlock_pending_invitations_for$
2198
+ BEGIN
2199
+ RETURN QUERY SELECT invitation.id, invitation.tenant_ref, ws.name, ws.handle,
2200
+ coalesce(inviter.display, 'A member'), invitation.role
2201
+ FROM slotlock.workspace_invitations invitation
2202
+ JOIN slotlock.workspaces ws ON ws.tenant_ref = invitation.tenant_ref AND ws.status = 'active'
2203
+ LEFT JOIN slotlock.dashboard_people inviter ON inviter.person_ref = invitation.inviter_ref
2204
+ WHERE invitation.accepted_at IS NULL AND invitation.revoked_at IS NULL
2205
+ AND invitation.expires_at > statement_timestamp()
2206
+ AND (invitation.github_ref = requested_person
2207
+ OR invitation.email = ANY (SELECT lower(btrim(given)) FROM unnest(coalesce(requested_emails, ARRAY[]::text[])) given))
2208
+ AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
2209
+ WHERE member.tenant_ref = invitation.tenant_ref AND member.person_ref = requested_person
2210
+ AND member.status = 'active')
2211
+ ORDER BY invitation.sent_at DESC
2212
+ LIMIT 20;
2213
+ END
2214
+ $slotlock_pending_invitations_for$;
2215
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[22]} FROM PUBLIC;
2216
+
2217
+ -- Whether a team workspace serves its keys: only while it exists and is active.
2218
+ CREATE OR REPLACE FUNCTION slotlock.workspace_active(requested_tenant text)
2219
+ RETURNS boolean
2220
+ LANGUAGE plpgsql
2221
+ STABLE
2222
+ SECURITY DEFINER
2223
+ SET search_path = pg_catalog, slotlock, pg_temp
2224
+ AS $slotlock_workspace_active$
2225
+ BEGIN
2226
+ RETURN EXISTS (SELECT 1 FROM slotlock.workspaces ws
2227
+ WHERE ws.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', '')
2228
+ AND ws.status = 'active');
2229
+ END
2230
+ $slotlock_workspace_active$;
2231
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[23]} FROM PUBLIC;
2232
+
2233
+ -- Invitations finished (accepted, revoked or expired) more than 30 days ago, with the address they
2234
+ -- were sent to: deleted.
2235
+ CREATE OR REPLACE FUNCTION slotlock.prune_workspace_invitations()
2236
+ RETURNS integer
2237
+ LANGUAGE plpgsql
2238
+ VOLATILE
2239
+ SECURITY DEFINER
2240
+ SET search_path = pg_catalog, slotlock, pg_temp
2241
+ AS $slotlock_prune_workspace_invitations$
2242
+ DECLARE
2243
+ pruned integer;
2244
+ BEGIN
2245
+ -- Ended when accepted, revoked or, untouched, expired.
2246
+ DELETE FROM slotlock.workspace_invitations finished
2247
+ WHERE coalesce(finished.accepted_at, finished.revoked_at, finished.expires_at)
2248
+ < statement_timestamp() - interval '30 days';
2249
+ GET DIAGNOSTICS pruned = ROW_COUNT;
2250
+ RETURN pruned;
2251
+ END
2252
+ $slotlock_prune_workspace_invitations$;
2253
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[24]} FROM PUBLIC;
2254
+
2255
+ -- Each dashboard session a person signed in with, for the Account page: when it began, when it was
2256
+ -- last seen (to five minutes), the browser and address it began from, and whether the person
2257
+ -- signed it out from another. Only a SHA-256 digest of the session's id is kept. Owner-only;
2258
+ -- reached through the functions below.
2259
+ CREATE TABLE IF NOT EXISTS slotlock.dashboard_sessions (
2260
+ sid_sha256 bytea PRIMARY KEY,
2261
+ person_ref text NOT NULL,
2262
+ started_at timestamptz NOT NULL DEFAULT statement_timestamp(),
2263
+ last_seen_at timestamptz NOT NULL DEFAULT statement_timestamp(),
2264
+ expires_at timestamptz NOT NULL,
2265
+ ended_at timestamptz,
2266
+ user_agent text,
2267
+ ip_address text,
2268
+ CONSTRAINT slotlock_dashboard_sessions_valid CHECK (
2269
+ octet_length(sid_sha256) = 32
2270
+ AND person_ref ~ '^(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})$'
2271
+ AND (user_agent IS NULL OR char_length(user_agent) BETWEEN 1 AND 256)
2272
+ AND (ip_address IS NULL OR char_length(ip_address) BETWEEN 1 AND 64)
2273
+ AND expires_at > started_at
2274
+ )
2275
+ );
2276
+ CREATE INDEX IF NOT EXISTS slotlock_dashboard_sessions_person_idx
2277
+ ON slotlock.dashboard_sessions (person_ref, last_seen_at DESC);
2278
+ ALTER TABLE slotlock.dashboard_sessions ENABLE ROW LEVEL SECURITY;
2279
+ REVOKE ALL ON slotlock.dashboard_sessions FROM PUBLIC;
2280
+
2281
+ -- A session begun: recorded, and the person's expired ones dropped; at most 50 kept, newest first.
2282
+ CREATE OR REPLACE FUNCTION slotlock.dashboard_session_started(
2283
+ requested_person text, requested_sid bytea, requested_expires timestamptz, requested_agent text,
2284
+ requested_address text)
2285
+ RETURNS void
2286
+ LANGUAGE plpgsql
2287
+ VOLATILE
2288
+ SECURITY DEFINER
2289
+ SET search_path = pg_catalog, slotlock, pg_temp
2290
+ AS $slotlock_dashboard_session_started$
2291
+ BEGIN
2292
+ IF requested_person ~ '^sso:' THEN
2293
+ PERFORM 1 FROM slotlock.sso_identities identity WHERE identity.person_ref = requested_person FOR SHARE;
2294
+ IF NOT FOUND THEN
2295
+ RAISE EXCEPTION 'Slotlock dashboard: that person is no longer here' USING ERRCODE = 'P0002';
2296
+ END IF;
2297
+ END IF;
2298
+ INSERT INTO slotlock.dashboard_sessions (sid_sha256, person_ref, expires_at, user_agent, ip_address)
2299
+ VALUES (requested_sid, requested_person, requested_expires,
2300
+ NULLIF(left(requested_agent, 256), ''), NULLIF(left(requested_address, 64), ''))
2301
+ ON CONFLICT ON CONSTRAINT dashboard_sessions_pkey DO NOTHING;
2302
+ DELETE FROM slotlock.dashboard_sessions old
2303
+ WHERE old.person_ref = requested_person
2304
+ AND (old.expires_at <= statement_timestamp()
2305
+ -- The 50 newest are kept, sessions through a team's provider alone going first: a
2306
+ -- provider signing in as a linked member cannot push out the sessions they began.
2307
+ OR old.sid_sha256 IN (SELECT kept.sid_sha256 FROM slotlock.dashboard_sessions kept
2308
+ WHERE kept.person_ref = requested_person
2309
+ ORDER BY (kept.sso_connection_id IS NULL) DESC, kept.started_at DESC OFFSET 50));
2310
+ END
2311
+ $slotlock_dashboard_session_started$;
2312
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[25]} FROM PUBLIC;
2313
+
2314
+ -- Whether the person's session may go on: recorded for them, not signed out from another device,
2315
+ -- not expired. A session with no record (one the 50 kept dropped, one from before these records)
2316
+ -- does not, so a session signed out stays out however many sign-ins follow. Noted seen at most
2317
+ -- every five minutes.
2318
+ CREATE OR REPLACE FUNCTION slotlock.dashboard_session_seen(requested_person text, requested_sid bytea)
2319
+ RETURNS boolean
2320
+ LANGUAGE plpgsql
2321
+ VOLATILE
2322
+ SECURITY DEFINER
2323
+ SET search_path = pg_catalog, slotlock, pg_temp
2324
+ AS $slotlock_dashboard_session_seen$
2325
+ BEGIN
2326
+ UPDATE slotlock.dashboard_sessions seen SET last_seen_at = statement_timestamp()
2327
+ WHERE seen.sid_sha256 = requested_sid AND seen.person_ref = requested_person AND seen.ended_at IS NULL
2328
+ AND seen.last_seen_at < statement_timestamp() - interval '5 minutes';
2329
+ RETURN EXISTS (
2330
+ SELECT 1 FROM slotlock.dashboard_sessions alive
2331
+ WHERE alive.sid_sha256 = requested_sid AND alive.person_ref = requested_person
2332
+ AND alive.ended_at IS NULL AND alive.expires_at > statement_timestamp());
2333
+ END
2334
+ $slotlock_dashboard_session_seen$;
2335
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[26]} FROM PUBLIC;
2336
+
2337
+ -- The person's sessions still going, most recently seen first.
2338
+ CREATE OR REPLACE FUNCTION slotlock.dashboard_sessions_of(requested_person text)
2339
+ RETURNS TABLE (sid_sha256 bytea, started_at timestamptz, last_seen_at timestamptz, expires_at timestamptz,
2340
+ user_agent text, ip_address text)
2341
+ LANGUAGE plpgsql
2342
+ STABLE
2343
+ SECURITY DEFINER
2344
+ SET search_path = pg_catalog, slotlock, pg_temp
2345
+ AS $slotlock_dashboard_sessions_of$
2346
+ BEGIN
2347
+ RETURN QUERY SELECT kept.sid_sha256, kept.started_at, kept.last_seen_at, kept.expires_at, kept.user_agent,
2348
+ kept.ip_address
2349
+ FROM slotlock.dashboard_sessions kept
2350
+ WHERE kept.person_ref = requested_person AND kept.ended_at IS NULL
2351
+ AND kept.expires_at > statement_timestamp()
2352
+ ORDER BY kept.last_seen_at DESC
2353
+ LIMIT 50;
2354
+ END
2355
+ $slotlock_dashboard_sessions_of$;
2356
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[27]} FROM PUBLIC;
2357
+
2358
+ -- Sign one of the person's sessions out (or, with no session, all of them): whether any was.
2359
+ CREATE OR REPLACE FUNCTION slotlock.dashboard_session_end(requested_person text, requested_sid bytea)
2360
+ RETURNS boolean
2361
+ LANGUAGE plpgsql
2362
+ VOLATILE
2363
+ SECURITY DEFINER
2364
+ SET search_path = pg_catalog, slotlock, pg_temp
2365
+ AS $slotlock_dashboard_session_end$
2366
+ DECLARE
2367
+ changed integer;
2368
+ BEGIN
2369
+ UPDATE slotlock.dashboard_sessions ending SET ended_at = statement_timestamp()
2370
+ WHERE ending.person_ref = requested_person AND ending.ended_at IS NULL
2371
+ AND (requested_sid IS NULL OR ending.sid_sha256 = requested_sid);
2372
+ GET DIAGNOSTICS changed = ROW_COUNT;
2373
+ RETURN changed > 0;
2374
+ END
2375
+ $slotlock_dashboard_session_end$;
2376
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[28]} FROM PUBLIC;
2377
+
2378
+ -- Sessions that ended or expired more than a day ago: deleted.
2379
+ CREATE OR REPLACE FUNCTION slotlock.prune_dashboard_sessions()
2380
+ RETURNS integer
2381
+ LANGUAGE plpgsql
2382
+ VOLATILE
2383
+ SECURITY DEFINER
2384
+ SET search_path = pg_catalog, slotlock, pg_temp
2385
+ AS $slotlock_prune_dashboard_sessions$
2386
+ DECLARE
2387
+ pruned integer;
2388
+ BEGIN
2389
+ DELETE FROM slotlock.dashboard_sessions old
2390
+ WHERE least(coalesce(old.ended_at, 'infinity'), old.expires_at) < statement_timestamp() - interval '1 day';
2391
+ GET DIAGNOSTICS pruned = ROW_COUNT;
2392
+ RETURN pruned;
2393
+ END
2394
+ $slotlock_prune_dashboard_sessions$;
2395
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[29]} FROM PUBLIC;
1057
2396
 
1058
- -- Turn Slotlock on for an organization; true when it was not on. Who may (an owner of it, read from
1059
- -- GitHub at sign-in) is the dashboard's to check.
1060
- CREATE OR REPLACE FUNCTION slotlock.enable_organization(requested_org text, requested_login text, requested_by text)
1061
- RETURNS boolean
2397
+ -- A team workspace being deleted: its keys stop at once, its people stay until it goes seven days
2398
+ -- later, and any Owner can cancel meanwhile. Its status and when it goes, held together.
2399
+ ALTER TABLE slotlock.workspaces ADD COLUMN IF NOT EXISTS delete_after timestamptz;
2400
+ DO $slotlock_workspaces_deleting$
2401
+ BEGIN
2402
+ IF NOT EXISTS (
2403
+ SELECT 1 FROM pg_constraint
2404
+ WHERE conname = 'slotlock_workspaces_status_valid' AND conrelid = 'slotlock.workspaces'::regclass
2405
+ ) THEN
2406
+ ALTER TABLE slotlock.workspaces DROP CONSTRAINT IF EXISTS slotlock_workspaces_valid;
2407
+ ALTER TABLE slotlock.workspaces ADD CONSTRAINT slotlock_workspaces_valid CHECK (
2408
+ tenant_ref ~ '^team:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
2409
+ AND handle ~ '^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$'
2410
+ AND char_length(name) BETWEEN 1 AND 80
2411
+ AND name !~ '[[:cntrl:]]'
2412
+ AND (created_by IS NULL OR created_by ~ '^(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})$')
2413
+ );
2414
+ ALTER TABLE slotlock.workspaces ADD CONSTRAINT slotlock_workspaces_status_valid CHECK (
2415
+ status IN ('active', 'deleting') AND (status = 'deleting') = (delete_after IS NOT NULL)
2416
+ );
2417
+ END IF;
2418
+ END
2419
+ $slotlock_workspaces_deleting$;
2420
+
2421
+ -- Schedule a team's deletion, by an Owner who types its handle: refused while it has a subscription
2422
+ -- (cancelled first, as erase_tenant demands, so nobody is charged for a team that is gone).
2423
+ CREATE OR REPLACE FUNCTION slotlock.schedule_team_deletion(requested_tenant text, requested_actor text, requested_handle text)
2424
+ RETURNS text
1062
2425
  LANGUAGE plpgsql
1063
2426
  VOLATILE
1064
2427
  SECURITY DEFINER
1065
2428
  SET search_path = pg_catalog, slotlock, pg_temp
1066
- AS $slotlock_enable_organization$
2429
+ AS $slotlock_schedule_team_deletion$
1067
2430
  DECLARE
1068
- was boolean;
2431
+ team_handle text;
1069
2432
  BEGIN
1070
- SELECT org.enabled INTO was FROM slotlock.dashboard_organizations org
1071
- WHERE org.org_id = requested_org FOR UPDATE;
1072
- INSERT INTO slotlock.dashboard_organizations AS org (org_id, login, enabled, changed_by)
1073
- VALUES (requested_org, requested_login, true, requested_by)
1074
- ON CONFLICT (org_id) DO UPDATE
1075
- SET login = EXCLUDED.login, enabled = true,
1076
- changed_by = CASE WHEN org.enabled THEN org.changed_by ELSE EXCLUDED.changed_by END,
1077
- changed_at = CASE WHEN org.enabled THEN org.changed_at ELSE statement_timestamp() END;
1078
- RETURN was IS NOT TRUE;
2433
+ SELECT ws.handle INTO team_handle FROM slotlock.workspaces ws
2434
+ WHERE ws.tenant_ref = requested_tenant AND ws.status = 'active' FOR UPDATE;
2435
+ IF team_handle IS NULL THEN
2436
+ RETURN 'not_found';
2437
+ END IF;
2438
+ IF NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
2439
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor
2440
+ AND member.status = 'active' AND member.role = 'owner') THEN
2441
+ RETURN 'not_allowed';
2442
+ END IF;
2443
+ IF requested_handle IS DISTINCT FROM team_handle THEN
2444
+ RETURN 'confirm';
2445
+ END IF;
2446
+ IF EXISTS (SELECT 1 FROM slotlock.tenant_plans paid
2447
+ WHERE paid.tenant_ref = requested_tenant AND paid.stripe_subscription IS NOT NULL) THEN
2448
+ RETURN 'subscription_live';
2449
+ END IF;
2450
+ UPDATE slotlock.workspaces ws SET status = 'deleting', delete_after = statement_timestamp() + interval '7 days'
2451
+ WHERE ws.tenant_ref = requested_tenant;
2452
+ RETURN NULL;
1079
2453
  END
1080
- $slotlock_enable_organization$;
1081
- REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[3]} FROM PUBLIC;
2454
+ $slotlock_schedule_team_deletion$;
2455
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[30]} FROM PUBLIC;
1082
2456
 
1083
- -- Turn it off; true when it was on.
1084
- CREATE OR REPLACE FUNCTION slotlock.disable_organization(requested_org text, requested_by text)
1085
- RETURNS boolean
1086
- LANGUAGE sql
2457
+ -- Cancel a team's deletion, by any of its Owners, before it goes.
2458
+ CREATE OR REPLACE FUNCTION slotlock.cancel_team_deletion(requested_tenant text, requested_actor text)
2459
+ RETURNS text
2460
+ LANGUAGE plpgsql
1087
2461
  VOLATILE
1088
2462
  SECURITY DEFINER
1089
2463
  SET search_path = pg_catalog, slotlock, pg_temp
1090
- AS $slotlock_disable_organization$
1091
- UPDATE slotlock.dashboard_organizations org
1092
- SET enabled = false, changed_by = requested_by, changed_at = statement_timestamp()
1093
- WHERE org.org_id = requested_org AND org.enabled
1094
- RETURNING true;
1095
- $slotlock_disable_organization$;
1096
- REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[4]} FROM PUBLIC;
2464
+ AS $slotlock_cancel_team_deletion$
2465
+ BEGIN
2466
+ PERFORM 1 FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant AND ws.status = 'deleting' FOR UPDATE;
2467
+ IF NOT FOUND THEN
2468
+ RETURN 'not_found';
2469
+ END IF;
2470
+ IF NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships member
2471
+ WHERE member.tenant_ref = requested_tenant AND member.person_ref = requested_actor
2472
+ AND member.status = 'active' AND member.role = 'owner') THEN
2473
+ RETURN 'not_allowed';
2474
+ END IF;
2475
+ UPDATE slotlock.workspaces ws SET status = 'active', delete_after = NULL WHERE ws.tenant_ref = requested_tenant;
2476
+ RETURN NULL;
2477
+ END
2478
+ $slotlock_cancel_team_deletion$;
2479
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[31]} FROM PUBLIC;
2480
+
2481
+ -- Erase the teams whose seven days have passed, live and test, each on its own. One with a
2482
+ -- subscription is left until it is cancelled; one that fails otherwise is tried again a day later,
2483
+ -- so neither holds up the rest. How many went.
2484
+ CREATE OR REPLACE FUNCTION slotlock.erase_deleted_teams()
2485
+ RETURNS integer
2486
+ LANGUAGE plpgsql
2487
+ VOLATILE
2488
+ SECURITY DEFINER
2489
+ SET search_path = pg_catalog, slotlock, pg_temp
2490
+ AS $slotlock_erase_deleted_teams$
2491
+ DECLARE
2492
+ due text;
2493
+ erased integer := 0;
2494
+ BEGIN
2495
+ FOR due IN
2496
+ SELECT ws.tenant_ref FROM slotlock.workspaces ws
2497
+ WHERE ws.status = 'deleting' AND ws.delete_after <= statement_timestamp()
2498
+ AND NOT EXISTS (SELECT 1 FROM slotlock.tenant_plans billed
2499
+ WHERE billed.tenant_ref = ws.tenant_ref AND billed.stripe_subscription IS NOT NULL)
2500
+ ORDER BY ws.delete_after
2501
+ LIMIT 20
2502
+ LOOP
2503
+ BEGIN
2504
+ -- Read again, and locked, as it is now: a team an Owner kept since the list was read (while
2505
+ -- an earlier one was being erased) is left, and keeping it waits for this erasure to finish.
2506
+ PERFORM 1 FROM slotlock.workspaces ws
2507
+ WHERE ws.tenant_ref = due AND ws.status = 'deleting' AND ws.delete_after <= statement_timestamp()
2508
+ FOR UPDATE;
2509
+ IF FOUND THEN
2510
+ PERFORM * FROM slotlock.erase_tenant(due || '~test');
2511
+ PERFORM * FROM slotlock.erase_tenant(due);
2512
+ erased := erased + 1;
2513
+ END IF;
2514
+ EXCEPTION WHEN OTHERS THEN
2515
+ UPDATE slotlock.workspaces ws SET delete_after = statement_timestamp() + interval '1 day'
2516
+ WHERE ws.tenant_ref = due AND ws.status = 'deleting';
2517
+ RAISE WARNING 'Slotlock could not erase deleted team %, trying again in a day: %', due, SQLERRM;
2518
+ END;
2519
+ END LOOP;
2520
+ RETURN erased;
2521
+ END
2522
+ $slotlock_erase_deleted_teams$;
2523
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[32]} FROM PUBLIC;
2524
+
2525
+ -- When a team being deleted goes, or null.
2526
+ CREATE OR REPLACE FUNCTION slotlock.workspace_delete_after(requested_tenant text)
2527
+ RETURNS timestamptz
2528
+ LANGUAGE plpgsql
2529
+ STABLE
2530
+ SECURITY DEFINER
2531
+ SET search_path = pg_catalog, slotlock, pg_temp
2532
+ AS $slotlock_workspace_delete_after$
2533
+ BEGIN
2534
+ RETURN (SELECT ws.delete_after FROM slotlock.workspaces ws
2535
+ WHERE ws.tenant_ref = pg_catalog.regexp_replace(requested_tenant, '~test$', ''));
2536
+ END
2537
+ $slotlock_workspace_delete_after$;
2538
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[33]} FROM PUBLIC;
1097
2539
  `;
1098
2540
  // The schema's version: one row, written by the DDL itself, so running SLOTLOCK_CORE_DDL directly
1099
2541
  // is versioned too. applySchema adds digests of the DDL and catalog so a later no-op migration can
@@ -1893,6 +3335,9 @@ DECLARE
1893
3335
  setting text;
1894
3336
  previous jsonb := '{}'::jsonb;
1895
3337
  tested record;
3338
+ cleared_rows bigint;
3339
+ sso_people text[] := ARRAY[]::text[];
3340
+ departing text;
1896
3341
  BEGIN
1897
3342
  IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500 THEN
1898
3343
  RAISE EXCEPTION 'Slotlock tenant reference must be 1-500 UTF-8 bytes'
@@ -1910,6 +3355,26 @@ BEGIN
1910
3355
  RAISE EXCEPTION 'Slotlock tenant has a live subscription: cancel it before erasing the tenant'
1911
3356
  USING ERRCODE = 'object_in_use', HINT = 'billing_active';
1912
3357
  END IF;
3358
+ -- Erasing a person never takes other people's workspace with it: a team's only Owner hands it
3359
+ -- on, or deletes it, first. The teams they own are locked first, as every change to a team's
3360
+ -- people locks it, so an Owner leaving meanwhile waits, and this check sees what it left.
3361
+ PERFORM 1 FROM slotlock.workspaces ws
3362
+ WHERE ws.tenant_ref IN (SELECT mine.tenant_ref FROM slotlock.workspace_memberships mine
3363
+ WHERE mine.person_ref = requested_tenant_ref AND mine.status = 'active'
3364
+ AND mine.role = 'owner')
3365
+ ORDER BY ws.tenant_ref
3366
+ FOR UPDATE;
3367
+ IF requested_tenant_ref ~ '^(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})$' AND EXISTS (
3368
+ SELECT 1 FROM slotlock.workspace_memberships mine
3369
+ JOIN slotlock.workspaces ws ON ws.tenant_ref = mine.tenant_ref AND ws.status = 'active'
3370
+ WHERE mine.person_ref = requested_tenant_ref AND mine.status = 'active' AND mine.role = 'owner'
3371
+ AND NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships other
3372
+ WHERE other.tenant_ref = mine.tenant_ref AND other.status = 'active'
3373
+ AND other.role = 'owner' AND other.person_ref <> mine.person_ref)
3374
+ ) THEN
3375
+ RAISE EXCEPTION 'Slotlock person is the only owner of a team workspace: make another owner, or delete it, first'
3376
+ USING ERRCODE = 'object_in_use', HINT = 'sole_owner';
3377
+ END IF;
1913
3378
  FOR setting IN
1914
3379
  SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
1915
3380
  LOOP
@@ -1948,6 +3413,46 @@ BEGIN
1948
3413
  DELETE FROM slotlock.approval_policies WHERE tenant_ref = requested_tenant_ref;
1949
3414
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
1950
3415
  RETURN NEXT;
3416
+ erased_table := 'availability_exceptions';
3417
+ DELETE FROM slotlock.availability_exceptions WHERE tenant_ref = requested_tenant_ref;
3418
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3419
+ RETURN NEXT;
3420
+ erased_table := 'holiday_calendars';
3421
+ DELETE FROM slotlock.holiday_calendars WHERE tenant_ref = requested_tenant_ref;
3422
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3423
+ RETURN NEXT;
3424
+ erased_table := 'availability_defaults';
3425
+ DELETE FROM slotlock.availability_defaults WHERE tenant_ref = requested_tenant_ref;
3426
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3427
+ RETURN NEXT;
3428
+ erased_table := 'contacts';
3429
+ DELETE FROM slotlock.contacts WHERE tenant_ref = requested_tenant_ref;
3430
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3431
+ RETURN NEXT;
3432
+ erased_table := 'reminder_dispatches';
3433
+ DELETE FROM slotlock.reminder_dispatches WHERE tenant_ref = requested_tenant_ref;
3434
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3435
+ RETURN NEXT;
3436
+ erased_table := 'reservation_reminder_dispatches';
3437
+ DELETE FROM slotlock.reservation_reminder_dispatches WHERE tenant_ref = requested_tenant_ref;
3438
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3439
+ RETURN NEXT;
3440
+ erased_table := 'hold_payments';
3441
+ DELETE FROM slotlock.hold_payments WHERE tenant_ref = requested_tenant_ref;
3442
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3443
+ RETURN NEXT;
3444
+ erased_table := 'source_busy';
3445
+ DELETE FROM slotlock.source_busy WHERE tenant_ref = requested_tenant_ref;
3446
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3447
+ RETURN NEXT;
3448
+ erased_table := 'tenant_export_parts';
3449
+ DELETE FROM slotlock.tenant_export_parts WHERE tenant_ref = requested_tenant_ref;
3450
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3451
+ RETURN NEXT;
3452
+ erased_table := 'tenant_exports';
3453
+ DELETE FROM slotlock.tenant_exports WHERE tenant_ref = requested_tenant_ref;
3454
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3455
+ RETURN NEXT;
1951
3456
  erased_table := 'reservations';
1952
3457
  DELETE FROM slotlock.reservations
1953
3458
  WHERE tenant_ref = requested_tenant_ref
@@ -2031,6 +3536,14 @@ BEGIN
2031
3536
  DELETE FROM slotlock.assistant_conversations WHERE tenant_ref = requested_tenant_ref;
2032
3537
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
2033
3538
  RETURN NEXT;
3539
+ erased_table := 'assistant_chats';
3540
+ DELETE FROM slotlock.assistant_chats WHERE tenant_ref = requested_tenant_ref;
3541
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3542
+ RETURN NEXT;
3543
+ erased_table := 'audit_events';
3544
+ DELETE FROM slotlock.audit_events WHERE tenant_ref = requested_tenant_ref;
3545
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3546
+ RETURN NEXT;
2034
3547
  erased_table := 'assistant_settings';
2035
3548
  DELETE FROM slotlock.assistant_settings WHERE tenant_ref = requested_tenant_ref;
2036
3549
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
@@ -2113,6 +3626,124 @@ BEGIN
2113
3626
  )
2114
3627
  SELECT count(*) INTO erased_rows FROM erased;
2115
3628
  RETURN NEXT;
3629
+ -- A team's directory goes with it; a person comes off the directory users bound to them.
3630
+ erased_table := 'scim_users';
3631
+ DELETE FROM slotlock.scim_users directory WHERE directory.tenant_ref = requested_tenant_ref;
3632
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3633
+ IF requested_tenant_ref ~ '^(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})$' THEN
3634
+ UPDATE slotlock.scim_users directory SET person_ref = NULL WHERE directory.person_ref = requested_tenant_ref;
3635
+ GET DIAGNOSTICS cleared_rows = ROW_COUNT;
3636
+ erased_rows := erased_rows + cleared_rows;
3637
+ END IF;
3638
+ RETURN NEXT;
3639
+ erased_table := 'scim_groups';
3640
+ DELETE FROM slotlock.scim_groups grouped WHERE grouped.tenant_ref = requested_tenant_ref;
3641
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3642
+ RETURN NEXT;
3643
+ erased_table := 'scim_tokens';
3644
+ DELETE FROM slotlock.scim_tokens token WHERE token.tenant_ref = requested_tenant_ref;
3645
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3646
+ IF requested_tenant_ref ~ '^(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})$' THEN
3647
+ UPDATE slotlock.scim_tokens token SET created_by = NULL WHERE token.created_by = requested_tenant_ref;
3648
+ GET DIAGNOSTICS cleared_rows = ROW_COUNT;
3649
+ erased_rows := erased_rows + cleared_rows;
3650
+ END IF;
3651
+ RETURN NEXT;
3652
+ -- A team's single sign-on goes with it (its identities, mappings, proofs and the sessions signed in
3653
+ -- through it alone with the connection); a person takes their identities, and their bypasses.
3654
+ erased_table := 'sso_bypasses';
3655
+ DELETE FROM slotlock.sso_bypasses bypass
3656
+ WHERE bypass.tenant_ref = requested_tenant_ref OR bypass.person_ref = requested_tenant_ref;
3657
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3658
+ RETURN NEXT;
3659
+ -- Who signed in only through the team's provider: erased below, with no way in left.
3660
+ IF requested_tenant_ref ~ '^team:' THEN
3661
+ SELECT coalesce(array_agg(DISTINCT identity.person_ref), ARRAY[]::text[]) INTO sso_people
3662
+ FROM slotlock.sso_identities identity
3663
+ JOIN slotlock.sso_connections linked ON linked.id = identity.connection_id AND linked.tenant_ref = requested_tenant_ref
3664
+ WHERE identity.person_ref ~ '^sso:';
3665
+ END IF;
3666
+ erased_table := 'sso_identities';
3667
+ DELETE FROM slotlock.sso_identities identity
3668
+ WHERE identity.person_ref = requested_tenant_ref
3669
+ OR identity.connection_id IN (SELECT linked.id FROM slotlock.sso_connections linked WHERE linked.tenant_ref = requested_tenant_ref);
3670
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3671
+ RETURN NEXT;
3672
+ erased_table := 'sso_connections';
3673
+ DELETE FROM slotlock.sso_connections linked WHERE linked.tenant_ref = requested_tenant_ref;
3674
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3675
+ IF requested_tenant_ref ~ '^(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})$' THEN
3676
+ UPDATE slotlock.sso_connections linked SET created_by = NULL WHERE linked.created_by = requested_tenant_ref;
3677
+ GET DIAGNOSTICS cleared_rows = ROW_COUNT;
3678
+ erased_rows := erased_rows + cleared_rows;
3679
+ -- What a test sign-in showed (the tester's email among it) goes with the tester.
3680
+ UPDATE slotlock.sso_connections linked SET last_test_claims = NULL, tested_by = NULL
3681
+ WHERE linked.tested_by = requested_tenant_ref;
3682
+ GET DIAGNOSTICS cleared_rows = ROW_COUNT;
3683
+ erased_rows := erased_rows + cleared_rows;
3684
+ END IF;
3685
+ RETURN NEXT;
3686
+ -- A team's requests to join and domains go with it; a person's own tenant takes their requests,
3687
+ -- and their name off the domains they claimed and the requests they decided.
3688
+ erased_table := 'workspace_join_requests';
3689
+ DELETE FROM slotlock.workspace_join_requests asked
3690
+ WHERE asked.tenant_ref = requested_tenant_ref
3691
+ OR (requested_tenant_ref ~ '^(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})$'
3692
+ AND asked.person_ref = requested_tenant_ref);
3693
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3694
+ IF requested_tenant_ref ~ '^(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})$' THEN
3695
+ UPDATE slotlock.workspace_join_requests asked SET decided_by = NULL WHERE asked.decided_by = requested_tenant_ref;
3696
+ GET DIAGNOSTICS cleared_rows = ROW_COUNT;
3697
+ erased_rows := erased_rows + cleared_rows;
3698
+ END IF;
3699
+ RETURN NEXT;
3700
+ erased_table := 'workspace_join_blocks';
3701
+ DELETE FROM slotlock.workspace_join_blocks blocked WHERE blocked.tenant_ref = requested_tenant_ref;
3702
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3703
+ RETURN NEXT;
3704
+ erased_table := 'workspace_domains';
3705
+ DELETE FROM slotlock.workspace_domains claim WHERE claim.tenant_ref = requested_tenant_ref;
3706
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3707
+ IF requested_tenant_ref ~ '^(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})$' THEN
3708
+ UPDATE slotlock.workspace_domains claim SET created_by = NULL WHERE claim.created_by = requested_tenant_ref;
3709
+ GET DIAGNOSTICS cleared_rows = ROW_COUNT;
3710
+ erased_rows := erased_rows + cleared_rows;
3711
+ END IF;
3712
+ RETURN NEXT;
3713
+ -- A team workspace's record and invitations go with it.
3714
+ erased_table := 'workspace_invitations';
3715
+ DELETE FROM slotlock.workspace_invitations invitation
3716
+ WHERE invitation.tenant_ref = requested_tenant_ref
3717
+ OR (requested_tenant_ref ~ '^(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})$'
3718
+ AND (invitation.inviter_ref = requested_tenant_ref OR invitation.accepted_by = requested_tenant_ref));
3719
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3720
+ RETURN NEXT;
3721
+ erased_table := 'workspaces';
3722
+ DELETE FROM slotlock.workspaces ws WHERE ws.tenant_ref = requested_tenant_ref;
3723
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3724
+ IF requested_tenant_ref ~ '^(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})$' THEN
3725
+ UPDATE slotlock.workspaces ws SET created_by = NULL WHERE ws.created_by = requested_tenant_ref;
3726
+ GET DIAGNOSTICS cleared_rows = ROW_COUNT;
3727
+ erased_rows := erased_rows + cleared_rows;
3728
+ END IF;
3729
+ RETURN NEXT;
3730
+ -- A shared workspace's members go with it; a person's own tenant takes their memberships of
3731
+ -- every workspace, and their record of signing in, with it.
3732
+ erased_table := 'workspace_memberships';
3733
+ DELETE FROM slotlock.workspace_memberships member
3734
+ WHERE member.tenant_ref = requested_tenant_ref
3735
+ OR (requested_tenant_ref ~ '^(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})$'
3736
+ AND member.person_ref = requested_tenant_ref);
3737
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3738
+ RETURN NEXT;
3739
+ erased_table := 'dashboard_sessions';
3740
+ DELETE FROM slotlock.dashboard_sessions session_kept WHERE session_kept.person_ref = requested_tenant_ref;
3741
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3742
+ RETURN NEXT;
3743
+ erased_table := 'dashboard_people';
3744
+ DELETE FROM slotlock.dashboard_people person WHERE person.person_ref = requested_tenant_ref;
3745
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
3746
+ RETURN NEXT;
2116
3747
  -- An organization's record of being turned on goes with its tenant (Teams on Cloud).
2117
3748
  erased_table := 'dashboard_organizations';
2118
3749
  DELETE FROM slotlock.dashboard_organizations org
@@ -2154,6 +3785,22 @@ BEGIN
2154
3785
  FOR setting IN SELECT jsonb_object_keys(previous) LOOP
2155
3786
  PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
2156
3787
  END LOOP;
3788
+
3789
+ -- The people who signed in only through the erased team's provider (sso:), reported as one line:
3790
+ -- they were members of no other team, and can no longer sign in to ask for their own erasure.
3791
+ IF cardinality(sso_people) > 0 THEN
3792
+ erased_table := 'sso_people';
3793
+ erased_rows := 0;
3794
+ FOREACH departing IN ARRAY sso_people LOOP
3795
+ IF NOT EXISTS (SELECT 1 FROM slotlock.workspace_memberships other
3796
+ WHERE other.person_ref = departing AND other.status = 'active')
3797
+ AND NOT EXISTS (SELECT 1 FROM slotlock.sso_identities other WHERE other.person_ref = departing) THEN
3798
+ PERFORM 1 FROM slotlock.erase_tenant(departing);
3799
+ erased_rows := erased_rows + 1;
3800
+ END IF;
3801
+ END LOOP;
3802
+ RETURN NEXT;
3803
+ END IF;
2157
3804
  END
2158
3805
  $slotlock_erase_tenant$;
2159
3806
  REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[1]} FROM PUBLIC;
@@ -2199,6 +3846,9 @@ BEGIN
2199
3846
  FOREACH emptied IN ARRAY ARRAY[
2200
3847
  'calendar_event_attendees', 'calendar_event_reminders', 'calendar_event_exceptions',
2201
3848
  'calendar_event_occurrences', 'composite_claims', 'approvals', 'approval_policies',
3849
+ 'availability_exceptions', 'holiday_calendars', 'availability_defaults', 'contacts', 'reminder_dispatches',
3850
+ 'reservation_reminder_dispatches',
3851
+ 'hold_payments', 'source_busy', 'tenant_export_parts', 'tenant_exports',
2202
3852
  'calendar_events', 'calendar_event_tombstones', 'calendar_event_commands',
2203
3853
  'reservation_tombstones', 'reservation_conflict_archive', 'calendar_coverage', 'hold_commands',
2204
3854
  'waitlist_entries', 'booking_rule_defaults', 'pool_settings', 'resource_group_members',
@@ -2407,8 +4057,21 @@ BEGIN
2407
4057
  AND gone.expires_at <= now();
2408
4058
  GET DIAGNOSTICS step = ROW_COUNT;
2409
4059
  deleted := deleted + step;
2410
- SELECT min(live.expires_at) INTO upcoming FROM slotlock.assistant_conversations live
2411
- WHERE live.tenant_ref = due.tenant_ref;
4060
+ DELETE FROM slotlock.assistant_chats gone
4061
+ WHERE gone.tenant_ref = due.tenant_ref
4062
+ AND (gone.member_ref, gone.chat_id) IN (
4063
+ SELECT expired.member_ref, expired.chat_id FROM slotlock.assistant_chats expired
4064
+ WHERE expired.tenant_ref = due.tenant_ref AND expired.expires_at <= now()
4065
+ LIMIT greatest(max_rows - deleted, 0))
4066
+ AND gone.expires_at <= now();
4067
+ GET DIAGNOSTICS step = ROW_COUNT;
4068
+ deleted := deleted + step;
4069
+ SELECT least(
4070
+ (SELECT min(live.expires_at) FROM slotlock.assistant_conversations live
4071
+ WHERE live.tenant_ref = due.tenant_ref),
4072
+ (SELECT min(chat.expires_at) FROM slotlock.assistant_chats chat
4073
+ WHERE chat.tenant_ref = due.tenant_ref AND chat.expires_at IS NOT NULL))
4074
+ INTO upcoming;
2412
4075
  IF upcoming IS NULL THEN
2413
4076
  DELETE FROM slotlock.assistant_tenants WHERE tenant_ref = due.tenant_ref;
2414
4077
  ELSE
@@ -2750,7 +4413,7 @@ AS $slotlock_location_path$
2750
4413
  FROM slotlock.locations place JOIN up ON place.tenant_ref = requested_tenant_ref AND place.ref = up.parent_ref
2751
4414
  WHERE up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
2752
4415
  )
2753
- SELECT COALESCE(array_agg(up.ref), '{}'::text[]) FROM up;
4416
+ SELECT COALESCE(array_agg(up.ref ORDER BY up.steps), '{}'::text[]) FROM up;
2754
4417
  $slotlock_location_path$;
2755
4418
  -- 0.5 allowed a filter of at most 20 types, fewer than there are now. The wider CHECK admits every
2756
4419
  -- row the old one did, so it is added NOT VALID (no scan under the lock), and the old one dropped.
@@ -2821,6 +4484,78 @@ END
2821
4484
  $slotlock_waitlist_key$;
2822
4485
  CREATE UNIQUE INDEX IF NOT EXISTS slotlock_waitlist_entries_key_idx
2823
4486
  ON slotlock.waitlist_entries (tenant_ref, idempotency_key) WHERE idempotency_key IS NOT NULL;
4487
+ -- Waitlists that match (RFC 0004 B6): an entry may ask for any slot of a length within its window
4488
+ -- (duration_ms; NULL is the whole window), and an offer ends accepted (its hold confirmed), declined
4489
+ -- (let go) or lapsed (expired), when it was decided. The wider checks admit every row the old ones
4490
+ -- did, so they are added NOT VALID (no scan under the lock), and the old ones dropped.
4491
+ ALTER TABLE slotlock.waitlist_entries ADD COLUMN IF NOT EXISTS duration_ms bigint;
4492
+ ALTER TABLE slotlock.waitlist_entries ADD COLUMN IF NOT EXISTS decided_at timestamptz;
4493
+ DO $slotlock_waitlist_outcomes$
4494
+ BEGIN
4495
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_waitlist_entries_outcome_valid'
4496
+ AND conrelid = 'slotlock.waitlist_entries'::regclass) THEN
4497
+ ALTER TABLE slotlock.waitlist_entries ADD CONSTRAINT slotlock_waitlist_entries_outcome_valid CHECK (
4498
+ status IN ('waiting', 'offered', 'accepted', 'declined', 'lapsed', 'expired')
4499
+ AND (status <> 'offered' OR (hold_id IS NOT NULL AND offered_at IS NOT NULL AND offered_resource_id IS NOT NULL))
4500
+ AND (status <> 'waiting' OR (hold_id IS NULL AND offered_at IS NULL))
4501
+ AND (duration_ms IS NULL OR (duration_ms >= 60000
4502
+ AND duration_ms <= extract(epoch FROM ends_at - starts_at) * 1000))
4503
+ ) NOT VALID;
4504
+ END IF;
4505
+ ALTER TABLE slotlock.waitlist_entries DROP CONSTRAINT IF EXISTS slotlock_waitlist_entries_status_valid;
4506
+ ALTER TABLE slotlock.waitlist_entries DROP CONSTRAINT IF EXISTS slotlock_waitlist_entries_offer_valid;
4507
+ END
4508
+ $slotlock_waitlist_outcomes$;
4509
+
4510
+ -- How an offer ends (RFC 0004 B6), from what becomes of its hold, whatever path took it: confirmed
4511
+ -- (or consumed by a booking that names it, under slotlock.ending = 'confirmed') is accepted;
4512
+ -- expired is lapsed; let go before then is declined. The entry says so, and the feed.
4513
+ CREATE OR REPLACE FUNCTION slotlock.waitlist_offer_settle()
4514
+ RETURNS trigger
4515
+ LANGUAGE plpgsql
4516
+ SET search_path = pg_catalog, slotlock, pg_temp
4517
+ AS $slotlock_waitlist_offer_settle$
4518
+ DECLARE
4519
+ outcome text;
4520
+ settled slotlock.waitlist_entries%ROWTYPE;
4521
+ BEGIN
4522
+ IF OLD.status <> 'held' OR OLD.source IS DISTINCT FROM 'slotlock:waitlist' THEN RETURN NULL; END IF;
4523
+ IF TG_OP = 'UPDATE' THEN
4524
+ IF NEW.status = 'held' THEN RETURN NULL; END IF;
4525
+ outcome := 'accepted';
4526
+ ELSIF current_setting('slotlock.ending', true) = 'confirmed' THEN
4527
+ outcome := 'accepted';
4528
+ ELSIF OLD.expires_at <= statement_timestamp() THEN
4529
+ outcome := 'lapsed';
4530
+ ELSE
4531
+ outcome := 'declined';
4532
+ END IF;
4533
+ UPDATE slotlock.waitlist_entries SET status = outcome, decided_at = statement_timestamp()
4534
+ WHERE hold_id = OLD.id AND status = 'offered'
4535
+ RETURNING * INTO settled;
4536
+ IF settled.id IS NULL THEN RETURN NULL; END IF;
4537
+ -- Erasure (erase_tenant, running as the schema's owner) is not reported.
4538
+ IF settled.tenant_ref = current_setting('slotlock.erasing', true)
4539
+ AND current_user = (SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_catalog.pg_namespace
4540
+ WHERE nspname = 'slotlock') THEN
4541
+ RETURN NULL;
4542
+ END IF;
4543
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
4544
+ VALUES (
4545
+ settled.tenant_ref, settled.offered_resource_id, 'waitlist.' || outcome, settled.id::text,
4546
+ jsonb_strip_nulls(jsonb_build_object(
4547
+ 'id', settled.id, 'hold_id', settled.hold_id, 'resource_id', settled.offered_resource_id,
4548
+ 'pool', settled.pool_ref, 'holder_ref', settled.holder_ref)),
4549
+ COALESCE(NULLIF(current_setting('slotlock.actor', true), ''),
4550
+ CASE WHEN outcome = 'lapsed' THEN 'slotlock:maintenance' END)
4551
+ );
4552
+ RETURN NULL;
4553
+ END
4554
+ $slotlock_waitlist_offer_settle$;
4555
+ DROP TRIGGER IF EXISTS slotlock_waitlist_offer_settle ON slotlock.reservations;
4556
+ CREATE TRIGGER slotlock_waitlist_offer_settle
4557
+ AFTER UPDATE OF status OR DELETE ON slotlock.reservations
4558
+ FOR EACH ROW EXECUTE FUNCTION slotlock.waitlist_offer_settle();
2824
4559
  CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
2825
4560
  ON slotlock.waitlist_entries (tenant_ref, starts_at);
2826
4561
 
@@ -2929,6 +4664,357 @@ CREATE TABLE IF NOT EXISTS slotlock.assistant_conversations (
2929
4664
  CREATE INDEX IF NOT EXISTS slotlock_assistant_conversations_expiry_idx
2930
4665
  ON slotlock.assistant_conversations (tenant_ref, expires_at);
2931
4666
 
4667
+ -- A member's conversations with the dashboard assistant (its history), each with the writes it
4668
+ -- proposed. Kept until the member deletes one, or until expires_at where the deployment gives
4669
+ -- conversations a lifetime; at most ${SLOTLOCK_ASSISTANT_MAX_CHATS} a member, the least recent going first.
4670
+ CREATE TABLE IF NOT EXISTS slotlock.assistant_chats (
4671
+ tenant_ref text NOT NULL,
4672
+ member_ref text NOT NULL,
4673
+ chat_id text NOT NULL,
4674
+ title text NOT NULL,
4675
+ messages jsonb NOT NULL DEFAULT '[]'::jsonb,
4676
+ actions jsonb NOT NULL DEFAULT '{}'::jsonb,
4677
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
4678
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
4679
+ expires_at timestamptz,
4680
+ CONSTRAINT slotlock_assistant_chats_pkey PRIMARY KEY (tenant_ref, member_ref, chat_id),
4681
+ CONSTRAINT slotlock_assistant_chats_identity_valid CHECK (
4682
+ octet_length(tenant_ref) BETWEEN 1 AND 500 AND octet_length(member_ref) BETWEEN 1 AND 500
4683
+ AND chat_id ~ '^[A-Za-z0-9_-]{16}$'
4684
+ ),
4685
+ CONSTRAINT slotlock_assistant_chats_title_valid CHECK (char_length(title) BETWEEN 1 AND 120),
4686
+ CONSTRAINT slotlock_assistant_chats_messages_valid CHECK (
4687
+ jsonb_typeof(messages) = 'array' AND octet_length(messages::text) <= 262144
4688
+ ),
4689
+ CONSTRAINT slotlock_assistant_chats_actions_valid CHECK (
4690
+ jsonb_typeof(actions) = 'object' AND octet_length(actions::text) <= 131072
4691
+ )
4692
+ );
4693
+ CREATE INDEX IF NOT EXISTS slotlock_assistant_chats_recent_idx
4694
+ ON slotlock.assistant_chats (tenant_ref, member_ref, updated_at DESC);
4695
+ CREATE INDEX IF NOT EXISTS slotlock_assistant_chats_expiry_idx
4696
+ ON slotlock.assistant_chats (tenant_ref, expires_at) WHERE expires_at IS NOT NULL;
4697
+
4698
+ -- The one conversation a member had before chats were kept (0.8 and earlier) becomes their first
4699
+ -- chat, titled by its first message, and leaves the old table: a later migration does not bring
4700
+ -- back a chat the member deleted. A server of 0.8 still running meanwhile keeps using the old table;
4701
+ -- what it writes there is moved by the next migration as a chat of its own (its id is the
4702
+ -- conversation's, with when it was last written), never into one already moved.
4703
+ WITH moved AS (
4704
+ DELETE FROM slotlock.assistant_conversations old
4705
+ WHERE old.expires_at > statement_timestamp()
4706
+ RETURNING old.tenant_ref, old.member_ref, old.messages, old.actions, old.updated_at
4707
+ )
4708
+ INSERT INTO slotlock.assistant_chats AS chat
4709
+ (tenant_ref, member_ref, chat_id, title, messages, actions, created_at, updated_at, expires_at)
4710
+ SELECT moved.tenant_ref, moved.member_ref,
4711
+ pg_catalog.translate(pg_catalog.left(pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(
4712
+ moved.tenant_ref || pg_catalog.chr(10) || moved.member_ref || pg_catalog.chr(10)
4713
+ || (extract(epoch FROM moved.updated_at) * 1000000)::bigint::text, 'UTF8')), 'base64'), 16), '+/', '-_'),
4714
+ coalesce(nullif(pg_catalog.left(pg_catalog.regexp_replace(pg_catalog.btrim(first_user.text), '\\s+', ' ', 'g'), 80), ''),
4715
+ 'Conversation'),
4716
+ moved.messages, moved.actions, moved.updated_at, moved.updated_at, NULL
4717
+ FROM moved
4718
+ LEFT JOIN LATERAL (
4719
+ SELECT entry ->> 'text' AS text FROM pg_catalog.jsonb_array_elements(moved.messages) AS entry
4720
+ WHERE entry ->> 'role' = 'user' LIMIT 1
4721
+ ) first_user ON true
4722
+ ON CONFLICT DO NOTHING;
4723
+
4724
+ -- A workspace's audit log: who did what to its people, keys, webhooks, billing and settings, from
4725
+ -- where, and whether it was refused. Not the change feed: that keeps 30 days and goes to the
4726
+ -- tenant's webhooks; this keeps SLOTLOCK_AUDIT_RETENTION_DAYS (180) and goes nowhere. The serving
4727
+ -- role may only add and read rows (no UPDATE, DELETE or TRUNCATE): an event once written stays as
4728
+ -- it was until slotlock.prune_audit_events, or erasure, takes it. Names are never stored, only
4729
+ -- references (github:<id>, api_key:<id>), resolved when the log is read.
4730
+ CREATE TABLE IF NOT EXISTS slotlock.audit_events (
4731
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4732
+ tenant_ref text NOT NULL,
4733
+ occurred_at timestamptz NOT NULL DEFAULT statement_timestamp(),
4734
+ action text NOT NULL,
4735
+ actor_type text NOT NULL,
4736
+ actor_ref text NOT NULL,
4737
+ actor_role text,
4738
+ target_type text,
4739
+ target_ref text,
4740
+ outcome text NOT NULL DEFAULT 'success',
4741
+ reason text,
4742
+ ip_address text,
4743
+ user_agent text,
4744
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
4745
+ CONSTRAINT slotlock_audit_events_valid CHECK (
4746
+ octet_length(tenant_ref) BETWEEN 1 AND 500
4747
+ AND action ~ '^[a-z][a-z_]{0,31}(\\.[a-z][a-z_]{0,31}){1,2}$'
4748
+ AND actor_type IN ('person', 'api_key', 'assistant', 'system')
4749
+ AND octet_length(actor_ref) BETWEEN 1 AND 500
4750
+ AND (actor_role IS NULL OR actor_role IN ('owner', 'admin', 'developer', 'member', 'viewer', 'billing'))
4751
+ AND (target_type IS NULL OR target_type ~ '^[a-z][a-z_]{0,31}$')
4752
+ AND (target_ref IS NULL OR octet_length(target_ref) BETWEEN 1 AND 500)
4753
+ AND outcome IN ('success', 'denied')
4754
+ AND (reason IS NULL OR octet_length(reason) BETWEEN 1 AND 200)
4755
+ AND (ip_address IS NULL OR octet_length(ip_address) BETWEEN 1 AND 64)
4756
+ AND (user_agent IS NULL OR octet_length(user_agent) BETWEEN 1 AND 256)
4757
+ AND jsonb_typeof(metadata) = 'object'
4758
+ AND pg_column_size(metadata) <= 4096
4759
+ )
4760
+ );
4761
+ CREATE INDEX IF NOT EXISTS slotlock_audit_events_recent_idx
4762
+ ON slotlock.audit_events (tenant_ref, occurred_at DESC, id);
4763
+
4764
+ -- Each tenant's events form a chain: each holds the SHA-256 of the one before it with its own
4765
+ -- content, numbered in the order they were written. Rewriting, removing or reordering an event
4766
+ -- (which the serving role cannot do, but an owner of the database could) breaks every hash after
4767
+ -- it, and slotlock.verify_audit_chain says where. Events from before the chain have none.
4768
+ ALTER TABLE slotlock.audit_events ADD COLUMN IF NOT EXISTS chain_index bigint;
4769
+ ALTER TABLE slotlock.audit_events ADD COLUMN IF NOT EXISTS prev_hash bytea;
4770
+ ALTER TABLE slotlock.audit_events ADD COLUMN IF NOT EXISTS hash bytea;
4771
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_audit_events_chain_idx
4772
+ ON slotlock.audit_events (tenant_ref, chain_index);
4773
+
4774
+ -- An event as its hash covers it: every field, in a fixed order, separated by the unit separator.
4775
+ CREATE OR REPLACE FUNCTION slotlock.audit_event_text(event slotlock.audit_events)
4776
+ RETURNS text
4777
+ LANGUAGE sql
4778
+ STABLE
4779
+ SET search_path = pg_catalog, slotlock, pg_temp
4780
+ AS $slotlock_audit_event_text$
4781
+ SELECT concat_ws(chr(31), event.tenant_ref, event.chain_index::text, event.id::text,
4782
+ (extract(epoch FROM event.occurred_at) * 1000000)::bigint::text, event.action,
4783
+ event.actor_type, event.actor_ref, coalesce(event.actor_role, ''),
4784
+ coalesce(event.target_type, ''), coalesce(event.target_ref, ''), event.outcome,
4785
+ coalesce(event.reason, ''), coalesce(event.ip_address, ''),
4786
+ coalesce(event.user_agent, ''), event.metadata::text);
4787
+ $slotlock_audit_event_text$;
4788
+
4789
+ -- Before each insert: the tenant's chain one writer at a time, the event numbered after the last
4790
+ -- and hashed with it.
4791
+ CREATE OR REPLACE FUNCTION slotlock.chain_audit_event()
4792
+ RETURNS trigger
4793
+ LANGUAGE plpgsql
4794
+ SET search_path = pg_catalog, slotlock, pg_temp
4795
+ AS $slotlock_chain_audit_event$
4796
+ DECLARE
4797
+ last_index bigint;
4798
+ last_hash bytea;
4799
+ BEGIN
4800
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:audit-chain:' || NEW.tenant_ref, 0));
4801
+ SELECT kept.chain_index, kept.hash INTO last_index, last_hash FROM slotlock.audit_events kept
4802
+ WHERE kept.tenant_ref = NEW.tenant_ref AND kept.chain_index IS NOT NULL
4803
+ ORDER BY kept.chain_index DESC LIMIT 1;
4804
+ -- The time is the database's, whatever the insert said: an event dated in the past would have
4805
+ -- retention take the events after it.
4806
+ NEW.occurred_at := statement_timestamp();
4807
+ NEW.chain_index := coalesce(last_index, 0) + 1;
4808
+ NEW.prev_hash := last_hash;
4809
+ NEW.hash := sha256(coalesce(last_hash, ''::bytea) || convert_to(slotlock.audit_event_text(NEW), 'UTF8'));
4810
+ RETURN NEW;
4811
+ END
4812
+ $slotlock_chain_audit_event$;
4813
+ DROP TRIGGER IF EXISTS slotlock_audit_events_chain ON slotlock.audit_events;
4814
+ CREATE TRIGGER slotlock_audit_events_chain BEFORE INSERT ON slotlock.audit_events
4815
+ FOR EACH ROW EXECUTE FUNCTION slotlock.chain_audit_event();
4816
+
4817
+ -- Whether a tenant's chain holds, from its oldest event kept: how many events it checked, and the
4818
+ -- first whose number, link or hash is wrong (null when none is). Run as the serving role, under
4819
+ -- the tenant's own row security.
4820
+ CREATE OR REPLACE FUNCTION slotlock.verify_audit_chain(requested_tenant text)
4821
+ RETURNS TABLE (checked bigint, broken_at bigint)
4822
+ LANGUAGE plpgsql
4823
+ STABLE
4824
+ SET search_path = pg_catalog, slotlock, pg_temp
4825
+ AS $slotlock_verify_audit_chain$
4826
+ DECLARE
4827
+ event slotlock.audit_events;
4828
+ expected_index bigint;
4829
+ expected_prev bytea;
4830
+ seen bigint := 0;
4831
+ loose_at timestamptz;
4832
+ BEGIN
4833
+ -- An event with no place in the chain, written after the chain began, was taken off it: broken
4834
+ -- where it stood, the first chained event after it (or past the last).
4835
+ SELECT min(loose.occurred_at) INTO loose_at FROM slotlock.audit_events loose
4836
+ WHERE loose.tenant_ref = requested_tenant AND loose.chain_index IS NULL
4837
+ AND loose.occurred_at >= (SELECT min(first.occurred_at) FROM slotlock.audit_events first
4838
+ WHERE first.tenant_ref = requested_tenant AND first.chain_index IS NOT NULL);
4839
+ IF loose_at IS NOT NULL THEN
4840
+ RETURN QUERY SELECT 0::bigint, coalesce(
4841
+ (SELECT min(after.chain_index) FROM slotlock.audit_events after
4842
+ WHERE after.tenant_ref = requested_tenant AND after.chain_index IS NOT NULL AND after.occurred_at > loose_at),
4843
+ (SELECT max(last.chain_index) + 1 FROM slotlock.audit_events last WHERE last.tenant_ref = requested_tenant));
4844
+ RETURN;
4845
+ END IF;
4846
+ FOR event IN
4847
+ SELECT * FROM slotlock.audit_events kept
4848
+ WHERE kept.tenant_ref = requested_tenant AND kept.chain_index IS NOT NULL
4849
+ ORDER BY kept.chain_index
4850
+ LOOP
4851
+ IF (seen > 0 AND (event.chain_index <> expected_index OR event.prev_hash IS DISTINCT FROM expected_prev))
4852
+ OR event.hash IS DISTINCT FROM
4853
+ sha256(coalesce(event.prev_hash, ''::bytea) || convert_to(slotlock.audit_event_text(event), 'UTF8')) THEN
4854
+ RETURN QUERY SELECT seen, event.chain_index;
4855
+ RETURN;
4856
+ END IF;
4857
+ seen := seen + 1;
4858
+ expected_index := event.chain_index + 1;
4859
+ expected_prev := event.hash;
4860
+ END LOOP;
4861
+ RETURN QUERY SELECT seen, NULL::bigint;
4862
+ END
4863
+ $slotlock_verify_audit_chain$;
4864
+
4865
+ -- A tenant's events past retention. The serving role cannot delete audit events itself: this is
4866
+ -- the one way they go, besides erasure, and it reaches only events retention has ended.
4867
+ CREATE OR REPLACE FUNCTION slotlock.prune_audit_events(requested_tenant text)
4868
+ RETURNS integer
4869
+ LANGUAGE plpgsql
4870
+ VOLATILE
4871
+ SECURITY DEFINER
4872
+ SET search_path = pg_catalog, slotlock, pg_temp
4873
+ AS $slotlock_prune_audit_events$
4874
+ DECLARE
4875
+ pruned integer;
4876
+ kept_days integer;
4877
+ BEGIN
4878
+ -- SLOTLOCK_AUDIT_RETENTION_DAYS, or the plan's change history if longer (Team's 400 days).
4879
+ kept_days := greatest(${SLOTLOCK_AUDIT_RETENTION_DAYS}, coalesce(${planChangeRetentionDaysSql('requested_tenant')}, 0));
4880
+ -- The start of the chain only, so what is kept still links up: every event up to the last one
4881
+ -- written before retention's cut, and events from before the chain past it.
4882
+ DELETE FROM slotlock.audit_events gone
4883
+ WHERE gone.tenant_ref = requested_tenant
4884
+ AND ((gone.chain_index IS NULL
4885
+ AND gone.occurred_at < statement_timestamp() - make_interval(days => kept_days))
4886
+ OR gone.chain_index <= (
4887
+ SELECT max(old.chain_index) FROM slotlock.audit_events old
4888
+ WHERE old.tenant_ref = requested_tenant
4889
+ AND old.occurred_at < statement_timestamp() - make_interval(days => kept_days)));
4890
+ GET DIAGNOSTICS pruned = ROW_COUNT;
4891
+ RETURN pruned;
4892
+ END
4893
+ $slotlock_prune_audit_events$;
4894
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[8]} FROM PUBLIC;
4895
+
4896
+ -- A tenant's changes past retention (RFC 0004 D2). The serving role cannot change or delete a
4897
+ -- change itself: this is the one way they go, besides erasure. It keeps the store's days
4898
+ -- (requested_days, 1 to 3650), or the plan's (SLOTLOCK_CHANGE_RETENTION_BY_PLAN) if longer, so a
4899
+ -- serving role cannot cut a hosted tenant's history short. Oldest first in feed order, and only up
4900
+ -- to the first change kept (within retention, still owed to an enabled webhook, or later than the
4901
+ -- oldest running transaction), so every kept change is after every pruned one and a reader starting
4902
+ -- over reads past the gap.
4903
+ CREATE OR REPLACE FUNCTION slotlock.prune_changes(requested_tenant text, batch integer, requested_days integer)
4904
+ RETURNS integer
4905
+ LANGUAGE plpgsql
4906
+ VOLATILE
4907
+ SECURITY DEFINER
4908
+ SET search_path = pg_catalog, slotlock, pg_temp
4909
+ AS $slotlock_prune_changes$
4910
+ DECLARE
4911
+ kept_days integer;
4912
+ pruned integer;
4913
+ BEGIN
4914
+ IF batch IS NULL OR batch < 1 OR batch > 10000 OR requested_days IS NULL OR requested_days < 1
4915
+ OR requested_days > 3650 THEN
4916
+ RAISE EXCEPTION 'Slotlock prunes 1 to 10000 changes at a time, keeping 1 to 3650 days'
4917
+ USING ERRCODE = 'invalid_parameter_value';
4918
+ END IF;
4919
+ -- A role that cannot delete changes itself (the serving role) acts only for the tenant it
4920
+ -- acts in, where row security names one: the function runs as the schema's owner, which row
4921
+ -- security may not bind. The owner, or a library on its connection, needs no tenant context.
4922
+ IF NOT pg_catalog.has_table_privilege(session_user, 'slotlock.outbox', 'DELETE')
4923
+ AND EXISTS (SELECT 1 FROM slotlock.rls_policy_contracts)
4924
+ AND NOT EXISTS (SELECT 1 FROM slotlock.rls_policy_contracts contract
4925
+ WHERE current_setting(contract.tenant_context_setting, true) = requested_tenant) THEN
4926
+ RAISE EXCEPTION 'Slotlock prunes the history of only the tenant the caller acts in'
4927
+ USING ERRCODE = 'insufficient_privilege';
4928
+ END IF;
4929
+ kept_days := greatest(requested_days, coalesce(${planChangeRetentionDaysSql('requested_tenant')}, 0));
4930
+ WITH head AS MATERIALIZED (
4931
+ SELECT outbox.id, outbox.txid,
4932
+ outbox.occurred_at >= now() - make_interval(days => kept_days)
4933
+ OR EXISTS (
4934
+ SELECT 1 FROM slotlock.webhook_endpoints endpoint
4935
+ WHERE endpoint.tenant_ref = outbox.tenant_ref AND endpoint.disabled_at IS NULL
4936
+ AND (outbox.txid, outbox.id) > (endpoint.cursor_txid, endpoint.cursor_id)
4937
+ ) AS kept
4938
+ FROM slotlock.outbox
4939
+ WHERE outbox.tenant_ref = requested_tenant
4940
+ AND outbox.txid < pg_snapshot_xmin(pg_current_snapshot())
4941
+ ORDER BY outbox.txid, outbox.id
4942
+ LIMIT batch
4943
+ ), doomed AS MATERIALIZED (
4944
+ SELECT change.id, change.txid FROM head change
4945
+ WHERE NOT EXISTS (
4946
+ SELECT 1 FROM head keeper
4947
+ WHERE keeper.kept AND (keeper.txid, keeper.id) <= (change.txid, change.id))
4948
+ ), missed AS (
4949
+ -- A disabled endpoint that had not received one of them has lost it.
4950
+ UPDATE slotlock.webhook_endpoints endpoint SET changes_pruned = true
4951
+ WHERE endpoint.tenant_ref = requested_tenant AND endpoint.disabled_at IS NOT NULL
4952
+ AND NOT endpoint.changes_pruned
4953
+ AND EXISTS (
4954
+ SELECT 1 FROM doomed
4955
+ WHERE (doomed.txid, doomed.id) > (endpoint.cursor_txid, endpoint.cursor_id))
4956
+ ), deleted AS (
4957
+ DELETE FROM slotlock.outbox change USING doomed WHERE change.id = doomed.id RETURNING 1
4958
+ )
4959
+ SELECT count(*)::integer INTO pruned FROM deleted;
4960
+ RETURN pruned;
4961
+ END
4962
+ $slotlock_prune_changes$;
4963
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[21]} FROM PUBLIC;
4964
+
4965
+ -- Taking one person out of a tenant's change history (RFC 0004 D3, slotlock subject erase): the
4966
+ -- one edit it allows. A holder's reference and a booking's reference become \`replacement\` (an
4967
+ -- \`erased-\` name, the same one everywhere the erasure reached, so what was one booking still reads
4968
+ -- as one), and a contact's reference is taken off. Nothing else in a change is touched, and the
4969
+ -- serving role can write nothing but such a name. Row security keeps it to the caller's tenant.
4970
+ CREATE OR REPLACE FUNCTION slotlock.erase_subject_changes(
4971
+ requested_tenant text, kind text, erased text, replacement text
4972
+ )
4973
+ RETURNS integer
4974
+ LANGUAGE plpgsql
4975
+ VOLATILE
4976
+ SECURITY DEFINER
4977
+ SET search_path = pg_catalog, slotlock, pg_temp
4978
+ AS $slotlock_erase_subject_changes$
4979
+ DECLARE
4980
+ changed integer;
4981
+ BEGIN
4982
+ IF kind IS NULL OR kind NOT IN ('holder', 'contact', 'reference')
4983
+ OR erased IS NULL OR octet_length(erased) NOT BETWEEN 1 AND 500
4984
+ OR (kind <> 'contact' AND (replacement IS NULL OR replacement !~ '^erased-[0-9a-f]{32}$')) THEN
4985
+ RAISE EXCEPTION 'Slotlock erases a holder, a contact or a reference from the history, and names it erased-<32 hex>'
4986
+ USING ERRCODE = 'invalid_parameter_value';
4987
+ END IF;
4988
+ -- A role that cannot update changes itself (the serving role) acts only for the tenant it
4989
+ -- acts in, where row security names one: the function runs as the schema's owner, which row
4990
+ -- security may not bind. The owner, or a library on its connection, needs no tenant context.
4991
+ IF NOT pg_catalog.has_table_privilege(session_user, 'slotlock.outbox', 'UPDATE')
4992
+ AND EXISTS (SELECT 1 FROM slotlock.rls_policy_contracts)
4993
+ AND NOT EXISTS (SELECT 1 FROM slotlock.rls_policy_contracts contract
4994
+ WHERE current_setting(contract.tenant_context_setting, true) = requested_tenant) THEN
4995
+ RAISE EXCEPTION 'Slotlock erases from the history of only the tenant the caller acts in'
4996
+ USING ERRCODE = 'insufficient_privilege';
4997
+ END IF;
4998
+ IF kind = 'holder' THEN
4999
+ UPDATE slotlock.outbox change
5000
+ SET data = pg_catalog.jsonb_set(change.data, '{holder_ref}', pg_catalog.to_jsonb(replacement))
5001
+ WHERE change.tenant_ref = requested_tenant AND change.data ->> 'holder_ref' = erased;
5002
+ ELSIF kind = 'contact' THEN
5003
+ UPDATE slotlock.outbox change SET data = change.data - 'contact_ref'
5004
+ WHERE change.tenant_ref = requested_tenant AND change.data ->> 'contact_ref' = erased;
5005
+ ELSE
5006
+ -- A booking's changes are named by its reference and carry it.
5007
+ UPDATE slotlock.outbox change
5008
+ SET subject = CASE WHEN change.subject = erased THEN replacement ELSE change.subject END,
5009
+ data = pg_catalog.jsonb_set(change.data, '{reference}', pg_catalog.to_jsonb(replacement))
5010
+ WHERE change.tenant_ref = requested_tenant AND change.data ->> 'reference' = erased;
5011
+ END IF;
5012
+ GET DIAGNOSTICS changed = ROW_COUNT;
5013
+ RETURN changed;
5014
+ END
5015
+ $slotlock_erase_subject_changes$;
5016
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[22]} FROM PUBLIC;
5017
+
2932
5018
  -- The tenant a reservation row belongs to: its own, or (a hold) its resource's.
2933
5019
  CREATE OR REPLACE FUNCTION slotlock.outbox_reservation_change()
2934
5020
  RETURNS trigger
@@ -2980,8 +5066,8 @@ BEGIN
2980
5066
  END;
2981
5067
  END IF;
2982
5068
  INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
2983
- VALUES (
2984
- owner, changed.resource_id, kind,
5069
+ SELECT
5070
+ owner, changed.resource_id, written.change_type,
2985
5071
  COALESCE(changed.external_ref, changed.id::text),
2986
5072
  jsonb_strip_nulls(jsonb_build_object(
2987
5073
  'id', changed.id,
@@ -2990,6 +5076,23 @@ BEGIN
2990
5076
  'start', changed.starts_at,
2991
5077
  'end', changed.ends_at,
2992
5078
  'buffer_after_ms', changed.buffer_after_ms,
5079
+ -- Whom to tell and whether (RFC 0004 P3): a reference, never the person; notify only when off.
5080
+ 'contact_ref', changed.contact_ref,
5081
+ 'notify', CASE WHEN NOT changed.notify THEN false END,
5082
+ 'payment_required', CASE WHEN changed.payment_required THEN true END,
5083
+ -- A paid hold that ended without a booking (expired, let go, or left when another of its
5084
+ -- group was booked): its payment was never applied, and is the integrator's to refund (RFC
5085
+ -- 0004 C3). Booked from (slotlock.ending = 'confirmed') or taken over by an approval, it was.
5086
+ 'payment_unapplied', CASE
5087
+ WHEN TG_OP = 'DELETE' AND OLD.status = 'held' AND OLD.payment_ref IS NOT NULL
5088
+ AND COALESCE(current_setting('slotlock.ending', true), '') NOT IN ('confirmed', 'approval')
5089
+ THEN jsonb_strip_nulls(jsonb_build_object(
5090
+ 'payment_ref', OLD.payment_ref, 'amount_minor', OLD.paid_amount_minor, 'currency', OLD.paid_currency))
5091
+ END,
5092
+ -- A cancellation's fee and an admin's reason for going past a cutoff (RFC 0004 B5).
5093
+ 'cancellation_fee', CASE WHEN kind = 'reservation.cancelled'
5094
+ THEN NULLIF(current_setting('slotlock.cancellation_fee', true), '')::jsonb END,
5095
+ 'override_reason', NULLIF(current_setting('slotlock.override_reason', true), ''),
2993
5096
  -- A no-show carries the revision its reference is retired at, and the check-in window.
2994
5097
  'revision', CASE WHEN changed.status = 'held' THEN NULL
2995
5098
  WHEN kind = 'reservation.no_show' THEN changed.revision + 1
@@ -3003,7 +5106,13 @@ BEGIN
3003
5106
  'revision', OLD.revision) END
3004
5107
  )),
3005
5108
  NULLIF(current_setting('slotlock.actor', true), '')
3006
- );
5109
+ -- A booking moved in time or to another resource is also reservation.rescheduled (RFC 0004
5110
+ -- B4); reservation.updated stays for those who read it.
5111
+ FROM (VALUES (kind), (CASE WHEN kind = 'reservation.updated'
5112
+ AND (OLD.starts_at, OLD.ends_at, OLD.resource_id)
5113
+ IS DISTINCT FROM (NEW.starts_at, NEW.ends_at, NEW.resource_id)
5114
+ THEN 'reservation.rescheduled' END)) AS written(change_type)
5115
+ WHERE written.change_type IS NOT NULL;
3007
5116
  RETURN NULL;
3008
5117
  END
3009
5118
  $slotlock_outbox_reservation$;
@@ -3047,6 +5156,11 @@ BEGIN
3047
5156
  'transparency', changed.transparency,
3048
5157
  'recurring', changed.recurrence_rule IS NOT NULL,
3049
5158
  'revision', changed.revision,
5159
+ 'notify', CASE WHEN NOT changed.notify THEN false END,
5160
+ -- A cancellation's fee and an admin's reason for going past a cutoff (RFC 0004 B5).
5161
+ 'cancellation_fee', CASE WHEN kind = 'event.cancelled'
5162
+ THEN NULLIF(current_setting('slotlock.cancellation_fee', true), '')::jsonb END,
5163
+ 'override_reason', NULLIF(current_setting('slotlock.override_reason', true), ''),
3050
5164
  'before', CASE WHEN TG_OP = 'UPDATE' THEN jsonb_build_object(
3051
5165
  'resource_id', OLD.resource_id, 'start', OLD.starts_at, 'end', OLD.ends_at,
3052
5166
  'status', OLD.status, 'transparency', OLD.transparency, 'revision', OLD.revision) END
@@ -3983,6 +6097,12 @@ CREATE INDEX IF NOT EXISTS slotlock_resources_labels_idx ON slotlock.resources U
3983
6097
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
3984
6098
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
3985
6099
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
6100
+ -- When maintenance said the booking ended (reservation.completed, RFC 0004 B4): it changes nothing
6101
+ -- anyone wrote, so the feed's trigger ignores it.
6102
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS completed_at timestamptz;
6103
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_uncompleted_idx
6104
+ ON slotlock.reservations (tenant_ref, ends_at)
6105
+ WHERE status = 'confirmed' AND completed_at IS NULL;
3986
6106
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS occupied_ends_at timestamptz;
3987
6107
  -- The leading buffer a booking keeps before its start (its resource's bufferBeforeMinutes when it
3988
6108
  -- was made), and where its occupied time starts: its start less that buffer. A row written before
@@ -4078,6 +6198,31 @@ ALTER TABLE slotlock.reservation_tombstones ALTER COLUMN cancelled_at SET DEFAUL
4078
6198
  -- are kept too.
4079
6199
  ALTER TABLE slotlock.reservation_tombstones
4080
6200
  ADD COLUMN IF NOT EXISTS stray boolean NOT NULL DEFAULT false;
6201
+ -- Why the booking ended (RFC 0004 B4): cancelled or no_show (a hold leaves no tombstone; released
6202
+ -- and expired are on the feed). Rows from before keep 'cancelled', as every one of them was. A late
6203
+ -- cancellation's fee (B5), recorded for the integrator to charge, and an admin's reason for going
6204
+ -- past a cutoff.
6205
+ ALTER TABLE slotlock.reservation_tombstones
6206
+ ADD COLUMN IF NOT EXISTS end_reason text NOT NULL DEFAULT 'cancelled';
6207
+ ALTER TABLE slotlock.reservation_tombstones ADD COLUMN IF NOT EXISTS cancellation_fee jsonb;
6208
+ ALTER TABLE slotlock.reservation_tombstones ADD COLUMN IF NOT EXISTS override_reason text;
6209
+ DO $slotlock_tombstone_end_reason$
6210
+ BEGIN
6211
+ IF NOT EXISTS (
6212
+ SELECT 1 FROM pg_catalog.pg_constraint
6213
+ WHERE conrelid = 'slotlock.reservation_tombstones'::regclass
6214
+ AND conname = 'slotlock_reservation_tombstones_end_valid'
6215
+ ) THEN
6216
+ ALTER TABLE slotlock.reservation_tombstones
6217
+ ADD CONSTRAINT slotlock_reservation_tombstones_end_valid CHECK (
6218
+ end_reason IN ('cancelled', 'no_show', 'released', 'expired', 'erased')
6219
+ AND (cancellation_fee IS NULL OR (jsonb_typeof(cancellation_fee) = 'object'
6220
+ AND octet_length(cancellation_fee::text) <= 200))
6221
+ AND (override_reason IS NULL OR char_length(override_reason) BETWEEN 1 AND 500)
6222
+ );
6223
+ END IF;
6224
+ END
6225
+ $slotlock_tombstone_end_reason$;
4081
6226
  -- Resource identity: one resource per tenant/external_ref namespace (plus one isolated NULL-tenant
4082
6227
  -- legacy namespace). Dedupe first (repoint reservations to the keeper, drop the rest) so the
4083
6228
  -- unique indexes also bind on DBs poisoned by the pre-index
@@ -4755,6 +6900,9 @@ CREATE TABLE IF NOT EXISTS slotlock.approval_policies (
4755
6900
  AND lead_minutes BETWEEN 0 AND 43200
4756
6901
  )
4757
6902
  );
6903
+ -- A policy holds agents' bookings, and the tenant's own through the application API (bundles and
6904
+ -- pool bookings) only when it includes them (RFC 0004 B7).
6905
+ ALTER TABLE slotlock.approval_policies ADD COLUMN IF NOT EXISTS include_application boolean NOT NULL DEFAULT false;
4758
6906
 
4759
6907
  -- A write waiting for a person: its time held by holds whose holder is the approval (never the
4760
6908
  -- agent, so it cannot release them and they do not count against its caps), and the request to run
@@ -4799,6 +6947,147 @@ CREATE INDEX IF NOT EXISTS slotlock_approvals_pending_idx
4799
6947
  CREATE INDEX IF NOT EXISTS slotlock_approvals_expiry_idx
4800
6948
  ON slotlock.approvals (expires_at) WHERE status = 'pending';
4801
6949
 
6950
+ -- Stored hours (RFC 0004 B2): the tenant's (no location) and each location's. A resource without
6951
+ -- its own inherits its location's, the nearest up the chain, then its tenant's, then the server's.
6952
+ -- Rules are written validated (weekly rules, at most 50).
6953
+ CREATE TABLE IF NOT EXISTS slotlock.availability_defaults (
6954
+ tenant_ref text NOT NULL,
6955
+ location_ref text,
6956
+ rules jsonb NOT NULL,
6957
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
6958
+ CONSTRAINT slotlock_availability_defaults_location_fkey FOREIGN KEY (tenant_ref, location_ref)
6959
+ REFERENCES slotlock.locations (tenant_ref, ref) ON DELETE CASCADE,
6960
+ CONSTRAINT slotlock_availability_defaults_valid CHECK (
6961
+ octet_length(tenant_ref) BETWEEN 1 AND 500
6962
+ AND jsonb_typeof(rules) = 'array' AND jsonb_array_length(rules) <= 50
6963
+ AND octet_length(rules::text) <= 65536
6964
+ )
6965
+ );
6966
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_availability_defaults_scope_idx
6967
+ ON slotlock.availability_defaults (tenant_ref, (COALESCE(location_ref, '')));
6968
+
6969
+ -- Exceptions to the weekly hours (RFC 0004 B1): whole local days, closed or open with hours of
6970
+ -- their own, for the tenant, a location (and every resource inside it) or one resource. An import
6971
+ -- names its source's id, so importing again updates the same row.
6972
+ CREATE TABLE IF NOT EXISTS slotlock.availability_exceptions (
6973
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6974
+ tenant_ref text NOT NULL,
6975
+ location_ref text,
6976
+ resource_id uuid,
6977
+ starts_on date NOT NULL,
6978
+ ends_on date NOT NULL,
6979
+ outcome text NOT NULL,
6980
+ hours jsonb,
6981
+ label text NOT NULL,
6982
+ source text NOT NULL DEFAULT 'manual',
6983
+ source_uid text,
6984
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
6985
+ CONSTRAINT slotlock_availability_exceptions_location_fkey FOREIGN KEY (tenant_ref, location_ref)
6986
+ REFERENCES slotlock.locations (tenant_ref, ref) ON DELETE CASCADE,
6987
+ CONSTRAINT slotlock_availability_exceptions_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
6988
+ REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
6989
+ CONSTRAINT slotlock_availability_exceptions_valid CHECK (
6990
+ octet_length(tenant_ref) BETWEEN 1 AND 500
6991
+ AND (location_ref IS NULL OR resource_id IS NULL)
6992
+ AND ends_on >= starts_on AND ends_on - starts_on < 367
6993
+ AND outcome IN ('closed', 'open')
6994
+ AND ((outcome = 'open') = (hours IS NOT NULL))
6995
+ AND (hours IS NULL OR (jsonb_typeof(hours) = 'array' AND jsonb_array_length(hours) BETWEEN 1 AND 4))
6996
+ AND char_length(label) BETWEEN 1 AND 200 AND btrim(label) <> ''
6997
+ AND source IN ('manual', 'ics', 'vavailability')
6998
+ AND (source_uid IS NULL OR octet_length(source_uid) BETWEEN 1 AND 600)
6999
+ )
7000
+ );
7001
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_availability_exceptions_source_idx
7002
+ ON slotlock.availability_exceptions
7003
+ (tenant_ref, (COALESCE(location_ref, '')), (COALESCE(resource_id::text, '')), source_uid)
7004
+ WHERE source_uid IS NOT NULL;
7005
+ CREATE INDEX IF NOT EXISTS slotlock_availability_exceptions_days_idx
7006
+ ON slotlock.availability_exceptions (tenant_ref, ends_on, starts_on);
7007
+ CREATE INDEX IF NOT EXISTS slotlock_availability_exceptions_resource_idx
7008
+ ON slotlock.availability_exceptions (resource_id) WHERE resource_id IS NOT NULL;
7009
+
7010
+ -- A holiday calendar imported into closures (RFC 0004 B1), for the tenant, a location or a
7011
+ -- resource, by name: its closures name it, so importing it again removes the holidays it has
7012
+ -- dropped. With a URL it is a subscription, which maintenance imports again by next_import_at.
7013
+ -- Deleting it deletes its closures.
7014
+ CREATE TABLE IF NOT EXISTS slotlock.holiday_calendars (
7015
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7016
+ tenant_ref text NOT NULL,
7017
+ location_ref text,
7018
+ resource_id uuid,
7019
+ name text NOT NULL,
7020
+ url text,
7021
+ imported_at timestamptz,
7022
+ next_import_at timestamptz,
7023
+ last_error text,
7024
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
7025
+ CONSTRAINT slotlock_holiday_calendars_tenant_key UNIQUE (id, tenant_ref),
7026
+ CONSTRAINT slotlock_holiday_calendars_location_fkey FOREIGN KEY (tenant_ref, location_ref)
7027
+ REFERENCES slotlock.locations (tenant_ref, ref) ON DELETE CASCADE,
7028
+ CONSTRAINT slotlock_holiday_calendars_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
7029
+ REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
7030
+ CONSTRAINT slotlock_holiday_calendars_valid CHECK (
7031
+ octet_length(tenant_ref) BETWEEN 1 AND 500
7032
+ AND (location_ref IS NULL OR resource_id IS NULL)
7033
+ AND char_length(name) BETWEEN 1 AND 200 AND btrim(name) <> ''
7034
+ AND (url IS NULL OR (url ~ '^https://' AND octet_length(url) <= 2048))
7035
+ AND (next_import_at IS NULL OR url IS NOT NULL)
7036
+ AND (last_error IS NULL OR char_length(last_error) <= 500)
7037
+ )
7038
+ );
7039
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_holiday_calendars_name_idx
7040
+ ON slotlock.holiday_calendars (tenant_ref, (COALESCE(location_ref, '')), (COALESCE(resource_id::text, '')), name);
7041
+ CREATE INDEX IF NOT EXISTS slotlock_holiday_calendars_due_idx
7042
+ ON slotlock.holiday_calendars (tenant_ref, next_import_at) WHERE next_import_at IS NOT NULL;
7043
+ -- The calendar an imported closure came from: NULL for one added by hand, or imported before
7044
+ -- calendars were kept (claimed by the next import of a calendar that names it).
7045
+ ALTER TABLE slotlock.availability_exceptions ADD COLUMN IF NOT EXISTS calendar_id uuid;
7046
+ DO $slotlock_exception_calendar$
7047
+ BEGIN
7048
+ IF NOT EXISTS (
7049
+ SELECT 1 FROM pg_constraint
7050
+ WHERE conname = 'slotlock_availability_exceptions_calendar_fkey'
7051
+ AND conrelid = 'slotlock.availability_exceptions'::regclass
7052
+ ) THEN
7053
+ ALTER TABLE slotlock.availability_exceptions
7054
+ ADD CONSTRAINT slotlock_availability_exceptions_calendar_fkey FOREIGN KEY (calendar_id, tenant_ref)
7055
+ REFERENCES slotlock.holiday_calendars (id, tenant_ref) ON DELETE CASCADE;
7056
+ END IF;
7057
+ END
7058
+ $slotlock_exception_calendar$;
7059
+ CREATE INDEX IF NOT EXISTS slotlock_availability_exceptions_calendar_idx
7060
+ ON slotlock.availability_exceptions (calendar_id) WHERE calendar_id IS NOT NULL;
7061
+
7062
+ -- The hours a resource without its own inherits: its location's, the nearest up the chain, else its
7063
+ -- tenant's, as {source, location?, rules}; NULL leaves the server's default. Runs as the caller, so
7064
+ -- row-level security keeps it inside the tenant.
7065
+ CREATE OR REPLACE FUNCTION slotlock.inherited_availability(requested_tenant_ref text, requested_location_ref text)
7066
+ RETURNS jsonb
7067
+ LANGUAGE sql
7068
+ STABLE
7069
+ SET search_path = pg_catalog, slotlock, pg_temp
7070
+ AS $slotlock_inherited_availability$
7071
+ WITH RECURSIVE up(ref, parent_ref, steps) AS (
7072
+ SELECT place.ref, place.parent_ref, 1 FROM slotlock.locations place
7073
+ WHERE requested_location_ref IS NOT NULL
7074
+ AND place.tenant_ref = requested_tenant_ref AND place.ref = requested_location_ref
7075
+ UNION ALL
7076
+ SELECT place.ref, place.parent_ref, up.steps + 1
7077
+ FROM slotlock.locations place JOIN up ON place.tenant_ref = requested_tenant_ref AND place.ref = up.parent_ref
7078
+ WHERE up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
7079
+ )
7080
+ SELECT COALESCE(
7081
+ (SELECT jsonb_build_object('source', 'location', 'location', up.ref, 'rules', defaults.rules)
7082
+ FROM up JOIN slotlock.availability_defaults defaults
7083
+ ON defaults.tenant_ref = requested_tenant_ref AND defaults.location_ref = up.ref
7084
+ ORDER BY up.steps LIMIT 1),
7085
+ (SELECT jsonb_build_object('source', 'tenant', 'rules', defaults.rules)
7086
+ FROM slotlock.availability_defaults defaults
7087
+ WHERE defaults.tenant_ref = requested_tenant_ref AND defaults.location_ref IS NULL)
7088
+ );
7089
+ $slotlock_inherited_availability$;
7090
+
4802
7091
  -- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
4803
7092
  -- 0.1 the buffer bound only tenant-owned bookings to each other (slotlock_reservations_buffered_no_overlap),
4804
7093
  -- so a hold could take a booking's buffer and a buffered booking could cover a hold. The upgrade
@@ -5075,6 +7364,22 @@ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS recurrence_ends_by
5075
7364
  ALTER TABLE slotlock.calendar_events
5076
7365
  ADD COLUMN IF NOT EXISTS buffer_after_ms integer NOT NULL DEFAULT 0
5077
7366
  CONSTRAINT slotlock_calendar_events_buffer_valid CHECK (buffer_after_ms BETWEEN 0 AND 86400000);
7367
+ -- A turnaround may run to a week (RFC 0004 B3): the wider check admits every row the old one did,
7368
+ -- so it is added NOT VALID (no scan under the lock), and the old one dropped.
7369
+ DO $slotlock_calendar_event_week_turnaround$
7370
+ BEGIN
7371
+ IF NOT EXISTS (
7372
+ SELECT 1 FROM pg_catalog.pg_constraint
7373
+ WHERE conrelid = 'slotlock.calendar_events'::regclass
7374
+ AND conname = 'slotlock_calendar_events_buffer_week_valid'
7375
+ ) THEN
7376
+ ALTER TABLE slotlock.calendar_events
7377
+ ADD CONSTRAINT slotlock_calendar_events_buffer_week_valid
7378
+ CHECK (buffer_after_ms BETWEEN 0 AND 604800000) NOT VALID;
7379
+ END IF;
7380
+ ALTER TABLE slotlock.calendar_events DROP CONSTRAINT IF EXISTS slotlock_calendar_events_buffer_valid;
7381
+ END
7382
+ $slotlock_calendar_event_week_turnaround$;
5078
7383
  -- The leading buffer a booked event keeps before each occurrence, kept for a later roll alike.
5079
7384
  ALTER TABLE slotlock.calendar_events
5080
7385
  ADD COLUMN IF NOT EXISTS buffer_before_ms integer NOT NULL DEFAULT 0
@@ -5398,6 +7703,311 @@ CREATE TABLE IF NOT EXISTS slotlock.calendar_event_occurrences (
5398
7703
  )
5399
7704
  );
5400
7705
 
7706
+ -- Zone rule changes reach everything (RFC 0004 B9): a series' occurrences, and reservations that
7707
+ -- carry the wall time they were booked for, keep it, so maintenance finds those whose wall time now
7708
+ -- falls at another instant. An occurrence's is its series' zone's wall time when written; a
7709
+ -- reservation's is what the writer gave (local_start), in its resource's zone.
7710
+ ALTER TABLE slotlock.calendar_event_occurrences ADD COLUMN IF NOT EXISTS wall_starts_at timestamp;
7711
+ ALTER TABLE slotlock.calendar_event_occurrences ADD COLUMN IF NOT EXISTS zone_shift_reported_at timestamptz;
7712
+ CREATE OR REPLACE FUNCTION slotlock.calendar_event_occurrence_wall_time()
7713
+ RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, slotlock, pg_temp
7714
+ AS $slotlock_occurrence_wall_time$
7715
+ BEGIN
7716
+ IF TG_OP = 'INSERT' OR NEW.starts_at IS DISTINCT FROM OLD.starts_at THEN
7717
+ NEW.wall_starts_at := slotlock.zoned_wall(
7718
+ NEW.starts_at, (SELECT event.timezone FROM slotlock.calendar_events event WHERE event.id = NEW.event_id));
7719
+ NEW.zone_shift_reported_at := NULL;
7720
+ END IF;
7721
+ RETURN NEW;
7722
+ END
7723
+ $slotlock_occurrence_wall_time$;
7724
+ DROP TRIGGER IF EXISTS slotlock_calendar_event_occurrence_wall_time ON slotlock.calendar_event_occurrences;
7725
+ CREATE TRIGGER slotlock_calendar_event_occurrence_wall_time
7726
+ BEFORE INSERT OR UPDATE ON slotlock.calendar_event_occurrences
7727
+ FOR EACH ROW EXECUTE FUNCTION slotlock.calendar_event_occurrence_wall_time();
7728
+ -- Occurrences to come written before this release: their wall time as the rules read now. A new
7729
+ -- database has none, and may not have slotlock.zoned_wall yet at this point.
7730
+ DO $slotlock_occurrence_wall_backfill$
7731
+ BEGIN
7732
+ IF to_regprocedure('slotlock.zoned_wall(timestamptz, text)') IS NOT NULL THEN
7733
+ EXECUTE $backfill$
7734
+ UPDATE slotlock.calendar_event_occurrences occurrence
7735
+ SET wall_starts_at = slotlock.zoned_wall(occurrence.starts_at, event.timezone)
7736
+ FROM slotlock.calendar_events event
7737
+ WHERE event.id = occurrence.event_id AND occurrence.wall_starts_at IS NULL
7738
+ AND occurrence.starts_at > now()
7739
+ $backfill$;
7740
+ END IF;
7741
+ END
7742
+ $slotlock_occurrence_wall_backfill$;
7743
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS wall_starts_at timestamp;
7744
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS zone_shift_reported_at timestamptz;
7745
+
7746
+ -- Integrations (RFC 0004 P3). Core never sends a message and never holds a provider token; it keeps
7747
+ -- what the packages that do need. A booking may name the person to tell (contact_ref) and say not
7748
+ -- to tell anyone (notify); a hold may wait for the integrator's payment before it is confirmed.
7749
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS contact_ref text;
7750
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS notify boolean NOT NULL DEFAULT true;
7751
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS payment_required boolean NOT NULL DEFAULT false;
7752
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS payment_ref text;
7753
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS paid_at timestamptz;
7754
+ -- What a hold's payment must be, and what was received (RFC 0004 C3), in the currency's minor unit.
7755
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS payment_amount_minor bigint;
7756
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS payment_currency text;
7757
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS paid_amount_minor bigint;
7758
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS paid_currency text;
7759
+ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS notify boolean NOT NULL DEFAULT true;
7760
+ DO $slotlock_integration_columns$
7761
+ BEGIN
7762
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_reservations_integration_valid'
7763
+ AND conrelid = 'slotlock.reservations'::regclass) THEN
7764
+ ALTER TABLE slotlock.reservations ADD CONSTRAINT slotlock_reservations_integration_valid CHECK (
7765
+ (contact_ref IS NULL OR contact_ref ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$')
7766
+ AND (payment_ref IS NULL OR octet_length(payment_ref) BETWEEN 1 AND 255)
7767
+ AND (paid_at IS NULL OR payment_ref IS NOT NULL)
7768
+ ) NOT VALID;
7769
+ END IF;
7770
+ END
7771
+ $slotlock_integration_columns$;
7772
+ DO $slotlock_payment_amounts$
7773
+ BEGIN
7774
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_reservations_payment_amounts_valid'
7775
+ AND conrelid = 'slotlock.reservations'::regclass) THEN
7776
+ ALTER TABLE slotlock.reservations ADD CONSTRAINT slotlock_reservations_payment_amounts_valid CHECK (
7777
+ (payment_amount_minor IS NULL) = (payment_currency IS NULL)
7778
+ AND (paid_amount_minor IS NULL) = (paid_currency IS NULL)
7779
+ AND (payment_amount_minor IS NULL OR payment_amount_minor BETWEEN 0 AND 1000000000000)
7780
+ AND (paid_amount_minor IS NULL OR paid_amount_minor BETWEEN 0 AND 1000000000000)
7781
+ AND (payment_currency IS NULL OR payment_currency ~ '^[A-Z]{3}$')
7782
+ AND (paid_currency IS NULL OR paid_currency ~ '^[A-Z]{3}$')
7783
+ ) NOT VALID;
7784
+ END IF;
7785
+ END
7786
+ $slotlock_payment_amounts$;
7787
+ -- Forgetting a person finds the bookings that name them.
7788
+ CREATE INDEX IF NOT EXISTS slotlock_reservations_contact_idx
7789
+ ON slotlock.reservations (tenant_ref, contact_ref) WHERE contact_ref IS NOT NULL;
7790
+
7791
+ -- The people a tenant's bookings may tell (RFC 0004 P3): how to reach each, and what each agreed
7792
+ -- to. A person is erased by deleting their row; bookings keep no copy of what it held.
7793
+ CREATE TABLE IF NOT EXISTS slotlock.contacts (
7794
+ tenant_ref text NOT NULL,
7795
+ ref text NOT NULL,
7796
+ name text,
7797
+ email text,
7798
+ phone text,
7799
+ whatsapp text,
7800
+ consent jsonb NOT NULL DEFAULT '{}'::jsonb,
7801
+ locale text,
7802
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
7803
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
7804
+ PRIMARY KEY (tenant_ref, ref),
7805
+ CONSTRAINT slotlock_contacts_valid CHECK (
7806
+ octet_length(tenant_ref) BETWEEN 1 AND 500
7807
+ AND ref ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$'
7808
+ AND (name IS NULL OR char_length(name) BETWEEN 1 AND 200)
7809
+ AND (email IS NULL OR (char_length(email) BETWEEN 3 AND 320 AND email LIKE '%_@_%'))
7810
+ AND (phone IS NULL OR phone ~ '^[+][1-9][0-9]{6,14}$')
7811
+ AND (whatsapp IS NULL OR whatsapp ~ '^[+][1-9][0-9]{6,14}$')
7812
+ AND jsonb_typeof(consent) = 'object' AND octet_length(consent::text) <= 512
7813
+ AND (locale IS NULL OR locale ~ '^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$')
7814
+ )
7815
+ );
7816
+
7817
+ -- Each reminder once (RFC 0004 P3): when maintenance put reminder.due on the feed for an
7818
+ -- occurrence, so a later pass never says it again.
7819
+ CREATE TABLE IF NOT EXISTS slotlock.reminder_dispatches (
7820
+ tenant_ref text NOT NULL,
7821
+ event_id uuid NOT NULL,
7822
+ recurrence_id text NOT NULL,
7823
+ ordinal integer NOT NULL,
7824
+ due_at timestamptz NOT NULL,
7825
+ dispatched_at timestamptz NOT NULL DEFAULT statement_timestamp(),
7826
+ PRIMARY KEY (event_id, recurrence_id, due_at),
7827
+ CONSTRAINT slotlock_reminder_dispatches_event_fkey FOREIGN KEY (event_id, tenant_ref)
7828
+ REFERENCES slotlock.calendar_events (id, tenant_ref) ON DELETE CASCADE
7829
+ );
7830
+ CREATE INDEX IF NOT EXISTS slotlock_reminder_dispatches_tenant_idx
7831
+ ON slotlock.reminder_dispatches (tenant_ref, due_at);
7832
+
7833
+ -- A booking's reminders (RFC 0004 P3): minutes before its start, at most five; each said once on
7834
+ -- the feed as reminder.due, and again if the booking moves.
7835
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS reminder_minutes integer[];
7836
+ -- The start a booking or event had when it was moved while cancelling it was already late (past its
7837
+ -- cancel cutoff, or inside its fee's window): a later cancellation is judged from it, so moving
7838
+ -- cannot take a booking out of what a late cancellation owes (RFC 0004 B5).
7839
+ ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS cutoff_anchor timestamptz;
7840
+ ALTER TABLE slotlock.calendar_events ADD COLUMN IF NOT EXISTS cutoff_anchor timestamptz;
7841
+ DO $slotlock_reservation_reminders$
7842
+ BEGIN
7843
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_reservations_reminders_valid'
7844
+ AND conrelid = 'slotlock.reservations'::regclass) THEN
7845
+ ALTER TABLE slotlock.reservations ADD CONSTRAINT slotlock_reservations_reminders_valid CHECK (
7846
+ reminder_minutes IS NULL
7847
+ OR (cardinality(reminder_minutes) BETWEEN 1 AND 5
7848
+ AND 0 <= ALL (reminder_minutes) AND 527040 >= ALL (reminder_minutes))
7849
+ ) NOT VALID;
7850
+ END IF;
7851
+ END
7852
+ $slotlock_reservation_reminders$;
7853
+ CREATE TABLE IF NOT EXISTS slotlock.reservation_reminder_dispatches (
7854
+ tenant_ref text NOT NULL,
7855
+ reservation_id uuid NOT NULL,
7856
+ ordinal integer NOT NULL CHECK (ordinal BETWEEN 0 AND 4),
7857
+ due_at timestamptz NOT NULL,
7858
+ dispatched_at timestamptz NOT NULL DEFAULT statement_timestamp(),
7859
+ PRIMARY KEY (reservation_id, due_at),
7860
+ CONSTRAINT slotlock_reservation_reminder_dispatches_fkey FOREIGN KEY (reservation_id, tenant_ref)
7861
+ REFERENCES slotlock.reservations (id, tenant_ref) ON DELETE CASCADE
7862
+ );
7863
+ -- A reminder said is kept by when it was due, not by its place in the list: one added, removed or
7864
+ -- reordered (so every place has another time) says nothing again that was said, and a booking or
7865
+ -- occurrence moved has new times, said again. Reminders due at the same time are said once. Kept by
7866
+ -- place before, so the key moves, each time said kept once.
7867
+ DO $slotlock_reminder_dispatch_keys$
7868
+ DECLARE
7869
+ dispatches text;
7870
+ kept_by_place text;
7871
+ BEGIN
7872
+ FOREACH dispatches IN ARRAY ARRAY['reminder_dispatches', 'reservation_reminder_dispatches'] LOOP
7873
+ SELECT key.conname INTO kept_by_place
7874
+ FROM pg_catalog.pg_constraint key
7875
+ WHERE key.conrelid = pg_catalog.format('slotlock.%I', dispatches)::regclass AND key.contype = 'p'
7876
+ AND EXISTS (SELECT 1 FROM pg_catalog.pg_attribute column_
7877
+ WHERE column_.attrelid = key.conrelid AND column_.attnum = ANY (key.conkey)
7878
+ AND column_.attname = 'ordinal');
7879
+ IF kept_by_place IS NOT NULL THEN
7880
+ IF dispatches = 'reminder_dispatches' THEN
7881
+ DELETE FROM slotlock.reminder_dispatches sent USING slotlock.reminder_dispatches other
7882
+ WHERE other.event_id = sent.event_id AND other.recurrence_id = sent.recurrence_id
7883
+ AND other.due_at = sent.due_at AND other.ordinal < sent.ordinal;
7884
+ EXECUTE pg_catalog.format('ALTER TABLE slotlock.reminder_dispatches DROP CONSTRAINT %I', kept_by_place);
7885
+ ALTER TABLE slotlock.reminder_dispatches ADD PRIMARY KEY (event_id, recurrence_id, due_at);
7886
+ ELSE
7887
+ DELETE FROM slotlock.reservation_reminder_dispatches sent USING slotlock.reservation_reminder_dispatches other
7888
+ WHERE other.reservation_id = sent.reservation_id AND other.due_at = sent.due_at AND other.ordinal < sent.ordinal;
7889
+ EXECUTE pg_catalog.format('ALTER TABLE slotlock.reservation_reminder_dispatches DROP CONSTRAINT %I', kept_by_place);
7890
+ ALTER TABLE slotlock.reservation_reminder_dispatches ADD PRIMARY KEY (reservation_id, due_at);
7891
+ END IF;
7892
+ END IF;
7893
+ END LOOP;
7894
+ END
7895
+ $slotlock_reminder_dispatch_keys$;
7896
+ CREATE INDEX IF NOT EXISTS slotlock_reservation_reminder_dispatches_tenant_idx
7897
+ ON slotlock.reservation_reminder_dispatches (tenant_ref, due_at);
7898
+
7899
+ -- Each hold's payment, kept once recorded (RFC 0004 C3), however the hold ends: confirmed in place,
7900
+ -- booked as an event (its row deleted), released or expired. So the same payment, told again later,
7901
+ -- is answered as the payment already recorded, never as one to refund.
7902
+ CREATE TABLE IF NOT EXISTS slotlock.hold_payments (
7903
+ tenant_ref text NOT NULL,
7904
+ hold_id uuid PRIMARY KEY,
7905
+ resource_id uuid NOT NULL,
7906
+ starts_at timestamptz NOT NULL,
7907
+ ends_at timestamptz NOT NULL,
7908
+ buffer_after_ms bigint NOT NULL DEFAULT 0,
7909
+ expires_at timestamptz NOT NULL,
7910
+ payment_ref text NOT NULL CHECK (octet_length(payment_ref) BETWEEN 1 AND 255),
7911
+ paid_at timestamptz NOT NULL DEFAULT statement_timestamp(),
7912
+ payment_amount_minor bigint,
7913
+ payment_currency text,
7914
+ paid_amount_minor bigint,
7915
+ paid_currency text,
7916
+ CONSTRAINT slotlock_hold_payments_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
7917
+ REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE
7918
+ );
7919
+ -- Whether a paid hold was booked (RFC 0004 C3): confirmed, or booked from by a write that named it
7920
+ -- (booked_at), or taken over by an approval (approval_id) and so booked as that approval goes. A
7921
+ -- payment whose hold ended any other way was never applied: markHoldPaid answers it hold_ended, and
7922
+ -- the feed's hold.expired or hold.released says so (payment_unapplied), for the payment to be
7923
+ -- refunded. Payments recorded before this was kept are taken as applied: none is refunded unasked.
7924
+ DO $slotlock_hold_payments_outcome$
7925
+ BEGIN
7926
+ IF NOT EXISTS (
7927
+ SELECT 1 FROM information_schema.columns
7928
+ WHERE table_schema = 'slotlock' AND table_name = 'hold_payments' AND column_name = 'booked_at'
7929
+ ) THEN
7930
+ ALTER TABLE slotlock.hold_payments ADD COLUMN booked_at timestamptz, ADD COLUMN approval_id uuid;
7931
+ UPDATE slotlock.hold_payments payment SET booked_at = payment.paid_at
7932
+ WHERE NOT EXISTS (SELECT 1 FROM slotlock.reservations hold
7933
+ WHERE hold.id = payment.hold_id AND hold.status = 'held');
7934
+ END IF;
7935
+ END
7936
+ $slotlock_hold_payments_outcome$;
7937
+ -- When each channel's consent was last given or withdrawn, as proof of it.
7938
+ ALTER TABLE slotlock.contacts ADD COLUMN IF NOT EXISTS consent_at jsonb NOT NULL DEFAULT '{}'::jsonb;
7939
+
7940
+ -- Busy time a calendar reports without its events (Google freeBusy, Microsoft getSchedule), for one
7941
+ -- resource and source (RFC 0004 C1): replaced window by window with the source's coverage, counted
7942
+ -- in free/busy and refused to every booking Slotlock makes, as an event's time would be.
7943
+ CREATE TABLE IF NOT EXISTS slotlock.source_busy (
7944
+ tenant_ref text NOT NULL,
7945
+ resource_id uuid NOT NULL,
7946
+ source text NOT NULL,
7947
+ starts_at timestamptz NOT NULL,
7948
+ ends_at timestamptz NOT NULL,
7949
+ CONSTRAINT slotlock_source_busy_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
7950
+ REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE,
7951
+ CONSTRAINT slotlock_source_busy_valid CHECK (
7952
+ octet_length(tenant_ref) BETWEEN 1 AND 500 AND octet_length(source) BETWEEN 1 AND 500
7953
+ AND ends_at > starts_at
7954
+ )
7955
+ );
7956
+ CREATE INDEX IF NOT EXISTS slotlock_source_busy_resource_idx
7957
+ ON slotlock.source_busy (resource_id, starts_at, ends_at);
7958
+ CREATE INDEX IF NOT EXISTS slotlock_source_busy_source_idx
7959
+ ON slotlock.source_busy (resource_id, source, starts_at);
7960
+ -- Busy time that ended long ago goes in retention (pruneBookingRetention), tenant by tenant.
7961
+ CREATE INDEX IF NOT EXISTS slotlock_source_busy_ended_idx
7962
+ ON slotlock.source_busy (tenant_ref, ends_at);
7963
+
7964
+ -- A copy of everything a tenant keeps, for it to download (RFC 0004 D3, POST /v1/tenant/exports):
7965
+ -- read in one snapshot into parts of NDJSON, one table's rows to a part. Kept for
7966
+ -- SLOTLOCK_TENANT_EXPORT_HOURS once it is ready (or since it was asked for, if it never is), then
7967
+ -- deleted by maintenance; at once when the tenant deletes it or is erased.
7968
+ CREATE TABLE IF NOT EXISTS slotlock.tenant_exports (
7969
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7970
+ tenant_ref text NOT NULL,
7971
+ status text NOT NULL DEFAULT 'pending',
7972
+ requested_by text,
7973
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
7974
+ finished_at timestamptz,
7975
+ expires_at timestamptz NOT NULL,
7976
+ parts integer NOT NULL DEFAULT 0,
7977
+ row_count bigint NOT NULL DEFAULT 0,
7978
+ bytes bigint NOT NULL DEFAULT 0,
7979
+ tables jsonb NOT NULL DEFAULT '{}'::jsonb,
7980
+ error text,
7981
+ CONSTRAINT slotlock_tenant_exports_tenant_key UNIQUE (id, tenant_ref),
7982
+ CONSTRAINT slotlock_tenant_exports_valid CHECK (
7983
+ octet_length(tenant_ref) BETWEEN 1 AND 500
7984
+ AND status IN ('pending', 'ready', 'failed')
7985
+ AND (requested_by IS NULL OR octet_length(requested_by) BETWEEN 1 AND 200)
7986
+ AND (error IS NULL OR octet_length(error) BETWEEN 1 AND 200)
7987
+ AND parts >= 0 AND row_count >= 0 AND bytes >= 0
7988
+ AND jsonb_typeof(tables) = 'object'
7989
+ )
7990
+ );
7991
+ CREATE INDEX IF NOT EXISTS slotlock_tenant_exports_tenant_idx
7992
+ ON slotlock.tenant_exports (tenant_ref, created_at);
7993
+ CREATE INDEX IF NOT EXISTS slotlock_tenant_exports_expiry_idx
7994
+ ON slotlock.tenant_exports (tenant_ref, expires_at);
7995
+ CREATE TABLE IF NOT EXISTS slotlock.tenant_export_parts (
7996
+ export_id uuid NOT NULL,
7997
+ tenant_ref text NOT NULL,
7998
+ part integer NOT NULL,
7999
+ table_name text NOT NULL,
8000
+ row_count integer NOT NULL,
8001
+ content text NOT NULL,
8002
+ CONSTRAINT slotlock_tenant_export_parts_pkey PRIMARY KEY (export_id, part),
8003
+ CONSTRAINT slotlock_tenant_export_parts_export_fkey FOREIGN KEY (export_id, tenant_ref)
8004
+ REFERENCES slotlock.tenant_exports (id, tenant_ref) ON DELETE CASCADE,
8005
+ CONSTRAINT slotlock_tenant_export_parts_valid CHECK (
8006
+ octet_length(tenant_ref) BETWEEN 1 AND 500
8007
+ AND part >= 1 AND table_name ~ '^[a-z_]{1,63}$' AND row_count >= 1
8008
+ )
8009
+ );
8010
+
5401
8011
  -- A cancelled UID remains terminal. This blocks delayed create delivery after the event row and
5402
8012
  -- its derived reservations have been removed.
5403
8013
  CREATE TABLE IF NOT EXISTS slotlock.calendar_event_tombstones (
@@ -5612,7 +8222,7 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
5612
8222
  ON slotlock.calendar_event_occurrences (tenant_ref, starts_at, ends_at);
5613
8223
  CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
5614
8224
  ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
5615
- ${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_PARTNER_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
8225
+ ${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_PARTNER_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_IDENTITY_DDL}${SLOTLOCK_SSO_DDL}${SLOTLOCK_SCIM_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
5616
8226
  /**
5617
8227
  * Whether the outbox row `row` (a coverage change) is in this release's shape: named by a source_id
5618
8228
  * or src_redacted, in `subject` and `data.source_id` alike, with no `source` or `last_error`.
@@ -5742,6 +8352,8 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
5742
8352
  'booking_rule_defaults',
5743
8353
  'assistant_settings',
5744
8354
  'assistant_conversations',
8355
+ 'assistant_chats',
8356
+ 'audit_events',
5745
8357
  'locations',
5746
8358
  'resource_groups',
5747
8359
  'resource_group_members',
@@ -5749,6 +8361,16 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
5749
8361
  'composite_claims',
5750
8362
  'approval_policies',
5751
8363
  'approvals',
8364
+ 'availability_defaults',
8365
+ 'availability_exceptions',
8366
+ 'holiday_calendars',
8367
+ 'contacts',
8368
+ 'reminder_dispatches',
8369
+ 'reservation_reminder_dispatches',
8370
+ 'hold_payments',
8371
+ 'source_busy',
8372
+ 'tenant_exports',
8373
+ 'tenant_export_parts',
5752
8374
  ]);
5753
8375
  const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
5754
8376
  const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
@@ -5764,9 +8386,15 @@ export function createSlotlockApplicationRoleGrantsDdl(role) {
5764
8386
  if (!POSTGRES_ROLE_NAME.test(role) || RESERVED_ROLE_NAMES.has(role) || role.startsWith('pg_')) {
5765
8387
  throw Object.assign(new Error('Slotlock application role must be a lowercase, non-reserved PostgreSQL role name'), { code: 'invalid_role_name' });
5766
8388
  }
5767
- const tables = SLOTLOCK_TENANT_TABLES.map((table) => `slotlock.${table}`).join(', ');
8389
+ const appendOnly = SLOTLOCK_APPEND_ONLY_TABLES;
8390
+ const tables = SLOTLOCK_TENANT_TABLES.filter((table) => !appendOnly.includes(table))
8391
+ .map((table) => `slotlock.${table}`)
8392
+ .join(', ');
8393
+ const added = SLOTLOCK_APPEND_ONLY_TABLES.map((table) => `slotlock.${table}`).join(', ');
5768
8394
  return `GRANT USAGE ON SCHEMA slotlock TO "${role}";
5769
8395
  GRANT SELECT, INSERT, UPDATE, DELETE ON ${tables} TO "${role}";
8396
+ GRANT SELECT, INSERT ON ${added} TO "${role}";
8397
+ REVOKE UPDATE, DELETE ON ${added} FROM "${role}";
5770
8398
  GRANT EXECUTE ON FUNCTION ${SLOTLOCK_DEFINER_FUNCTIONS.join(', ')} TO "${role}";
5771
8399
  `;
5772
8400
  }
@@ -5797,6 +8425,11 @@ export const SLOTLOCK_RESOURCE_SCOPED_TABLES = Object.freeze({
5797
8425
  // A change or a waitlist entry about no one resource (a key, a pool) is not the key's.
5798
8426
  outbox: resourceScopeAllows('resource_id'),
5799
8427
  waitlist_entries: resourceScopeAllows('resource_id'),
8428
+ // The tenant's and locations' exceptions shape every resource's hours, so every key reads them.
8429
+ availability_exceptions: `(resource_id IS NULL OR ${resourceScopeAllows('resource_id')})`,
8430
+ holiday_calendars: `(resource_id IS NULL OR ${resourceScopeAllows('resource_id')})`,
8431
+ source_busy: resourceScopeAllows('resource_id'),
8432
+ hold_payments: resourceScopeAllows('resource_id'),
5800
8433
  ...Object.fromEntries([
5801
8434
  'calendar_event_attendees',
5802
8435
  'calendar_event_reminders',
@@ -5862,6 +8495,10 @@ ALTER TABLE slotlock.assistant_settings ENABLE ROW LEVEL SECURITY;
5862
8495
  ALTER TABLE slotlock.assistant_settings FORCE ROW LEVEL SECURITY;
5863
8496
  ALTER TABLE slotlock.assistant_conversations ENABLE ROW LEVEL SECURITY;
5864
8497
  ALTER TABLE slotlock.assistant_conversations FORCE ROW LEVEL SECURITY;
8498
+ ALTER TABLE slotlock.assistant_chats ENABLE ROW LEVEL SECURITY;
8499
+ ALTER TABLE slotlock.assistant_chats FORCE ROW LEVEL SECURITY;
8500
+ ALTER TABLE slotlock.audit_events ENABLE ROW LEVEL SECURITY;
8501
+ ALTER TABLE slotlock.audit_events FORCE ROW LEVEL SECURITY;
5865
8502
  ALTER TABLE slotlock.locations ENABLE ROW LEVEL SECURITY;
5866
8503
  ALTER TABLE slotlock.locations FORCE ROW LEVEL SECURITY;
5867
8504
  ALTER TABLE slotlock.resource_groups ENABLE ROW LEVEL SECURITY;
@@ -5876,6 +8513,26 @@ ALTER TABLE slotlock.approval_policies ENABLE ROW LEVEL SECURITY;
5876
8513
  ALTER TABLE slotlock.approval_policies FORCE ROW LEVEL SECURITY;
5877
8514
  ALTER TABLE slotlock.approvals ENABLE ROW LEVEL SECURITY;
5878
8515
  ALTER TABLE slotlock.approvals FORCE ROW LEVEL SECURITY;
8516
+ ALTER TABLE slotlock.availability_defaults ENABLE ROW LEVEL SECURITY;
8517
+ ALTER TABLE slotlock.availability_defaults FORCE ROW LEVEL SECURITY;
8518
+ ALTER TABLE slotlock.availability_exceptions ENABLE ROW LEVEL SECURITY;
8519
+ ALTER TABLE slotlock.availability_exceptions FORCE ROW LEVEL SECURITY;
8520
+ ALTER TABLE slotlock.contacts ENABLE ROW LEVEL SECURITY;
8521
+ ALTER TABLE slotlock.contacts FORCE ROW LEVEL SECURITY;
8522
+ ALTER TABLE slotlock.reminder_dispatches ENABLE ROW LEVEL SECURITY;
8523
+ ALTER TABLE slotlock.reminder_dispatches FORCE ROW LEVEL SECURITY;
8524
+ ALTER TABLE slotlock.reservation_reminder_dispatches ENABLE ROW LEVEL SECURITY;
8525
+ ALTER TABLE slotlock.reservation_reminder_dispatches FORCE ROW LEVEL SECURITY;
8526
+ ALTER TABLE slotlock.hold_payments ENABLE ROW LEVEL SECURITY;
8527
+ ALTER TABLE slotlock.hold_payments FORCE ROW LEVEL SECURITY;
8528
+ ALTER TABLE slotlock.holiday_calendars ENABLE ROW LEVEL SECURITY;
8529
+ ALTER TABLE slotlock.holiday_calendars FORCE ROW LEVEL SECURITY;
8530
+ ALTER TABLE slotlock.source_busy ENABLE ROW LEVEL SECURITY;
8531
+ ALTER TABLE slotlock.source_busy FORCE ROW LEVEL SECURITY;
8532
+ ALTER TABLE slotlock.tenant_exports ENABLE ROW LEVEL SECURITY;
8533
+ ALTER TABLE slotlock.tenant_exports FORCE ROW LEVEL SECURITY;
8534
+ ALTER TABLE slotlock.tenant_export_parts ENABLE ROW LEVEL SECURITY;
8535
+ ALTER TABLE slotlock.tenant_export_parts FORCE ROW LEVEL SECURITY;
5879
8536
 
5880
8537
  -- One database must have one tenant-context contract. Re-applying with an adapter-specific
5881
8538
  -- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
@@ -5908,13 +8565,25 @@ BEGIN
5908
8565
  'booking_rule_defaults',
5909
8566
  'assistant_settings',
5910
8567
  'assistant_conversations',
8568
+ 'assistant_chats',
8569
+ 'audit_events',
5911
8570
  'locations',
5912
8571
  'resource_groups',
5913
8572
  'resource_group_members',
5914
8573
  'pool_settings',
5915
8574
  'composite_claims',
5916
8575
  'approval_policies',
5917
- 'approvals'
8576
+ 'approvals',
8577
+ 'availability_defaults',
8578
+ 'availability_exceptions',
8579
+ 'holiday_calendars',
8580
+ 'contacts',
8581
+ 'reminder_dispatches',
8582
+ 'reservation_reminder_dispatches',
8583
+ 'hold_payments',
8584
+ 'source_busy',
8585
+ 'tenant_exports',
8586
+ 'tenant_export_parts'
5918
8587
  )
5919
8588
  LOOP
5920
8589
  EXECUTE format(
@@ -6156,6 +8825,26 @@ BEGIN
6156
8825
  WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6157
8826
  END IF;
6158
8827
 
8828
+ IF NOT EXISTS (
8829
+ SELECT 1 FROM pg_policies
8830
+ WHERE schemaname = 'slotlock' AND tablename = 'assistant_chats'
8831
+ AND policyname = 'tenant_isolation_v1'
8832
+ ) THEN
8833
+ CREATE POLICY tenant_isolation_v1 ON slotlock.assistant_chats
8834
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
8835
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
8836
+ END IF;
8837
+
8838
+ IF NOT EXISTS (
8839
+ SELECT 1 FROM pg_policies
8840
+ WHERE schemaname = 'slotlock' AND tablename = 'audit_events'
8841
+ AND policyname = 'tenant_isolation_v1'
8842
+ ) THEN
8843
+ CREATE POLICY tenant_isolation_v1 ON slotlock.audit_events
8844
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
8845
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
8846
+ END IF;
8847
+
6159
8848
  IF NOT EXISTS (
6160
8849
  SELECT 1 FROM pg_policies
6161
8850
  WHERE schemaname = 'slotlock' AND tablename = 'locations'
@@ -6225,6 +8914,106 @@ BEGIN
6225
8914
  USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6226
8915
  WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6227
8916
  END IF;
8917
+
8918
+ IF NOT EXISTS (
8919
+ SELECT 1 FROM pg_policies
8920
+ WHERE schemaname = 'slotlock' AND tablename = 'availability_defaults'
8921
+ AND policyname = 'tenant_isolation_v1'
8922
+ ) THEN
8923
+ CREATE POLICY tenant_isolation_v1 ON slotlock.availability_defaults
8924
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
8925
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
8926
+ END IF;
8927
+
8928
+ IF NOT EXISTS (
8929
+ SELECT 1 FROM pg_policies
8930
+ WHERE schemaname = 'slotlock' AND tablename = 'availability_exceptions'
8931
+ AND policyname = 'tenant_isolation_v1'
8932
+ ) THEN
8933
+ CREATE POLICY tenant_isolation_v1 ON slotlock.availability_exceptions
8934
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
8935
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
8936
+ END IF;
8937
+
8938
+ IF NOT EXISTS (
8939
+ SELECT 1 FROM pg_policies
8940
+ WHERE schemaname = 'slotlock' AND tablename = 'contacts'
8941
+ AND policyname = 'tenant_isolation_v1'
8942
+ ) THEN
8943
+ CREATE POLICY tenant_isolation_v1 ON slotlock.contacts
8944
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
8945
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
8946
+ END IF;
8947
+
8948
+ IF NOT EXISTS (
8949
+ SELECT 1 FROM pg_policies
8950
+ WHERE schemaname = 'slotlock' AND tablename = 'reminder_dispatches'
8951
+ AND policyname = 'tenant_isolation_v1'
8952
+ ) THEN
8953
+ CREATE POLICY tenant_isolation_v1 ON slotlock.reminder_dispatches
8954
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
8955
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
8956
+ END IF;
8957
+
8958
+ IF NOT EXISTS (
8959
+ SELECT 1 FROM pg_policies
8960
+ WHERE schemaname = 'slotlock' AND tablename = 'reservation_reminder_dispatches'
8961
+ AND policyname = 'tenant_isolation_v1'
8962
+ ) THEN
8963
+ CREATE POLICY tenant_isolation_v1 ON slotlock.reservation_reminder_dispatches
8964
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
8965
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
8966
+ END IF;
8967
+
8968
+ IF NOT EXISTS (
8969
+ SELECT 1 FROM pg_policies
8970
+ WHERE schemaname = 'slotlock' AND tablename = 'hold_payments'
8971
+ AND policyname = 'tenant_isolation_v1'
8972
+ ) THEN
8973
+ CREATE POLICY tenant_isolation_v1 ON slotlock.hold_payments
8974
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
8975
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
8976
+ END IF;
8977
+
8978
+ IF NOT EXISTS (
8979
+ SELECT 1 FROM pg_policies
8980
+ WHERE schemaname = 'slotlock' AND tablename = 'holiday_calendars'
8981
+ AND policyname = 'tenant_isolation_v1'
8982
+ ) THEN
8983
+ CREATE POLICY tenant_isolation_v1 ON slotlock.holiday_calendars
8984
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
8985
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
8986
+ END IF;
8987
+
8988
+ IF NOT EXISTS (
8989
+ SELECT 1 FROM pg_policies
8990
+ WHERE schemaname = 'slotlock' AND tablename = 'source_busy'
8991
+ AND policyname = 'tenant_isolation_v1'
8992
+ ) THEN
8993
+ CREATE POLICY tenant_isolation_v1 ON slotlock.source_busy
8994
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
8995
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
8996
+ END IF;
8997
+
8998
+ IF NOT EXISTS (
8999
+ SELECT 1 FROM pg_policies
9000
+ WHERE schemaname = 'slotlock' AND tablename = 'tenant_exports'
9001
+ AND policyname = 'tenant_isolation_v1'
9002
+ ) THEN
9003
+ CREATE POLICY tenant_isolation_v1 ON slotlock.tenant_exports
9004
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
9005
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
9006
+ END IF;
9007
+
9008
+ IF NOT EXISTS (
9009
+ SELECT 1 FROM pg_policies
9010
+ WHERE schemaname = 'slotlock' AND tablename = 'tenant_export_parts'
9011
+ AND policyname = 'tenant_isolation_v1'
9012
+ ) THEN
9013
+ CREATE POLICY tenant_isolation_v1 ON slotlock.tenant_export_parts
9014
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
9015
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
9016
+ END IF;
6228
9017
  END
6229
9018
  $slotlock_rls$;
6230
9019
 
@@ -6268,6 +9057,10 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_settings
6268
9057
  IS 'slotlock:tenant-context:${tenantContextSetting}';
6269
9058
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
6270
9059
  IS 'slotlock:tenant-context:${tenantContextSetting}';
9060
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_chats
9061
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
9062
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.audit_events
9063
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
6271
9064
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.locations
6272
9065
  IS 'slotlock:tenant-context:${tenantContextSetting}';
6273
9066
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.resource_groups
@@ -6282,6 +9075,26 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.approval_policies
6282
9075
  IS 'slotlock:tenant-context:${tenantContextSetting}';
6283
9076
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.approvals
6284
9077
  IS 'slotlock:tenant-context:${tenantContextSetting}';
9078
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.availability_defaults
9079
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
9080
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.availability_exceptions
9081
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
9082
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.contacts
9083
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
9084
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.reminder_dispatches
9085
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
9086
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.reservation_reminder_dispatches
9087
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
9088
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.hold_payments
9089
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
9090
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.holiday_calendars
9091
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
9092
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.source_busy
9093
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
9094
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.tenant_exports
9095
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
9096
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.tenant_export_parts
9097
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
6285
9098
  ${SLOTLOCK_RESOURCE_SCOPE_POLICIES}
6286
9099
 
6287
9100
  INSERT INTO slotlock.rls_policy_contracts (
@@ -6326,13 +9139,25 @@ WHERE schemaname = 'slotlock'
6326
9139
  'booking_rule_defaults',
6327
9140
  'assistant_settings',
6328
9141
  'assistant_conversations',
9142
+ 'assistant_chats',
9143
+ 'audit_events',
6329
9144
  'locations',
6330
9145
  'resource_groups',
6331
9146
  'resource_group_members',
6332
9147
  'pool_settings',
6333
9148
  'composite_claims',
6334
9149
  'approval_policies',
6335
- 'approvals'
9150
+ 'approvals',
9151
+ 'availability_defaults',
9152
+ 'availability_exceptions',
9153
+ 'holiday_calendars',
9154
+ 'contacts',
9155
+ 'reminder_dispatches',
9156
+ 'reservation_reminder_dispatches',
9157
+ 'hold_payments',
9158
+ 'source_busy',
9159
+ 'tenant_exports',
9160
+ 'tenant_export_parts'
6336
9161
  )
6337
9162
  AND policyname = 'tenant_isolation_v1'
6338
9163
  ON CONFLICT (table_name) DO UPDATE