slotlock 0.6.2 → 0.7.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 (100) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1544
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +560 -49
  8. package/agent-store-backend.d.ts +39 -7
  9. package/agent-store-backend.js +1439 -104
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +218 -29
  12. package/application-api.d.ts +59 -6
  13. package/application-api.js +842 -39
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +58 -27
  17. package/assistant.js +151 -53
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/booking-rules.d.ts +30 -41
  21. package/booking-rules.js +17 -25
  22. package/change-watch.d.ts +1 -0
  23. package/change-watch.js +1 -1
  24. package/cli.js +3 -4
  25. package/config.d.ts +42 -6
  26. package/config.js +114 -7
  27. package/dashboard-icon.d.ts +1 -0
  28. package/dashboard-icon.js +5 -0
  29. package/dashboard.d.ts +203 -7
  30. package/dashboard.js +1558 -64
  31. package/ddl.d.ts +67 -35
  32. package/ddl.js +2732 -162
  33. package/engine.d.ts +6 -9
  34. package/engine.js +5 -9
  35. package/event-feed.d.ts +53 -35
  36. package/event-feed.js +201 -64
  37. package/event-schema.js +6 -9
  38. package/hooks.d.ts +2 -4
  39. package/hooks.js +2 -4
  40. package/ical-worker-thread.js +3 -4
  41. package/ical-worker.d.ts +8 -10
  42. package/ical-worker.js +7 -9
  43. package/index.d.ts +15 -4
  44. package/index.js +8 -2
  45. package/interop.js +1 -4
  46. package/maintenance.d.ts +24 -10
  47. package/maintenance.js +59 -28
  48. package/mcp-app.d.ts +7 -9
  49. package/mcp-app.js +7 -9
  50. package/mcp-modern.d.ts +14 -16
  51. package/mcp-modern.js +23 -27
  52. package/metrics.d.ts +41 -0
  53. package/metrics.js +22 -1
  54. package/node-server.d.ts +9 -9
  55. package/node-server.js +25 -34
  56. package/openapi.json +14675 -5957
  57. package/otel.d.ts +86 -0
  58. package/otel.js +273 -0
  59. package/overage.d.ts +33 -0
  60. package/overage.js +115 -0
  61. package/package-version.js +2 -4
  62. package/package.json +1 -1
  63. package/partner-billing.d.ts +81 -0
  64. package/partner-billing.js +333 -0
  65. package/partner-names.d.ts +8 -0
  66. package/partner-names.js +10 -0
  67. package/partners.d.ts +147 -0
  68. package/partners.js +375 -0
  69. package/plans.d.ts +92 -0
  70. package/plans.js +115 -0
  71. package/quotas.d.ts +108 -0
  72. package/quotas.js +258 -0
  73. package/rate-limit.d.ts +27 -2
  74. package/rate-limit.js +106 -23
  75. package/resource-profile.d.ts +135 -0
  76. package/resource-profile.js +263 -0
  77. package/rest-api.d.ts +15 -14
  78. package/rest-api.js +79 -15
  79. package/rules.d.ts +6 -7
  80. package/rules.js +30 -42
  81. package/sandbox.d.ts +70 -0
  82. package/sandbox.js +295 -0
  83. package/self-host.d.ts +46 -4
  84. package/self-host.js +990 -41
  85. package/slotlock.manifest.json +1 -1
  86. package/store.d.ts +482 -165
  87. package/store.js +1460 -484
  88. package/sync.d.ts +44 -59
  89. package/sync.js +325 -469
  90. package/teams.d.ts +25 -0
  91. package/teams.js +63 -0
  92. package/timezone.d.ts +25 -33
  93. package/timezone.js +50 -53
  94. package/types.d.ts +130 -57
  95. package/usage.d.ts +106 -0
  96. package/usage.js +238 -0
  97. package/webhooks.d.ts +22 -25
  98. package/webhooks.js +35 -40
  99. package/weekly-hours.d.ts +3 -0
  100. package/weekly-hours.js +1 -1
package/ddl.js CHANGED
@@ -1,17 +1,15 @@
1
- // Slotlock owns its schema. This DDL lives in the dedicated `slotlock` Postgres schema and is applied
2
- // by the package itself (store.applySchema). It is idempotent for fresh and evolved deployments.
1
+ // Slotlock owns its schema: this DDL lives in the `slotlock` Postgres schema, is applied by
2
+ // store.applySchema, and is idempotent.
3
3
  //
4
- // The EXCLUDE constraint is the load-bearing piece (RFC-0001 spike criterion a): the DATABASE —
5
- // not application code — arbitrates double-booking, per resource, on the half-open `[)` range.
6
- // The same pattern scales from an integration's booking tables to a first-class reservation store.
4
+ // The EXCLUDE constraint is the load-bearing piece: the database, not application code, arbitrates
5
+ // double-booking per resource on the half-open `[)` range.
7
6
  //
8
- // The deployment role owns every table, so any function or operator it resolves through a schema
9
- // another role can create objects in ("$user", public) runs with that ownership. The DDL therefore
10
- // resolves names only in pg_catalog, then the session's temporary schema, while it runs, and hands
11
- // the caller's search_path back at the end, so a caller that keeps going in the same transaction
12
- // resolves its own names as before. Apply it in one transaction: under autocommit every statement
13
- // is a transaction of its own, and the pin ends with the statement that sets it.
14
- import { SLOTLOCK_CHANGE_ORIGINS, legacyCoverageSourceDigestSql } from './event-feed.js';
7
+ // The deployment role owns every table, so a function or operator resolved through a schema another
8
+ // role can write to ("$user", public) would run with that ownership. The DDL pins search_path to
9
+ // pg_catalog and pg_temp while it runs and restores the caller's at the end. Apply it in one
10
+ // transaction: under autocommit the pin ends with its own statement.
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
+ import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_WEBHOOK_FILTER_REFS, SLOTLOCK_WEBHOOK_FILTER_RESOURCES, legacyCoverageSourceDigestSql, } from './event-feed.js';
15
13
  import { SLOTLOCK_MAX_AVAILABILITY_RULES } from './weekly-hours.js';
16
14
  const CALLER_SEARCH_PATH_SETTING = 'slotlock.caller_search_path';
17
15
  const DEPLOYMENT_SEARCH_PATH_PIN = `SELECT pg_catalog.set_config('${CALLER_SEARCH_PATH_SETTING}', pg_catalog.current_setting('search_path'), true);
@@ -19,17 +17,11 @@ SELECT pg_catalog.set_config('search_path', 'pg_catalog, pg_temp', true);
19
17
  `;
20
18
  const DEPLOYMENT_SEARCH_PATH_RESTORE = `SELECT pg_catalog.set_config('search_path', pg_catalog.current_setting('${CALLER_SEARCH_PATH_SETTING}'), true);
21
19
  `;
22
- // applyTenantRls forces row-level security, which binds the table owner too, and the deployment
23
- // sets no tenant: its data steps (reaping holds and listing conflicts before the overlap
24
- // constraint, merging duplicate resources, binding archives, backfilling event children) would see
25
- // no rows, skip what they repair and leave a constraint to meet rows its own check never saw. The
26
- // deployment lifts FORCE from the tables that have it before its first data step and puts it back
27
- // after its last, on those tables only. applySchema runs both in one transaction, which holds the
28
- // tables locked in between; a failure rolls the lift back with everything else. Run statement by
29
- // statement, the lift commits on its own and a refused data step stops the run before the restore,
30
- // so the tables lifted are recorded in a table, not the session: the next deployment, from any
31
- // session or connection, puts FORCE back on every table recorded, and `slotlock serve` refuses to
32
- // start while a tenant table is left without it.
20
+ // applyTenantRls forces RLS, which binds the owner too, and the deployment sets no tenant, so its
21
+ // data steps would see no rows. FORCE is lifted from the tables that have it before the first data
22
+ // step and restored after the last. The lifted tables are recorded in a table, not the session, so
23
+ // if a statement-by-statement run stops before the restore, the next deployment from any session
24
+ // restores them, and `slotlock serve` refuses to start meanwhile.
33
25
  const DEPLOYMENT_LIFT_FORCED_ROW_SECURITY = `CREATE TABLE IF NOT EXISTS slotlock.deployment_lifted_row_security (
34
26
  table_name text PRIMARY KEY
35
27
  );
@@ -74,19 +66,18 @@ $slotlock_restore_forced_row_security$;
74
66
  /** The start of the message with which the deployment DDL refuses a `slotlock` schema. */
75
67
  export const SLOTLOCK_SCHEMA_CONTROL_REFUSAL = 'Slotlock will not deploy into schema slotlock';
76
68
  /**
77
- * The version of the schema this release creates, recorded in `slotlock.schema_version` by every
78
- * migration. Releases up to 0.2 recorded nothing; 0.1 was version 1 and 0.2 version 2; 0.3 to 0.5
79
- * recorded 3. 0.6 is 4, so a 0.5 release refuses it before it changes anything: its DDL would put
80
- * back the trigger that names a coverage source by its guessable digest.
69
+ * The schema version this release creates, recorded in `slotlock.schema_version`. Up to 0.2
70
+ * 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
+ * refuses it rather than restoring the trigger that names a coverage source by its guessable
72
+ * digest.
81
73
  */
82
74
  export const SLOTLOCK_SCHEMA_VERSION = 4;
83
75
  /** The start of the message with which the deployment DDL refuses a schema a newer release migrated. */
84
76
  export const SLOTLOCK_SCHEMA_NEWER_REFUSAL = 'Slotlock will not migrate a schema a newer release migrated';
85
77
  /** The oldest PostgreSQL the DDL runs on (`server_version_num`): it uses pg_input_is_valid. */
86
78
  const MIN_SERVER_VERSION_NUM = 160000;
87
- // Before anything changes: a server too old for the DDL fails here, with a message that names the
88
- // version, rather than part way through on a function it lacks; and a schema a newer release
89
- // migrated is refused, since this release's DDL would put back what that release replaced.
79
+ // Before anything changes: an old server fails here with a message naming the version rather than
80
+ // midway, and a schema a newer release migrated is refused.
90
81
  const SLOTLOCK_SCHEMA_PRECONDITIONS = `DO $slotlock_schema_preconditions$
91
82
  DECLARE
92
83
  installed integer;
@@ -107,14 +98,11 @@ BEGIN
107
98
  END
108
99
  $slotlock_schema_preconditions$;
109
100
  `;
110
- // Every slotlock.<name> the deployment uses resolves in that schema, and the deployment writes rows
111
- // into its tables, firing their triggers, as the owner of every table. A role that owns the schema,
112
- // can create objects in it, owns an object in it, owns the function a trigger on its tables runs, or
113
- // may create such a trigger (after this check, before the first write) can therefore run code as the
114
- // deployment role (IF NOT EXISTS keeps whatever it made first); one that owns an extension installed
115
- // there can DROP EXTENSION ... CASCADE and take the exclusion constraints with it. The deployment
116
- // refuses before it touches anything there, listing each finding as a JSON array in the error's
117
- // DETAIL. Superusers are trusted: they can do all of this anyway.
101
+ // The deployment writes rows as the owner of every table, firing their triggers. A role that owns
102
+ // or can create objects in the schema, owns an object or trigger function there, or may create a
103
+ // trigger could therefore run code as the deployment role; one owning an extension there could DROP
104
+ // EXTENSION ... CASCADE and take the exclusion constraints. The deployment refuses before touching
105
+ // anything, with findings as a JSON array in DETAIL. Superusers are trusted.
118
106
  const SLOTLOCK_SCHEMA_CONTROL_CHECK = `DO $slotlock_schema_control$
119
107
  DECLARE
120
108
  refusals text[];
@@ -190,9 +178,8 @@ END
190
178
  $slotlock_schema_control$;
191
179
  `;
192
180
  /**
193
- * What every migration checks before it changes anything, applySchema and applyTenantRls included
194
- * when they find nothing to change: that no other role controls the schema, then the server version
195
- * and the schema version. Run with search_path pinned, as the DDL pins it.
181
+ * What every migration checks before changing anything, even when it finds nothing to change: no
182
+ * other role controls the schema, then server and schema version. Run with search_path pinned.
196
183
  */
197
184
  export const SLOTLOCK_DEPLOYMENT_CHECKS = `${SLOTLOCK_SCHEMA_CONTROL_CHECK}${SLOTLOCK_SCHEMA_PRECONDITIONS}`;
198
185
  /** Active (unrevoked, unexpired) API keys one tenant may hold. */
@@ -206,6 +193,8 @@ export const SLOTLOCK_API_KEY_RETAINED_LIMIT = 1_000;
206
193
  export const SLOTLOCK_API_KEY_TABLES = Object.freeze([
207
194
  'api_keys',
208
195
  'api_key_retired_digests',
196
+ 'api_key_resource_scopes',
197
+ 'api_key_limits',
209
198
  ]);
210
199
  /**
211
200
  * The SECURITY DEFINER functions through which the serving role reaches API keys, by signature.
@@ -218,16 +207,25 @@ export const SLOTLOCK_API_KEY_FUNCTIONS = Object.freeze([
218
207
  'slotlock.revoke_api_key(text, uuid)',
219
208
  'slotlock.authenticate_api_key(bytea)',
220
209
  'slotlock.erase_api_keys(text)',
210
+ 'slotlock.set_api_key_resource_scope(text, uuid, text[], text[])',
211
+ 'slotlock.authenticate_scoped_api_key(bytea)',
212
+ 'slotlock.list_api_key_resource_scopes(text)',
213
+ 'slotlock.set_api_key_limits(text, uuid, integer, integer, integer, boolean)',
214
+ 'slotlock.list_api_key_limits(text)',
215
+ 'slotlock.authenticate_limited_api_key(bytea)',
221
216
  ]);
222
217
  /**
223
218
  * The table behind the dashboard's shared records (dashboard.ts). No role but its owner holds a
224
219
  * right on it; it is reached only through SLOTLOCK_DASHBOARD_FUNCTIONS.
225
220
  */
226
- export const SLOTLOCK_DASHBOARD_TABLES = Object.freeze(['dashboard_tokens']);
221
+ export const SLOTLOCK_DASHBOARD_TABLES = Object.freeze(['dashboard_tokens', 'dashboard_organizations']);
227
222
  /** The SECURITY DEFINER functions through which the serving role reaches the dashboard's records. */
228
223
  export const SLOTLOCK_DASHBOARD_FUNCTIONS = Object.freeze([
229
224
  'slotlock.use_dashboard_token(text, bytea, timestamptz, bytea)',
230
225
  'slotlock.dashboard_session_ended(bytea, timestamptz)',
226
+ 'slotlock.enabled_organizations(text)',
227
+ 'slotlock.enable_organization(text, text, text)',
228
+ 'slotlock.disable_organization(text, text)',
231
229
  ]);
232
230
  /** Live form records one person may leave in the dashboard's records (12 hours of forms). */
233
231
  export const SLOTLOCK_DASHBOARD_FORM_LIMIT = 1_000;
@@ -240,9 +238,18 @@ export const SLOTLOCK_SCHEMA_FUNCTIONS = Object.freeze([
240
238
  /** The table of suspended tenants: they may read, and write nothing (store.ts withTenant). */
241
239
  export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze([
242
240
  'tenant_suspensions',
241
+ 'tenant_plans',
242
+ 'plan_defaults',
243
+ 'usage_daily',
244
+ 'usage_flushes',
245
+ 'usage_cursors',
246
+ 'usage_meter_start',
247
+ 'usage_notices',
248
+ 'overage_reports',
243
249
  'webhook_tenants',
244
250
  'rate_buckets',
245
251
  'assistant_tenants',
252
+ 'maintenance_tenants',
246
253
  ]);
247
254
  /**
248
255
  * The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
@@ -257,18 +264,99 @@ export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
257
264
  'slotlock.take_rate_token(text, double precision, double precision)',
258
265
  'slotlock.register_assistant_tenant(text, timestamptz)',
259
266
  'slotlock.sweep_assistant_conversations(integer)',
267
+ 'slotlock.list_maintenance_tenant()',
268
+ 'slotlock.maintenance_tenants(text)',
269
+ 'slotlock.tenant_plan(text)',
270
+ 'slotlock.set_tenant_plan(text, text, text, integer, integer, integer, text, text, timestamptz)',
271
+ 'slotlock.plan_default()',
272
+ 'slotlock.enrol_tenant_plan(text)',
273
+ 'slotlock.record_usage(uuid, jsonb)',
274
+ 'slotlock.roll_up_usage(text, integer)',
275
+ 'slotlock.tenant_usage(text, date, date)',
276
+ 'slotlock.tenant_quota_usage(text, date)',
277
+ 'slotlock.note_usage_threshold(text, date, text, integer, bigint, bigint)',
278
+ 'slotlock.tenant_plan_quotas(text)',
279
+ 'slotlock.reset_test_tenant(text)',
260
280
  ]);
281
+ /** The functions usage metering runs (SLOTLOCK_USAGE=on): serve checks it may, before it starts. */
282
+ export const SLOTLOCK_USAGE_FUNCTIONS = Object.freeze([
283
+ 'slotlock.record_usage(uuid, jsonb)',
284
+ 'slotlock.roll_up_usage(text, integer)',
285
+ 'slotlock.tenant_usage(text, date, date)',
286
+ ]);
287
+ /** The functions quotas run (SLOTLOCK_QUOTAS=on, quotas.ts): serve checks it may, before it starts. */
288
+ export const SLOTLOCK_QUOTA_FUNCTIONS = Object.freeze([
289
+ 'slotlock.tenant_plan(text)',
290
+ 'slotlock.tenant_quota_usage(text, date)',
291
+ 'slotlock.note_usage_threshold(text, date, text, integer, bigint, bigint)',
292
+ ]);
293
+ /** What a plan's monthly quotas count (quotas.ts): operations run and bookings made. */
294
+ export const SLOTLOCK_QUOTA_METRICS = Object.freeze(['operations', 'bookings']);
295
+ /** The shares of a quota, in percent, at which a tenant is told once a month (usage.threshold_reached). */
296
+ export const SLOTLOCK_QUOTA_NOTICE_PERCENTS = Object.freeze([80, 100]);
297
+ /** The most bookings past the roll-up cursor one quota read counts: more than any plan allows. */
298
+ export const SLOTLOCK_QUOTA_UNROLLED_LIMIT = 100_000;
261
299
  /**
262
- * Functions only the deployment role runs: suspending and lifting a suspension. No other role is
263
- * granted them, so a serving role cannot lift a suspension.
300
+ * What usage.ts meters, per tenant and day: operations run, bookings and holds committed, webhook
301
+ * deliveries accepted, messages to the dashboard assistant, and resources held (a count each day, not
302
+ * a sum).
303
+ */
304
+ export const SLOTLOCK_USAGE_METRICS = Object.freeze([
305
+ 'operations',
306
+ 'bookings',
307
+ 'holds',
308
+ 'webhook_deliveries',
309
+ 'assistant_messages',
310
+ 'resources',
311
+ ]);
312
+ /** The metrics a server counts and records (slotlock.record_usage); the rest the database counts. */
313
+ export const SLOTLOCK_RECORDED_USAGE_METRICS = Object.freeze([
314
+ 'operations',
315
+ 'webhook_deliveries',
316
+ 'assistant_messages',
317
+ ]);
318
+ /** Days a tenant's daily usage is kept: a yearly plan, and a month for disputes after it. */
319
+ export const SLOTLOCK_USAGE_RETENTION_DAYS = 400;
320
+ /**
321
+ * Functions only the deployment role runs: suspending and lifting a suspension, and setting the
322
+ * hosted plan every tenant without one of its own is on. No other role is granted them, so a serving
323
+ * role cannot lift a suspension or change the default plan.
264
324
  */
265
325
  export const SLOTLOCK_OPERATOR_FUNCTIONS = Object.freeze([
266
326
  'slotlock.suspend_tenant(text, text)',
267
327
  'slotlock.unsuspend_tenant(text)',
328
+ 'slotlock.set_plan_default(text, integer, integer, integer, timestamptz)',
268
329
  ]);
269
330
  /** Every SECURITY DEFINER function the serving role may execute, by signature. */
331
+ /**
332
+ * Partner accounts (RFC 0003 part 4): what the serving role may call for a partner key and the
333
+ * partner API. A partner, its keys and its contract are made by the schema owner (`slotlock partner`).
334
+ */
335
+ export const SLOTLOCK_PARTNER_FUNCTIONS = Object.freeze([
336
+ 'slotlock.partner_tenant_blocked(text)',
337
+ 'slotlock.authenticate_partner_key(bytea)',
338
+ 'slotlock.partner_contract(text)',
339
+ 'slotlock.put_partner_account(text, text, text)',
340
+ 'slotlock.list_partner_accounts(text)',
341
+ 'slotlock.get_partner_account(text, text)',
342
+ 'slotlock.set_partner_account_suspended(text, text, boolean, text)',
343
+ 'slotlock.partner_usage(text, date, date, text)',
344
+ 'slotlock.partner_account_tenant(text, text)',
345
+ 'slotlock.record_partner_reconciliation(text, date, integer, integer, numeric)',
346
+ ]);
347
+ /** The partner tables: owner-only, reached through SLOTLOCK_PARTNER_FUNCTIONS. */
348
+ export const SLOTLOCK_PARTNER_TABLES = Object.freeze([
349
+ 'partners',
350
+ 'partner_keys',
351
+ 'partner_accounts',
352
+ 'partner_invoices',
353
+ 'partner_month_closes',
354
+ 'partner_erased_usage',
355
+ 'partner_reconciliations',
356
+ ]);
270
357
  export const SLOTLOCK_DEFINER_FUNCTIONS = Object.freeze([
271
358
  ...SLOTLOCK_API_KEY_FUNCTIONS,
359
+ ...SLOTLOCK_PARTNER_FUNCTIONS,
272
360
  ...SLOTLOCK_DASHBOARD_FUNCTIONS,
273
361
  ...SLOTLOCK_SCHEMA_FUNCTIONS,
274
362
  ...SLOTLOCK_TENANT_ADMIN_FUNCTIONS,
@@ -276,6 +364,7 @@ export const SLOTLOCK_DEFINER_FUNCTIONS = Object.freeze([
276
364
  /** Tables no role but their owner reads or writes, reached only through SLOTLOCK_DEFINER_FUNCTIONS. */
277
365
  export const SLOTLOCK_DEFINER_TABLES = Object.freeze([
278
366
  ...SLOTLOCK_API_KEY_TABLES,
367
+ ...SLOTLOCK_PARTNER_TABLES,
279
368
  ...SLOTLOCK_DASHBOARD_TABLES,
280
369
  ...SLOTLOCK_SCHEMA_TABLES,
281
370
  ...SLOTLOCK_TENANT_ADMIN_TABLES,
@@ -301,15 +390,13 @@ const apiKeySelect = (alias) => ['id', 'tenant_ref', 'name', 'prefix', 'scopes',
301
390
  .concat(['last_used_at', 'revoked_at'])
302
391
  .map((column) => `${alias}.${column}`)
303
392
  .join(', ');
304
- // API keys (api-keys.ts). The tables hold a SHA-256 digest per key, never the key. Row-level security
305
- // is enabled with no policy and no role is granted anything on them, so only their owner reads or
306
- // writes them, through the functions below; the serving role may execute those and nothing else here.
393
+ // API keys (api-keys.ts): SHA-256 digests only, never the key. RLS is enabled with no policy and no
394
+ // grants, so only the owner reaches the tables, through the functions below, which the serving role
395
+ // may execute.
307
396
  //
308
- // Each function takes its tenant as an argument, as every store method does: the serving role
309
- // already names the tenant of every query it makes. Within a call it cannot reach another tenant's
310
- // keys, read a digest, or use a digest again once its key was rotated out or erased: those digests
311
- // stay in api_key_retired_digests (digests only, nothing about the tenant), and a revoked key's row
312
- // stays in api_keys until erasure, so the unique digest refuses it too.
397
+ // Each function takes its tenant as an argument. A call cannot reach another tenant's keys, read a
398
+ // digest, or reuse a digest rotated out or erased: those stay in api_key_retired_digests (digests
399
+ // only), and a revoked key's row stays until erasure.
313
400
  const SLOTLOCK_API_KEYS_DDL = `
314
401
  CREATE TABLE IF NOT EXISTS slotlock.api_keys (
315
402
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -329,7 +416,8 @@ CREATE TABLE IF NOT EXISTS slotlock.api_keys (
329
416
  CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500 AND tenant_ref !~ '[[:cntrl:]]'),
330
417
  CONSTRAINT slotlock_api_keys_name_valid
331
418
  CHECK (char_length(name) BETWEEN 1 AND 100 AND name !~ '[[:cntrl:]]'),
332
- CONSTRAINT slotlock_api_keys_prefix_valid CHECK (prefix ~ '^slk_[0-9A-Za-z]{8}$'),
419
+ -- A test environment's key (RFC 0003 G9) says so: slk_test_, and only there (the DO block below).
420
+ CONSTRAINT slotlock_api_keys_prefix_valid CHECK (prefix ~ '^slk_(test_)?[0-9A-Za-z]{8}$'),
333
421
  -- A non-empty list of read, write and admin, each once, in that order (api-keys.ts).
334
422
  CONSTRAINT slotlock_api_keys_scopes_known
335
423
  CHECK (scopes IN (
@@ -348,6 +436,23 @@ CREATE TABLE IF NOT EXISTS slotlock.api_keys (
348
436
  CREATE INDEX IF NOT EXISTS slotlock_api_keys_tenant_idx
349
437
  ON slotlock.api_keys (tenant_ref, created_at DESC, id DESC);
350
438
  ALTER TABLE slotlock.api_keys ENABLE ROW LEVEL SECURITY;
439
+ -- A tenant reference ending in ~test is the test environment of the one before it (RFC 0003 G9):
440
+ -- its keys start slk_test_, and no other tenant's do, so a key says where it acts. Added NOT VALID
441
+ -- (DEPLOYMENT_VALIDATE_CONSTRAINTS); the prefix check it widens is replaced with it.
442
+ DO $slotlock_api_keys_environment$
443
+ BEGIN
444
+ IF NOT EXISTS (
445
+ SELECT 1 FROM pg_catalog.pg_constraint
446
+ WHERE conrelid = 'slotlock.api_keys'::regclass AND conname = 'slotlock_api_keys_environment_valid'
447
+ ) THEN
448
+ ALTER TABLE slotlock.api_keys DROP CONSTRAINT IF EXISTS slotlock_api_keys_prefix_valid;
449
+ ALTER TABLE slotlock.api_keys ADD CONSTRAINT slotlock_api_keys_prefix_valid
450
+ CHECK (prefix ~ '^slk_(test_)?[0-9A-Za-z]{8}$') NOT VALID;
451
+ ALTER TABLE slotlock.api_keys ADD CONSTRAINT slotlock_api_keys_environment_valid
452
+ CHECK ((prefix ~ '^slk_test_') = (tenant_ref ~ '~test$')) NOT VALID;
453
+ END IF;
454
+ END
455
+ $slotlock_api_keys_environment$;
351
456
 
352
457
  -- Schemas from 0.1 allow only read and write. The CHECK with admin admits every row the old one
353
458
  -- did, so it is added NOT VALID (no table scan under the lock) and the old one dropped in the same
@@ -423,7 +528,9 @@ BEGIN
423
528
  WHERE active.tenant_ref = requested_tenant_ref
424
529
  AND active.revoked_at IS NULL
425
530
  AND (active.expires_at IS NULL OR active.expires_at > now())
426
- ) < ${SLOTLOCK_API_KEY_ACTIVE_LIMIT}
531
+ ) < LEAST(${SLOTLOCK_API_KEY_ACTIVE_LIMIT}, COALESCE(
532
+ (SELECT plan.max_api_keys FROM slotlock.tenant_plan(requested_tenant_ref) plan),
533
+ ${SLOTLOCK_API_KEY_ACTIVE_LIMIT}))
427
534
  AND (SELECT count(*) FROM slotlock.api_keys kept
428
535
  WHERE kept.tenant_ref = requested_tenant_ref
429
536
  ) < ${SLOTLOCK_API_KEY_RETAINED_LIMIT}
@@ -554,6 +661,190 @@ AS $slotlock_erase_api_keys$
554
661
  $slotlock_erase_api_keys$;
555
662
  REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[5]} FROM PUBLIC;
556
663
 
664
+ -- Keys limited to part of their tenant (RFC 0003 G6): the locations (each with every location in
665
+ -- it) and groups whose resources the key reaches, and no other. Set when the key is made and kept
666
+ -- through rotation; gone with the key. Owner-only, reached through the functions below; the
667
+ -- database enforces it (withTenant's slotlock.resource_scope and the resource_scope_v1 policies).
668
+ CREATE TABLE IF NOT EXISTS slotlock.api_key_resource_scopes (
669
+ key_id uuid PRIMARY KEY REFERENCES slotlock.api_keys (id) ON DELETE CASCADE,
670
+ tenant_ref text NOT NULL,
671
+ locations text[] NOT NULL DEFAULT '{}',
672
+ groups text[] NOT NULL DEFAULT '{}',
673
+ CONSTRAINT slotlock_api_key_resource_scopes_valid CHECK (
674
+ octet_length(tenant_ref) BETWEEN 1 AND 500
675
+ AND cardinality(locations) + cardinality(groups) >= 1
676
+ AND cardinality(locations) <= ${SLOTLOCK_MAX_KEY_SCOPE_REFS}
677
+ AND cardinality(groups) <= ${SLOTLOCK_MAX_KEY_SCOPE_REFS}
678
+ AND array_position(locations, NULL) IS NULL AND array_position(groups, NULL) IS NULL
679
+ )
680
+ );
681
+ ALTER TABLE slotlock.api_key_resource_scopes ENABLE ROW LEVEL SECURITY;
682
+ REVOKE ALL ON slotlock.api_key_resource_scopes FROM PUBLIC;
683
+
684
+ -- Limit one of the tenant's active keys, once: true when set, false when the tenant has no such
685
+ -- active key or it is limited already. Each reference as locations and groups take them.
686
+ CREATE OR REPLACE FUNCTION slotlock.set_api_key_resource_scope(
687
+ requested_tenant_ref text, requested_id uuid, requested_locations text[], requested_groups text[]
688
+ )
689
+ RETURNS boolean
690
+ LANGUAGE plpgsql
691
+ VOLATILE
692
+ SECURITY DEFINER
693
+ SET search_path = pg_catalog, slotlock, pg_temp
694
+ AS $slotlock_set_api_key_resource_scope$
695
+ BEGIN
696
+ IF requested_locations IS NULL OR requested_groups IS NULL
697
+ OR EXISTS (SELECT 1 FROM unnest(requested_locations || requested_groups) named(ref)
698
+ WHERE named.ref IS NULL OR named.ref !~ '^[a-z0-9][a-z0-9._-]{0,99}$') THEN
699
+ RAISE EXCEPTION 'Slotlock key scope references must be location and group references'
700
+ USING ERRCODE = 'invalid_parameter_value';
701
+ END IF;
702
+ IF NOT EXISTS (SELECT 1 FROM slotlock.api_keys limited
703
+ WHERE limited.tenant_ref = requested_tenant_ref AND limited.id = requested_id
704
+ AND limited.revoked_at IS NULL) THEN
705
+ RETURN false;
706
+ END IF;
707
+ INSERT INTO slotlock.api_key_resource_scopes (key_id, tenant_ref, locations, groups)
708
+ VALUES (
709
+ requested_id, requested_tenant_ref,
710
+ ARRAY(SELECT DISTINCT ref FROM unnest(requested_locations) ref ORDER BY ref),
711
+ ARRAY(SELECT DISTINCT ref FROM unnest(requested_groups) ref ORDER BY ref))
712
+ ON CONFLICT (key_id) DO NOTHING;
713
+ RETURN FOUND;
714
+ END
715
+ $slotlock_set_api_key_resource_scope$;
716
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[6]} FROM PUBLIC;
717
+
718
+ -- As authenticate_api_key, with the key's scope: null locations and groups for a key with none.
719
+ -- A key of a partner's account whose partner is not active (RFC 0003 P2) authenticates as nothing.
720
+ CREATE OR REPLACE FUNCTION slotlock.authenticate_scoped_api_key(presented_secret_hash bytea)
721
+ RETURNS TABLE (key_id uuid, key_tenant_ref text, key_scopes text[], scope_locations text[], scope_groups text[])
722
+ LANGUAGE plpgsql
723
+ VOLATILE
724
+ SECURITY DEFINER
725
+ SET search_path = pg_catalog, slotlock, pg_temp
726
+ AS $slotlock_authenticate_scoped_api_key$
727
+ BEGIN
728
+ RETURN QUERY
729
+ SELECT found.key_id, found.key_tenant_ref, found.key_scopes, scope.locations, scope.groups
730
+ FROM slotlock.authenticate_api_key(presented_secret_hash) found
731
+ LEFT JOIN slotlock.api_key_resource_scopes scope ON scope.key_id = found.key_id
732
+ WHERE NOT slotlock.partner_tenant_blocked(found.key_tenant_ref);
733
+ END
734
+ $slotlock_authenticate_scoped_api_key$;
735
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[7]} FROM PUBLIC;
736
+
737
+ -- The scopes of the tenant's limited keys, for its key list.
738
+ CREATE OR REPLACE FUNCTION slotlock.list_api_key_resource_scopes(requested_tenant_ref text)
739
+ RETURNS TABLE (key_id uuid, locations text[], groups text[])
740
+ LANGUAGE sql
741
+ STABLE
742
+ SECURITY DEFINER
743
+ SET search_path = pg_catalog, slotlock, pg_temp
744
+ AS $slotlock_list_api_key_resource_scopes$
745
+ SELECT scope.key_id, scope.locations, scope.groups
746
+ FROM slotlock.api_key_resource_scopes scope
747
+ WHERE scope.tenant_ref = requested_tenant_ref;
748
+ $slotlock_list_api_key_resource_scopes$;
749
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[8]} FROM PUBLIC;
750
+
751
+ -- What one agent's key may do, below the server's limits (RFC 0004 A5): its live holds and the
752
+ -- hours they hold, its requests a minute, and whether its bookings wait for a person. Null is the
753
+ -- server's. Kept through rotation (the key keeps its id); gone with the key. Owner-only, reached
754
+ -- through the functions below.
755
+ CREATE TABLE IF NOT EXISTS slotlock.api_key_limits (
756
+ key_id uuid PRIMARY KEY REFERENCES slotlock.api_keys (id) ON DELETE CASCADE,
757
+ tenant_ref text NOT NULL,
758
+ max_holds integer,
759
+ max_held_hours integer,
760
+ requests_per_minute integer,
761
+ requires_approval boolean NOT NULL DEFAULT false,
762
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
763
+ CONSTRAINT slotlock_api_key_limits_valid CHECK (
764
+ octet_length(tenant_ref) BETWEEN 1 AND 500
765
+ AND (max_holds IS NULL OR max_holds BETWEEN 1 AND 10000)
766
+ AND (max_held_hours IS NULL OR max_held_hours BETWEEN 1 AND 876000)
767
+ AND (requests_per_minute IS NULL OR requests_per_minute BETWEEN 1 AND 100000)
768
+ )
769
+ );
770
+ ALTER TABLE slotlock.api_key_limits ENABLE ROW LEVEL SECURITY;
771
+ REVOKE ALL ON slotlock.api_key_limits FROM PUBLIC;
772
+
773
+ -- Set one of the tenant's active keys' limits, replacing what it had; none at all removes them.
774
+ -- False when the tenant has no such active key.
775
+ CREATE OR REPLACE FUNCTION slotlock.set_api_key_limits(
776
+ requested_tenant_ref text, requested_id uuid, requested_max_holds integer,
777
+ requested_max_held_hours integer, requested_requests_per_minute integer, requested_approval boolean
778
+ )
779
+ RETURNS boolean
780
+ LANGUAGE plpgsql
781
+ VOLATILE
782
+ SECURITY DEFINER
783
+ SET search_path = pg_catalog, slotlock, pg_temp
784
+ AS $slotlock_set_api_key_limits$
785
+ BEGIN
786
+ IF NOT EXISTS (SELECT 1 FROM slotlock.api_keys limited
787
+ WHERE limited.tenant_ref = requested_tenant_ref AND limited.id = requested_id
788
+ AND limited.revoked_at IS NULL) THEN
789
+ RETURN false;
790
+ END IF;
791
+ IF requested_max_holds IS NULL AND requested_max_held_hours IS NULL
792
+ AND requested_requests_per_minute IS NULL AND requested_approval IS NOT TRUE THEN
793
+ DELETE FROM slotlock.api_key_limits WHERE key_id = requested_id;
794
+ RETURN true;
795
+ END IF;
796
+ INSERT INTO slotlock.api_key_limits (
797
+ key_id, tenant_ref, max_holds, max_held_hours, requests_per_minute, requires_approval
798
+ ) VALUES (
799
+ requested_id, requested_tenant_ref, requested_max_holds, requested_max_held_hours,
800
+ requested_requests_per_minute, requested_approval IS TRUE
801
+ )
802
+ ON CONFLICT (key_id) DO UPDATE
803
+ SET max_holds = EXCLUDED.max_holds, max_held_hours = EXCLUDED.max_held_hours,
804
+ requests_per_minute = EXCLUDED.requests_per_minute,
805
+ requires_approval = EXCLUDED.requires_approval, updated_at = statement_timestamp();
806
+ RETURN true;
807
+ END
808
+ $slotlock_set_api_key_limits$;
809
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[9]} FROM PUBLIC;
810
+
811
+ -- The limits of the tenant's keys that have any, for its key list.
812
+ CREATE OR REPLACE FUNCTION slotlock.list_api_key_limits(requested_tenant_ref text)
813
+ RETURNS TABLE (key_id uuid, max_holds integer, max_held_hours integer, requests_per_minute integer, requires_approval boolean)
814
+ LANGUAGE sql
815
+ STABLE
816
+ SECURITY DEFINER
817
+ SET search_path = pg_catalog, slotlock, pg_temp
818
+ AS $slotlock_list_api_key_limits$
819
+ SELECT limits.key_id, limits.max_holds, limits.max_held_hours, limits.requests_per_minute,
820
+ limits.requires_approval
821
+ FROM slotlock.api_key_limits limits
822
+ WHERE limits.tenant_ref = requested_tenant_ref;
823
+ $slotlock_list_api_key_limits$;
824
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[10]} FROM PUBLIC;
825
+
826
+ -- As authenticate_scoped_api_key, with the key's limits (null for the server's).
827
+ CREATE OR REPLACE FUNCTION slotlock.authenticate_limited_api_key(presented_secret_hash bytea)
828
+ RETURNS TABLE (
829
+ key_id uuid, key_tenant_ref text, key_scopes text[], scope_locations text[], scope_groups text[],
830
+ max_holds integer, max_held_hours integer, requests_per_minute integer, requires_approval boolean
831
+ )
832
+ LANGUAGE plpgsql
833
+ VOLATILE
834
+ SECURITY DEFINER
835
+ SET search_path = pg_catalog, slotlock, pg_temp
836
+ AS $slotlock_authenticate_limited_api_key$
837
+ BEGIN
838
+ RETURN QUERY
839
+ SELECT found.key_id, found.key_tenant_ref, found.key_scopes, found.scope_locations,
840
+ found.scope_groups, limits.max_holds, limits.max_held_hours, limits.requests_per_minute,
841
+ COALESCE(limits.requires_approval, false)
842
+ FROM slotlock.authenticate_scoped_api_key(presented_secret_hash) found
843
+ LEFT JOIN slotlock.api_key_limits limits ON limits.key_id = found.key_id;
844
+ END
845
+ $slotlock_authenticate_limited_api_key$;
846
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_API_KEY_FUNCTIONS[11]} FROM PUBLIC;
847
+
557
848
  -- A key created, given a new secret or revoked appends to its tenant's feed (key.created,
558
849
  -- key.rotated, key.revoked): its id, scopes and expiry, never its digest, prefix or name. Only the
559
850
  -- functions above write keys, as the schema's owner, which forced row-level security on the feed
@@ -603,19 +894,14 @@ CREATE OR REPLACE TRIGGER slotlock_outbox_api_key_change
603
894
  AFTER INSERT OR UPDATE OF revoked_at, secret_hash ON slotlock.api_keys
604
895
  FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_api_key_change();
605
896
  `;
606
- // The dashboard's records (dashboard.ts), shared by every server on this database: a finished sign-in
607
- // (its callback works once), a sent form (it runs once) and a signed-out session (a copied cookie
608
- // stays out). Each is a SHA-256 digest of a value only the dashboard's sealed cookies and pages
609
- // carry. Form records also carry a digest of the person who sent them, so the database can cap them;
610
- // nothing here names a tenant. Row-level security is enabled with no policy and no role is granted
611
- // anything, so the serving role reaches the table only through the two functions below.
897
+ // The dashboard's shared records (dashboard.ts): finished sign-ins (a callback works once), sent
898
+ // forms (run once) and signed-out sessions (a copied cookie stays out), each a SHA-256 digest; form
899
+ // records also carry the sender's digest so the database can cap them. RLS with no policy and no
900
+ // grants: reached only through the two functions below.
612
901
  //
613
- // The database's clock decides. Servers seal an expiry into each cookie and form by their own
614
- // clocks, which may run apart from each other and from the database's; a record judged by the
615
- // database while its token was judged by a server could lapse while some server still took the
616
- // token. So the functions refuse a token whose expiry the database's clock has passed, or that
617
- // claims to outlive a day (no dashboard token lives longer than 12 hours), and keep each record
618
- // until exactly that expiry: while the database would accept the token, its record is there.
902
+ // The database's clock decides: server clocks may drift, so the functions refuse a token the
903
+ // database's clock says has expired, or one claiming to outlive a day, and keep each record until
904
+ // exactly that expiry.
619
905
  const SLOTLOCK_DASHBOARD_DDL = `
620
906
  CREATE TABLE IF NOT EXISTS slotlock.dashboard_tokens (
621
907
  kind text NOT NULL,
@@ -735,12 +1021,84 @@ AS $slotlock_dashboard_session_ended$
735
1021
  AND ended.expires_at > now());
736
1022
  $slotlock_dashboard_session_ended$;
737
1023
  REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[1]} FROM PUBLIC;
1024
+
1025
+ -- GitHub organizations an owner turned Slotlock on for (Teams on Cloud, RFC 0003, with
1026
+ -- SLOTLOCK_DASHBOARD_ORGS=enabled): their github-org:<id> tenants are served while enabled. Turning
1027
+ -- one off stops its keys at once and keeps its data, which the organization can turn back on to
1028
+ -- reach, or erase. Owner-only; read and written through the functions below.
1029
+ CREATE TABLE IF NOT EXISTS slotlock.dashboard_organizations (
1030
+ org_id text PRIMARY KEY,
1031
+ login text NOT NULL,
1032
+ enabled boolean NOT NULL,
1033
+ changed_by text NOT NULL,
1034
+ changed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1035
+ CONSTRAINT slotlock_dashboard_organizations_valid CHECK (
1036
+ org_id ~ '^[1-9][0-9]{0,19}$'
1037
+ AND login ~ '^[A-Za-z0-9][A-Za-z0-9-]{0,38}$'
1038
+ AND changed_by ~ '^github:[1-9][0-9]{0,19}$'
1039
+ )
1040
+ );
1041
+ ALTER TABLE slotlock.dashboard_organizations ENABLE ROW LEVEL SECURITY;
1042
+ REVOKE ALL ON slotlock.dashboard_organizations FROM PUBLIC;
1043
+
1044
+ -- Of these organization ids (comma-separated), the ones turned on.
1045
+ CREATE OR REPLACE FUNCTION slotlock.enabled_organizations(requested_ids text)
1046
+ RETURNS SETOF text
1047
+ LANGUAGE sql
1048
+ STABLE
1049
+ SECURITY DEFINER
1050
+ 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;
1057
+
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
1062
+ LANGUAGE plpgsql
1063
+ VOLATILE
1064
+ SECURITY DEFINER
1065
+ SET search_path = pg_catalog, slotlock, pg_temp
1066
+ AS $slotlock_enable_organization$
1067
+ DECLARE
1068
+ was boolean;
1069
+ 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;
1079
+ END
1080
+ $slotlock_enable_organization$;
1081
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_DASHBOARD_FUNCTIONS[3]} FROM PUBLIC;
1082
+
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
1087
+ VOLATILE
1088
+ SECURITY DEFINER
1089
+ 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;
738
1097
  `;
739
- // The schema's version. One row, written by the DDL itself at its end, so a schema migrated by
740
- // running SLOTLOCK_CORE_DDL directly is versioned too. applySchema adds digests of the DDL and of the
741
- // catalog it left, so that a later migration with nothing to change can tell, and take no lock on
742
- // any table. Nothing here names a tenant; RLS is enabled with no policy and no role is granted
743
- // anything, so a server reads the version only through installed_schema_version().
1098
+ // The schema's version: one row, written by the DDL itself, so running SLOTLOCK_CORE_DDL directly
1099
+ // is versioned too. applySchema adds digests of the DDL and catalog so a later no-op migration can
1100
+ // skip without locking. RLS with no policy and no grants: read only through
1101
+ // installed_schema_version().
744
1102
  const SLOTLOCK_SCHEMA_VERSION_DDL = `
745
1103
  CREATE TABLE IF NOT EXISTS slotlock.schema_version (
746
1104
  singleton boolean PRIMARY KEY DEFAULT true,
@@ -772,16 +1130,14 @@ AS $slotlock_installed_schema_version$
772
1130
  $slotlock_installed_schema_version$;
773
1131
  REVOKE ALL ON FUNCTION ${SLOTLOCK_SCHEMA_FUNCTIONS[0]} FROM PUBLIC;
774
1132
  `;
775
- // Tenant suspension and erasure. A suspended tenant's server transactions (withTenant) may read,
776
- // and write nothing; its keys still authenticate, but it gets no new key or secret. The table names
777
- // tenants and why they were suspended, so RLS is enabled with no policy and no role is granted
778
- // anything: a server reads it only through tenant_suspended(), and only the deployment role, which
779
- // owns it, suspends or lifts a suspension. Erasure keeps the suspension, so an erased abuser stays
780
- // suspended.
1133
+ // Tenant suspension and erasure. A suspended tenant may read and write nothing; its keys still
1134
+ // authenticate but it gets no new key. The table names tenants and reasons, so RLS with no policy
1135
+ // and no grants: servers read it only through tenant_suspended(), and only the deployment role
1136
+ // (owner) suspends. Erasure keeps the suspension.
781
1137
  //
782
- // Every server transaction for a tenant holds the tenant's lock shared, from before it reads the
783
- // suspension; suspension and erasure take it exclusively. So a suspension waits for the tenant's
784
- // transactions in flight, and every one that starts after it sees it.
1138
+ // Every server transaction for a tenant holds its lock shared before reading the suspension;
1139
+ // suspension and erasure take it exclusively, so a suspension waits for transactions in flight and
1140
+ // every later one sees it.
785
1141
  const SLOTLOCK_TENANT_ADMIN_DDL = `
786
1142
  CREATE TABLE IF NOT EXISTS slotlock.tenant_suspensions (
787
1143
  tenant_ref text PRIMARY KEY,
@@ -802,13 +1158,689 @@ STABLE
802
1158
  SECURITY DEFINER
803
1159
  SET search_path = pg_catalog, slotlock, pg_temp
804
1160
  AS $slotlock_tenant_suspended$
1161
+ -- Its own suspension, its live tenant's (a test environment, RFC 0003 G9), or its partner's (a
1162
+ -- partner's account, RFC 0003 P2).
805
1163
  SELECT EXISTS (
806
1164
  SELECT 1 FROM slotlock.tenant_suspensions suspended
807
- WHERE suspended.tenant_ref = requested_tenant_ref
808
- );
1165
+ WHERE suspended.tenant_ref IN (requested_tenant_ref, regexp_replace(requested_tenant_ref, '~test$', ''))
1166
+ ) OR slotlock.partner_tenant_blocked(requested_tenant_ref);
809
1167
  $slotlock_tenant_suspended$;
810
1168
  REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[0]} FROM PUBLIC;
811
1169
 
1170
+ -- A hosted tenant's plan (plans.ts): the limits it buys, set from its Stripe subscription. A tenant
1171
+ -- with no row is on the deployment's default plan (plan_defaults) once that applies, and otherwise
1172
+ -- has no plan limits; self-hosted Slotlock writes neither.
1173
+ CREATE TABLE IF NOT EXISTS slotlock.tenant_plans (
1174
+ tenant_ref text PRIMARY KEY,
1175
+ plan text NOT NULL,
1176
+ status text NOT NULL,
1177
+ max_resources integer NOT NULL,
1178
+ max_api_keys integer NOT NULL,
1179
+ max_webhook_endpoints integer NOT NULL,
1180
+ stripe_customer text,
1181
+ stripe_subscription text,
1182
+ current_period_end timestamptz,
1183
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1184
+ CONSTRAINT slotlock_tenant_plans_tenant_ref_valid
1185
+ CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
1186
+ CONSTRAINT slotlock_tenant_plans_plan_valid CHECK (plan IN ('free', 'developer', 'team')),
1187
+ CONSTRAINT slotlock_tenant_plans_status_valid CHECK (status IN ('active', 'past_due', 'canceled')),
1188
+ CONSTRAINT slotlock_tenant_plans_limits_valid CHECK (
1189
+ max_resources BETWEEN 1 AND 100000
1190
+ AND max_api_keys BETWEEN 1 AND ${SLOTLOCK_API_KEY_ACTIVE_LIMIT}
1191
+ AND max_webhook_endpoints BETWEEN 1 AND 1000
1192
+ ),
1193
+ CONSTRAINT slotlock_tenant_plans_stripe_valid CHECK (
1194
+ (stripe_customer IS NULL OR stripe_customer ~ '^cus_[A-Za-z0-9]{1,250}$')
1195
+ AND (stripe_subscription IS NULL OR stripe_subscription ~ '^sub_[A-Za-z0-9]{1,250}$')
1196
+ )
1197
+ );
1198
+ ALTER TABLE slotlock.tenant_plans ENABLE ROW LEVEL SECURITY;
1199
+ -- A partner's account (RFC 0003 P4) is on one of its contract's plans: its own name and numbers,
1200
+ -- quotas included, set by put_partner_account. Every other row is a plan Pylota sells.
1201
+ ALTER TABLE slotlock.tenant_plans
1202
+ ADD COLUMN IF NOT EXISTS source text NOT NULL DEFAULT 'direct',
1203
+ ADD COLUMN IF NOT EXISTS partner_id text,
1204
+ ADD COLUMN IF NOT EXISTS quota_operations bigint,
1205
+ ADD COLUMN IF NOT EXISTS quota_bookings bigint,
1206
+ ADD COLUMN IF NOT EXISTS quota_stop_percent integer;
1207
+ DO $slotlock_tenant_plans_source$
1208
+ BEGIN
1209
+ -- Replaced too when it predates the Team plan (Teams on Cloud), whose name it must take.
1210
+ IF NOT EXISTS (
1211
+ SELECT 1 FROM pg_catalog.pg_constraint
1212
+ WHERE conrelid = 'slotlock.tenant_plans'::regclass AND conname = 'slotlock_tenant_plans_source_valid'
1213
+ AND pg_catalog.pg_get_constraintdef(oid) LIKE '%team%'
1214
+ ) THEN
1215
+ ALTER TABLE slotlock.tenant_plans DROP CONSTRAINT IF EXISTS slotlock_tenant_plans_source_valid;
1216
+ -- Wider than the plan check it replaces, so added NOT VALID (DEPLOYMENT_VALIDATE_CONSTRAINTS).
1217
+ ALTER TABLE slotlock.tenant_plans DROP CONSTRAINT IF EXISTS slotlock_tenant_plans_plan_valid;
1218
+ ALTER TABLE slotlock.tenant_plans ADD CONSTRAINT slotlock_tenant_plans_source_valid CHECK (
1219
+ (source = 'direct' AND partner_id IS NULL AND plan IN ('free', 'developer', 'team')
1220
+ AND quota_operations IS NULL AND quota_bookings IS NULL AND quota_stop_percent IS NULL)
1221
+ OR (source = 'partner' AND partner_id ~ '^[a-z][a-z0-9-]{1,39}$' AND plan ~ '^[a-z][a-z0-9_]{0,40}$'
1222
+ AND quota_operations BETWEEN 1 AND 1000000000000 AND quota_bookings BETWEEN 1 AND 1000000000000
1223
+ AND quota_stop_percent BETWEEN 100 AND 1000)
1224
+ ) NOT VALID;
1225
+ END IF;
1226
+ END
1227
+ $slotlock_tenant_plans_source$;
1228
+ REVOKE ALL ON slotlock.tenant_plans FROM PUBLIC;
1229
+
1230
+ -- The plan a hosted deployment puts every tenant without a row of its own on, from applies_from:
1231
+ -- one row or none. Both servers read it through tenant_plan, so a limit holds whichever one a write
1232
+ -- comes through. Only the deployment role sets it (set_plan_default, \`slotlock plan default\`).
1233
+ CREATE TABLE IF NOT EXISTS slotlock.plan_defaults (
1234
+ only_row boolean PRIMARY KEY DEFAULT true,
1235
+ plan text NOT NULL,
1236
+ max_resources integer NOT NULL,
1237
+ max_api_keys integer NOT NULL,
1238
+ max_webhook_endpoints integer NOT NULL,
1239
+ applies_from timestamptz NOT NULL,
1240
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1241
+ CONSTRAINT slotlock_plan_defaults_one_row CHECK (only_row),
1242
+ CONSTRAINT slotlock_plan_defaults_plan_valid CHECK (plan IN ('free', 'developer')),
1243
+ CONSTRAINT slotlock_plan_defaults_limits_valid CHECK (
1244
+ max_resources BETWEEN 1 AND 100000
1245
+ AND max_api_keys BETWEEN 1 AND ${SLOTLOCK_API_KEY_ACTIVE_LIMIT}
1246
+ AND max_webhook_endpoints BETWEEN 1 AND 1000
1247
+ )
1248
+ );
1249
+ ALTER TABLE slotlock.plan_defaults ENABLE ROW LEVEL SECURITY;
1250
+ REVOKE ALL ON slotlock.plan_defaults FROM PUBLIC;
1251
+
1252
+ -- The tenant's own plan, or the default once it applies; no row when neither does.
1253
+ CREATE OR REPLACE FUNCTION slotlock.tenant_plan(requested_tenant_ref text)
1254
+ RETURNS TABLE (
1255
+ plan text, status text, max_resources integer, max_api_keys integer,
1256
+ max_webhook_endpoints integer, stripe_customer text, stripe_subscription text,
1257
+ current_period_end timestamptz
1258
+ )
1259
+ LANGUAGE sql
1260
+ STABLE
1261
+ SECURITY DEFINER
1262
+ SET search_path = pg_catalog, slotlock, pg_temp
1263
+ AS $slotlock_tenant_plan$
1264
+ -- A test environment (<tenant>~test, RFC 0003 G9) is on its live tenant's plan, with no billing.
1265
+ WITH live AS (
1266
+ SELECT regexp_replace(requested_tenant_ref, '~test$', '') AS tenant_ref,
1267
+ requested_tenant_ref ~ '~test$' AS test
1268
+ )
1269
+ SELECT current.plan, current.status, current.max_resources, current.max_api_keys,
1270
+ current.max_webhook_endpoints,
1271
+ CASE WHEN live.test THEN NULL ELSE current.stripe_customer END,
1272
+ CASE WHEN live.test THEN NULL ELSE current.stripe_subscription END,
1273
+ CASE WHEN live.test THEN NULL ELSE current.current_period_end END
1274
+ FROM live JOIN slotlock.tenant_plans current ON current.tenant_ref = live.tenant_ref
1275
+ UNION ALL
1276
+ SELECT fallback.plan, 'active', fallback.max_resources, fallback.max_api_keys,
1277
+ fallback.max_webhook_endpoints, NULL, NULL, NULL
1278
+ FROM live, slotlock.plan_defaults fallback
1279
+ WHERE fallback.applies_from <= statement_timestamp()
1280
+ AND NOT EXISTS (SELECT 1 FROM slotlock.tenant_plans current
1281
+ WHERE current.tenant_ref = live.tenant_ref);
1282
+ $slotlock_tenant_plan$;
1283
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[10]} FROM PUBLIC;
1284
+
1285
+ -- The default plan and when it applies, for the dashboard and \`slotlock plan status\`.
1286
+ CREATE OR REPLACE FUNCTION slotlock.plan_default()
1287
+ RETURNS TABLE (
1288
+ plan text, max_resources integer, max_api_keys integer, max_webhook_endpoints integer,
1289
+ applies_from timestamptz
1290
+ )
1291
+ LANGUAGE sql
1292
+ STABLE
1293
+ SECURITY DEFINER
1294
+ SET search_path = pg_catalog, slotlock, pg_temp
1295
+ AS $slotlock_plan_default$
1296
+ SELECT fallback.plan, fallback.max_resources, fallback.max_api_keys,
1297
+ fallback.max_webhook_endpoints, fallback.applies_from
1298
+ FROM slotlock.plan_defaults fallback;
1299
+ $slotlock_plan_default$;
1300
+
1301
+ -- The tenant's plan and its own quotas, when it has them (a partner's account, RFC 0003 P4): null
1302
+ -- quotas are the plan's (plans.ts SLOTLOCK_PLAN_QUOTAS).
1303
+ CREATE OR REPLACE FUNCTION slotlock.tenant_plan_quotas(requested_tenant_ref text)
1304
+ RETURNS TABLE (plan text, operations bigint, bookings bigint, stop_percent integer)
1305
+ LANGUAGE sql
1306
+ STABLE
1307
+ SECURITY DEFINER
1308
+ SET search_path = pg_catalog, slotlock, pg_temp
1309
+ AS $slotlock_tenant_plan_quotas$
1310
+ SELECT given.plan, own.quota_operations, own.quota_bookings, own.quota_stop_percent
1311
+ FROM slotlock.tenant_plan(requested_tenant_ref) given
1312
+ LEFT JOIN slotlock.tenant_plans own
1313
+ ON own.tenant_ref = regexp_replace(requested_tenant_ref, '~test$', '');
1314
+ $slotlock_tenant_plan_quotas$;
1315
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[19]} FROM PUBLIC;
1316
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[12]} FROM PUBLIC;
1317
+
1318
+ -- Before the default applies to every tenant, a new one is put on it at once: one with no API key
1319
+ -- (ever), no row in the feed or anything maintenance lists, and no webhook. Existing tenants keep
1320
+ -- what they had until applies_from, as the notice given to them says. True when a plan was written.
1321
+ -- Under the key lock, so a key being issued is either counted here or sees the plan.
1322
+ CREATE OR REPLACE FUNCTION slotlock.enrol_tenant_plan(requested_tenant_ref text)
1323
+ RETURNS boolean
1324
+ LANGUAGE plpgsql
1325
+ VOLATILE
1326
+ SECURITY DEFINER
1327
+ SET search_path = pg_catalog, slotlock, pg_temp
1328
+ AS $slotlock_enrol_tenant_plan$
1329
+ BEGIN
1330
+ -- A test environment's plan is its live tenant's (RFC 0003 G9): that is the one enrolled.
1331
+ requested_tenant_ref := regexp_replace(requested_tenant_ref, '~test$', '');
1332
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
1333
+ INSERT INTO slotlock.tenant_plans (
1334
+ tenant_ref, plan, status, max_resources, max_api_keys, max_webhook_endpoints
1335
+ )
1336
+ SELECT requested_tenant_ref, fallback.plan, 'active', fallback.max_resources,
1337
+ fallback.max_api_keys, fallback.max_webhook_endpoints
1338
+ FROM slotlock.plan_defaults fallback
1339
+ WHERE fallback.applies_from > statement_timestamp()
1340
+ AND NOT EXISTS (SELECT 1 FROM slotlock.api_keys issued
1341
+ WHERE issued.tenant_ref = requested_tenant_ref)
1342
+ AND NOT EXISTS (SELECT 1 FROM slotlock.maintenance_tenants listed
1343
+ WHERE listed.tenant_ref = requested_tenant_ref)
1344
+ AND NOT EXISTS (SELECT 1 FROM slotlock.webhook_tenants hooked
1345
+ WHERE hooked.tenant_ref = requested_tenant_ref)
1346
+ ON CONFLICT (tenant_ref) DO NOTHING;
1347
+ RETURN FOUND;
1348
+ END
1349
+ $slotlock_enrol_tenant_plan$;
1350
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[13]} FROM PUBLIC;
1351
+
1352
+ -- Sets the default plan, or removes it with a null plan. Runs as its caller: only the deployment
1353
+ -- role, which owns the table, can.
1354
+ CREATE OR REPLACE FUNCTION slotlock.set_plan_default(
1355
+ requested_plan text,
1356
+ requested_max_resources integer,
1357
+ requested_max_api_keys integer,
1358
+ requested_max_webhook_endpoints integer,
1359
+ requested_applies_from timestamptz
1360
+ )
1361
+ RETURNS void
1362
+ LANGUAGE plpgsql
1363
+ VOLATILE
1364
+ SET search_path = pg_catalog, slotlock, pg_temp
1365
+ AS $slotlock_set_plan_default$
1366
+ BEGIN
1367
+ IF requested_plan IS NULL THEN
1368
+ DELETE FROM slotlock.plan_defaults;
1369
+ RETURN;
1370
+ END IF;
1371
+ INSERT INTO slotlock.plan_defaults AS current (
1372
+ only_row, plan, max_resources, max_api_keys, max_webhook_endpoints, applies_from, updated_at
1373
+ )
1374
+ VALUES (
1375
+ true, requested_plan, requested_max_resources, requested_max_api_keys,
1376
+ requested_max_webhook_endpoints, requested_applies_from, statement_timestamp()
1377
+ )
1378
+ ON CONFLICT (only_row) DO UPDATE
1379
+ SET plan = EXCLUDED.plan,
1380
+ max_resources = EXCLUDED.max_resources,
1381
+ max_api_keys = EXCLUDED.max_api_keys,
1382
+ max_webhook_endpoints = EXCLUDED.max_webhook_endpoints,
1383
+ applies_from = EXCLUDED.applies_from,
1384
+ updated_at = EXCLUDED.updated_at;
1385
+ END
1386
+ $slotlock_set_plan_default$;
1387
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_OPERATOR_FUNCTIONS[2]} FROM PUBLIC;
1388
+
1389
+ -- Sets the tenant's plan as its billing says it is now (the dashboard's Stripe webhook). Under the
1390
+ -- key lock, so a key being issued counts against the plan before or after, never between.
1391
+ CREATE OR REPLACE FUNCTION slotlock.set_tenant_plan(
1392
+ requested_tenant_ref text,
1393
+ requested_plan text,
1394
+ requested_status text,
1395
+ requested_max_resources integer,
1396
+ requested_max_api_keys integer,
1397
+ requested_max_webhook_endpoints integer,
1398
+ requested_stripe_customer text,
1399
+ requested_stripe_subscription text,
1400
+ requested_period_end timestamptz
1401
+ )
1402
+ RETURNS void
1403
+ LANGUAGE plpgsql
1404
+ VOLATILE
1405
+ SECURITY DEFINER
1406
+ SET search_path = pg_catalog, slotlock, pg_temp
1407
+ AS $slotlock_set_tenant_plan$
1408
+ BEGIN
1409
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
1410
+ INSERT INTO slotlock.tenant_plans AS current (
1411
+ tenant_ref, plan, status, max_resources, max_api_keys, max_webhook_endpoints,
1412
+ stripe_customer, stripe_subscription, current_period_end, updated_at
1413
+ )
1414
+ VALUES (
1415
+ requested_tenant_ref, requested_plan, requested_status, requested_max_resources,
1416
+ requested_max_api_keys, requested_max_webhook_endpoints, requested_stripe_customer,
1417
+ requested_stripe_subscription, requested_period_end, statement_timestamp()
1418
+ )
1419
+ ON CONFLICT (tenant_ref) DO UPDATE
1420
+ SET plan = EXCLUDED.plan,
1421
+ status = EXCLUDED.status,
1422
+ max_resources = EXCLUDED.max_resources,
1423
+ max_api_keys = EXCLUDED.max_api_keys,
1424
+ max_webhook_endpoints = EXCLUDED.max_webhook_endpoints,
1425
+ stripe_customer = COALESCE(EXCLUDED.stripe_customer, current.stripe_customer),
1426
+ stripe_subscription = EXCLUDED.stripe_subscription,
1427
+ current_period_end = EXCLUDED.current_period_end,
1428
+ updated_at = EXCLUDED.updated_at;
1429
+ END
1430
+ $slotlock_set_tenant_plan$;
1431
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[11]} FROM PUBLIC;
1432
+
1433
+ -- A tenant's use of the service (usage.ts), by UTC day: operations its keys and the assistant ran
1434
+ -- (recorded by the servers), and the bookings and holds it committed (rolled up from its feed). By
1435
+ -- API key ('' for none) and surface. Counts only, never what was asked; owner-only, read and written
1436
+ -- through the functions below, erased with the tenant, kept ${SLOTLOCK_USAGE_RETENTION_DAYS} days.
1437
+ CREATE TABLE IF NOT EXISTS slotlock.usage_daily (
1438
+ tenant_ref text NOT NULL,
1439
+ day date NOT NULL,
1440
+ metric text NOT NULL,
1441
+ key_id text NOT NULL DEFAULT '',
1442
+ origin text NOT NULL,
1443
+ count bigint NOT NULL,
1444
+ CONSTRAINT slotlock_usage_daily_pkey PRIMARY KEY (tenant_ref, day, metric, key_id, origin),
1445
+ CONSTRAINT slotlock_usage_daily_tenant_ref_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
1446
+ CONSTRAINT slotlock_usage_daily_key_id_valid
1447
+ CHECK (key_id = '' OR key_id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'),
1448
+ CONSTRAINT slotlock_usage_daily_origin_valid CHECK (origin ~ '^[a-z][a-z-]{0,39}$'),
1449
+ CONSTRAINT slotlock_usage_daily_count_valid CHECK (count > 0)
1450
+ );
1451
+ -- The metrics it takes, and no other. Its comment is the list it was built from: a release that
1452
+ -- adds a metric replaces it (NOT VALID, so nothing is scanned under the lock; validated after).
1453
+ DO $slotlock_usage_daily_metric$
1454
+ BEGIN
1455
+ ALTER TABLE slotlock.usage_daily DROP CONSTRAINT IF EXISTS slotlock_usage_daily_metric_valid;
1456
+ IF (
1457
+ SELECT pg_catalog.obj_description(oid, 'pg_constraint') FROM pg_catalog.pg_constraint
1458
+ WHERE conrelid = 'slotlock.usage_daily'::regclass AND conname = 'slotlock_usage_daily_metric_known'
1459
+ ) IS DISTINCT FROM '${SLOTLOCK_USAGE_METRICS.join(' ')}' THEN
1460
+ ALTER TABLE slotlock.usage_daily DROP CONSTRAINT IF EXISTS slotlock_usage_daily_metric_known;
1461
+ ALTER TABLE slotlock.usage_daily
1462
+ ADD CONSTRAINT slotlock_usage_daily_metric_known
1463
+ CHECK (metric IN (${SLOTLOCK_USAGE_METRICS.map((metric) => `'${metric}'`).join(', ')})) NOT VALID;
1464
+ COMMENT ON CONSTRAINT slotlock_usage_daily_metric_known ON slotlock.usage_daily
1465
+ IS '${SLOTLOCK_USAGE_METRICS.join(' ')}';
1466
+ END IF;
1467
+ END
1468
+ $slotlock_usage_daily_metric$;
1469
+ ALTER TABLE slotlock.usage_daily ENABLE ROW LEVEL SECURITY;
1470
+ REVOKE ALL ON slotlock.usage_daily FROM PUBLIC;
1471
+
1472
+ -- Each batch of operation counts a server recorded, by the id it chose for it, so a batch sent again
1473
+ -- (its answer lost) adds nothing. Kept a week.
1474
+ CREATE TABLE IF NOT EXISTS slotlock.usage_flushes (
1475
+ flush_id uuid PRIMARY KEY,
1476
+ at timestamptz NOT NULL DEFAULT statement_timestamp()
1477
+ );
1478
+ ALTER TABLE slotlock.usage_flushes ENABLE ROW LEVEL SECURITY;
1479
+ REVOKE ALL ON slotlock.usage_flushes FROM PUBLIC;
1480
+
1481
+ -- How far each tenant's feed has been rolled up into usage_daily, as a feed position (txid, id).
1482
+ CREATE TABLE IF NOT EXISTS slotlock.usage_cursors (
1483
+ tenant_ref text PRIMARY KEY,
1484
+ txid xid8 NOT NULL,
1485
+ id bigint NOT NULL,
1486
+ CONSTRAINT slotlock_usage_cursors_tenant_ref_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500)
1487
+ );
1488
+ ALTER TABLE slotlock.usage_cursors ENABLE ROW LEVEL SECURITY;
1489
+ REVOKE ALL ON slotlock.usage_cursors FROM PUBLIC;
1490
+
1491
+ -- Where metering began: a tenant with no cursor yet starts its roll-up here, so the feed written
1492
+ -- before this release is never counted. Recorded once, as the oldest transaction still running when
1493
+ -- the table is created: a change at or after it is counted, one before it never.
1494
+ CREATE TABLE IF NOT EXISTS slotlock.usage_meter_start (
1495
+ only_row boolean PRIMARY KEY DEFAULT true,
1496
+ txid xid8 NOT NULL,
1497
+ CONSTRAINT slotlock_usage_meter_start_one_row CHECK (only_row)
1498
+ );
1499
+ -- The one row, written the first time and never moved by a later migration.
1500
+ INSERT INTO slotlock.usage_meter_start (txid)
1501
+ VALUES (pg_catalog.pg_snapshot_xmin(pg_catalog.pg_current_snapshot()))
1502
+ ON CONFLICT (only_row) DO NOTHING;
1503
+ ALTER TABLE slotlock.usage_meter_start ENABLE ROW LEVEL SECURITY;
1504
+ REVOKE ALL ON slotlock.usage_meter_start FROM PUBLIC;
1505
+
1506
+ -- The quota notices a tenant was sent (usage.threshold_reached, quotas.ts): one per UTC month,
1507
+ -- metric and share of the plan's quota, so each goes once however many servers see the count pass
1508
+ -- it. Owner-only, erased with the tenant; a month's rows go when the next month's first is noted.
1509
+ CREATE TABLE IF NOT EXISTS slotlock.usage_notices (
1510
+ tenant_ref text NOT NULL,
1511
+ month date NOT NULL,
1512
+ metric text NOT NULL,
1513
+ percent integer NOT NULL,
1514
+ noticed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1515
+ CONSTRAINT slotlock_usage_notices_pkey PRIMARY KEY (tenant_ref, month, metric, percent),
1516
+ CONSTRAINT slotlock_usage_notices_tenant_ref_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
1517
+ CONSTRAINT slotlock_usage_notices_month_valid CHECK (extract(day FROM month) = 1),
1518
+ CONSTRAINT slotlock_usage_notices_metric_valid CHECK (metric IN (${SLOTLOCK_QUOTA_METRICS.map((metric) => `'${metric}'`).join(', ')})),
1519
+ CONSTRAINT slotlock_usage_notices_percent_valid CHECK (percent IN (${SLOTLOCK_QUOTA_NOTICE_PERCENTS.join(', ')}))
1520
+ );
1521
+ ALTER TABLE slotlock.usage_notices ENABLE ROW LEVEL SECURITY;
1522
+ REVOKE ALL ON slotlock.usage_notices FROM PUBLIC;
1523
+
1524
+ -- Metered overage (off unless SLOTLOCK_OVERAGE=on, overage.ts): how many operations above its
1525
+ -- quota each tenant's month has been reported to Stripe as, so each is reported once however often
1526
+ -- the report runs. Owner-only: \`slotlock overage report\` runs as the schema owner.
1527
+ CREATE TABLE IF NOT EXISTS slotlock.overage_reports (
1528
+ tenant_ref text NOT NULL,
1529
+ month date NOT NULL,
1530
+ reported bigint NOT NULL,
1531
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1532
+ CONSTRAINT slotlock_overage_reports_pkey PRIMARY KEY (tenant_ref, month),
1533
+ CONSTRAINT slotlock_overage_reports_valid CHECK (
1534
+ octet_length(tenant_ref) BETWEEN 1 AND 500 AND extract(day FROM month) = 1 AND reported >= 0
1535
+ )
1536
+ );
1537
+ ALTER TABLE slotlock.overage_reports ENABLE ROW LEVEL SECURITY;
1538
+ REVOKE ALL ON slotlock.overage_reports FROM PUBLIC;
1539
+
1540
+ -- Adds a server's batch of counts, once per flush id: true when added, false when that batch was
1541
+ -- added before. Each row names a tenant, a day within a day of today, a metric a server counts
1542
+ -- (operations, webhook deliveries, assistant messages), a key ('' for none), a surface and a count;
1543
+ -- rows for the same day, metric, key and surface are summed first.
1544
+ CREATE OR REPLACE FUNCTION slotlock.record_usage(requested_flush_id uuid, requested_rows jsonb)
1545
+ RETURNS boolean
1546
+ LANGUAGE plpgsql
1547
+ VOLATILE
1548
+ SECURITY DEFINER
1549
+ SET search_path = pg_catalog, slotlock, pg_temp
1550
+ AS $slotlock_record_usage$
1551
+ BEGIN
1552
+ IF requested_flush_id IS NULL OR requested_rows IS NULL OR jsonb_typeof(requested_rows) <> 'array'
1553
+ OR jsonb_array_length(requested_rows) NOT BETWEEN 1 AND 10000 THEN
1554
+ RAISE EXCEPTION 'Slotlock usage batch must be 1-10,000 rows with a flush id'
1555
+ USING ERRCODE = 'invalid_parameter_value';
1556
+ END IF;
1557
+ IF EXISTS (
1558
+ SELECT 1 FROM jsonb_array_elements(requested_rows) given
1559
+ WHERE jsonb_typeof(given) <> 'object'
1560
+ OR COALESCE(given ->> 'metric', '') NOT IN (${SLOTLOCK_RECORDED_USAGE_METRICS.map((metric) => `'${metric}'`).join(', ')})
1561
+ OR (given ->> 'day') !~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
1562
+ OR (given ->> 'day')::date NOT BETWEEN current_date - 1 AND current_date + 1
1563
+ OR jsonb_typeof(given -> 'count') <> 'number'
1564
+ OR (given ->> 'count')::numeric NOT BETWEEN 1 AND 1000000000
1565
+ ) THEN
1566
+ RAISE EXCEPTION 'Slotlock usage rows must be counts a server records, 1-1,000,000,000, within a day of today'
1567
+ USING ERRCODE = 'invalid_parameter_value';
1568
+ END IF;
1569
+ INSERT INTO slotlock.usage_flushes (flush_id) VALUES (requested_flush_id) ON CONFLICT DO NOTHING;
1570
+ IF NOT FOUND THEN
1571
+ RETURN false;
1572
+ END IF;
1573
+ -- Each tenant's shared lock, as withTenant takes it: an erasure in flight commits first, and the
1574
+ -- check below then sees it.
1575
+ PERFORM pg_advisory_xact_lock_shared(hashtextextended('slotlock:tenant:' || named.tenant_ref, 0))
1576
+ FROM (SELECT DISTINCT given ->> 'tenant_ref' AS tenant_ref
1577
+ FROM jsonb_array_elements(requested_rows) given
1578
+ ORDER BY 1) named;
1579
+ -- Only for a tenant that still exists, one with an API key or a feed: counts a server held while
1580
+ -- the tenant was erased are dropped rather than recreate rows for it.
1581
+ INSERT INTO slotlock.usage_daily AS kept (tenant_ref, day, metric, key_id, origin, count)
1582
+ SELECT given ->> 'tenant_ref', (given ->> 'day')::date, given ->> 'metric',
1583
+ COALESCE(given ->> 'key_id', ''), given ->> 'origin', sum((given ->> 'count')::bigint)
1584
+ FROM jsonb_array_elements(requested_rows) given
1585
+ WHERE EXISTS (SELECT 1 FROM slotlock.api_keys issued WHERE issued.tenant_ref = given ->> 'tenant_ref')
1586
+ OR EXISTS (SELECT 1 FROM slotlock.maintenance_tenants listed
1587
+ WHERE listed.tenant_ref = given ->> 'tenant_ref')
1588
+ GROUP BY 1, 2, 3, 4, 5
1589
+ ON CONFLICT (tenant_ref, day, metric, key_id, origin) DO UPDATE SET count = kept.count + EXCLUDED.count;
1590
+ -- Now and then, batches older than a week (never sent again by then) are forgotten.
1591
+ IF random() < 0.01 THEN
1592
+ DELETE FROM slotlock.usage_flushes WHERE at < statement_timestamp() - interval '7 days';
1593
+ END IF;
1594
+ RETURN true;
1595
+ END
1596
+ $slotlock_record_usage$;
1597
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[14]} FROM PUBLIC;
1598
+
1599
+ -- Rolls up to max_rows of the tenant's feed past its cursor into usage_daily: each committed
1600
+ -- reservation.created, event.created (a whole series is one) and hold.confirmed is a booking, each
1601
+ -- hold.created a hold, and maintenance's own changes count for nothing. The feed is read as its readers read it, in (txid, id) order and
1602
+ -- only from transactions below this snapshot's xmin, every one of which has ended, so no change is
1603
+ -- counted twice or skipped, and one that rolled back is never counted. The counts and the cursor
1604
+ -- move in one transaction. Also deletes the tenant's usage older than the retention. Returns how
1605
+ -- many changes it read: fewer than max_rows means it has caught up.
1606
+ CREATE OR REPLACE FUNCTION slotlock.roll_up_usage(requested_tenant_ref text, max_rows integer)
1607
+ RETURNS integer
1608
+ LANGUAGE plpgsql
1609
+ VOLATILE
1610
+ SECURITY DEFINER
1611
+ SET search_path = pg_catalog, slotlock, pg_temp
1612
+ AS $slotlock_roll_up_usage$
1613
+ DECLARE
1614
+ setting text;
1615
+ previous jsonb := '{}'::jsonb;
1616
+ from_txid xid8;
1617
+ from_id bigint;
1618
+ horizon xid8 := pg_snapshot_xmin(pg_current_snapshot());
1619
+ last_txid xid8;
1620
+ last_id bigint;
1621
+ scanned integer;
1622
+ BEGIN
1623
+ IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500
1624
+ OR max_rows IS NULL OR max_rows NOT BETWEEN 1 AND 100000 THEN
1625
+ RAISE EXCEPTION 'Slotlock usage roll-up arguments are invalid' USING ERRCODE = 'invalid_parameter_value';
1626
+ END IF;
1627
+ -- As every tenant transaction does (withTenant): an erasure waits for it, and it for an erasure,
1628
+ -- so it never counts into a tenant just erased. Then one roll-up of a tenant at a time.
1629
+ PERFORM pg_advisory_xact_lock_shared(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
1630
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:usage:' || requested_tenant_ref, 0));
1631
+ SELECT cursor.txid, cursor.id INTO from_txid, from_id
1632
+ FROM slotlock.usage_cursors cursor WHERE cursor.tenant_ref = requested_tenant_ref;
1633
+ IF NOT FOUND THEN
1634
+ SELECT start.txid, 0 INTO from_txid, from_id FROM slotlock.usage_meter_start start;
1635
+ END IF;
1636
+ -- The feed has forced row-level security, its owner bound too: read it in the tenant's context.
1637
+ FOR setting IN
1638
+ SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
1639
+ LOOP
1640
+ previous := previous || jsonb_build_object(setting, current_setting(setting, true));
1641
+ PERFORM set_config(setting, requested_tenant_ref, true);
1642
+ END LOOP;
1643
+ WITH batch AS MATERIALIZED (
1644
+ SELECT change.txid, change.id, change.type, change.origin, change.key_id, change.occurred_at
1645
+ FROM slotlock.outbox change
1646
+ WHERE change.tenant_ref = requested_tenant_ref
1647
+ AND (change.txid, change.id) > (from_txid, from_id)
1648
+ AND change.txid < horizon
1649
+ ORDER BY change.txid, change.id
1650
+ LIMIT max_rows
1651
+ ), counted AS (
1652
+ INSERT INTO slotlock.usage_daily AS kept (tenant_ref, day, metric, key_id, origin, count)
1653
+ SELECT requested_tenant_ref, (batch.occurred_at AT TIME ZONE 'UTC')::date,
1654
+ CASE batch.type WHEN 'hold.created' THEN 'holds' ELSE 'bookings' END,
1655
+ COALESCE(batch.key_id::text, ''), COALESCE(batch.origin, 'library'), count(*)
1656
+ FROM batch
1657
+ WHERE batch.type IN ('reservation.created', 'event.created', 'hold.confirmed', 'hold.created')
1658
+ AND COALESCE(batch.origin, 'library') <> 'maintenance'
1659
+ GROUP BY 1, 2, 3, 4, 5
1660
+ ON CONFLICT (tenant_ref, day, metric, key_id, origin) DO UPDATE SET count = kept.count + EXCLUDED.count
1661
+ )
1662
+ SELECT (SELECT batch.txid FROM batch ORDER BY batch.txid DESC, batch.id DESC LIMIT 1),
1663
+ (SELECT batch.id FROM batch ORDER BY batch.txid DESC, batch.id DESC LIMIT 1),
1664
+ (SELECT count(*) FROM batch)::integer
1665
+ INTO last_txid, last_id, scanned;
1666
+ -- How many resources the tenant holds today (archived ones included, as a plan counts them): the
1667
+ -- day's last pass leaves the count at the end of the day. A count, so it replaces, never adds.
1668
+ INSERT INTO slotlock.usage_daily AS kept (tenant_ref, day, metric, key_id, origin, count)
1669
+ SELECT requested_tenant_ref, (statement_timestamp() AT TIME ZONE 'UTC')::date, 'resources', '',
1670
+ 'maintenance', count(*)
1671
+ FROM slotlock.resources held
1672
+ WHERE held.tenant_ref = requested_tenant_ref
1673
+ HAVING count(*) > 0
1674
+ ON CONFLICT (tenant_ref, day, metric, key_id, origin) DO UPDATE SET count = EXCLUDED.count;
1675
+ IF scanned > 0 THEN
1676
+ INSERT INTO slotlock.usage_cursors AS kept (tenant_ref, txid, id)
1677
+ VALUES (requested_tenant_ref, last_txid, last_id)
1678
+ ON CONFLICT (tenant_ref) DO UPDATE SET txid = EXCLUDED.txid, id = EXCLUDED.id;
1679
+ END IF;
1680
+ DELETE FROM slotlock.usage_daily
1681
+ WHERE tenant_ref = requested_tenant_ref
1682
+ AND day < current_date - ${SLOTLOCK_USAGE_RETENTION_DAYS};
1683
+ FOR setting IN SELECT jsonb_object_keys(previous) LOOP
1684
+ PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
1685
+ END LOOP;
1686
+ RETURN scanned;
1687
+ END
1688
+ $slotlock_roll_up_usage$;
1689
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[15]} FROM PUBLIC;
1690
+
1691
+ -- The tenant's usage from from_day to to_day (inclusive, at most 400 days), by day, metric, key and
1692
+ -- surface.
1693
+ CREATE OR REPLACE FUNCTION slotlock.tenant_usage(
1694
+ requested_tenant_ref text, from_day date, to_day date
1695
+ )
1696
+ RETURNS TABLE (day date, metric text, key_id text, origin text, count bigint)
1697
+ LANGUAGE sql
1698
+ STABLE
1699
+ SECURITY DEFINER
1700
+ SET search_path = pg_catalog, slotlock, pg_temp
1701
+ AS $slotlock_tenant_usage$
1702
+ SELECT used.day, used.metric, used.key_id, used.origin, used.count
1703
+ FROM slotlock.usage_daily used
1704
+ WHERE used.tenant_ref = requested_tenant_ref
1705
+ AND used.day BETWEEN from_day AND to_day
1706
+ AND to_day - from_day BETWEEN 0 AND ${SLOTLOCK_USAGE_RETENTION_DAYS}
1707
+ ORDER BY used.day, used.metric, used.key_id, used.origin;
1708
+ $slotlock_tenant_usage$;
1709
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[16]} FROM PUBLIC;
1710
+
1711
+ -- The tenant's operations and bookings from from_day (its quota month's first day, UTC) until now,
1712
+ -- for its plan's quotas (quotas.ts): what usage_daily holds, plus the bookings in its feed past the
1713
+ -- roll-up cursor, counted as roll_up_usage will count them (at most ${SLOTLOCK_QUOTA_UNROLLED_LIMIT.toLocaleString('en-US')} of those, more
1714
+ -- than any quota). One statement, so a roll-up that commits meanwhile is counted once, never twice
1715
+ -- or not at all.
1716
+ CREATE OR REPLACE FUNCTION slotlock.tenant_quota_usage(requested_tenant_ref text, from_day date)
1717
+ RETURNS TABLE (operations bigint, bookings bigint)
1718
+ LANGUAGE plpgsql
1719
+ VOLATILE
1720
+ SECURITY DEFINER
1721
+ SET search_path = pg_catalog, slotlock, pg_temp
1722
+ AS $slotlock_tenant_quota_usage$
1723
+ DECLARE
1724
+ setting text;
1725
+ previous jsonb := '{}'::jsonb;
1726
+ today date := (statement_timestamp() AT TIME ZONE 'UTC')::date;
1727
+ BEGIN
1728
+ IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500
1729
+ OR from_day IS NULL OR from_day NOT BETWEEN today - 31 AND today + 1 THEN
1730
+ RAISE EXCEPTION 'Slotlock quota usage arguments are invalid' USING ERRCODE = 'invalid_parameter_value';
1731
+ END IF;
1732
+ -- The feed has forced row-level security, its owner bound too: read it in the tenant's context.
1733
+ FOR setting IN
1734
+ SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
1735
+ LOOP
1736
+ previous := previous || jsonb_build_object(setting, current_setting(setting, true));
1737
+ PERFORM set_config(setting, requested_tenant_ref, true);
1738
+ END LOOP;
1739
+ RETURN QUERY
1740
+ WITH rolled_to AS (
1741
+ SELECT COALESCE(
1742
+ (SELECT kept.txid FROM slotlock.usage_cursors kept WHERE kept.tenant_ref = requested_tenant_ref),
1743
+ (SELECT start.txid FROM slotlock.usage_meter_start start)) AS txid,
1744
+ COALESCE(
1745
+ (SELECT kept.id FROM slotlock.usage_cursors kept WHERE kept.tenant_ref = requested_tenant_ref),
1746
+ 0) AS id
1747
+ ), unrolled AS (
1748
+ SELECT 1
1749
+ FROM slotlock.outbox change, rolled_to
1750
+ WHERE change.tenant_ref = requested_tenant_ref
1751
+ AND (change.txid, change.id) > (rolled_to.txid, rolled_to.id)
1752
+ AND change.type IN ('reservation.created', 'event.created', 'hold.confirmed')
1753
+ AND COALESCE(change.origin, 'library') <> 'maintenance'
1754
+ AND change.occurred_at >= from_day::timestamp AT TIME ZONE 'UTC'
1755
+ LIMIT ${SLOTLOCK_QUOTA_UNROLLED_LIMIT}
1756
+ ), rolled AS (
1757
+ SELECT COALESCE(sum(used.count) FILTER (WHERE used.metric = 'operations'), 0) AS operations,
1758
+ COALESCE(sum(used.count) FILTER (WHERE used.metric = 'bookings'), 0) AS bookings
1759
+ FROM slotlock.usage_daily used
1760
+ WHERE used.tenant_ref = requested_tenant_ref
1761
+ AND used.day >= from_day
1762
+ AND used.metric IN ('operations', 'bookings')
1763
+ )
1764
+ SELECT rolled.operations::bigint, (rolled.bookings + (SELECT count(*) FROM unrolled))::bigint
1765
+ FROM rolled;
1766
+ FOR setting IN SELECT jsonb_object_keys(previous) LOOP
1767
+ PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
1768
+ END LOOP;
1769
+ END
1770
+ $slotlock_tenant_quota_usage$;
1771
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[17]} FROM PUBLIC;
1772
+
1773
+ -- Notes that the tenant's use of a quota reached a share of it in a month, once: true the first
1774
+ -- time, when it also appends usage.threshold_reached to the tenant's feed (the metric, the share,
1775
+ -- the count, the quota and when the month ends), which its webhooks deliver as any change. Only for
1776
+ -- a tenant that still exists (one with an API key or a feed, as record_usage), under its shared lock.
1777
+ CREATE OR REPLACE FUNCTION slotlock.note_usage_threshold(
1778
+ requested_tenant_ref text,
1779
+ requested_month date,
1780
+ requested_metric text,
1781
+ requested_percent integer,
1782
+ requested_used bigint,
1783
+ requested_quota bigint
1784
+ )
1785
+ RETURNS boolean
1786
+ LANGUAGE plpgsql
1787
+ VOLATILE
1788
+ SECURITY DEFINER
1789
+ SET search_path = pg_catalog, slotlock, pg_temp
1790
+ AS $slotlock_note_usage_threshold$
1791
+ DECLARE
1792
+ setting text;
1793
+ previous jsonb := '{}'::jsonb;
1794
+ today date := (statement_timestamp() AT TIME ZONE 'UTC')::date;
1795
+ BEGIN
1796
+ IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500
1797
+ OR requested_month IS NULL OR extract(day FROM requested_month) <> 1
1798
+ OR requested_month NOT BETWEEN today - 31 AND today + 1
1799
+ OR requested_metric IS NULL
1800
+ OR requested_metric NOT IN (${SLOTLOCK_QUOTA_METRICS.map((metric) => `'${metric}'`).join(', ')})
1801
+ OR requested_percent IS NULL OR requested_percent NOT IN (${SLOTLOCK_QUOTA_NOTICE_PERCENTS.join(', ')})
1802
+ OR requested_used IS NULL OR requested_used < 0
1803
+ OR requested_quota IS NULL OR requested_quota < 1 THEN
1804
+ RAISE EXCEPTION 'Slotlock usage notice arguments are invalid' USING ERRCODE = 'invalid_parameter_value';
1805
+ END IF;
1806
+ -- As withTenant: an erasure in flight commits first, and the check below then sees it.
1807
+ PERFORM pg_advisory_xact_lock_shared(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
1808
+ IF NOT EXISTS (SELECT 1 FROM slotlock.api_keys issued WHERE issued.tenant_ref = requested_tenant_ref)
1809
+ AND NOT EXISTS (SELECT 1 FROM slotlock.maintenance_tenants listed
1810
+ WHERE listed.tenant_ref = requested_tenant_ref) THEN
1811
+ RETURN false;
1812
+ END IF;
1813
+ INSERT INTO slotlock.usage_notices (tenant_ref, month, metric, percent)
1814
+ VALUES (requested_tenant_ref, requested_month, requested_metric, requested_percent)
1815
+ ON CONFLICT DO NOTHING;
1816
+ IF NOT FOUND THEN
1817
+ RETURN false;
1818
+ END IF;
1819
+ DELETE FROM slotlock.usage_notices
1820
+ WHERE tenant_ref = requested_tenant_ref AND month < requested_month;
1821
+ FOR setting IN
1822
+ SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
1823
+ LOOP
1824
+ previous := previous || jsonb_build_object(setting, current_setting(setting, true));
1825
+ PERFORM set_config(setting, requested_tenant_ref, true);
1826
+ END LOOP;
1827
+ INSERT INTO slotlock.outbox (tenant_ref, type, subject, data)
1828
+ VALUES (
1829
+ requested_tenant_ref, 'usage.threshold_reached',
1830
+ 'usage_' || requested_metric || '_' || to_char(requested_month, 'YYYY_MM') || '_' || requested_percent,
1831
+ jsonb_build_object(
1832
+ 'metric', requested_metric, 'percent', requested_percent, 'used', requested_used,
1833
+ 'quota', requested_quota, 'period_start', to_char(requested_month, 'YYYY-MM-DD'),
1834
+ 'resets_at', to_char((requested_month + interval '1 month'), 'YYYY-MM-DD"T"00:00:00"Z"'))
1835
+ );
1836
+ FOR setting IN SELECT jsonb_object_keys(previous) LOOP
1837
+ PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
1838
+ END LOOP;
1839
+ RETURN true;
1840
+ END
1841
+ $slotlock_note_usage_threshold$;
1842
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[18]} FROM PUBLIC;
1843
+
812
1844
  -- True when the tenant was not suspended already; a second suspension keeps the first's reason.
813
1845
  CREATE OR REPLACE FUNCTION slotlock.suspend_tenant(requested_tenant_ref text, requested_reason text)
814
1846
  RETURNS boolean
@@ -860,6 +1892,7 @@ AS $slotlock_erase_tenant$
860
1892
  DECLARE
861
1893
  setting text;
862
1894
  previous jsonb := '{}'::jsonb;
1895
+ tested record;
863
1896
  BEGIN
864
1897
  IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500 THEN
865
1898
  RAISE EXCEPTION 'Slotlock tenant reference must be 1-500 UTF-8 bytes'
@@ -868,6 +1901,15 @@ BEGIN
868
1901
  PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
869
1902
  -- A key being issued commits first, so its row is deleted below with the rest.
870
1903
  PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || requested_tenant_ref, 0));
1904
+ -- A paid plan is cancelled first (plans.ts): erasing the tenant would leave its subscription
1905
+ -- renewing for an account that no longer exists. Cancelling it in Stripe moves the plan to Free
1906
+ -- (the dashboard's webhook), and then erasure runs. Under the key lock, which set_tenant_plan takes.
1907
+ IF EXISTS (SELECT 1 FROM slotlock.tenant_plans billed
1908
+ WHERE billed.tenant_ref = requested_tenant_ref
1909
+ AND billed.stripe_subscription IS NOT NULL) THEN
1910
+ RAISE EXCEPTION 'Slotlock tenant has a live subscription: cancel it before erasing the tenant'
1911
+ USING ERRCODE = 'object_in_use', HINT = 'billing_active';
1912
+ END IF;
871
1913
  FOR setting IN
872
1914
  SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
873
1915
  LOOP
@@ -894,6 +1936,18 @@ BEGIN
894
1936
  DELETE FROM slotlock.calendar_event_occurrences WHERE tenant_ref = requested_tenant_ref;
895
1937
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
896
1938
  RETURN NEXT;
1939
+ erased_table := 'composite_claims';
1940
+ DELETE FROM slotlock.composite_claims WHERE tenant_ref = requested_tenant_ref;
1941
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
1942
+ RETURN NEXT;
1943
+ erased_table := 'approvals';
1944
+ DELETE FROM slotlock.approvals WHERE tenant_ref = requested_tenant_ref;
1945
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
1946
+ RETURN NEXT;
1947
+ erased_table := 'approval_policies';
1948
+ DELETE FROM slotlock.approval_policies WHERE tenant_ref = requested_tenant_ref;
1949
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
1950
+ RETURN NEXT;
897
1951
  erased_table := 'reservations';
898
1952
  DELETE FROM slotlock.reservations
899
1953
  WHERE tenant_ref = requested_tenant_ref
@@ -937,6 +1991,42 @@ BEGIN
937
1991
  DELETE FROM slotlock.booking_rule_defaults WHERE tenant_ref = requested_tenant_ref;
938
1992
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
939
1993
  RETURN NEXT;
1994
+ erased_table := 'pool_settings';
1995
+ DELETE FROM slotlock.pool_settings WHERE tenant_ref = requested_tenant_ref;
1996
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
1997
+ RETURN NEXT;
1998
+ erased_table := 'tenant_plans';
1999
+ DELETE FROM slotlock.tenant_plans WHERE tenant_ref = requested_tenant_ref;
2000
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2001
+ RETURN NEXT;
2002
+ -- A partner's account: what it used in months its partner has not closed is kept for billing
2003
+ -- first (by the partner's account reference only), then its partner's record of it goes too.
2004
+ INSERT INTO slotlock.partner_erased_usage AS kept (partner_id, account, plan, month, metric, count)
2005
+ SELECT owned.partner_id, owned.account, owned.plan, monthly.month, monthly.metric, monthly.count
2006
+ FROM slotlock.partner_accounts owned
2007
+ JOIN (
2008
+ SELECT daily.month, daily.metric,
2009
+ CASE WHEN daily.metric = 'resources' THEN max(daily.count) ELSE sum(daily.count) END::bigint AS count
2010
+ FROM (
2011
+ SELECT date_trunc('month', used.day)::date AS month, used.day, used.metric, sum(used.count) AS count
2012
+ FROM slotlock.usage_daily used
2013
+ WHERE used.tenant_ref = requested_tenant_ref
2014
+ AND used.day >= (date_trunc('month', statement_timestamp()) - interval '1 month')::date
2015
+ GROUP BY 1, used.day, used.metric
2016
+ ) daily
2017
+ GROUP BY daily.month, daily.metric
2018
+ ) monthly ON true
2019
+ WHERE owned.tenant_ref = requested_tenant_ref
2020
+ AND NOT EXISTS (SELECT 1 FROM slotlock.partner_month_closes closed
2021
+ WHERE closed.partner_id = owned.partner_id AND closed.month = monthly.month)
2022
+ ON CONFLICT (partner_id, account, month, metric) DO UPDATE
2023
+ SET count = CASE WHEN kept.metric = 'resources' THEN GREATEST(kept.count, EXCLUDED.count)
2024
+ ELSE kept.count + EXCLUDED.count END,
2025
+ plan = EXCLUDED.plan, erased_at = statement_timestamp();
2026
+ erased_table := 'partner_accounts';
2027
+ DELETE FROM slotlock.partner_accounts WHERE tenant_ref = requested_tenant_ref;
2028
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2029
+ RETURN NEXT;
940
2030
  erased_table := 'assistant_conversations';
941
2031
  DELETE FROM slotlock.assistant_conversations WHERE tenant_ref = requested_tenant_ref;
942
2032
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
@@ -945,10 +2035,24 @@ BEGIN
945
2035
  DELETE FROM slotlock.assistant_settings WHERE tenant_ref = requested_tenant_ref;
946
2036
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
947
2037
  RETURN NEXT;
2038
+ -- A resource's group memberships, then the resource; its location after it, as resources and
2039
+ -- locations under it name it.
2040
+ erased_table := 'resource_group_members';
2041
+ DELETE FROM slotlock.resource_group_members WHERE tenant_ref = requested_tenant_ref;
2042
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2043
+ RETURN NEXT;
948
2044
  erased_table := 'resources';
949
2045
  DELETE FROM slotlock.resources WHERE tenant_ref = requested_tenant_ref;
950
2046
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
951
2047
  RETURN NEXT;
2048
+ erased_table := 'resource_groups';
2049
+ DELETE FROM slotlock.resource_groups WHERE tenant_ref = requested_tenant_ref;
2050
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2051
+ RETURN NEXT;
2052
+ erased_table := 'locations';
2053
+ DELETE FROM slotlock.locations WHERE tenant_ref = requested_tenant_ref;
2054
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2055
+ RETURN NEXT;
952
2056
  -- After every booking table: what their deletion just appended goes with the rest.
953
2057
  erased_table := 'outbox';
954
2058
  DELETE FROM slotlock.outbox WHERE tenant_ref = requested_tenant_ref;
@@ -970,6 +2074,35 @@ BEGIN
970
2074
  DELETE FROM slotlock.webhook_tenants WHERE tenant_ref = requested_tenant_ref;
971
2075
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
972
2076
  RETURN NEXT;
2077
+ erased_table := 'maintenance_tenants';
2078
+ DELETE FROM slotlock.maintenance_tenants WHERE tenant_ref = requested_tenant_ref;
2079
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2080
+ RETURN NEXT;
2081
+ erased_table := 'usage_daily';
2082
+ DELETE FROM slotlock.usage_daily WHERE tenant_ref = requested_tenant_ref;
2083
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2084
+ RETURN NEXT;
2085
+ erased_table := 'usage_cursors';
2086
+ DELETE FROM slotlock.usage_cursors WHERE tenant_ref = requested_tenant_ref;
2087
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2088
+ RETURN NEXT;
2089
+ erased_table := 'usage_notices';
2090
+ DELETE FROM slotlock.usage_notices WHERE tenant_ref = requested_tenant_ref;
2091
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2092
+ RETURN NEXT;
2093
+ erased_table := 'overage_reports';
2094
+ DELETE FROM slotlock.overage_reports WHERE tenant_ref = requested_tenant_ref;
2095
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2096
+ RETURN NEXT;
2097
+ -- Before the keys, so their count is reported (the key's deletion would take them too).
2098
+ erased_table := 'api_key_limits';
2099
+ DELETE FROM slotlock.api_key_limits WHERE tenant_ref = requested_tenant_ref;
2100
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2101
+ RETURN NEXT;
2102
+ erased_table := 'api_key_resource_scopes';
2103
+ DELETE FROM slotlock.api_key_resource_scopes WHERE tenant_ref = requested_tenant_ref;
2104
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2105
+ RETURN NEXT;
973
2106
  erased_table := 'api_keys';
974
2107
  WITH erased AS (
975
2108
  DELETE FROM slotlock.api_keys WHERE tenant_ref = requested_tenant_ref RETURNING secret_hash
@@ -980,6 +2113,13 @@ BEGIN
980
2113
  )
981
2114
  SELECT count(*) INTO erased_rows FROM erased;
982
2115
  RETURN NEXT;
2116
+ -- An organization's record of being turned on goes with its tenant (Teams on Cloud).
2117
+ erased_table := 'dashboard_organizations';
2118
+ DELETE FROM slotlock.dashboard_organizations org
2119
+ WHERE requested_tenant_ref ~ '^github-org:[1-9][0-9]{0,19}$'
2120
+ AND org.org_id = substr(requested_tenant_ref, 12);
2121
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
2122
+ RETURN NEXT;
983
2123
  erased_table := 'dashboard_tokens';
984
2124
  erased_rows := 0;
985
2125
  IF requested_tenant_ref LIKE 'github:%' THEN
@@ -988,9 +2128,29 @@ BEGIN
988
2128
  AND owner_hash = sha256(convert_to(
989
2129
  'slotlock-dashboard-owner.' || substr(requested_tenant_ref, 8), 'UTF8'));
990
2130
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
2131
+ ELSIF requested_tenant_ref LIKE 'google:%' THEN
2132
+ -- A Google person's form records are under their whole reference (dashboard.ts).
2133
+ DELETE FROM slotlock.dashboard_tokens
2134
+ WHERE kind = 'form'
2135
+ AND owner_hash = sha256(convert_to('slotlock-dashboard-owner.' || requested_tenant_ref, 'UTF8'));
2136
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
991
2137
  END IF;
992
2138
  RETURN NEXT;
993
2139
 
2140
+ -- A live tenant goes with its test environment (RFC 0003 G9), reported as test.<table>.
2141
+ IF requested_tenant_ref !~ '~test$' AND octet_length(requested_tenant_ref) <= 495 THEN
2142
+ FOR tested IN
2143
+ SELECT inner_erased.erased_table AS name, inner_erased.erased_rows AS count
2144
+ FROM slotlock.erase_tenant(requested_tenant_ref || '~test') inner_erased
2145
+ LOOP
2146
+ IF tested.count > 0 THEN
2147
+ erased_table := 'test.' || tested.name;
2148
+ erased_rows := tested.count;
2149
+ RETURN NEXT;
2150
+ END IF;
2151
+ END LOOP;
2152
+ END IF;
2153
+
994
2154
  FOR setting IN SELECT jsonb_object_keys(previous) LOOP
995
2155
  PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
996
2156
  END LOOP;
@@ -998,6 +2158,76 @@ END
998
2158
  $slotlock_erase_tenant$;
999
2159
  REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[1]} FROM PUBLIC;
1000
2160
 
2161
+ -- Empty a test environment (RFC 0004 A6 sandbox reset): its resources, locations and groups, every
2162
+ -- booking, event, hold and waitlist entry, its approval policies, booking rule defaults and pool
2163
+ -- settings, and its feed, as if it were new. What sets it up is kept: its API keys (and their
2164
+ -- scopes and limits), webhook endpoints, plan, usage and assistant. Only a test environment
2165
+ -- (a tenant ending ~test, api-keys.ts), and only the one the caller acts in (its tenant context), so
2166
+ -- a server resets nothing it was not asked to; a live tenant is never touched.
2167
+ CREATE OR REPLACE FUNCTION slotlock.reset_test_tenant(requested_tenant_ref text)
2168
+ RETURNS TABLE (reset_table text, reset_rows bigint)
2169
+ LANGUAGE plpgsql
2170
+ VOLATILE
2171
+ SECURITY DEFINER
2172
+ SET search_path = pg_catalog, slotlock, pg_temp
2173
+ AS $slotlock_reset_test_tenant$
2174
+ DECLARE
2175
+ emptied text;
2176
+ gone bigint;
2177
+ BEGIN
2178
+ IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500
2179
+ OR right(requested_tenant_ref, 5) <> '~test' THEN
2180
+ RAISE EXCEPTION 'Slotlock resets only a test environment (a tenant ending ~test)'
2181
+ USING ERRCODE = 'insufficient_privilege', HINT = 'sandbox_test_only';
2182
+ END IF;
2183
+ IF NOT EXISTS (SELECT 1 FROM slotlock.rls_policy_contracts contract
2184
+ WHERE current_setting(contract.tenant_context_setting, true) = requested_tenant_ref) THEN
2185
+ RAISE EXCEPTION 'Slotlock resets only the tenant the caller acts in'
2186
+ USING ERRCODE = 'insufficient_privilege', HINT = 'sandbox_test_only';
2187
+ END IF;
2188
+ -- Waits for the tenant's transactions under way, as erasure does.
2189
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || requested_tenant_ref, 0));
2190
+ -- Nothing reset is a change anyone is told of: the feed goes with what it described.
2191
+ PERFORM set_config('slotlock.erasing', requested_tenant_ref, true);
2192
+ reset_table := 'reservations';
2193
+ DELETE FROM slotlock.reservations
2194
+ WHERE tenant_ref = requested_tenant_ref
2195
+ OR resource_id IN (SELECT owned.id FROM slotlock.resources owned
2196
+ WHERE owned.tenant_ref = requested_tenant_ref);
2197
+ GET DIAGNOSTICS reset_rows = ROW_COUNT;
2198
+ RETURN NEXT;
2199
+ FOREACH emptied IN ARRAY ARRAY[
2200
+ 'calendar_event_attendees', 'calendar_event_reminders', 'calendar_event_exceptions',
2201
+ 'calendar_event_occurrences', 'composite_claims', 'approvals', 'approval_policies',
2202
+ 'calendar_events', 'calendar_event_tombstones', 'calendar_event_commands',
2203
+ 'reservation_tombstones', 'reservation_conflict_archive', 'calendar_coverage', 'hold_commands',
2204
+ 'waitlist_entries', 'booking_rule_defaults', 'pool_settings', 'resource_group_members',
2205
+ 'resources', 'resource_groups', 'outbox'
2206
+ ] LOOP
2207
+ reset_table := emptied;
2208
+ EXECUTE format('DELETE FROM slotlock.%I WHERE tenant_ref = $1', emptied) USING requested_tenant_ref;
2209
+ GET DIAGNOSTICS reset_rows = ROW_COUNT;
2210
+ RETURN NEXT;
2211
+ END LOOP;
2212
+ -- Locations last: one may be inside another, so the innermost go first.
2213
+ reset_table := 'locations';
2214
+ reset_rows := 0;
2215
+ LOOP
2216
+ DELETE FROM slotlock.locations place
2217
+ WHERE place.tenant_ref = requested_tenant_ref
2218
+ AND NOT EXISTS (SELECT 1 FROM slotlock.locations inner_place
2219
+ WHERE inner_place.tenant_ref = requested_tenant_ref
2220
+ AND inner_place.parent_ref = place.ref);
2221
+ GET DIAGNOSTICS gone = ROW_COUNT;
2222
+ EXIT WHEN gone = 0;
2223
+ reset_rows := reset_rows + gone;
2224
+ END LOOP;
2225
+ RETURN NEXT;
2226
+ PERFORM set_config('slotlock.erasing', '', true);
2227
+ END
2228
+ $slotlock_reset_test_tenant$;
2229
+ REVOKE ALL ON FUNCTION slotlock.reset_test_tenant(text) FROM PUBLIC;
2230
+
1001
2231
  -- The tenants with a webhook endpoint, which a server delivers for. Row-level security hides every
1002
2232
  -- other tenant's endpoints from the serving role, so this list (names only) is how a server that
1003
2233
  -- restarted finds the tenants whose events still wait. No role but its owner reads it.
@@ -1192,17 +2422,100 @@ BEGIN
1192
2422
  END
1193
2423
  $slotlock_sweep_assistant_conversations$;
1194
2424
  REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[7]} FROM PUBLIC;
2425
+
2426
+ -- The tenants with data, which \`serve\` maintains every hour whether or not their keys call: what
2427
+ -- retention ends is deleted for a tenant gone quiet too. Forced row-level security hides every
2428
+ -- tenant's rows from the owner as well, so this list (names only) is how a server finds them. A
2429
+ -- tenant is listed by the first row it writes to a table maintenance prunes (each change on its
2430
+ -- feed is one, so every booking, event, resource and endpoint counts), and erasure takes it off.
2431
+ -- While it is empty (first created), it starts from every tenant already in a tenant table: the
2432
+ -- deployment has lifted FORCE by now. No role but its owner reads it.
2433
+ CREATE TABLE IF NOT EXISTS slotlock.maintenance_tenants (
2434
+ tenant_ref text PRIMARY KEY,
2435
+ CONSTRAINT slotlock_maintenance_tenants_tenant_ref_valid
2436
+ CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500)
2437
+ );
2438
+ DO $slotlock_maintenance_tenants_known$
2439
+ DECLARE
2440
+ tenant_table text;
2441
+ BEGIN
2442
+ IF NOT EXISTS (SELECT 1 FROM slotlock.maintenance_tenants) THEN
2443
+ FOR tenant_table IN
2444
+ SELECT rel.relname::text
2445
+ FROM pg_catalog.pg_class rel
2446
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
2447
+ JOIN pg_catalog.pg_attribute att ON att.attrelid = rel.oid
2448
+ WHERE ns.nspname = 'slotlock' AND rel.relkind IN ('r', 'p')
2449
+ AND att.attname = 'tenant_ref' AND NOT att.attisdropped
2450
+ AND rel.relname <> 'maintenance_tenants'
2451
+ ORDER BY rel.relname
2452
+ LOOP
2453
+ EXECUTE pg_catalog.format(
2454
+ 'INSERT INTO slotlock.maintenance_tenants (tenant_ref)
2455
+ SELECT DISTINCT tenant_ref FROM slotlock.%I
2456
+ WHERE octet_length(tenant_ref) BETWEEN 1 AND 500
2457
+ ON CONFLICT (tenant_ref) DO NOTHING', tenant_table);
2458
+ END LOOP;
2459
+ END IF;
2460
+ END
2461
+ $slotlock_maintenance_tenants_known$;
2462
+ ALTER TABLE slotlock.maintenance_tenants ENABLE ROW LEVEL SECURITY;
2463
+ REVOKE ALL ON slotlock.maintenance_tenants FROM PUBLIC;
2464
+
2465
+ -- Lists the tenants of the rows a statement appended (only the writer's own: RLS checks each row).
2466
+ -- Once listed, a tenant costs each later statement one index probe.
2467
+ CREATE OR REPLACE FUNCTION slotlock.list_maintenance_tenant()
2468
+ RETURNS trigger
2469
+ LANGUAGE plpgsql
2470
+ SECURITY DEFINER
2471
+ SET search_path = pg_catalog, slotlock, pg_temp
2472
+ AS $slotlock_list_maintenance_tenant$
2473
+ BEGIN
2474
+ INSERT INTO slotlock.maintenance_tenants (tenant_ref)
2475
+ SELECT DISTINCT appended.tenant_ref FROM appended
2476
+ WHERE appended.tenant_ref IS NOT NULL
2477
+ ON CONFLICT (tenant_ref) DO NOTHING;
2478
+ RETURN NULL;
2479
+ END
2480
+ $slotlock_list_maintenance_tenant$;
2481
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[8]} FROM PUBLIC;
2482
+ -- The feed, and what can be written without a change on it: a reference retired with no booking,
2483
+ -- an event's cancellation and idempotency answers, a hold's answer, a waitlist entry.
2484
+ DO $slotlock_maintenance_tenants_triggers$
2485
+ DECLARE
2486
+ tenant_table text;
2487
+ BEGIN
2488
+ FOREACH tenant_table IN ARRAY ARRAY[
2489
+ 'outbox', 'reservation_tombstones', 'calendar_event_tombstones', 'calendar_event_commands',
2490
+ 'hold_commands', 'waitlist_entries'
2491
+ ] LOOP
2492
+ EXECUTE pg_catalog.format(
2493
+ 'CREATE OR REPLACE TRIGGER slotlock_list_maintenance_tenant
2494
+ AFTER INSERT ON slotlock.%I REFERENCING NEW TABLE AS appended
2495
+ FOR EACH STATEMENT EXECUTE FUNCTION slotlock.list_maintenance_tenant()', tenant_table);
2496
+ END LOOP;
2497
+ END
2498
+ $slotlock_maintenance_tenants_triggers$;
2499
+
2500
+ -- A page of 1,000 tenant names after \`after\` (NULL: from the start); a caller pages to the end.
2501
+ CREATE OR REPLACE FUNCTION slotlock.maintenance_tenants(after text)
2502
+ RETURNS SETOF text
2503
+ LANGUAGE sql
2504
+ STABLE
2505
+ SECURITY DEFINER
2506
+ SET search_path = pg_catalog, slotlock, pg_temp
2507
+ AS $slotlock_maintenance_tenants$
2508
+ SELECT tenant_ref FROM slotlock.maintenance_tenants
2509
+ WHERE after IS NULL OR tenant_ref > after
2510
+ ORDER BY tenant_ref LIMIT 1000;
2511
+ $slotlock_maintenance_tenants$;
2512
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[9]} FROM PUBLIC;
1195
2513
  `;
1196
- // A constraint an upgrade added NOT VALID (no table scan under its ACCESS EXCLUSIVE lock) is checked
1197
- // here. applySchema runs this in a transaction of its own once the DDL has committed: inside the
1198
- // DDL's transaction every table it altered stays under ACCESS EXCLUSIVE until commit, so the scan
1199
- // would hold up every serving query. On its own, each VALIDATE takes SHARE UPDATE EXCLUSIVE (and
1200
- // ROW SHARE on a foreign key's referenced table), which no read or row write conflicts with
1201
- // (PostgreSQL 17, ALTER TABLE and Explicit Locking). It sees every row whatever row-level security
1202
- // the table forces: the check scans the table itself, and referential checks bypass row security.
1203
- // One that old rows still break stays NOT VALID, with a notice, rather than stopping the migration:
1204
- // it binds every new row either way, and applySchema tries it again at each later migration. Once
1205
- // valid, a constraint is never checked again.
2514
+ // Validate constraints an upgrade added NOT VALID. applySchema runs this in its own transaction
2515
+ // after the DDL commits: inside the DDL's, altered tables stay ACCESS EXCLUSIVE and the scan would
2516
+ // block serving queries. Alone, VALIDATE takes SHARE UPDATE EXCLUSIVE, which no read or row write
2517
+ // conflicts with (PostgreSQL 17 docs), and it sees every row regardless of RLS. One old rows still
2518
+ // break stays NOT VALID with a notice (it still binds new rows) and is retried at each migration.
1206
2519
  const DEPLOYMENT_VALIDATE_CONSTRAINTS = `DO $slotlock_validate_constraints$
1207
2520
  DECLARE
1208
2521
  unchecked record;
@@ -1228,7 +2541,6 @@ $slotlock_validate_constraints$;
1228
2541
  `;
1229
2542
  /** The constraint validation applySchema runs after the rest of SLOTLOCK_CORE_DDL has committed. */
1230
2543
  export const SLOTLOCK_VALIDATION_DDL = DEPLOYMENT_VALIDATE_CONSTRAINTS;
1231
- // The digests that let the next migration skip are recorded by applySchema once it has validated.
1232
2544
  const DEPLOYMENT_RECORD_VERSION = `INSERT INTO slotlock.schema_version (singleton, version, applied_at)
1233
2545
  VALUES (true, ${SLOTLOCK_SCHEMA_VERSION}, now())
1234
2546
  ON CONFLICT (singleton) DO UPDATE
@@ -1378,7 +2690,68 @@ CREATE TABLE IF NOT EXISTS slotlock.webhook_endpoints (
1378
2690
  last_error IS NULL OR char_length(last_error) <= 500
1379
2691
  )
1380
2692
  );
2693
+ -- A create's retry identity (RFC 0004 P0-5), kept as long as the endpoint: the same key and
2694
+ -- request answer the endpoint (and its secret) the first made, another is idempotency_conflict.
2695
+ ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS idempotency_key text;
2696
+ ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS payload_hash text;
2697
+ DO $slotlock_webhook_key$
2698
+ BEGIN
2699
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_webhook_endpoints_key_valid'
2700
+ AND conrelid = 'slotlock.webhook_endpoints'::regclass) THEN
2701
+ ALTER TABLE slotlock.webhook_endpoints ADD CONSTRAINT slotlock_webhook_endpoints_key_valid CHECK (
2702
+ (idempotency_key IS NULL AND payload_hash IS NULL)
2703
+ OR (octet_length(idempotency_key) BETWEEN 1 AND 500 AND payload_hash ~ '^[0-9a-f]{64}$')
2704
+ ) NOT VALID;
2705
+ END IF;
2706
+ END
2707
+ $slotlock_webhook_key$;
2708
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_webhook_endpoints_key_idx
2709
+ ON slotlock.webhook_endpoints (tenant_ref, idempotency_key) WHERE idempotency_key IS NOT NULL;
1381
2710
  ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS changes_pruned boolean NOT NULL DEFAULT false;
2711
+ -- Which resources' changes an endpoint receives (RFC 0003 G5): with any of these, only changes about
2712
+ -- a resource it names, or one in a pool, at a location (or a location in it) or in a group it names,
2713
+ -- matched as the change is delivered. Null: no such filter.
2714
+ ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS filter_resource_ids uuid[];
2715
+ ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS filter_pools text[];
2716
+ ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS filter_locations text[];
2717
+ ALTER TABLE slotlock.webhook_endpoints ADD COLUMN IF NOT EXISTS filter_groups text[];
2718
+ DO $slotlock_webhook_filter$
2719
+ BEGIN
2720
+ IF NOT EXISTS (
2721
+ SELECT 1 FROM pg_catalog.pg_constraint
2722
+ WHERE conrelid = 'slotlock.webhook_endpoints'::regclass
2723
+ AND conname = 'slotlock_webhook_endpoints_filter_valid'
2724
+ ) THEN
2725
+ ALTER TABLE slotlock.webhook_endpoints
2726
+ ADD CONSTRAINT slotlock_webhook_endpoints_filter_valid CHECK (
2727
+ (filter_resource_ids IS NULL
2728
+ OR cardinality(filter_resource_ids) BETWEEN 1 AND ${SLOTLOCK_WEBHOOK_FILTER_RESOURCES})
2729
+ AND (filter_pools IS NULL OR cardinality(filter_pools) BETWEEN 1 AND ${SLOTLOCK_WEBHOOK_FILTER_REFS})
2730
+ AND (filter_locations IS NULL OR cardinality(filter_locations) BETWEEN 1 AND ${SLOTLOCK_WEBHOOK_FILTER_REFS})
2731
+ AND (filter_groups IS NULL OR cardinality(filter_groups) BETWEEN 1 AND ${SLOTLOCK_WEBHOOK_FILTER_REFS})
2732
+ ) NOT VALID;
2733
+ END IF;
2734
+ END
2735
+ $slotlock_webhook_filter$;
2736
+
2737
+ -- A location and every location it is in, by reference (none for none): what a location filter
2738
+ -- matches a resource's location against. Run as its caller, in the tenant's context.
2739
+ CREATE OR REPLACE FUNCTION slotlock.location_path(requested_tenant_ref text, requested_ref text)
2740
+ RETURNS text[]
2741
+ LANGUAGE sql
2742
+ STABLE
2743
+ SET search_path = pg_catalog, slotlock, pg_temp
2744
+ AS $slotlock_location_path$
2745
+ WITH RECURSIVE up(ref, parent_ref, steps) AS (
2746
+ SELECT place.ref, place.parent_ref, 1 FROM slotlock.locations place
2747
+ WHERE place.tenant_ref = requested_tenant_ref AND place.ref = requested_ref
2748
+ UNION ALL
2749
+ SELECT place.ref, place.parent_ref, up.steps + 1
2750
+ FROM slotlock.locations place JOIN up ON place.tenant_ref = requested_tenant_ref AND place.ref = up.parent_ref
2751
+ WHERE up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
2752
+ )
2753
+ SELECT COALESCE(array_agg(up.ref), '{}'::text[]) FROM up;
2754
+ $slotlock_location_path$;
1382
2755
  -- 0.5 allowed a filter of at most 20 types, fewer than there are now. The wider CHECK admits every
1383
2756
  -- row the old one did, so it is added NOT VALID (no scan under the lock), and the old one dropped.
1384
2757
  DO $slotlock_webhook_type_count$
@@ -1431,6 +2804,23 @@ CREATE TABLE IF NOT EXISTS slotlock.waitlist_entries (
1431
2804
  );
1432
2805
  CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_queue_idx
1433
2806
  ON slotlock.waitlist_entries (tenant_ref, created_at, id) WHERE status = 'waiting';
2807
+ -- A join's retry identity (RFC 0004 P0-5), kept as long as the entry: the same key and request
2808
+ -- answer the entry the first made, another request is idempotency_conflict.
2809
+ ALTER TABLE slotlock.waitlist_entries ADD COLUMN IF NOT EXISTS idempotency_key text;
2810
+ ALTER TABLE slotlock.waitlist_entries ADD COLUMN IF NOT EXISTS payload_hash text;
2811
+ DO $slotlock_waitlist_key$
2812
+ BEGIN
2813
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'slotlock_waitlist_entries_key_valid'
2814
+ AND conrelid = 'slotlock.waitlist_entries'::regclass) THEN
2815
+ ALTER TABLE slotlock.waitlist_entries ADD CONSTRAINT slotlock_waitlist_entries_key_valid CHECK (
2816
+ (idempotency_key IS NULL AND payload_hash IS NULL)
2817
+ OR (octet_length(idempotency_key) BETWEEN 1 AND 500 AND payload_hash ~ '^[0-9a-f]{64}$')
2818
+ ) NOT VALID;
2819
+ END IF;
2820
+ END
2821
+ $slotlock_waitlist_key$;
2822
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_waitlist_entries_key_idx
2823
+ ON slotlock.waitlist_entries (tenant_ref, idempotency_key) WHERE idempotency_key IS NOT NULL;
1434
2824
  CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
1435
2825
  ON slotlock.waitlist_entries (tenant_ref, starts_at);
1436
2826
 
@@ -1451,6 +2841,46 @@ CREATE TABLE IF NOT EXISTS slotlock.booking_rule_defaults (
1451
2841
  )
1452
2842
  );
1453
2843
 
2844
+ -- The order a pool booking tries a pool's members in (RFC 0003 G7): stable (by id), least_booked
2845
+ -- (fewest bookings made on each in the last 30 days first) or round_robin (from the one after the
2846
+ -- member booked last). A pool without a row is stable.
2847
+ CREATE TABLE IF NOT EXISTS slotlock.pool_settings (
2848
+ tenant_ref text NOT NULL,
2849
+ pool_ref text NOT NULL,
2850
+ member_order text NOT NULL DEFAULT 'stable',
2851
+ last_member uuid,
2852
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
2853
+ CONSTRAINT slotlock_pool_settings_pkey PRIMARY KEY (tenant_ref, pool_ref),
2854
+ CONSTRAINT slotlock_pool_settings_valid CHECK (
2855
+ octet_length(tenant_ref) BETWEEN 1 AND 500
2856
+ AND pool_ref ~ '^[a-z0-9][a-z0-9._-]{0,99}$'
2857
+ AND member_order IN (${SLOTLOCK_POOL_ORDERS.map((order) => `'${order}'`).join(', ')})
2858
+ )
2859
+ );
2860
+
2861
+ -- At most SLOTLOCK_MAX_POOL_SETTINGS pools a tenant set an order for, under the tenant's lock.
2862
+ CREATE OR REPLACE FUNCTION slotlock.pool_settings_guard()
2863
+ RETURNS trigger
2864
+ LANGUAGE plpgsql
2865
+ SET search_path = pg_catalog, slotlock, pg_temp
2866
+ AS $slotlock_pool_settings_guard$
2867
+ BEGIN
2868
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:pool-settings:' || NEW.tenant_ref, 0));
2869
+ -- An upsert of a pool the tenant has set fires this before its conflict is found.
2870
+ IF NOT EXISTS (SELECT 1 FROM slotlock.pool_settings kept
2871
+ WHERE kept.tenant_ref = NEW.tenant_ref AND kept.pool_ref = NEW.pool_ref)
2872
+ AND (SELECT count(*) FROM slotlock.pool_settings kept WHERE kept.tenant_ref = NEW.tenant_ref)
2873
+ >= ${SLOTLOCK_MAX_POOL_SETTINGS} THEN
2874
+ RAISE EXCEPTION 'Slotlock keeps an order for at most ${SLOTLOCK_MAX_POOL_SETTINGS} pools a tenant'
2875
+ USING ERRCODE = 'check_violation', HINT = 'pool_order_limit_reached';
2876
+ END IF;
2877
+ RETURN NEW;
2878
+ END
2879
+ $slotlock_pool_settings_guard$;
2880
+ CREATE OR REPLACE TRIGGER slotlock_pool_settings_guard
2881
+ BEFORE INSERT ON slotlock.pool_settings
2882
+ FOR EACH ROW EXECUTE FUNCTION slotlock.pool_settings_guard();
2883
+
1454
2884
  -- The model a tenant's dashboard assistant uses (dashboard.ts, assistant.ts), one row a tenant.
1455
2885
  -- The API key is stored only sealed (AES-256-GCM under the deployment's SLOTLOCK_ASSISTANT_SECRET,
1456
2886
  -- bound to the tenant), with its last four characters to show.
@@ -1539,7 +2969,11 @@ BEGIN
1539
2969
  kind := CASE WHEN NEW.status = 'held' THEN 'hold.updated' ELSE 'reservation.updated' END;
1540
2970
  END IF;
1541
2971
  ELSE
2972
+ -- A no-show release (releaseNoShows) names its deletion, so the feed has one change for it,
2973
+ -- not a cancellation as well. The marker only renames: a deletion is always recorded.
1542
2974
  kind := CASE
2975
+ WHEN OLD.status <> 'held' AND current_setting('slotlock.ending', true) = 'no_show'
2976
+ THEN 'reservation.no_show'
1543
2977
  WHEN OLD.status <> 'held' THEN 'reservation.cancelled'
1544
2978
  WHEN OLD.expires_at <= statement_timestamp() THEN 'hold.expired'
1545
2979
  ELSE 'hold.released'
@@ -1556,7 +2990,12 @@ BEGIN
1556
2990
  'start', changed.starts_at,
1557
2991
  'end', changed.ends_at,
1558
2992
  'buffer_after_ms', changed.buffer_after_ms,
1559
- 'revision', CASE WHEN changed.status = 'held' THEN NULL ELSE changed.revision END,
2993
+ -- A no-show carries the revision its reference is retired at, and the check-in window.
2994
+ 'revision', CASE WHEN changed.status = 'held' THEN NULL
2995
+ WHEN kind = 'reservation.no_show' THEN changed.revision + 1
2996
+ ELSE changed.revision END,
2997
+ 'check_in_minutes', CASE WHEN kind = 'reservation.no_show'
2998
+ THEN NULLIF(current_setting('slotlock.ending_detail', true), '')::int END,
1560
2999
  'expires_at', CASE WHEN changed.status = 'held' THEN changed.expires_at END,
1561
3000
  -- A move says where it came from.
1562
3001
  'before', CASE WHEN TG_OP = 'UPDATE' THEN jsonb_build_object(
@@ -1706,9 +3145,11 @@ LANGUAGE sql
1706
3145
  STABLE
1707
3146
  SET search_path = pg_catalog, slotlock, pg_temp
1708
3147
  AS $slotlock_outbox_erasing$
1709
- SELECT owner = current_setting('slotlock.erasing', true)
3148
+ -- Never null: the erasing setting is unset outside erasure, and a null here would read as neither
3149
+ -- erasing nor not to a caller that writes NOT outbox_erasing(...).
3150
+ SELECT COALESCE(owner = current_setting('slotlock.erasing', true)
1710
3151
  AND current_user = (SELECT pg_catalog.pg_get_userbyid(nspowner) FROM pg_catalog.pg_namespace
1711
- WHERE nspname = 'slotlock');
3152
+ WHERE nspname = 'slotlock'), false);
1712
3153
  $slotlock_outbox_erasing$;
1713
3154
 
1714
3155
  CREATE OR REPLACE FUNCTION slotlock.outbox_resource_change()
@@ -1721,6 +3162,8 @@ DECLARE
1721
3162
  kind text;
1722
3163
  before jsonb := '{}'::jsonb;
1723
3164
  after jsonb := '{}'::jsonb;
3165
+ -- The profile fields that changed, by name only: a name or description is calendar content.
3166
+ profile_changed text[] := '{}';
1724
3167
  details jsonb;
1725
3168
  BEGIN
1726
3169
  changed := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
@@ -1728,7 +3171,7 @@ BEGIN
1728
3171
  IF TG_OP = 'INSERT' THEN
1729
3172
  kind := 'resource.created';
1730
3173
  details := jsonb_build_object(
1731
- 'timezone', NEW.timezone, 'visibility', NEW.visibility, 'pool', NEW.pool_ref,
3174
+ 'timezone', NEW.timezone, 'visibility', NEW.visibility, 'pool', NEW.pool_ref, 'location', NEW.location_ref,
1732
3175
  'availability', NEW.availability_rules, 'booking_rules', NEW.booking_rules);
1733
3176
  ELSIF TG_OP = 'DELETE' THEN
1734
3177
  kind := 'resource.deleted';
@@ -1750,6 +3193,16 @@ BEGIN
1750
3193
  before := before || jsonb_build_object('pool', OLD.pool_ref);
1751
3194
  after := after || jsonb_build_object('pool', NEW.pool_ref);
1752
3195
  END IF;
3196
+ IF OLD.location_ref IS DISTINCT FROM NEW.location_ref THEN
3197
+ before := before || jsonb_build_object('location', OLD.location_ref);
3198
+ after := after || jsonb_build_object('location', NEW.location_ref);
3199
+ END IF;
3200
+ -- The composite it is a part of (RFC 0003 G8), read through to_jsonb: a migration that runs
3201
+ -- this before the column exists sees no change rather than an error.
3202
+ IF (to_jsonb(OLD) -> 'parent_id') IS DISTINCT FROM (to_jsonb(NEW) -> 'parent_id') THEN
3203
+ before := before || jsonb_build_object('parent', to_jsonb(OLD) -> 'parent_id');
3204
+ after := after || jsonb_build_object('parent', to_jsonb(NEW) -> 'parent_id');
3205
+ END IF;
1753
3206
  IF OLD.availability_rules IS DISTINCT FROM NEW.availability_rules THEN
1754
3207
  before := before || jsonb_build_object('availability', OLD.availability_rules);
1755
3208
  after := after || jsonb_build_object('availability', NEW.availability_rules);
@@ -1758,9 +3211,24 @@ BEGIN
1758
3211
  before := before || jsonb_build_object('booking_rules', OLD.booking_rules);
1759
3212
  after := after || jsonb_build_object('booking_rules', NEW.booking_rules);
1760
3213
  END IF;
1761
- IF after = '{}'::jsonb THEN RETURN NULL; END IF;
3214
+ IF OLD.name IS DISTINCT FROM NEW.name THEN profile_changed := profile_changed || 'name'::text; END IF;
3215
+ IF OLD.description IS DISTINCT FROM NEW.description THEN
3216
+ profile_changed := profile_changed || 'description'::text;
3217
+ END IF;
3218
+ IF OLD.kind IS DISTINCT FROM NEW.kind THEN profile_changed := profile_changed || 'kind'::text; END IF;
3219
+ IF OLD.capacity IS DISTINCT FROM NEW.capacity THEN
3220
+ profile_changed := profile_changed || 'capacity'::text;
3221
+ END IF;
3222
+ IF OLD.labels IS DISTINCT FROM NEW.labels THEN profile_changed := profile_changed || 'labels'::text; END IF;
3223
+ IF OLD.attributes IS DISTINCT FROM NEW.attributes THEN
3224
+ profile_changed := profile_changed || 'attributes'::text;
3225
+ END IF;
3226
+ IF after = '{}'::jsonb AND cardinality(profile_changed) = 0 THEN RETURN NULL; END IF;
1762
3227
  kind := 'resource.updated';
1763
- details := jsonb_build_object('before', before, 'after', after);
3228
+ details := jsonb_build_object('before', before, 'after', after)
3229
+ || CASE WHEN cardinality(profile_changed) > 0
3230
+ THEN jsonb_build_object('profile_changed', to_jsonb(profile_changed))
3231
+ ELSE '{}'::jsonb END;
1764
3232
  END IF;
1765
3233
  INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
1766
3234
  VALUES (
@@ -1832,61 +3300,574 @@ BEGIN
1832
3300
  INSERT INTO slotlock.outbox (tenant_ref, type, subject, data, actor)
1833
3301
  VALUES (
1834
3302
  changed.tenant_ref, kind, changed.id::text,
1835
- jsonb_build_object('id', changed.id, 'event_types', to_jsonb(changed.event_types)),
3303
+ jsonb_build_object('id', changed.id, 'event_types', to_jsonb(changed.event_types))
3304
+ || CASE WHEN changed.filter_resource_ids IS NULL AND changed.filter_pools IS NULL
3305
+ AND changed.filter_locations IS NULL AND changed.filter_groups IS NULL
3306
+ THEN '{}'::jsonb
3307
+ ELSE jsonb_build_object('filter', jsonb_strip_nulls(jsonb_build_object(
3308
+ 'resource_ids', to_jsonb(changed.filter_resource_ids), 'pools', to_jsonb(changed.filter_pools),
3309
+ 'locations', to_jsonb(changed.filter_locations), 'groups', to_jsonb(changed.filter_groups))))
3310
+ END,
1836
3311
  NULLIF(current_setting('slotlock.actor', true), '')
1837
3312
  );
1838
3313
  RETURN NULL;
1839
3314
  END
1840
3315
  $slotlock_outbox_webhook$;
1841
3316
 
3317
+ -- A resource deleted: resource.deleted, recorded just before its row goes, while its groups are
3318
+ -- still there (a foreign key's cascade takes them before any AFTER trigger of the resource runs),
3319
+ -- with where it was: its pool, location and groups, so an endpoint filtered by them still receives
3320
+ -- it (event-feed.ts FILTER_MATCH). A delete that fails takes this row back with it.
3321
+ CREATE OR REPLACE FUNCTION slotlock.outbox_resource_deleting()
3322
+ RETURNS trigger
3323
+ LANGUAGE plpgsql
3324
+ SET search_path = pg_catalog, slotlock, pg_temp
3325
+ AS $slotlock_outbox_resource_deleting$
3326
+ DECLARE
3327
+ was_in text[];
3328
+ BEGIN
3329
+ IF OLD.tenant_ref IS NULL OR slotlock.outbox_erasing(OLD.tenant_ref) THEN RETURN OLD; END IF;
3330
+ was_in := ARRAY(SELECT member.group_ref FROM slotlock.resource_group_members member
3331
+ WHERE member.tenant_ref = OLD.tenant_ref AND member.resource_id = OLD.id
3332
+ ORDER BY member.group_ref);
3333
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
3334
+ VALUES (
3335
+ OLD.tenant_ref, OLD.id, 'resource.deleted', OLD.id::text,
3336
+ jsonb_build_object('id', OLD.id, 'reference', OLD.external_ref)
3337
+ || jsonb_strip_nulls(jsonb_build_object('pool', OLD.pool_ref, 'location', OLD.location_ref))
3338
+ || CASE WHEN cardinality(was_in) > 0 THEN jsonb_build_object('groups', to_jsonb(was_in)) ELSE '{}'::jsonb END,
3339
+ NULLIF(current_setting('slotlock.actor', true), '')
3340
+ );
3341
+ RETURN OLD;
3342
+ END
3343
+ $slotlock_outbox_resource_deleting$;
3344
+
3345
+ -- Deletion has its own trigger, before the row goes (outbox_resource_deleting).
1842
3346
  CREATE OR REPLACE TRIGGER slotlock_outbox_resource_change
1843
- AFTER INSERT OR UPDATE OR DELETE ON slotlock.resources
3347
+ AFTER INSERT OR UPDATE ON slotlock.resources
1844
3348
  FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_resource_change();
3349
+ CREATE OR REPLACE TRIGGER slotlock_outbox_resource_deleting
3350
+ BEFORE DELETE ON slotlock.resources
3351
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_resource_deleting();
1845
3352
  CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_change
1846
3353
  AFTER INSERT OR DELETE ON slotlock.calendar_coverage
1847
3354
  FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_coverage_change();
1848
3355
 
1849
- -- A coverage change an earlier release's server (0.3-0.5) still running writes, as it is inserted:
1850
- -- named by its source's source_id (src_redacted for a source it cannot find), without the source or
1851
- -- the error read from it. 0.5.2 and earlier wrote the source itself (an iCalendar URL with a token,
1852
- -- an address) and its last error; 0.5.3 its guessable digest. This release's own rows are in shape
1853
- -- and pass unchanged. It runs as the inserting role, under its tenant's RLS like the insert itself.
1854
- CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_in_shape()
1855
- RETURNS trigger
3356
+ -- A coverage change an earlier release's server (0.3-0.5) still running writes, as it is inserted:
3357
+ -- named by its source's source_id (src_redacted for a source it cannot find), without the source or
3358
+ -- the error read from it. 0.5.2 and earlier wrote the source itself (an iCalendar URL with a token,
3359
+ -- an address) and its last error; 0.5.3 its guessable digest. This release's own rows are in shape
3360
+ -- and pass unchanged. It runs as the inserting role, under its tenant's RLS like the insert itself.
3361
+ CREATE OR REPLACE FUNCTION slotlock.outbox_coverage_in_shape()
3362
+ RETURNS trigger
3363
+ LANGUAGE plpgsql
3364
+ SECURITY INVOKER
3365
+ SET search_path = pg_catalog, slotlock, pg_temp
3366
+ AS $slotlock_outbox_coverage_in_shape$
3367
+ DECLARE
3368
+ named text;
3369
+ BEGIN
3370
+ IF ${coverageChangeInShapeSql('NEW')} THEN RETURN NEW; END IF;
3371
+ named := ${coverageChangeSourceIdSql('NEW')};
3372
+ NEW.subject := named;
3373
+ NEW.data := (NEW.data - 'source' - 'last_error') || pg_catalog.jsonb_build_object('source_id', named);
3374
+ RETURN NEW;
3375
+ END
3376
+ $slotlock_outbox_coverage_in_shape$;
3377
+ CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_in_shape
3378
+ BEFORE INSERT ON slotlock.outbox
3379
+ FOR EACH ROW WHEN (NEW.type LIKE 'coverage.%')
3380
+ EXECUTE FUNCTION slotlock.outbox_coverage_in_shape();
3381
+ -- A resource put in a group or taken out of one (RFC 0003 G3): resource.updated, with the groups
3382
+ -- it was in before and is in after, by reference, sorted. Once per statement, from the rows the
3383
+ -- statement changed, so each resource it touched has one change, whatever its other groups; adding
3384
+ -- a resource to a group it is in changes no row and appends none. Taken out because its group was
3385
+ -- deleted is a change; because the resource was deleted (resource.deleted says so) or its tenant
3386
+ -- erased is not.
3387
+ CREATE OR REPLACE FUNCTION slotlock.outbox_group_membership_change()
3388
+ RETURNS trigger
3389
+ LANGUAGE plpgsql
3390
+ SET search_path = pg_catalog, slotlock, pg_temp
3391
+ AS $slotlock_outbox_group_membership$
3392
+ BEGIN
3393
+ INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
3394
+ SELECT resource.tenant_ref, resource.id, 'resource.updated', resource.id::text,
3395
+ jsonb_build_object(
3396
+ 'id', resource.id, 'reference', resource.external_ref,
3397
+ 'before', jsonb_build_object('groups', to_jsonb(CASE WHEN TG_OP = 'INSERT'
3398
+ THEN ARRAY(SELECT kept FROM unnest(now_in.refs) kept WHERE kept <> ALL (touched.groups) ORDER BY kept)
3399
+ ELSE ARRAY(SELECT DISTINCT was FROM unnest(now_in.refs || touched.groups) was ORDER BY was) END)),
3400
+ 'after', jsonb_build_object('groups', to_jsonb(now_in.refs))),
3401
+ NULLIF(current_setting('slotlock.actor', true), '')
3402
+ FROM (SELECT changed.tenant_ref, changed.resource_id, array_agg(changed.group_ref) AS groups
3403
+ FROM changed_members changed
3404
+ GROUP BY changed.tenant_ref, changed.resource_id) touched
3405
+ JOIN slotlock.resources resource
3406
+ ON resource.id = touched.resource_id AND resource.tenant_ref = touched.tenant_ref
3407
+ CROSS JOIN LATERAL (
3408
+ SELECT ARRAY(SELECT member.group_ref FROM slotlock.resource_group_members member
3409
+ WHERE member.tenant_ref = touched.tenant_ref AND member.resource_id = touched.resource_id
3410
+ ORDER BY member.group_ref) AS refs) now_in
3411
+ -- IS NOT TRUE, not NOT: outbox_erasing is null for the schema owner outside erasure, and a
3412
+ -- cascade's trigger runs as the owner on PostgreSQL 18.
3413
+ WHERE slotlock.outbox_erasing(touched.tenant_ref) IS NOT TRUE
3414
+ ORDER BY resource.id;
3415
+ RETURN NULL;
3416
+ END
3417
+ $slotlock_outbox_group_membership$;
3418
+ -- A trigger with a transition table takes one event; both name it changed_members for the function.
3419
+ CREATE OR REPLACE TRIGGER slotlock_outbox_group_member_added
3420
+ AFTER INSERT ON slotlock.resource_group_members
3421
+ REFERENCING NEW TABLE AS changed_members
3422
+ FOR EACH STATEMENT EXECUTE FUNCTION slotlock.outbox_group_membership_change();
3423
+ CREATE OR REPLACE TRIGGER slotlock_outbox_group_member_removed
3424
+ AFTER DELETE ON slotlock.resource_group_members
3425
+ REFERENCING OLD TABLE AS changed_members
3426
+ FOR EACH STATEMENT EXECUTE FUNCTION slotlock.outbox_group_membership_change();
3427
+ CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_change
3428
+ AFTER INSERT OR DELETE ON slotlock.webhook_endpoints
3429
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
3430
+ -- Only an update that sets disabled_at: leases and deliveries, which do not, cost nothing here.
3431
+ CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_enabled
3432
+ AFTER UPDATE OF disabled_at ON slotlock.webhook_endpoints
3433
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
3434
+
3435
+ DROP TRIGGER IF EXISTS slotlock_outbox_reservation_change ON slotlock.reservations;
3436
+ CREATE TRIGGER slotlock_outbox_reservation_change
3437
+ AFTER INSERT OR UPDATE OR DELETE ON slotlock.reservations
3438
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_reservation_change();
3439
+ DROP TRIGGER IF EXISTS slotlock_outbox_calendar_event_change ON slotlock.calendar_events;
3440
+ CREATE TRIGGER slotlock_outbox_calendar_event_change
3441
+ AFTER INSERT OR UPDATE OR DELETE ON slotlock.calendar_events
3442
+ FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_calendar_event_change();
3443
+ `;
3444
+ /**
3445
+ * Partner accounts (RFC 0003 part 4, P1-P4, P6): a partner (stackapi.app, say) creates and runs
3446
+ * Slotlock tenants for its own users, `partner:<partner>:<account>`, with plans from its contract,
3447
+ * and Pylota invoices the partner. Owner-only tables; the serving role reaches them only through
3448
+ * SLOTLOCK_PARTNER_FUNCTIONS, each scoped to the one partner a partner key names.
3449
+ */
3450
+ const SLOTLOCK_PARTNER_DDL = `
3451
+ CREATE TABLE IF NOT EXISTS slotlock.partners (
3452
+ id text PRIMARY KEY,
3453
+ name text NOT NULL,
3454
+ status text NOT NULL DEFAULT 'active',
3455
+ -- The plans its accounts may be on, the scopes their keys may have, and its prices (partners.ts).
3456
+ contract jsonb NOT NULL,
3457
+ stripe_customer text,
3458
+ -- Where usage.period_closed goes (P5), signed with the secret.
3459
+ webhook_url text,
3460
+ webhook_secret text,
3461
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
3462
+ CONSTRAINT slotlock_partners_valid CHECK (
3463
+ id ~ '^[a-z][a-z0-9-]{1,39}$'
3464
+ AND char_length(name) BETWEEN 1 AND 100 AND name !~ '[[:cntrl:]]'
3465
+ AND status IN ('active', 'suspended')
3466
+ AND jsonb_typeof(contract) = 'object'
3467
+ AND (stripe_customer IS NULL OR stripe_customer ~ '^cus_[A-Za-z0-9]{1,250}$')
3468
+ AND (webhook_url IS NULL OR (webhook_url ~ '^https://' AND octet_length(webhook_url) <= 2048))
3469
+ AND (webhook_secret IS NULL OR webhook_secret ~ '^whsec_[A-Za-z0-9+/=]{32,200}$')
3470
+ )
3471
+ );
3472
+ ALTER TABLE slotlock.partners ENABLE ROW LEVEL SECURITY;
3473
+ REVOKE ALL ON slotlock.partners FROM PUBLIC;
3474
+
3475
+ CREATE TABLE IF NOT EXISTS slotlock.partner_keys (
3476
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
3477
+ partner_id text NOT NULL REFERENCES slotlock.partners (id) ON DELETE CASCADE,
3478
+ name text NOT NULL,
3479
+ prefix text NOT NULL,
3480
+ secret_hash bytea NOT NULL,
3481
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
3482
+ last_used_at timestamptz,
3483
+ revoked_at timestamptz,
3484
+ CONSTRAINT slotlock_partner_keys_secret_hash_key UNIQUE (secret_hash),
3485
+ CONSTRAINT slotlock_partner_keys_valid CHECK (
3486
+ octet_length(secret_hash) = 32
3487
+ AND char_length(name) BETWEEN 1 AND 100 AND name !~ '[[:cntrl:]]'
3488
+ AND prefix ~ '^slk_partner_[0-9A-Za-z]{6}$'
3489
+ )
3490
+ );
3491
+ ALTER TABLE slotlock.partner_keys ENABLE ROW LEVEL SECURITY;
3492
+ REVOKE ALL ON slotlock.partner_keys FROM PUBLIC;
3493
+
3494
+ CREATE TABLE IF NOT EXISTS slotlock.partner_accounts (
3495
+ partner_id text NOT NULL REFERENCES slotlock.partners (id) ON DELETE RESTRICT,
3496
+ account text NOT NULL,
3497
+ tenant_ref text NOT NULL,
3498
+ plan text NOT NULL,
3499
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
3500
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
3501
+ CONSTRAINT slotlock_partner_accounts_pkey PRIMARY KEY (partner_id, account),
3502
+ CONSTRAINT slotlock_partner_accounts_tenant_key UNIQUE (tenant_ref),
3503
+ CONSTRAINT slotlock_partner_accounts_valid CHECK (
3504
+ account ~ '^[A-Za-z0-9._-]{1,128}$'
3505
+ AND tenant_ref = 'partner:' || partner_id || ':' || account
3506
+ AND plan ~ '^[a-z][a-z0-9_]{0,40}$'
3507
+ )
3508
+ );
3509
+ ALTER TABLE slotlock.partner_accounts ENABLE ROW LEVEL SECURITY;
3510
+ REVOKE ALL ON slotlock.partner_accounts FROM PUBLIC;
3511
+
3512
+ -- Each line of a partner's month (P6), recorded before it is sent to Stripe and with its Stripe id
3513
+ -- after, so a month is billed once however often the job runs.
3514
+ CREATE TABLE IF NOT EXISTS slotlock.partner_invoices (
3515
+ partner_id text NOT NULL REFERENCES slotlock.partners (id) ON DELETE RESTRICT,
3516
+ month date NOT NULL,
3517
+ line text NOT NULL,
3518
+ description text NOT NULL,
3519
+ quantity bigint NOT NULL,
3520
+ amount bigint NOT NULL,
3521
+ currency text NOT NULL,
3522
+ stripe_item text,
3523
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
3524
+ sent_at timestamptz,
3525
+ CONSTRAINT slotlock_partner_invoices_pkey PRIMARY KEY (partner_id, month, line),
3526
+ CONSTRAINT slotlock_partner_invoices_valid CHECK (
3527
+ extract(day FROM month) = 1
3528
+ AND line ~ '^[a-z][a-z0-9_]{0,60}$'
3529
+ AND char_length(description) BETWEEN 1 AND 500
3530
+ AND quantity >= 0 AND amount >= 0
3531
+ AND currency ~ '^[a-z]{3}$'
3532
+ AND (stripe_item IS NULL OR stripe_item ~ '^ii_[A-Za-z0-9]{1,250}$')
3533
+ )
3534
+ );
3535
+ ALTER TABLE slotlock.partner_invoices ENABLE ROW LEVEL SECURITY;
3536
+ REVOKE ALL ON slotlock.partner_invoices FROM PUBLIC;
3537
+
3538
+ -- A partner's month as it was first closed (P5, P6): each account's totals and the checksum the
3539
+ -- usage.period_closed webhook carries. Every later run sends the same body under the same id, and
3540
+ -- the month's invoice lines are priced from these totals only, once.
3541
+ CREATE TABLE IF NOT EXISTS slotlock.partner_month_closes (
3542
+ partner_id text NOT NULL REFERENCES slotlock.partners (id) ON DELETE RESTRICT,
3543
+ month date NOT NULL,
3544
+ accounts jsonb NOT NULL,
3545
+ checksum text NOT NULL,
3546
+ closed_at timestamptz NOT NULL DEFAULT statement_timestamp(),
3547
+ webhook_status integer,
3548
+ webhook_delivered_at timestamptz,
3549
+ CONSTRAINT slotlock_partner_month_closes_pkey PRIMARY KEY (partner_id, month),
3550
+ CONSTRAINT slotlock_partner_month_closes_valid CHECK (
3551
+ extract(day FROM month) = 1 AND checksum ~ '^[0-9a-f]{64}$' AND jsonb_typeof(accounts) = 'array'
3552
+ AND (webhook_status IS NULL OR webhook_status BETWEEN 100 AND 599)
3553
+ )
3554
+ );
3555
+ ALTER TABLE slotlock.partner_month_closes ENABLE ROW LEVEL SECURITY;
3556
+ REVOKE ALL ON slotlock.partner_month_closes FROM PUBLIC;
3557
+
3558
+ -- What an account of a partner's used in a month not yet closed, kept when the account is erased
3559
+ -- (erase_tenant), so its month is still billed. By the partner's own account reference: the tenant
3560
+ -- and everything in it are gone.
3561
+ CREATE TABLE IF NOT EXISTS slotlock.partner_erased_usage (
3562
+ partner_id text NOT NULL REFERENCES slotlock.partners (id) ON DELETE RESTRICT,
3563
+ account text NOT NULL,
3564
+ plan text NOT NULL,
3565
+ month date NOT NULL,
3566
+ metric text NOT NULL,
3567
+ count bigint NOT NULL,
3568
+ erased_at timestamptz NOT NULL DEFAULT statement_timestamp(),
3569
+ CONSTRAINT slotlock_partner_erased_usage_pkey PRIMARY KEY (partner_id, account, month, metric),
3570
+ CONSTRAINT slotlock_partner_erased_usage_valid CHECK (extract(day FROM month) = 1 AND count >= 0)
3571
+ );
3572
+ ALTER TABLE slotlock.partner_erased_usage ENABLE ROW LEVEL SECURITY;
3573
+ REVOKE ALL ON slotlock.partner_erased_usage FROM PUBLIC;
3574
+
3575
+ -- Each day a partner compared its counts with Slotlock's (P5, the partner API's reconcile call):
3576
+ -- how many accounts, how many differed by more than ${SLOTLOCK_RECONCILE_TOLERANCE_PERCENT}%, and by
3577
+ -- how much at most. A day with any is an incident on our side: \`slotlock billing daily\` lists it.
3578
+ CREATE TABLE IF NOT EXISTS slotlock.partner_reconciliations (
3579
+ partner_id text NOT NULL REFERENCES slotlock.partners (id) ON DELETE CASCADE,
3580
+ day date NOT NULL,
3581
+ accounts integer NOT NULL,
3582
+ mismatched integer NOT NULL,
3583
+ worst_percent numeric NOT NULL,
3584
+ reconciled_at timestamptz NOT NULL DEFAULT statement_timestamp(),
3585
+ CONSTRAINT slotlock_partner_reconciliations_pkey PRIMARY KEY (partner_id, day),
3586
+ CONSTRAINT slotlock_partner_reconciliations_valid CHECK (
3587
+ accounts BETWEEN 0 AND 100000 AND mismatched BETWEEN 0 AND accounts AND worst_percent >= 0
3588
+ )
3589
+ );
3590
+ ALTER TABLE slotlock.partner_reconciliations ENABLE ROW LEVEL SECURITY;
3591
+ REVOKE ALL ON slotlock.partner_reconciliations FROM PUBLIC;
3592
+
3593
+ -- Whether the tenant is a partner's account whose partner is not active: its keys authenticate as
3594
+ -- nothing and it is suspended with its partner (tenant_suspended). Any other tenant reference, one
3595
+ -- that merely starts with partner: included, is never blocked here.
3596
+ CREATE OR REPLACE FUNCTION slotlock.partner_tenant_blocked(requested_tenant_ref text)
3597
+ RETURNS boolean
3598
+ LANGUAGE sql
3599
+ STABLE
3600
+ SECURITY DEFINER
3601
+ SET search_path = pg_catalog, slotlock, pg_temp
3602
+ AS $slotlock_partner_tenant_blocked$
3603
+ SELECT EXISTS (
3604
+ SELECT 1 FROM slotlock.partner_accounts owned
3605
+ JOIN slotlock.partners partner ON partner.id = owned.partner_id
3606
+ WHERE owned.tenant_ref = regexp_replace(requested_tenant_ref, '~test$', '')
3607
+ AND partner.status <> 'active'
3608
+ );
3609
+ $slotlock_partner_tenant_blocked$;
3610
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[0]} FROM PUBLIC;
3611
+
3612
+ -- A partner key's partner, while the key is not revoked and the partner is active; last_used_at
3613
+ -- moves at most once a minute.
3614
+ CREATE OR REPLACE FUNCTION slotlock.authenticate_partner_key(presented_secret_hash bytea)
3615
+ RETURNS TABLE (key_id uuid, partner_id text)
3616
+ LANGUAGE plpgsql
3617
+ VOLATILE
3618
+ SECURITY DEFINER
3619
+ SET search_path = pg_catalog, slotlock, pg_temp
3620
+ AS $slotlock_authenticate_partner_key$
3621
+ #variable_conflict use_column
3622
+ BEGIN
3623
+ UPDATE slotlock.partner_keys used
3624
+ SET last_used_at = now()
3625
+ WHERE used.secret_hash = presented_secret_hash AND used.revoked_at IS NULL
3626
+ AND (used.last_used_at IS NULL OR used.last_used_at < now() - interval '1 minute');
3627
+ RETURN QUERY
3628
+ SELECT found.id, found.partner_id
3629
+ FROM slotlock.partner_keys found
3630
+ JOIN slotlock.partners partner ON partner.id = found.partner_id
3631
+ WHERE found.secret_hash = presented_secret_hash AND found.revoked_at IS NULL
3632
+ AND partner.status = 'active';
3633
+ END
3634
+ $slotlock_authenticate_partner_key$;
3635
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[1]} FROM PUBLIC;
3636
+
3637
+ CREATE OR REPLACE FUNCTION slotlock.partner_contract(requested_partner_id text)
3638
+ RETURNS jsonb
3639
+ LANGUAGE sql
3640
+ STABLE
3641
+ SECURITY DEFINER
3642
+ SET search_path = pg_catalog, slotlock, pg_temp
3643
+ AS $slotlock_partner_contract$
3644
+ SELECT partner.contract FROM slotlock.partners partner
3645
+ WHERE partner.id = requested_partner_id AND partner.status = 'active';
3646
+ $slotlock_partner_contract$;
3647
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[2]} FROM PUBLIC;
3648
+
3649
+ -- Create the partner's account, or put it on another of its contract's plans: the account's tenant
3650
+ -- gets that plan's limits and quotas (tenant_plans, source partner). Refused (invalid_parameter_value)
3651
+ -- for a plan the contract does not have; nothing for a partner that is not active.
3652
+ CREATE OR REPLACE FUNCTION slotlock.put_partner_account(
3653
+ requested_partner_id text, requested_account text, requested_plan text
3654
+ )
3655
+ RETURNS TABLE (account text, tenant_ref text, plan text, created_at timestamptz, updated_at timestamptz, created boolean)
3656
+ LANGUAGE plpgsql
3657
+ VOLATILE
3658
+ SECURITY DEFINER
3659
+ SET search_path = pg_catalog, slotlock, pg_temp
3660
+ AS $slotlock_put_partner_account$
3661
+ #variable_conflict use_column
3662
+ DECLARE
3663
+ terms jsonb;
3664
+ chosen jsonb;
3665
+ tenant text := 'partner:' || requested_partner_id || ':' || requested_account;
3666
+ existed boolean;
3667
+ BEGIN
3668
+ SELECT partner.contract INTO terms FROM slotlock.partners partner
3669
+ WHERE partner.id = requested_partner_id AND partner.status = 'active';
3670
+ IF NOT FOUND THEN RETURN; END IF;
3671
+ IF requested_account IS NULL OR requested_account !~ '^[A-Za-z0-9._-]{1,128}$' THEN
3672
+ RAISE EXCEPTION 'Slotlock partner account must be 1-128 letters, digits, dots, dashes or underscores'
3673
+ USING ERRCODE = 'invalid_parameter_value', HINT = 'invalid_partner_account';
3674
+ END IF;
3675
+ chosen := terms -> 'plans' -> requested_plan;
3676
+ IF requested_plan IS NULL OR chosen IS NULL OR jsonb_typeof(chosen) <> 'object' THEN
3677
+ RAISE EXCEPTION 'Slotlock partner plan is not one of the contract''s'
3678
+ USING ERRCODE = 'invalid_parameter_value', HINT = 'invalid_partner_plan';
3679
+ END IF;
3680
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || tenant, 0));
3681
+ existed := EXISTS (SELECT 1 FROM slotlock.partner_accounts kept
3682
+ WHERE kept.partner_id = requested_partner_id AND kept.account = requested_account);
3683
+ INSERT INTO slotlock.partner_accounts AS kept (partner_id, account, tenant_ref, plan)
3684
+ VALUES (requested_partner_id, requested_account, tenant, requested_plan)
3685
+ ON CONFLICT (partner_id, account) DO UPDATE
3686
+ SET plan = EXCLUDED.plan,
3687
+ updated_at = CASE WHEN kept.plan = EXCLUDED.plan THEN kept.updated_at ELSE statement_timestamp() END;
3688
+ INSERT INTO slotlock.tenant_plans AS kept (
3689
+ tenant_ref, plan, status, max_resources, max_api_keys, max_webhook_endpoints,
3690
+ source, partner_id, quota_operations, quota_bookings, quota_stop_percent
3691
+ ) VALUES (
3692
+ tenant, requested_plan, 'active',
3693
+ (chosen ->> 'resources')::integer, (chosen ->> 'api_keys')::integer, (chosen ->> 'webhook_endpoints')::integer,
3694
+ 'partner', requested_partner_id,
3695
+ (chosen ->> 'operations')::bigint, (chosen ->> 'bookings')::bigint, (chosen ->> 'stop_percent')::integer
3696
+ )
3697
+ ON CONFLICT (tenant_ref) DO UPDATE
3698
+ SET plan = EXCLUDED.plan, status = 'active',
3699
+ max_resources = EXCLUDED.max_resources, max_api_keys = EXCLUDED.max_api_keys,
3700
+ max_webhook_endpoints = EXCLUDED.max_webhook_endpoints,
3701
+ source = 'partner', partner_id = EXCLUDED.partner_id,
3702
+ quota_operations = EXCLUDED.quota_operations, quota_bookings = EXCLUDED.quota_bookings,
3703
+ quota_stop_percent = EXCLUDED.quota_stop_percent,
3704
+ stripe_customer = NULL, stripe_subscription = NULL, current_period_end = NULL,
3705
+ updated_at = statement_timestamp();
3706
+ RETURN QUERY
3707
+ SELECT kept.account, kept.tenant_ref, kept.plan, kept.created_at, kept.updated_at, NOT existed
3708
+ FROM slotlock.partner_accounts kept
3709
+ WHERE kept.partner_id = requested_partner_id AND kept.account = requested_account;
3710
+ END
3711
+ $slotlock_put_partner_account$;
3712
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[3]} FROM PUBLIC;
3713
+
3714
+ -- PL/pgSQL, as partner_usage: these read tables created after this section, which a SQL function's
3715
+ -- body may not name when it is created on a new schema.
3716
+ CREATE OR REPLACE FUNCTION slotlock.list_partner_accounts(requested_partner_id text)
3717
+ RETURNS TABLE (account text, plan text, suspended boolean, created_at timestamptz, updated_at timestamptz)
3718
+ LANGUAGE plpgsql
3719
+ STABLE
3720
+ SECURITY DEFINER
3721
+ SET search_path = pg_catalog, slotlock, pg_temp
3722
+ AS $slotlock_list_partner_accounts$
3723
+ #variable_conflict use_column
3724
+ BEGIN
3725
+ RETURN QUERY
3726
+ SELECT owned.account, owned.plan,
3727
+ EXISTS (SELECT 1 FROM slotlock.tenant_suspensions held WHERE held.tenant_ref = owned.tenant_ref),
3728
+ owned.created_at, owned.updated_at
3729
+ FROM slotlock.partner_accounts owned
3730
+ WHERE owned.partner_id = requested_partner_id
3731
+ ORDER BY owned.account
3732
+ LIMIT 10000;
3733
+ END
3734
+ $slotlock_list_partner_accounts$;
3735
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[4]} FROM PUBLIC;
3736
+
3737
+ -- One account, with how many resources (archived ones included) and active keys it holds.
3738
+ CREATE OR REPLACE FUNCTION slotlock.get_partner_account(requested_partner_id text, requested_account text)
3739
+ RETURNS TABLE (account text, tenant_ref text, plan text, suspended boolean, created_at timestamptz,
3740
+ updated_at timestamptz, resources bigint, api_keys bigint)
3741
+ LANGUAGE plpgsql
3742
+ STABLE
3743
+ SECURITY DEFINER
3744
+ SET search_path = pg_catalog, slotlock, pg_temp
3745
+ AS $slotlock_get_partner_account$
3746
+ #variable_conflict use_column
3747
+ DECLARE
3748
+ owned slotlock.partner_accounts%ROWTYPE;
3749
+ setting text;
3750
+ previous jsonb := '{}'::jsonb;
3751
+ counted bigint;
3752
+ BEGIN
3753
+ SELECT * INTO owned FROM slotlock.partner_accounts kept
3754
+ WHERE kept.partner_id = requested_partner_id AND kept.account = requested_account;
3755
+ IF NOT FOUND THEN RETURN; END IF;
3756
+ -- Resources have forced row-level security, their owner bound too: counted in the account's context.
3757
+ FOR setting IN SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
3758
+ LOOP
3759
+ previous := previous || jsonb_build_object(setting, current_setting(setting, true));
3760
+ PERFORM set_config(setting, owned.tenant_ref, true);
3761
+ END LOOP;
3762
+ SELECT count(*) INTO counted FROM slotlock.resources held WHERE held.tenant_ref = owned.tenant_ref;
3763
+ FOR setting IN SELECT jsonb_object_keys(previous) LOOP
3764
+ PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
3765
+ END LOOP;
3766
+ RETURN QUERY
3767
+ SELECT owned.account, owned.tenant_ref, owned.plan,
3768
+ EXISTS (SELECT 1 FROM slotlock.tenant_suspensions held WHERE held.tenant_ref = owned.tenant_ref),
3769
+ owned.created_at, owned.updated_at, counted,
3770
+ (SELECT count(*) FROM slotlock.api_keys issued
3771
+ WHERE issued.tenant_ref = owned.tenant_ref AND issued.revoked_at IS NULL
3772
+ AND (issued.expires_at IS NULL OR issued.expires_at > now()));
3773
+ END
3774
+ $slotlock_get_partner_account$;
3775
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[5]} FROM PUBLIC;
3776
+
3777
+ -- Suspend an account of the partner's, or lift a suspension the partner made: one Pylota made (for
3778
+ -- abuse, say) the partner cannot lift. True when it changed something; null for no such account.
3779
+ CREATE OR REPLACE FUNCTION slotlock.set_partner_account_suspended(
3780
+ requested_partner_id text, requested_account text, requested_suspended boolean, requested_reason text
3781
+ )
3782
+ RETURNS boolean
1856
3783
  LANGUAGE plpgsql
1857
- SECURITY INVOKER
3784
+ VOLATILE
3785
+ SECURITY DEFINER
1858
3786
  SET search_path = pg_catalog, slotlock, pg_temp
1859
- AS $slotlock_outbox_coverage_in_shape$
3787
+ AS $slotlock_set_partner_account_suspended$
1860
3788
  DECLARE
1861
- named text;
3789
+ tenant text;
1862
3790
  BEGIN
1863
- IF ${coverageChangeInShapeSql('NEW')} THEN RETURN NEW; END IF;
1864
- named := ${coverageChangeSourceIdSql('NEW')};
1865
- NEW.subject := named;
1866
- NEW.data := (NEW.data - 'source' - 'last_error') || pg_catalog.jsonb_build_object('source_id', named);
1867
- RETURN NEW;
3791
+ SELECT owned.tenant_ref INTO tenant FROM slotlock.partner_accounts owned
3792
+ JOIN slotlock.partners partner ON partner.id = owned.partner_id AND partner.status = 'active'
3793
+ WHERE owned.partner_id = requested_partner_id AND owned.account = requested_account;
3794
+ IF NOT FOUND THEN RETURN NULL; END IF;
3795
+ -- As suspend_tenant: the tenant's lock and its key lock, so writes in flight finish first.
3796
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:tenant:' || tenant, 0));
3797
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:api-keys:' || tenant, 0));
3798
+ IF requested_suspended THEN
3799
+ INSERT INTO slotlock.tenant_suspensions (tenant_ref, reason)
3800
+ VALUES (tenant, 'partner: ' || COALESCE(left(requested_reason, 200), 'suspended by its partner'))
3801
+ ON CONFLICT (tenant_ref) DO NOTHING;
3802
+ RETURN FOUND;
3803
+ END IF;
3804
+ DELETE FROM slotlock.tenant_suspensions held
3805
+ WHERE held.tenant_ref = tenant AND held.reason LIKE 'partner: %';
3806
+ RETURN FOUND;
1868
3807
  END
1869
- $slotlock_outbox_coverage_in_shape$;
1870
- CREATE OR REPLACE TRIGGER slotlock_outbox_coverage_in_shape
1871
- BEFORE INSERT ON slotlock.outbox
1872
- FOR EACH ROW WHEN (NEW.type LIKE 'coverage.%')
1873
- EXECUTE FUNCTION slotlock.outbox_coverage_in_shape();
1874
- CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_change
1875
- AFTER INSERT OR DELETE ON slotlock.webhook_endpoints
1876
- FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
1877
- -- Only an update that sets disabled_at: leases and deliveries, which do not, cost nothing here.
1878
- CREATE OR REPLACE TRIGGER slotlock_outbox_webhook_enabled
1879
- AFTER UPDATE OF disabled_at ON slotlock.webhook_endpoints
1880
- FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_webhook_change();
3808
+ $slotlock_set_partner_account_suspended$;
3809
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[6]} FROM PUBLIC;
1881
3810
 
1882
- DROP TRIGGER IF EXISTS slotlock_outbox_reservation_change ON slotlock.reservations;
1883
- CREATE TRIGGER slotlock_outbox_reservation_change
1884
- AFTER INSERT OR UPDATE OR DELETE ON slotlock.reservations
1885
- FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_reservation_change();
1886
- DROP TRIGGER IF EXISTS slotlock_outbox_calendar_event_change ON slotlock.calendar_events;
1887
- CREATE TRIGGER slotlock_outbox_calendar_event_change
1888
- AFTER INSERT OR UPDATE OR DELETE ON slotlock.calendar_events
1889
- FOR EACH ROW EXECUTE FUNCTION slotlock.outbox_calendar_event_change();
3811
+ -- The partner's accounts' usage (P5), from usage_daily, by account, UTC day and metric, from
3812
+ -- from_day to to_day (at most the retention); one account, or all of them.
3813
+ CREATE OR REPLACE FUNCTION slotlock.partner_usage(
3814
+ requested_partner_id text, from_day date, to_day date, requested_account text
3815
+ )
3816
+ RETURNS TABLE (account text, day date, metric text, count bigint)
3817
+ LANGUAGE plpgsql
3818
+ STABLE
3819
+ SECURITY DEFINER
3820
+ SET search_path = pg_catalog, slotlock, pg_temp
3821
+ AS $slotlock_partner_usage$
3822
+ #variable_conflict use_column
3823
+ BEGIN
3824
+ RETURN QUERY
3825
+ SELECT owned.account, used.day, used.metric,
3826
+ CASE WHEN used.metric = 'resources' THEN max(used.count) ELSE sum(used.count) END::bigint
3827
+ FROM slotlock.partner_accounts owned
3828
+ JOIN slotlock.usage_daily used ON used.tenant_ref = owned.tenant_ref
3829
+ WHERE owned.partner_id = requested_partner_id
3830
+ AND (requested_account IS NULL OR owned.account = requested_account)
3831
+ AND used.day BETWEEN from_day AND to_day
3832
+ AND to_day - from_day BETWEEN 0 AND ${SLOTLOCK_USAGE_RETENTION_DAYS}
3833
+ GROUP BY owned.account, used.day, used.metric
3834
+ ORDER BY owned.account, used.day, used.metric;
3835
+ END
3836
+ $slotlock_partner_usage$;
3837
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[7]} FROM PUBLIC;
3838
+
3839
+ -- The tenant of a partner's account, for a partner key acting for it (Slotlock-Account, RFC 0003
3840
+ -- P3): one indexed read, on every such request.
3841
+ CREATE OR REPLACE FUNCTION slotlock.partner_account_tenant(requested_partner_id text, requested_account text)
3842
+ RETURNS text
3843
+ LANGUAGE sql
3844
+ STABLE
3845
+ SECURITY DEFINER
3846
+ SET search_path = pg_catalog, slotlock, pg_temp
3847
+ AS $slotlock_partner_account_tenant$
3848
+ SELECT owned.tenant_ref FROM slotlock.partner_accounts owned
3849
+ WHERE owned.partner_id = requested_partner_id AND owned.account = requested_account;
3850
+ $slotlock_partner_account_tenant$;
3851
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[8]} FROM PUBLIC;
3852
+
3853
+ -- Record a partner's comparison of one day (the latest one stands).
3854
+ CREATE OR REPLACE FUNCTION slotlock.record_partner_reconciliation(
3855
+ requested_partner_id text, requested_day date, requested_accounts integer, requested_mismatched integer,
3856
+ requested_worst numeric
3857
+ )
3858
+ RETURNS void
3859
+ LANGUAGE sql
3860
+ VOLATILE
3861
+ SECURITY DEFINER
3862
+ SET search_path = pg_catalog, slotlock, pg_temp
3863
+ AS $slotlock_record_partner_reconciliation$
3864
+ INSERT INTO slotlock.partner_reconciliations AS kept (partner_id, day, accounts, mismatched, worst_percent)
3865
+ VALUES (requested_partner_id, requested_day, requested_accounts, requested_mismatched, requested_worst)
3866
+ ON CONFLICT (partner_id, day) DO UPDATE
3867
+ SET accounts = EXCLUDED.accounts, mismatched = EXCLUDED.mismatched,
3868
+ worst_percent = EXCLUDED.worst_percent, reconciled_at = statement_timestamp();
3869
+ $slotlock_record_partner_reconciliation$;
3870
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_PARTNER_FUNCTIONS[9]} FROM PUBLIC;
1890
3871
  `;
1891
3872
  // btree_gist is created in slotlock when it is missing; one installed elsewhere stays where it is.
1892
3873
  const CORE_DDL_BODY = `
@@ -1987,6 +3968,18 @@ CREATE INDEX IF NOT EXISTS slotlock_resources_pool_idx
1987
3968
  ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS visibility text NOT NULL DEFAULT 'shared';
1988
3969
  -- When the tenant archived it: listed nowhere and booked no more, its history kept. NULL: in use.
1989
3970
  ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS archived_at timestamptz;
3971
+ -- Its profile (resource-profile.ts, RFC 0003 G1), each part optional: a name shown instead of the
3972
+ -- reference, a description, what kind of thing it is (a hint for filtering, never a rule), how many
3973
+ -- people or seats it holds (informational, never enforced), labels to find it by, and attributes
3974
+ -- the integrator wants back. Calendar content: the feed names the fields that changed, never their
3975
+ -- values. Added with constant defaults, so no row is rewritten.
3976
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS name text;
3977
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS description text;
3978
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'other';
3979
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS capacity integer;
3980
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS labels text[] NOT NULL DEFAULT '{}';
3981
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS attributes jsonb NOT NULL DEFAULT '{}'::jsonb;
3982
+ CREATE INDEX IF NOT EXISTS slotlock_resources_labels_idx ON slotlock.resources USING gin (labels);
1990
3983
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS tenant_ref text;
1991
3984
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS external_ref text;
1992
3985
  ALTER TABLE slotlock.reservations ADD COLUMN IF NOT EXISTS buffer_after_ms bigint NOT NULL DEFAULT 0;
@@ -2273,6 +4266,30 @@ BEGIN
2273
4266
  ALTER TABLE slotlock.resources
2274
4267
  ADD CONSTRAINT slotlock_resources_visibility_valid CHECK (visibility IN ('shared', 'private'));
2275
4268
  END IF;
4269
+ -- The profile's bounds (SLOTLOCK_RESOURCE_PROFILE_LIMITS), NOT VALID so adding them scans
4270
+ -- nothing under the lock; DEPLOYMENT_VALIDATE_CONSTRAINTS validates them after the migration.
4271
+ IF NOT EXISTS (
4272
+ SELECT 1 FROM pg_constraint
4273
+ WHERE conname = 'slotlock_resources_profile_valid'
4274
+ AND conrelid = 'slotlock.resources'::regclass
4275
+ ) THEN
4276
+ ALTER TABLE slotlock.resources
4277
+ ADD CONSTRAINT slotlock_resources_profile_valid CHECK (
4278
+ (name IS NULL OR (char_length(name) BETWEEN 1 AND ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name}
4279
+ AND btrim(name) <> ''))
4280
+ AND (description IS NULL
4281
+ OR (char_length(description) BETWEEN 1 AND ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.description}
4282
+ AND btrim(description) <> ''))
4283
+ AND kind IN (${SLOTLOCK_RESOURCE_KINDS.map((kind) => `'${kind}'`).join(', ')})
4284
+ AND (capacity IS NULL OR capacity BETWEEN 1 AND ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.capacity})
4285
+ AND cardinality(labels) <= ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.labels}
4286
+ AND array_position(labels, NULL) IS NULL
4287
+ AND (cardinality(labels) = 0
4288
+ OR array_to_string(labels, ',') ~ '^[a-z0-9][a-z0-9._-]{0,63}(,[a-z0-9][a-z0-9._-]{0,63})*$')
4289
+ AND jsonb_typeof(attributes) = 'object'
4290
+ AND octet_length(attributes::text) <= ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.attributeBytes * 2}
4291
+ ) NOT VALID;
4292
+ END IF;
2276
4293
  IF NOT EXISTS (
2277
4294
  SELECT 1 FROM pg_constraint
2278
4295
  WHERE conname = 'slotlock_reservations_revision_valid'
@@ -2382,6 +4399,406 @@ BEGIN
2382
4399
  END IF;
2383
4400
  END $$;
2384
4401
 
4402
+ -- Where a tenant's resources are (RFC 0003 G2, resource-profile.ts): a tree of at most
4403
+ -- ${SLOTLOCK_MAX_LOCATION_DEPTH} levels (a region, a site, a building, a floor, a zone, or whatever the
4404
+ -- tenant's are), at most ${SLOTLOCK_MAX_LOCATIONS} a tenant, each named by a reference of the tenant's own.
4405
+ -- A location something is in (a resource, or a location under it) cannot be deleted: the foreign
4406
+ -- keys are NO ACTION, checked at the end of the statement, so erasing a whole tenant's tree in one
4407
+ -- statement passes. Its time zone, when it has one, is the one a resource created there without
4408
+ -- its own takes (the nearest location above that has one).
4409
+ CREATE TABLE IF NOT EXISTS slotlock.locations (
4410
+ tenant_ref text NOT NULL,
4411
+ ref text NOT NULL,
4412
+ name text NOT NULL,
4413
+ parent_ref text,
4414
+ timezone text,
4415
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
4416
+ CONSTRAINT slotlock_locations_pkey PRIMARY KEY (tenant_ref, ref),
4417
+ CONSTRAINT slotlock_locations_parent_fkey FOREIGN KEY (tenant_ref, parent_ref)
4418
+ REFERENCES slotlock.locations (tenant_ref, ref),
4419
+ CONSTRAINT slotlock_locations_valid CHECK (
4420
+ octet_length(tenant_ref) BETWEEN 1 AND 500
4421
+ AND ref ~ '^[a-z0-9][a-z0-9._-]{0,99}$'
4422
+ AND char_length(name) BETWEEN 1 AND ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} AND btrim(name) <> ''
4423
+ AND (parent_ref IS NULL OR parent_ref <> ref)
4424
+ AND (timezone IS NULL OR char_length(timezone) BETWEEN 1 AND 100)
4425
+ )
4426
+ );
4427
+ CREATE INDEX IF NOT EXISTS slotlock_locations_parent_idx
4428
+ ON slotlock.locations (tenant_ref, parent_ref) WHERE parent_ref IS NOT NULL;
4429
+
4430
+ -- A location's depth, a loop through its parents, and the tenant's count, under the tenant's
4431
+ -- location lock, so two writers never each pass a check the other then breaks.
4432
+ CREATE OR REPLACE FUNCTION slotlock.locations_guard()
4433
+ RETURNS trigger
4434
+ LANGUAGE plpgsql
4435
+ SET search_path = pg_catalog, slotlock, pg_temp
4436
+ AS $slotlock_locations_guard$
4437
+ DECLARE
4438
+ above integer := 0;
4439
+ below integer := 0;
4440
+ looped boolean := false;
4441
+ BEGIN
4442
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:locations:' || NEW.tenant_ref, 0));
4443
+ -- An upsert of a location the tenant has fires this before its conflict is found: not a new one.
4444
+ IF TG_OP = 'INSERT'
4445
+ AND NOT EXISTS (SELECT 1 FROM slotlock.locations kept
4446
+ WHERE kept.tenant_ref = NEW.tenant_ref AND kept.ref = NEW.ref)
4447
+ AND (SELECT count(*) FROM slotlock.locations kept WHERE kept.tenant_ref = NEW.tenant_ref)
4448
+ >= ${SLOTLOCK_MAX_LOCATIONS} THEN
4449
+ RAISE EXCEPTION 'Slotlock keeps at most ${SLOTLOCK_MAX_LOCATIONS} locations a tenant'
4450
+ USING ERRCODE = 'check_violation', HINT = 'location_limit_reached';
4451
+ END IF;
4452
+ IF NEW.parent_ref IS NOT NULL THEN
4453
+ WITH RECURSIVE up(at, parent, steps) AS (
4454
+ SELECT kept.ref, kept.parent_ref, 1 FROM slotlock.locations kept
4455
+ WHERE kept.tenant_ref = NEW.tenant_ref AND kept.ref = NEW.parent_ref
4456
+ UNION ALL
4457
+ SELECT kept.ref, kept.parent_ref, up.steps + 1
4458
+ FROM slotlock.locations kept JOIN up ON kept.tenant_ref = NEW.tenant_ref AND kept.ref = up.parent
4459
+ WHERE up.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
4460
+ )
4461
+ SELECT coalesce(max(up.steps), 0), coalesce(bool_or(up.at = NEW.ref), false)
4462
+ INTO above, looped FROM up;
4463
+ IF looped THEN
4464
+ RAISE EXCEPTION 'Slotlock location cannot be under itself'
4465
+ USING ERRCODE = 'check_violation', HINT = 'location_cycle';
4466
+ END IF;
4467
+ END IF;
4468
+ IF TG_OP = 'UPDATE' THEN
4469
+ WITH RECURSIVE down(at, steps) AS (
4470
+ SELECT kept.ref, 1 FROM slotlock.locations kept
4471
+ WHERE kept.tenant_ref = NEW.tenant_ref AND kept.parent_ref = NEW.ref
4472
+ UNION ALL
4473
+ SELECT kept.ref, down.steps + 1
4474
+ FROM slotlock.locations kept JOIN down ON kept.tenant_ref = NEW.tenant_ref AND kept.parent_ref = down.at
4475
+ WHERE down.steps <= ${SLOTLOCK_MAX_LOCATION_DEPTH}
4476
+ )
4477
+ SELECT coalesce(max(down.steps), 0) INTO below FROM down;
4478
+ END IF;
4479
+ IF above + 1 + below > ${SLOTLOCK_MAX_LOCATION_DEPTH} THEN
4480
+ RAISE EXCEPTION 'Slotlock locations are at most ${SLOTLOCK_MAX_LOCATION_DEPTH} levels deep'
4481
+ USING ERRCODE = 'check_violation', HINT = 'location_too_deep';
4482
+ END IF;
4483
+ RETURN NEW;
4484
+ END
4485
+ $slotlock_locations_guard$;
4486
+ CREATE OR REPLACE TRIGGER slotlock_locations_guard
4487
+ BEFORE INSERT OR UPDATE OF parent_ref ON slotlock.locations
4488
+ FOR EACH ROW EXECUTE FUNCTION slotlock.locations_guard();
4489
+
4490
+ -- The location a resource is at, one at most; null when not said.
4491
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS location_ref text;
4492
+ CREATE INDEX IF NOT EXISTS slotlock_resources_location_idx
4493
+ ON slotlock.resources (tenant_ref, location_ref) WHERE location_ref IS NOT NULL;
4494
+ DO $slotlock_resources_location$
4495
+ BEGIN
4496
+ IF NOT EXISTS (
4497
+ SELECT 1 FROM pg_constraint
4498
+ WHERE conname = 'slotlock_resources_location_fkey'
4499
+ AND conrelid = 'slotlock.resources'::regclass
4500
+ ) THEN
4501
+ -- NOT VALID: the column is new, and DEPLOYMENT_VALIDATE_CONSTRAINTS validates it after.
4502
+ ALTER TABLE slotlock.resources
4503
+ ADD CONSTRAINT slotlock_resources_location_fkey FOREIGN KEY (tenant_ref, location_ref)
4504
+ REFERENCES slotlock.locations (tenant_ref, ref) NOT VALID;
4505
+ END IF;
4506
+ END
4507
+ $slotlock_resources_location$;
4508
+
4509
+ -- Any set of resources a tenant wants (RFC 0003 G3): a team, a department, a contract, "Saturday
4510
+ -- clinic", "airport vans". Many a resource, at most ${SLOTLOCK_MAX_GROUPS_PER_RESOURCE}, and at most
4511
+ -- ${SLOTLOCK_MAX_RESOURCE_GROUPS} a tenant. No booking rule reads them: a pool is how resources are
4512
+ -- booked as a class.
4513
+ CREATE TABLE IF NOT EXISTS slotlock.resource_groups (
4514
+ tenant_ref text NOT NULL,
4515
+ ref text NOT NULL,
4516
+ name text NOT NULL,
4517
+ description text,
4518
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
4519
+ CONSTRAINT slotlock_resource_groups_pkey PRIMARY KEY (tenant_ref, ref),
4520
+ CONSTRAINT slotlock_resource_groups_valid CHECK (
4521
+ octet_length(tenant_ref) BETWEEN 1 AND 500
4522
+ AND ref ~ '^[a-z0-9][a-z0-9._-]{0,99}$'
4523
+ AND char_length(name) BETWEEN 1 AND ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.name} AND btrim(name) <> ''
4524
+ AND (description IS NULL
4525
+ OR (char_length(description) BETWEEN 1 AND ${SLOTLOCK_RESOURCE_PROFILE_LIMITS.description}
4526
+ AND btrim(description) <> ''))
4527
+ )
4528
+ );
4529
+ CREATE TABLE IF NOT EXISTS slotlock.resource_group_members (
4530
+ tenant_ref text NOT NULL,
4531
+ group_ref text NOT NULL,
4532
+ resource_id uuid NOT NULL,
4533
+ added_at timestamptz NOT NULL DEFAULT statement_timestamp(),
4534
+ CONSTRAINT slotlock_resource_group_members_pkey PRIMARY KEY (tenant_ref, group_ref, resource_id),
4535
+ CONSTRAINT slotlock_resource_group_members_group_fkey FOREIGN KEY (tenant_ref, group_ref)
4536
+ REFERENCES slotlock.resource_groups (tenant_ref, ref) ON DELETE CASCADE,
4537
+ CONSTRAINT slotlock_resource_group_members_resource_fkey FOREIGN KEY (resource_id, tenant_ref)
4538
+ REFERENCES slotlock.resources (id, tenant_ref) ON DELETE CASCADE
4539
+ );
4540
+ CREATE INDEX IF NOT EXISTS slotlock_resource_group_members_resource_idx
4541
+ ON slotlock.resource_group_members (tenant_ref, resource_id);
4542
+
4543
+ -- The counts, under the tenant's group lock.
4544
+ CREATE OR REPLACE FUNCTION slotlock.resource_groups_guard()
4545
+ RETURNS trigger
4546
+ LANGUAGE plpgsql
4547
+ SET search_path = pg_catalog, slotlock, pg_temp
4548
+ AS $slotlock_resource_groups_guard$
4549
+ BEGIN
4550
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:groups:' || NEW.tenant_ref, 0));
4551
+ -- An upsert of a group, or a membership, the tenant has fires this before its conflict is found.
4552
+ IF TG_TABLE_NAME = 'resource_groups' THEN
4553
+ IF NOT EXISTS (SELECT 1 FROM slotlock.resource_groups kept
4554
+ WHERE kept.tenant_ref = NEW.tenant_ref AND kept.ref = NEW.ref)
4555
+ AND (SELECT count(*) FROM slotlock.resource_groups kept WHERE kept.tenant_ref = NEW.tenant_ref)
4556
+ >= ${SLOTLOCK_MAX_RESOURCE_GROUPS} THEN
4557
+ RAISE EXCEPTION 'Slotlock keeps at most ${SLOTLOCK_MAX_RESOURCE_GROUPS} groups a tenant'
4558
+ USING ERRCODE = 'check_violation', HINT = 'group_limit_reached';
4559
+ END IF;
4560
+ ELSIF NOT EXISTS (SELECT 1 FROM slotlock.resource_group_members kept
4561
+ WHERE kept.tenant_ref = NEW.tenant_ref AND kept.group_ref = NEW.group_ref
4562
+ AND kept.resource_id = NEW.resource_id)
4563
+ AND (SELECT count(*) FROM slotlock.resource_group_members kept
4564
+ WHERE kept.tenant_ref = NEW.tenant_ref AND kept.resource_id = NEW.resource_id)
4565
+ >= ${SLOTLOCK_MAX_GROUPS_PER_RESOURCE} THEN
4566
+ RAISE EXCEPTION 'Slotlock puts a resource in at most ${SLOTLOCK_MAX_GROUPS_PER_RESOURCE} groups'
4567
+ USING ERRCODE = 'check_violation', HINT = 'resource_group_limit_reached';
4568
+ END IF;
4569
+ RETURN NEW;
4570
+ END
4571
+ $slotlock_resource_groups_guard$;
4572
+ CREATE OR REPLACE TRIGGER slotlock_resource_groups_guard
4573
+ BEFORE INSERT ON slotlock.resource_groups
4574
+ FOR EACH ROW EXECUTE FUNCTION slotlock.resource_groups_guard();
4575
+ CREATE OR REPLACE TRIGGER slotlock_resource_group_members_guard
4576
+ BEFORE INSERT ON slotlock.resource_group_members
4577
+ FOR EACH ROW EXECUTE FUNCTION slotlock.resource_groups_guard();
4578
+
4579
+ -- Composite resources (RFC 0003 G8): a parent made of parts (a hall of three rooms), one level
4580
+ -- deep. A part has its parent and its place among the parent's parts. A booking of a part also
4581
+ -- claims its place on the parent ([i, i+1)); a booking of the parent claims every place ([0, ∞)).
4582
+ -- composite_claims keeps those apart by PostgreSQL's own exclusion constraint: the whole conflicts
4583
+ -- with every part, and two parts never with each other. A resource that is neither has no claims.
4584
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS parent_id uuid;
4585
+ ALTER TABLE slotlock.resources ADD COLUMN IF NOT EXISTS part_index integer;
4586
+ DO $slotlock_resources_parent$
4587
+ BEGIN
4588
+ IF NOT EXISTS (
4589
+ SELECT 1 FROM pg_catalog.pg_constraint
4590
+ WHERE conrelid = 'slotlock.resources'::regclass AND conname = 'slotlock_resources_parent_fkey'
4591
+ ) THEN
4592
+ -- Deleting a parent sets its parts free (the store refuses it while anything on it is live).
4593
+ ALTER TABLE slotlock.resources ADD CONSTRAINT slotlock_resources_parent_fkey
4594
+ FOREIGN KEY (parent_id, tenant_ref) REFERENCES slotlock.resources (id, tenant_ref)
4595
+ ON DELETE SET NULL (parent_id) NOT VALID;
4596
+ ALTER TABLE slotlock.resources ADD CONSTRAINT slotlock_resources_part_valid CHECK (
4597
+ (parent_id IS NULL OR part_index IS NOT NULL)
4598
+ AND (part_index IS NULL OR part_index BETWEEN 0 AND ${SLOTLOCK_MAX_COMPOSITE_PARTS - 1})
4599
+ AND parent_id IS DISTINCT FROM id
4600
+ ) NOT VALID;
4601
+ END IF;
4602
+ END
4603
+ $slotlock_resources_parent$;
4604
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_resources_part_idx
4605
+ ON slotlock.resources (parent_id, part_index) WHERE parent_id IS NOT NULL;
4606
+
4607
+ -- One level, ${SLOTLOCK_MAX_COMPOSITE_PARTS} parts at most, and never while either side holds a
4608
+ -- booking or live hold that has not ended: claims are made as bookings are, never backfilled.
4609
+ CREATE OR REPLACE FUNCTION slotlock.resources_parent_guard()
4610
+ RETURNS trigger
4611
+ LANGUAGE plpgsql
4612
+ SET search_path = pg_catalog, slotlock, pg_temp
4613
+ AS $slotlock_resources_parent_guard$
4614
+ BEGIN
4615
+ IF NEW.parent_id IS NOT DISTINCT FROM OLD.parent_id THEN
4616
+ RETURN NEW;
4617
+ END IF;
4618
+ PERFORM pg_advisory_xact_lock(hashtextextended('slotlock:composite:' || COALESCE(NEW.parent_id, OLD.parent_id)::text, 0));
4619
+ IF NEW.parent_id IS NOT NULL THEN
4620
+ IF EXISTS (SELECT 1 FROM slotlock.resources parent WHERE parent.id = NEW.parent_id AND parent.parent_id IS NOT NULL)
4621
+ OR EXISTS (SELECT 1 FROM slotlock.resources part WHERE part.parent_id = NEW.id) THEN
4622
+ RAISE EXCEPTION 'Slotlock composites are one level deep: a part has no parts, and a parent is no part'
4623
+ USING ERRCODE = 'check_violation', HINT = 'composite_too_deep';
4624
+ END IF;
4625
+ IF (SELECT count(*) FROM slotlock.resources part WHERE part.parent_id = NEW.parent_id)
4626
+ >= ${SLOTLOCK_MAX_COMPOSITE_PARTS} THEN
4627
+ RAISE EXCEPTION 'Slotlock composites have at most ${SLOTLOCK_MAX_COMPOSITE_PARTS} parts'
4628
+ USING ERRCODE = 'check_violation', HINT = 'composite_parts_limit';
4629
+ END IF;
4630
+ NEW.part_index := COALESCE(
4631
+ (SELECT min(free.slot) FROM pg_catalog.generate_series(0, ${SLOTLOCK_MAX_COMPOSITE_PARTS - 1}) AS free(slot)
4632
+ WHERE NOT EXISTS (SELECT 1 FROM slotlock.resources part
4633
+ WHERE part.parent_id = NEW.parent_id AND part.part_index = free.slot)),
4634
+ 0);
4635
+ ELSE
4636
+ NEW.part_index := NULL;
4637
+ END IF;
4638
+ IF EXISTS (
4639
+ SELECT 1 FROM slotlock.reservations booked
4640
+ WHERE booked.resource_id IN (NEW.id, NEW.parent_id, OLD.parent_id)
4641
+ AND COALESCE(booked.occupied_ends_at, booked.ends_at) > statement_timestamp()
4642
+ AND (booked.status <> 'held' OR booked.expires_at > statement_timestamp())
4643
+ ) THEN
4644
+ RAISE EXCEPTION 'Slotlock changes a composite only while neither side has a booking or live hold to come'
4645
+ USING ERRCODE = 'check_violation', HINT = 'composite_busy';
4646
+ END IF;
4647
+ RETURN NEW;
4648
+ END
4649
+ $slotlock_resources_parent_guard$;
4650
+ CREATE OR REPLACE TRIGGER slotlock_resources_parent_guard
4651
+ BEFORE UPDATE OF parent_id ON slotlock.resources
4652
+ FOR EACH ROW EXECUTE FUNCTION slotlock.resources_parent_guard();
4653
+
4654
+ CREATE TABLE IF NOT EXISTS slotlock.composite_claims (
4655
+ reservation_id uuid PRIMARY KEY REFERENCES slotlock.reservations (id) ON DELETE CASCADE,
4656
+ tenant_ref text NOT NULL,
4657
+ -- The composite: the parent, whichever of its resources the booking is on.
4658
+ resource_id uuid NOT NULL REFERENCES slotlock.resources (id) ON DELETE CASCADE,
4659
+ booked_resource_id uuid NOT NULL,
4660
+ span int4range NOT NULL,
4661
+ during tstzrange NOT NULL,
4662
+ status text NOT NULL,
4663
+ expires_at timestamptz,
4664
+ CONSTRAINT slotlock_composite_claims_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500 AND NOT isempty(span)),
4665
+ CONSTRAINT slotlock_composite_claims_no_overlap EXCLUDE USING gist (
4666
+ resource_id WITH =,
4667
+ during WITH &&,
4668
+ span WITH &&
4669
+ )
4670
+ );
4671
+ CREATE INDEX IF NOT EXISTS slotlock_composite_claims_booked_idx
4672
+ ON slotlock.composite_claims (booked_resource_id);
4673
+
4674
+ -- Each booking or hold on a part or a parent claims its place on the composite, in the same
4675
+ -- statement: a claim that overlaps another fails it with 23P01, as a booking that overlaps one on
4676
+ -- its own resource does.
4677
+ CREATE OR REPLACE FUNCTION slotlock.composite_claim()
4678
+ RETURNS trigger
4679
+ LANGUAGE plpgsql
4680
+ SET search_path = pg_catalog, slotlock, pg_temp
4681
+ AS $slotlock_composite_claim$
4682
+ DECLARE
4683
+ booked record;
4684
+ composite uuid;
4685
+ claimed int4range;
4686
+ BEGIN
4687
+ -- An update that moves nothing the claim holds leaves it (no column list on the trigger, so an
4688
+ -- upgrade that adds a column later in the DDL never finds one here first).
4689
+ IF TG_OP = 'UPDATE'
4690
+ AND NEW.resource_id = OLD.resource_id
4691
+ AND NEW.starts_at = OLD.starts_at AND NEW.ends_at = OLD.ends_at
4692
+ AND NEW.occupied_starts_at IS NOT DISTINCT FROM OLD.occupied_starts_at
4693
+ AND NEW.occupied_ends_at IS NOT DISTINCT FROM OLD.occupied_ends_at
4694
+ AND NEW.status = OLD.status
4695
+ AND NEW.expires_at IS NOT DISTINCT FROM OLD.expires_at THEN
4696
+ RETURN NULL;
4697
+ END IF;
4698
+ IF TG_OP <> 'INSERT' THEN
4699
+ DELETE FROM slotlock.composite_claims claim WHERE claim.reservation_id = OLD.id;
4700
+ END IF;
4701
+ IF TG_OP = 'DELETE' THEN
4702
+ RETURN NULL;
4703
+ END IF;
4704
+ SELECT resource.parent_id, resource.part_index, resource.tenant_ref,
4705
+ EXISTS (SELECT 1 FROM slotlock.resources part WHERE part.parent_id = resource.id) AS whole
4706
+ INTO booked
4707
+ FROM slotlock.resources resource WHERE resource.id = NEW.resource_id;
4708
+ IF booked.parent_id IS NOT NULL THEN
4709
+ composite := booked.parent_id;
4710
+ claimed := int4range(booked.part_index, booked.part_index + 1);
4711
+ ELSIF booked.whole THEN
4712
+ composite := NEW.resource_id;
4713
+ claimed := int4range(0, NULL);
4714
+ ELSE
4715
+ RETURN NULL;
4716
+ END IF;
4717
+ INSERT INTO slotlock.composite_claims (
4718
+ reservation_id, tenant_ref, resource_id, booked_resource_id, span, during, status, expires_at
4719
+ ) VALUES (
4720
+ NEW.id, booked.tenant_ref, composite, NEW.resource_id, claimed,
4721
+ tstzrange(COALESCE(NEW.occupied_starts_at, NEW.starts_at), COALESCE(NEW.occupied_ends_at, NEW.ends_at), '[)'),
4722
+ NEW.status, NEW.expires_at
4723
+ );
4724
+ RETURN NULL;
4725
+ END
4726
+ $slotlock_composite_claim$;
4727
+ CREATE OR REPLACE TRIGGER slotlock_reservations_composite_claim
4728
+ AFTER INSERT OR UPDATE OR DELETE ON slotlock.reservations
4729
+ FOR EACH ROW EXECUTE FUNCTION slotlock.composite_claim();
4730
+
4731
+ -- Approval policies (RFC 0004 A4): which writes a person approves before they are made, or
4732
+ -- refuses outright. applies_to names resources, pools, locations (each with the locations in it)
4733
+ -- or groups ({} is every resource); conditions name what makes a write need it: longer than some
4734
+ -- minutes, outside the resource's hours, or a kind of write. The wait is the policy's own, bounded
4735
+ -- by the booking's start less lead_minutes and by the server's maximum.
4736
+ CREATE TABLE IF NOT EXISTS slotlock.approval_policies (
4737
+ tenant_ref text NOT NULL,
4738
+ ref text NOT NULL,
4739
+ name text NOT NULL,
4740
+ applies_to jsonb NOT NULL DEFAULT '{}'::jsonb,
4741
+ conditions jsonb NOT NULL DEFAULT '{}'::jsonb,
4742
+ outcome text NOT NULL,
4743
+ approval_ttl_minutes integer NOT NULL DEFAULT 1440,
4744
+ lead_minutes integer NOT NULL DEFAULT 0,
4745
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
4746
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
4747
+ PRIMARY KEY (tenant_ref, ref),
4748
+ CONSTRAINT slotlock_approval_policies_valid CHECK (
4749
+ octet_length(tenant_ref) BETWEEN 1 AND 500
4750
+ AND ref ~ '^[a-z0-9][a-z0-9_-]{0,62}$'
4751
+ AND char_length(name) BETWEEN 1 AND 200
4752
+ AND jsonb_typeof(applies_to) = 'object' AND jsonb_typeof(conditions) = 'object'
4753
+ AND outcome IN ('require_approval', 'refuse')
4754
+ AND approval_ttl_minutes BETWEEN 1 AND 43200
4755
+ AND lead_minutes BETWEEN 0 AND 43200
4756
+ )
4757
+ );
4758
+
4759
+ -- A write waiting for a person: its time held by holds whose holder is the approval (never the
4760
+ -- agent, so it cannot release them and they do not count against its caps), and the request to run
4761
+ -- as its requester when granted, in the same transaction that lets the holds go.
4762
+ CREATE TABLE IF NOT EXISTS slotlock.approvals (
4763
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4764
+ tenant_ref text NOT NULL,
4765
+ policy_ref text NOT NULL,
4766
+ status text NOT NULL DEFAULT 'pending',
4767
+ operation text NOT NULL,
4768
+ request jsonb NOT NULL,
4769
+ requester jsonb NOT NULL,
4770
+ hold_ids uuid[] NOT NULL,
4771
+ -- What it holds, kept for the record once the holds are gone.
4772
+ resource_ids uuid[] NOT NULL,
4773
+ starts_at timestamptz NOT NULL,
4774
+ ends_at timestamptz NOT NULL,
4775
+ -- The requester, operation and idempotency key, hashed: a retry finds the approval it made.
4776
+ request_key text NOT NULL,
4777
+ expires_at timestamptz NOT NULL,
4778
+ decided_by text,
4779
+ decided_at timestamptz,
4780
+ reason text,
4781
+ created_at timestamptz NOT NULL DEFAULT statement_timestamp(),
4782
+ CONSTRAINT slotlock_approvals_valid CHECK (
4783
+ octet_length(tenant_ref) BETWEEN 1 AND 500
4784
+ AND status IN ('pending', 'granted', 'declined', 'expired')
4785
+ AND operation IN ('slotlock_create_event', 'slotlock_book_together', 'slotlock_update_event')
4786
+ AND jsonb_typeof(request) = 'object' AND jsonb_typeof(requester) = 'object'
4787
+ AND cardinality(hold_ids) BETWEEN 1 AND 20
4788
+ AND cardinality(resource_ids) BETWEEN 1 AND 20 AND ends_at > starts_at
4789
+ AND request_key ~ '^[0-9a-f]{64}$'
4790
+ AND (decided_by IS NULL OR octet_length(decided_by) BETWEEN 1 AND 500)
4791
+ AND (reason IS NULL OR char_length(reason) <= 500)
4792
+ AND ((status = 'pending') = (decided_at IS NULL))
4793
+ )
4794
+ );
4795
+ CREATE UNIQUE INDEX IF NOT EXISTS slotlock_approvals_request_idx
4796
+ ON slotlock.approvals (tenant_ref, request_key);
4797
+ CREATE INDEX IF NOT EXISTS slotlock_approvals_pending_idx
4798
+ ON slotlock.approvals (tenant_ref, created_at, id) WHERE status = 'pending';
4799
+ CREATE INDEX IF NOT EXISTS slotlock_approvals_expiry_idx
4800
+ ON slotlock.approvals (expires_at) WHERE status = 'pending';
4801
+
2385
4802
  -- Every row occupies from its start to its occupied end: its end plus its turnaround buffer. Up to
2386
4803
  -- 0.1 the buffer bound only tenant-owned bookings to each other (slotlock_reservations_buffered_no_overlap),
2387
4804
  -- so a hold could take a booking's buffer and a buffered booking could cover a hold. The upgrade
@@ -3195,7 +5612,7 @@ CREATE INDEX IF NOT EXISTS slotlock_calendar_event_occurrences_resource_time_idx
3195
5612
  ON slotlock.calendar_event_occurrences (tenant_ref, starts_at, ends_at);
3196
5613
  CREATE INDEX IF NOT EXISTS slotlock_reservations_calendar_event_idx
3197
5614
  ON slotlock.reservations (calendar_event_id) WHERE calendar_event_id IS NOT NULL;
3198
- ${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
5615
+ ${SLOTLOCK_OUTBOX_DDL}${SLOTLOCK_API_KEYS_DDL}${SLOTLOCK_PARTNER_DDL}${SLOTLOCK_DASHBOARD_DDL}${SLOTLOCK_TENANT_ADMIN_DDL}${DEPLOYMENT_RECORD_VERSION}`;
3199
5616
  /**
3200
5617
  * Whether the outbox row `row` (a coverage change) is in this release's shape: named by a source_id
3201
5618
  * or src_redacted, in `subject` and `data.source_id` alike, with no `source` or `last_error`.
@@ -3206,9 +5623,9 @@ function coverageChangeInShapeSql(row) {
3206
5623
  AND NOT ${row}.data ?| ARRAY['source', 'last_error'])`;
3207
5624
  }
3208
5625
  /**
3209
- * The source_id an earlier release's coverage change `row` names its source by: the source with
3210
- * its tenant and resource whose source (0.5.2 and earlier: the subject, or `data.source`) or 0.5.3
3211
- * digest (the subject) it carries, else src_redacted.
5626
+ * The source_id an earlier release's coverage change `row` names: the source matching its tenant,
5627
+ * resource and source (0.5.2 and earlier: the subject or `data.source`) or 0.5.3 digest, else
5628
+ * src_redacted.
3212
5629
  */
3213
5630
  function coverageChangeSourceIdSql(row) {
3214
5631
  return `COALESCE((
@@ -3224,16 +5641,12 @@ function coverageChangeSourceIdSql(row) {
3224
5641
  /** Outbox ids one batch of the coverage-change rename reads (by primary key) in one transaction. */
3225
5642
  export const SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH = 5_000;
3226
5643
  /**
3227
- * One batch of the rename of coverage changes an earlier release wrote: of the outbox rows with ids
3228
- * after `after` up to `head` (SQL expressions, bigint), the first `batch` by id, each `coverage.*`
3229
- * one not yet in this release's shape gets its source's source_id as subject and `data.source_id`,
3230
- * and loses `source` and `last_error`. Before 0.5.3 coverage.stale carried the source itself (an
3231
- * iCalendar URL with a token, an address) and the error read from it; 0.5.3 named it by an unkeyed
3232
- * digest anyone could confirm a guess against. A source deleted since is `src_redacted`: nothing
3233
- * can match it any more, and a made-up id would look like a source the tenant could find. Selects
3234
- * the last id read (`last`, null when there was none) and how many it renamed (`renamed`), `INTO`
3235
- * `into` when given (PL/pgSQL). Rows in the new shape are left alone, so a batch run again changes
3236
- * nothing.
5644
+ * One batch of the rename of coverage changes an earlier release wrote: of the outbox rows after
5645
+ * `after` up to `head` (bigint SQL expressions), the first `batch` by id; each `coverage.*` row not
5646
+ * yet in shape gets its source_id as subject and `data.source_id`, and loses `source` and
5647
+ * `last_error` (before 0.5.3 these carried the source itself, a URL with a token; 0.5.3's unkeyed
5648
+ * digest could confirm a guess). A deleted source becomes `src_redacted`. Selects `last` (null when
5649
+ * none) and `renamed`, `INTO` `into` when given. Idempotent.
3237
5650
  */
3238
5651
  export function slotlockCoverageChangeRenameSql(after, head, batch, into = '') {
3239
5652
  return `WITH scanned AS MATERIALIZED (
@@ -3263,10 +5676,9 @@ SELECT (SELECT max(scanned.id) FROM scanned)::text AS last,
3263
5676
  (SELECT count(*) FROM renamed)::integer AS renamed${into ? `
3264
5677
  INTO ${into}` : ''}`;
3265
5678
  }
3266
- // The rename for SLOTLOCK_CORE_DDL, run whole in its one transaction, while FORCE is lifted (the
3267
- // structure has the outbox under ACCESS EXCLUSIVE for the whole transaction, so no change commits
3268
- // in between). It goes on from where the last pass stopped, and is skipped once done. applySchema
3269
- // runs it in batches after the structure commits instead, each its own short transaction.
5679
+ // The rename for SLOTLOCK_CORE_DDL, run whole in its one transaction while FORCE is lifted (the
5680
+ // outbox is under ACCESS EXCLUSIVE throughout). Resumes where the last pass stopped and is skipped
5681
+ // once done; applySchema instead runs it in batches after the structure commits.
3270
5682
  const DEPLOYMENT_RENAME_COVERAGE_CHANGES = `DO $slotlock_rename_coverage_changes$
3271
5683
  DECLARE
3272
5684
  scan_after bigint;
@@ -3330,18 +5742,23 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
3330
5742
  'booking_rule_defaults',
3331
5743
  'assistant_settings',
3332
5744
  'assistant_conversations',
5745
+ 'locations',
5746
+ 'resource_groups',
5747
+ 'resource_group_members',
5748
+ 'pool_settings',
5749
+ 'composite_claims',
5750
+ 'approval_policies',
5751
+ 'approvals',
3333
5752
  ]);
3334
5753
  const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
3335
5754
  const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
3336
5755
  /** Names GRANT reads as a pseudo-role (PUBLIC reaches every role) or PostgreSQL reserves. */
3337
5756
  const RESERVED_ROLE_NAMES = new Set(['public', 'current_role', 'current_user', 'session_user']);
3338
5757
  /**
3339
- * Build the grants for the application role: USAGE on the `slotlock` schema, DML on the store's
3340
- * tables and EXECUTE on the API key and dashboard functions. Nothing else: no ownership, DDL,
3341
- * sequence, RLS-contract, API key or dashboard table access. The role must not be
3342
- * able to leave forced RLS, itself or through a role it belongs to (`grantApplicationRole` refuses
3343
- * the known ways out; these statements check nothing). Rerun after every `applySchema()`, since a
3344
- * release may add a table.
5758
+ * Build the grants for the application role: USAGE on the schema, DML on the store's tables and
5759
+ * EXECUTE on the API key and dashboard functions, nothing else. The role must not be able to leave
5760
+ * forced RLS (`grantApplicationRole` checks; these statements do not). Rerun after every
5761
+ * `applySchema()`, since a release may add a table.
3345
5762
  */
3346
5763
  export function createSlotlockApplicationRoleGrantsDdl(role) {
3347
5764
  if (!POSTGRES_ROLE_NAME.test(role) || RESERVED_ROLE_NAMES.has(role) || role.startsWith('pg_')) {
@@ -3358,6 +5775,46 @@ GRANT EXECUTE ON FUNCTION ${SLOTLOCK_DEFINER_FUNCTIONS.join(', ')} TO "${role}";
3358
5775
  * `slotlock.tenant_ref`; adapters may supply an established transaction-local setting name.
3359
5776
  * Every policy fails closed when the setting is absent or empty, and FORCE binds the table owner.
3360
5777
  */
5778
+ /**
5779
+ * The transaction-local setting withTenant sets for a key limited to part of its tenant (RFC 0003
5780
+ * G6): the ids of the resources it reaches, as a uuid[] literal; empty for every other caller.
5781
+ */
5782
+ export const SLOTLOCK_RESOURCE_SCOPE_SETTING = 'slotlock.resource_scope';
5783
+ const RESOURCE_SCOPE_OPEN = `NULLIF(current_setting('${SLOTLOCK_RESOURCE_SCOPE_SETTING}', true), '') IS NULL`;
5784
+ const resourceScopeAllows = (column) => `(${RESOURCE_SCOPE_OPEN} OR ${column} = ANY (NULLIF(current_setting('${SLOTLOCK_RESOURCE_SCOPE_SETTING}', true), '')::uuid[]))`;
5785
+ /**
5786
+ * The tables a key limited to part of its tenant is held to, and how each row names its resource:
5787
+ * the resource itself, or the event it belongs to. Tombstones and archives name none and stay as the
5788
+ * tenant policy leaves them.
5789
+ */
5790
+ export const SLOTLOCK_RESOURCE_SCOPED_TABLES = Object.freeze({
5791
+ resources: resourceScopeAllows('id'),
5792
+ reservations: resourceScopeAllows('resource_id'),
5793
+ calendar_events: resourceScopeAllows('resource_id'),
5794
+ calendar_coverage: resourceScopeAllows('resource_id'),
5795
+ hold_commands: resourceScopeAllows('resource_id'),
5796
+ resource_group_members: resourceScopeAllows('resource_id'),
5797
+ // A change or a waitlist entry about no one resource (a key, a pool) is not the key's.
5798
+ outbox: resourceScopeAllows('resource_id'),
5799
+ waitlist_entries: resourceScopeAllows('resource_id'),
5800
+ ...Object.fromEntries([
5801
+ 'calendar_event_attendees',
5802
+ 'calendar_event_reminders',
5803
+ 'calendar_event_exceptions',
5804
+ 'calendar_event_occurrences',
5805
+ 'calendar_event_commands',
5806
+ ].map((table) => [
5807
+ table,
5808
+ `(${RESOURCE_SCOPE_OPEN} OR EXISTS (SELECT 1 FROM slotlock.calendar_events scoped_event WHERE scoped_event.id = event_id))`,
5809
+ ])),
5810
+ });
5811
+ /**
5812
+ * One restrictive policy on each table SLOTLOCK_RESOURCE_SCOPED_TABLES names: it only narrows what
5813
+ * the tenant policy allows, for reads and writes alike, and is open when no scope is set.
5814
+ */
5815
+ const SLOTLOCK_RESOURCE_SCOPE_POLICIES = Object.entries(SLOTLOCK_RESOURCE_SCOPED_TABLES)
5816
+ .map(([table, allows]) => `CREATE POLICY resource_scope_v1 ON slotlock.${table} AS RESTRICTIVE USING ${allows} WITH CHECK ${allows};`)
5817
+ .join('\n');
3361
5818
  export function createSlotlockTenantRlsDdl(tenantContextSetting = SLOTLOCK_TENANT_CONTEXT_SETTING) {
3362
5819
  if (tenantContextSetting.length > 128 ||
3363
5820
  !POSTGRES_SETTING_NAME.test(tenantContextSetting) ||
@@ -3405,6 +5862,20 @@ ALTER TABLE slotlock.assistant_settings ENABLE ROW LEVEL SECURITY;
3405
5862
  ALTER TABLE slotlock.assistant_settings FORCE ROW LEVEL SECURITY;
3406
5863
  ALTER TABLE slotlock.assistant_conversations ENABLE ROW LEVEL SECURITY;
3407
5864
  ALTER TABLE slotlock.assistant_conversations FORCE ROW LEVEL SECURITY;
5865
+ ALTER TABLE slotlock.locations ENABLE ROW LEVEL SECURITY;
5866
+ ALTER TABLE slotlock.locations FORCE ROW LEVEL SECURITY;
5867
+ ALTER TABLE slotlock.resource_groups ENABLE ROW LEVEL SECURITY;
5868
+ ALTER TABLE slotlock.resource_groups FORCE ROW LEVEL SECURITY;
5869
+ ALTER TABLE slotlock.resource_group_members ENABLE ROW LEVEL SECURITY;
5870
+ ALTER TABLE slotlock.resource_group_members FORCE ROW LEVEL SECURITY;
5871
+ ALTER TABLE slotlock.pool_settings ENABLE ROW LEVEL SECURITY;
5872
+ ALTER TABLE slotlock.pool_settings FORCE ROW LEVEL SECURITY;
5873
+ ALTER TABLE slotlock.composite_claims ENABLE ROW LEVEL SECURITY;
5874
+ ALTER TABLE slotlock.composite_claims FORCE ROW LEVEL SECURITY;
5875
+ ALTER TABLE slotlock.approval_policies ENABLE ROW LEVEL SECURITY;
5876
+ ALTER TABLE slotlock.approval_policies FORCE ROW LEVEL SECURITY;
5877
+ ALTER TABLE slotlock.approvals ENABLE ROW LEVEL SECURITY;
5878
+ ALTER TABLE slotlock.approvals FORCE ROW LEVEL SECURITY;
3408
5879
 
3409
5880
  -- One database must have one tenant-context contract. Re-applying with an adapter-specific
3410
5881
  -- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
@@ -3436,7 +5907,14 @@ BEGIN
3436
5907
  'waitlist_entries',
3437
5908
  'booking_rule_defaults',
3438
5909
  'assistant_settings',
3439
- 'assistant_conversations'
5910
+ 'assistant_conversations',
5911
+ 'locations',
5912
+ 'resource_groups',
5913
+ 'resource_group_members',
5914
+ 'pool_settings',
5915
+ 'composite_claims',
5916
+ 'approval_policies',
5917
+ 'approvals'
3440
5918
  )
3441
5919
  LOOP
3442
5920
  EXECUTE format(
@@ -3677,6 +6155,76 @@ BEGIN
3677
6155
  USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
3678
6156
  WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
3679
6157
  END IF;
6158
+
6159
+ IF NOT EXISTS (
6160
+ SELECT 1 FROM pg_policies
6161
+ WHERE schemaname = 'slotlock' AND tablename = 'locations'
6162
+ AND policyname = 'tenant_isolation_v1'
6163
+ ) THEN
6164
+ CREATE POLICY tenant_isolation_v1 ON slotlock.locations
6165
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6166
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6167
+ END IF;
6168
+
6169
+ IF NOT EXISTS (
6170
+ SELECT 1 FROM pg_policies
6171
+ WHERE schemaname = 'slotlock' AND tablename = 'resource_groups'
6172
+ AND policyname = 'tenant_isolation_v1'
6173
+ ) THEN
6174
+ CREATE POLICY tenant_isolation_v1 ON slotlock.resource_groups
6175
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6176
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6177
+ END IF;
6178
+
6179
+ IF NOT EXISTS (
6180
+ SELECT 1 FROM pg_policies
6181
+ WHERE schemaname = 'slotlock' AND tablename = 'resource_group_members'
6182
+ AND policyname = 'tenant_isolation_v1'
6183
+ ) THEN
6184
+ CREATE POLICY tenant_isolation_v1 ON slotlock.resource_group_members
6185
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6186
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6187
+ END IF;
6188
+
6189
+ IF NOT EXISTS (
6190
+ SELECT 1 FROM pg_policies
6191
+ WHERE schemaname = 'slotlock' AND tablename = 'pool_settings'
6192
+ AND policyname = 'tenant_isolation_v1'
6193
+ ) THEN
6194
+ CREATE POLICY tenant_isolation_v1 ON slotlock.pool_settings
6195
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6196
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6197
+ END IF;
6198
+
6199
+ IF NOT EXISTS (
6200
+ SELECT 1 FROM pg_policies
6201
+ WHERE schemaname = 'slotlock' AND tablename = 'composite_claims'
6202
+ AND policyname = 'tenant_isolation_v1'
6203
+ ) THEN
6204
+ CREATE POLICY tenant_isolation_v1 ON slotlock.composite_claims
6205
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6206
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6207
+ END IF;
6208
+
6209
+ IF NOT EXISTS (
6210
+ SELECT 1 FROM pg_policies
6211
+ WHERE schemaname = 'slotlock' AND tablename = 'approval_policies'
6212
+ AND policyname = 'tenant_isolation_v1'
6213
+ ) THEN
6214
+ CREATE POLICY tenant_isolation_v1 ON slotlock.approval_policies
6215
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6216
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6217
+ END IF;
6218
+
6219
+ IF NOT EXISTS (
6220
+ SELECT 1 FROM pg_policies
6221
+ WHERE schemaname = 'slotlock' AND tablename = 'approvals'
6222
+ AND policyname = 'tenant_isolation_v1'
6223
+ ) THEN
6224
+ CREATE POLICY tenant_isolation_v1 ON slotlock.approvals
6225
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
6226
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
6227
+ END IF;
3680
6228
  END
3681
6229
  $slotlock_rls$;
3682
6230
 
@@ -3720,6 +6268,21 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_settings
3720
6268
  IS 'slotlock:tenant-context:${tenantContextSetting}';
3721
6269
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
3722
6270
  IS 'slotlock:tenant-context:${tenantContextSetting}';
6271
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.locations
6272
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
6273
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.resource_groups
6274
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
6275
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.resource_group_members
6276
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
6277
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.pool_settings
6278
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
6279
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.composite_claims
6280
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
6281
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.approval_policies
6282
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
6283
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.approvals
6284
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
6285
+ ${SLOTLOCK_RESOURCE_SCOPE_POLICIES}
3723
6286
 
3724
6287
  INSERT INTO slotlock.rls_policy_contracts (
3725
6288
  table_name, tenant_context_setting, policy_fingerprint, installed_at
@@ -3762,7 +6325,14 @@ WHERE schemaname = 'slotlock'
3762
6325
  'waitlist_entries',
3763
6326
  'booking_rule_defaults',
3764
6327
  'assistant_settings',
3765
- 'assistant_conversations'
6328
+ 'assistant_conversations',
6329
+ 'locations',
6330
+ 'resource_groups',
6331
+ 'resource_group_members',
6332
+ 'pool_settings',
6333
+ 'composite_claims',
6334
+ 'approval_policies',
6335
+ 'approvals'
3766
6336
  )
3767
6337
  AND policyname = 'tenant_isolation_v1'
3768
6338
  ON CONFLICT (table_name) DO UPDATE