slotlock 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-store-backend.js +209 -15
  5. package/application-api.d.ts +10 -1
  6. package/application-api.js +213 -2
  7. package/availability-store.js +13 -5
  8. package/booking-rules.d.ts +11 -0
  9. package/booking-rules.js +21 -1
  10. package/config.d.ts +50 -0
  11. package/config.js +107 -0
  12. package/dashboard-scope.d.ts +7 -0
  13. package/dashboard-scope.js +21 -0
  14. package/dashboard.d.ts +97 -2
  15. package/dashboard.js +3068 -284
  16. package/ddl-identity.d.ts +25 -0
  17. package/ddl-identity.js +881 -0
  18. package/ddl-scim.d.ts +5 -0
  19. package/ddl-scim.js +740 -0
  20. package/ddl-sso.d.ts +15 -0
  21. package/ddl-sso.js +1033 -0
  22. package/ddl.d.ts +40 -9
  23. package/ddl.js +2123 -49
  24. package/domains.d.ts +117 -0
  25. package/domains.js +340 -0
  26. package/event-feed.d.ts +1 -1
  27. package/event-feed.js +6 -34
  28. package/holiday-import.d.ts +1 -1
  29. package/holiday-import.js +2 -3
  30. package/holidays.js +70 -11
  31. package/index.d.ts +12 -4
  32. package/index.js +8 -2
  33. package/invitation-email.d.ts +26 -0
  34. package/invitation-email.js +73 -0
  35. package/maintenance.d.ts +7 -2
  36. package/maintenance.js +15 -1
  37. package/oidc.d.ts +135 -0
  38. package/oidc.js +452 -0
  39. package/openapi.json +1886 -6
  40. package/package.json +2 -1
  41. package/scim-server.d.ts +19 -0
  42. package/scim-server.js +243 -0
  43. package/scim.d.ts +180 -0
  44. package/scim.js +416 -0
  45. package/self-host.d.ts +19 -6
  46. package/self-host.js +262 -7
  47. package/slotlock.manifest.json +1 -1
  48. package/store.d.ts +273 -2
  49. package/store.js +984 -72
  50. package/types.d.ts +33 -1
  51. package/types.js +68 -1
  52. package/workspace-domains.d.ts +146 -0
  53. package/workspace-domains.js +249 -0
  54. package/workspace-roles.d.ts +47 -0
  55. package/workspace-roles.js +304 -0
  56. package/workspace-scim.d.ts +89 -0
  57. package/workspace-scim.js +157 -0
  58. package/workspace-sso.d.ts +131 -0
  59. package/workspace-sso.js +180 -0
  60. package/workspaces.d.ts +240 -0
  61. package/workspaces.js +433 -0
package/store.js CHANGED
@@ -1,15 +1,17 @@
1
1
  // DB mechanics for the Slotlock core: explicit `postgres` client in, structured results out.
2
2
  import { SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_POOL_ORDERS, SLOTLOCK_RESOURCE_KINDS, isSlotlockResourceScope, parseSlotlockLocation, parseSlotlockResourceGroup, parseSlotlockResourceProfile, slotlockGroupingRef, } from './resource-profile.js';
3
3
  import { readSlotlockTenantUsage } from './usage.js';
4
+ import { isSlotlockWorkspaceRole } from './workspace-roles.js';
4
5
  import { slotlockPlanReadRefusal } from './plans.js';
5
6
  import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_RETENTION_DAYS, isSlotlockChangeOrigin, slotlockEventFeedMethods, } from './event-feed.js';
6
7
  import { cancellationFeeDue, cutoffRuleBroken, bookingBufferBeforeMs, bookingSearchBlocked, bookingSearchReach, parseBookingRuleDefaults, parseEffectiveBookingRules, bookingBufferAfterMs, bookingRuleBroken, bookingSearch, parseBookingRules, } from './booking-rules.js';
7
- import { createHash, randomUUID } from 'node:crypto';
8
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
8
9
  import ICAL from 'ical.js';
9
10
  import { SLOTLOCK_ASSISTANT_CHAT_ID, SLOTLOCK_ASSISTANT_MAX_CHATS, SLOTLOCK_CORE_DDL, SLOTLOCK_COVERAGE_CHANGE_RENAME_BATCH, SLOTLOCK_DEPLOYMENT_CHECKS, SLOTLOCK_SCHEMA_CONTROL_REFUSAL, SLOTLOCK_SCHEMA_NEWER_REFUSAL, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_STRUCTURE_DDL, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_RESOURCE_SCOPED_TABLES, SLOTLOCK_RESOURCE_SCOPE_SETTING, SLOTLOCK_TENANT_TABLES, SLOTLOCK_VALIDATION_DDL, createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, slotlockCoverageChangeRenameSql, slotlockTenantLockKey, } from './ddl.js';
10
11
  import { findNextAvailable, mergeIntervals } from './engine.js';
11
12
  import { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX, assertBoundedSearchWindow, expandRules, invalidWindowError, } from './rules.js';
12
13
  import { CalendarContractError, CalendarExpansionLimitError, expandCalendarEventSeries, normalizeCalendarEventContent, } from './sync.js';
14
+ import { SLOTLOCK_SUBJECT_TABLES, SLOTLOCK_TENANT_EXPORT_TABLES, } from './types.js';
13
15
  import { parseAvailabilityRules } from './weekly-hours.js';
14
16
  import { applicableHours, exceptionQueryDays } from './hours.js';
15
17
  import { zonedDateTimeToUtc } from './timezone.js';
@@ -34,6 +36,29 @@ const MAX_HOLD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
34
36
  const MAX_BUFFER_AFTER_MS = 30 * 24 * 60 * 60 * 1000;
35
37
  /** A day, as bufferBeforeMinutes allows: a read by start can then still use the index. */
36
38
  const MAX_BUFFER_BEFORE_MS = 24 * 60 * 60 * 1000;
39
+ /** An audit action: `area.verb` (or `area.thing.verb`), lowercase. */
40
+ const AUDIT_ACTION = /^[a-z][a-z_]{0,31}(\.[a-z][a-z_]{0,31}){1,2}$/;
41
+ const AUDIT_TARGET_TYPE = /^[a-z][a-z_]{0,31}$/;
42
+ const AUDIT_ACTOR_TYPES = new Set(['person', 'api_key', 'assistant', 'system']);
43
+ /** Text cut to at most `max` UTF-8 bytes on a character boundary; null when absent or empty. */
44
+ function cutBytes(value, max) {
45
+ if (value === undefined || value === '')
46
+ return null;
47
+ if (Buffer.byteLength(value, 'utf8') <= max)
48
+ return value;
49
+ let out = '';
50
+ for (const character of value) {
51
+ if (Buffer.byteLength(out + character, 'utf8') > max)
52
+ break;
53
+ out += character;
54
+ }
55
+ return out === '' ? null : out;
56
+ }
57
+ /** Audit metadata kept under the column's 4 KB: past it, the event says it was cut. */
58
+ function boundAuditMetadata(metadata) {
59
+ const text = JSON.stringify(metadata);
60
+ return text !== undefined && Buffer.byteLength(text, 'utf8') <= 3_000 ? metadata : { truncated: true };
61
+ }
37
62
  export { SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX };
38
63
  /**
39
64
  * Longest one-off event (ten years and some leap days); a recurring series is bounded by its
@@ -875,6 +900,169 @@ function pageLimit(limit) {
875
900
  function sandboxTestOnlyError() {
876
901
  return Object.assign(new Error('Slotlock does this only in a test environment (a tenant ending ~test)'), { code: 'sandbox_test_only' });
877
902
  }
903
+ /** Columns an export leaves out of a table it holds: secrets, and leases only a server reads. */
904
+ const TENANT_EXPORT_LEFT_OUT_COLUMNS = {
905
+ webhook_endpoints: ['secret', 'lease_token', 'lease_until'],
906
+ assistant_settings: ['sealed_key'],
907
+ };
908
+ /**
909
+ * Every table an export holds, as one statement (so one snapshot): each row as its table's name
910
+ * and a JSON line, without the tenant. `$1` is the tenant. A hold keeps no tenant of its own: its
911
+ * resource's is its. API keys come from slotlock.list_api_keys, which holds no digest.
912
+ */
913
+ const TENANT_EXPORT_SQL = SLOTLOCK_TENANT_EXPORT_TABLES.map((table) => {
914
+ if (table === 'api_keys') {
915
+ return `SELECT 'api_keys'::text AS table_name, (pg_catalog.to_jsonb(kept) - 'key_tenant_ref')::text AS line
916
+ FROM slotlock.list_api_keys($1) kept`;
917
+ }
918
+ const dropped = ['tenant_ref', ...(TENANT_EXPORT_LEFT_OUT_COLUMNS[table] ?? [])].map((column) => ` - '${column}'`).join('');
919
+ const where = table === 'reservations'
920
+ ? `kept.tenant_ref = $1 OR (kept.tenant_ref IS NULL AND kept.resource_id IN (
921
+ SELECT owned.id FROM slotlock.resources owned WHERE owned.tenant_ref = $1))`
922
+ : 'kept.tenant_ref = $1';
923
+ return `SELECT '${table}'::text AS table_name, (pg_catalog.to_jsonb(kept)${dropped})::text AS line
924
+ FROM slotlock.${table} kept WHERE ${where}`;
925
+ }).join('\nUNION ALL\n');
926
+ /**
927
+ * Read everything the tenant keeps through one cursor (one snapshot), handing each part (one
928
+ * table's rows, up to SLOTLOCK_TENANT_EXPORT_PART_ROWS or about a megabyte) to `onPart`.
929
+ */
930
+ async function readTenantExport(tx, tenantRef, onPart) {
931
+ const totals = { rows: 0, bytes: 0, tables: {} };
932
+ let current = null;
933
+ const flush = async () => {
934
+ if (current && current.lines.length > 0)
935
+ await onPart(current);
936
+ current = null;
937
+ };
938
+ await tx.unsafe(`DECLARE slotlock_tenant_export NO SCROLL CURSOR FOR\n${TENANT_EXPORT_SQL}`, [tenantRef]);
939
+ for (;;) {
940
+ const rows = await tx.unsafe('FETCH 1000 FROM slotlock_tenant_export');
941
+ if (rows.length === 0)
942
+ break;
943
+ for (const row of rows) {
944
+ const open = current;
945
+ if (open &&
946
+ (open.table !== row.table_name ||
947
+ open.lines.length >= SLOTLOCK_TENANT_EXPORT_PART_ROWS ||
948
+ open.bytes >= TENANT_EXPORT_PART_BYTES)) {
949
+ await flush();
950
+ }
951
+ current ??= { table: row.table_name, lines: [], bytes: 0 };
952
+ const bytes = Buffer.byteLength(row.line) + 1;
953
+ current.lines.push(row.line);
954
+ current.bytes += bytes;
955
+ totals.rows += 1;
956
+ totals.bytes += bytes;
957
+ totals.tables[row.table_name] = (totals.tables[row.table_name] ?? 0) + 1;
958
+ }
959
+ }
960
+ await flush();
961
+ await tx.unsafe('CLOSE slotlock_tenant_export');
962
+ return totals;
963
+ }
964
+ function toTenantExport(row) {
965
+ return {
966
+ id: row.id,
967
+ status: row.status,
968
+ requestedBy: row.requested_by,
969
+ createdAt: row.created_at,
970
+ finishedAt: row.finished_at,
971
+ expiresAt: row.expires_at,
972
+ parts: row.parts,
973
+ rows: Number(row.row_count),
974
+ bytes: Number(row.bytes),
975
+ tables: row.tables,
976
+ error: row.error,
977
+ };
978
+ }
979
+ function tenantExportRefusal(code) {
980
+ const message = {
981
+ export_in_progress: 'Slotlock builds one export of a tenant at a time: wait for the one asked for',
982
+ export_limit_reached: `Slotlock keeps at most ${SLOTLOCK_MAX_TENANT_EXPORTS} exports of a tenant: delete one first`,
983
+ export_too_large: `Slotlock exports at most ${SLOTLOCK_TENANT_EXPORT_MAX_BYTES / 1024 / 1024} MB through the API: its operator runs slotlock tenant export`,
984
+ }[code];
985
+ return Object.assign(new Error(message), { code });
986
+ }
987
+ /** A subject request's email: one address, compared without case. */
988
+ const SUBJECT_EMAIL = /^[^\s@]+@[^\s@]+$/;
989
+ /** Whom a subject request names, checked; null when it names none, more than one, or a bad one. */
990
+ function subjectKey(subject) {
991
+ if (typeof subject !== 'object' || subject === null)
992
+ return null;
993
+ const named = Object.entries(subject).filter(([, value]) => value !== undefined);
994
+ if (named.length !== 1)
995
+ return null;
996
+ const [kind, raw] = named[0];
997
+ if (typeof raw !== 'string')
998
+ return null;
999
+ if (kind === 'email') {
1000
+ const value = raw.trim().toLowerCase();
1001
+ const bytes = Buffer.byteLength(value);
1002
+ return bytes >= 3 && bytes <= 320 && SUBJECT_EMAIL.test(value) ? { kind, value } : null;
1003
+ }
1004
+ if (kind === 'contact')
1005
+ return isValidContactRef(raw) ? { kind, value: raw } : null;
1006
+ if (kind === 'holder' || kind === 'reference')
1007
+ return isValidIdentity(raw) ? { kind, value: raw } : null;
1008
+ return null;
1009
+ }
1010
+ function invalidSubjectError() {
1011
+ return Object.assign(new Error('Slotlock names a person by exactly one of email, holder, contact or reference'), { code: 'invalid_subject' });
1012
+ }
1013
+ function emptySubjectCounts() {
1014
+ return Object.fromEntries(SLOTLOCK_SUBJECT_TABLES.map((table) => [table, 0]));
1015
+ }
1016
+ /** The request fields an approval may keep a booking's or an event's reference in. */
1017
+ const SUBJECT_REFERENCE_KEYS = ['reference', 'reservation_ref', 'external_ref', 'event_ref'];
1018
+ /** The approvals whose request names the subject (SQL, on `approval`). */
1019
+ function subjectApprovalSql(tx, key, contactRefs) {
1020
+ const value = key.value;
1021
+ switch (key.kind) {
1022
+ case 'email':
1023
+ return tx `(lower(approval.request -> 'organizer' ->> 'email') = ${value}
1024
+ OR (jsonb_typeof(approval.request -> 'attendees') = 'array'
1025
+ AND EXISTS (SELECT 1 FROM jsonb_array_elements(approval.request -> 'attendees') AS attendee(value)
1026
+ WHERE lower(attendee.value ->> 'email') = ${value}))
1027
+ OR approval.request ->> 'contact_ref' = ANY(${contactRefs}::text[]))`;
1028
+ case 'holder':
1029
+ return tx `approval.request ->> 'holder_ref' = ${value}`;
1030
+ case 'contact':
1031
+ return tx `approval.request ->> 'contact_ref' = ${value}`;
1032
+ case 'reference':
1033
+ return tx `EXISTS (SELECT 1 FROM unnest(${[...SUBJECT_REFERENCE_KEYS]}::text[]) AS field(name)
1034
+ WHERE approval.request ->> field.name = ${value})`;
1035
+ }
1036
+ }
1037
+ /** An approval's request with the subject taken out (SQL, on `approval`). */
1038
+ function subjectScrubbedRequestSql(tx, key, contactRefs, replacement) {
1039
+ const value = key.value;
1040
+ switch (key.kind) {
1041
+ case 'email':
1042
+ return tx `(
1043
+ SELECT CASE WHEN lower(kept.request -> 'organizer' ->> 'email') = ${value} THEN kept.request - 'organizer'
1044
+ ELSE kept.request END
1045
+ FROM (SELECT CASE
1046
+ WHEN jsonb_typeof(scrubbed.request -> 'attendees') = 'array'
1047
+ THEN jsonb_set(scrubbed.request, '{attendees}', coalesce((
1048
+ SELECT jsonb_agg(attendee.value ORDER BY attendee.ordinality)
1049
+ FROM jsonb_array_elements(scrubbed.request -> 'attendees')
1050
+ WITH ORDINALITY AS attendee(value, ordinality)
1051
+ WHERE lower(attendee.value ->> 'email') IS DISTINCT FROM ${value}), '[]'::jsonb))
1052
+ ELSE scrubbed.request END AS request
1053
+ FROM (SELECT CASE WHEN approval.request ->> 'contact_ref' = ANY(${contactRefs}::text[])
1054
+ THEN approval.request - 'contact_ref' ELSE approval.request END AS request) scrubbed) kept)`;
1055
+ case 'holder':
1056
+ return tx `jsonb_set(approval.request, '{holder_ref}', to_jsonb(${replacement}::text))`;
1057
+ case 'contact':
1058
+ return tx `approval.request - 'contact_ref'`;
1059
+ case 'reference':
1060
+ return tx `(
1061
+ SELECT approval.request || coalesce(jsonb_object_agg(field.name, to_jsonb(${replacement}::text)), '{}'::jsonb)
1062
+ FROM unnest(${[...SUBJECT_REFERENCE_KEYS]}::text[]) AS field(name)
1063
+ WHERE approval.request ->> field.name = ${value})`;
1064
+ }
1065
+ }
878
1066
  function invalidIdentityError(message) {
879
1067
  return Object.assign(new Error(message), { code: 'invalid_identity' });
880
1068
  }
@@ -1319,6 +1507,7 @@ async function bookingRuling(tx, resourceId, interval, defaultHours) {
1319
1507
  rule: judge.broken(interval),
1320
1508
  turnaroundMs: judge.turnaroundMs,
1321
1509
  bufferBeforeMs: judge.bufferBeforeMs,
1510
+ holdPrice: judge.holdPrice,
1322
1511
  };
1323
1512
  }
1324
1513
  /**
@@ -1351,10 +1540,23 @@ async function localStartFor(tx, resourceId, localStart, start) {
1351
1540
  async function sourceBusyLoss(tx, resourceId, start, end) {
1352
1541
  const [hit] = await tx `
1353
1542
  SELECT 1 FROM slotlock.source_busy
1354
- WHERE resource_id = ${resourceId} AND starts_at < ${end} AND ends_at > ${start}
1543
+ WHERE resource_id IN (${compositeKin(tx, resourceId)}) AND starts_at < ${end} AND ends_at > ${start}
1355
1544
  LIMIT 1`;
1356
1545
  return hit !== undefined;
1357
1546
  }
1547
+ /**
1548
+ * The resources whose busy time keeps this one busy (RFC 0003 G8, RFC 0004 C1), as a subquery: it,
1549
+ * its composite if it is a part (the whole is in use), and its parts if it is a composite (a part
1550
+ * in use takes the whole). Never its sibling parts, which are booked apart.
1551
+ */
1552
+ function compositeKin(executor, resourceId) {
1553
+ return executor `
1554
+ SELECT ${resourceId}::uuid
1555
+ UNION ALL
1556
+ SELECT parent_id FROM slotlock.resources WHERE id = ${resourceId}::uuid AND parent_id IS NOT NULL
1557
+ UNION ALL
1558
+ SELECT id FROM slotlock.resources WHERE parent_id = ${resourceId}::uuid`;
1559
+ }
1358
1560
  /**
1359
1561
  * A source's busy time as kept: each interval valid, clipped to the window, sorted and merged
1360
1562
  * (touching ones too). Null when any is not an interval, or there are too many.
@@ -1488,6 +1690,42 @@ async function rulesNow(tx, resourceId) {
1488
1690
  * Mark what the next change of a booking records on the feed (RFC 0004 B5): a cancellation's fee
1489
1691
  * and an admin's reason for going past a cutoff. Cleared with nulls.
1490
1692
  */
1693
+ /**
1694
+ * Whether a paid hold ended without a booking (RFC 0004 C3), so its payment was never applied:
1695
+ * not booked (confirmed, or booked from), not still held, and not taken over by an approval that
1696
+ * was granted or may still be.
1697
+ */
1698
+ async function paidHoldEnded(tx, tenantRef, payment) {
1699
+ if (payment.booked_at)
1700
+ return false;
1701
+ const [hold] = await tx `
1702
+ SELECT status, expires_at > statement_timestamp() AS live FROM slotlock.reservations WHERE id = ${payment.id}`;
1703
+ if (hold)
1704
+ return hold.status === 'held' && !hold.live;
1705
+ if (payment.approval_id) {
1706
+ const [approval] = await tx `
1707
+ SELECT status, expires_at > statement_timestamp() AS live FROM slotlock.approvals
1708
+ WHERE id = ${payment.approval_id}::uuid AND tenant_ref = ${tenantRef}`;
1709
+ if (approval?.status === 'granted')
1710
+ return false;
1711
+ if (approval?.status === 'pending' && approval.live)
1712
+ return false;
1713
+ }
1714
+ return true;
1715
+ }
1716
+ /**
1717
+ * Whether cancelling a booking that starts at `start` would be late now (RFC 0004 B5): past its
1718
+ * cancel cutoff, or inside the window its cancellation fee is due in. A move made then keeps that
1719
+ * start as the booking's cutoff anchor, so a move cannot take it out of what a cancellation owes.
1720
+ */
1721
+ function cancellationLate(rules, start, now) {
1722
+ return (cutoffRuleBroken(rules, 'cancel', start, now) !== null ||
1723
+ (rules?.cancellationFee?.withinMinutes !== undefined && cancellationFeeDue(rules, start, now) !== null));
1724
+ }
1725
+ /** The start a cancellation is judged from: the cutoff anchor a late move kept, if earlier. */
1726
+ function cancellationStart(start, anchor) {
1727
+ return anchor && anchor.getTime() < start.getTime() ? anchor : start;
1728
+ }
1491
1729
  async function noteEnding(tx, fee, overrideReason) {
1492
1730
  await tx `
1493
1731
  SELECT set_config('slotlock.cancellation_fee', ${fee ? JSON.stringify(fee) : ''}, true),
@@ -1502,18 +1740,22 @@ async function eventEnding(tx, event, enforce, overrideReason) {
1502
1740
  if (event.recurring)
1503
1741
  return { ok: true, fee: null };
1504
1742
  const judged = await rulesNow(tx, event.resource_id);
1743
+ // From the start it had when a late move kept it, if one did.
1744
+ const start = cancellationStart(event.starts_at, event.cutoff_anchor);
1505
1745
  let note = null;
1506
- if (enforce && cutoffRuleBroken(judged.rules, 'cancel', event.starts_at, judged.now)) {
1746
+ if (enforce && cutoffRuleBroken(judged.rules, 'cancel', start, judged.now)) {
1507
1747
  if (overrideReason === undefined) {
1508
1748
  return { ok: false, refusal: { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' } };
1509
1749
  }
1510
1750
  note = overrideReason;
1511
1751
  }
1512
- const due = cancellationFeeDue(judged.rules, event.starts_at, judged.now);
1752
+ const due = cancellationFeeDue(judged.rules, start, judged.now);
1513
1753
  const fee = due ? { amountMinor: due.amountMinor, currency: due.currency } : null;
1514
1754
  await noteEnding(tx, fee ? { amount_minor: fee.amountMinor, currency: fee.currency } : null, note);
1515
1755
  return { ok: true, fee };
1516
1756
  }
1757
+ /** How long busy time a calendar reported is kept after it ended: the feed's retention. */
1758
+ const SOURCE_BUSY_RETENTION_DAYS = 30;
1517
1759
  /** How recently a sync must have read a resource whose booking rules require certainty. */
1518
1760
  const CERTAIN_COVERAGE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
1519
1761
  /**
@@ -1530,7 +1772,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1530
1772
  to_char((statement_timestamp() AT TIME ZONE 'UTC')::date - 2, 'YYYY-MM-DD') AS today
1531
1773
  FROM slotlock.resources WHERE id = ${resourceId}`;
1532
1774
  if (!row || row.effective_booking_rules === null || row.effective_booking_rules === undefined) {
1533
- return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0 };
1775
+ return { broken: () => null, turnaroundMs: 0, bufferBeforeMs: 0, holdPrice: undefined };
1534
1776
  }
1535
1777
  const resource = toResource({
1536
1778
  id: resourceId,
@@ -1583,6 +1825,7 @@ async function bookingJudge(tx, resourceId, defaultHours) {
1583
1825
  }) ?? (uncertain(interval) ? 'uncertain' : null),
1584
1826
  turnaroundMs,
1585
1827
  bufferBeforeMs,
1828
+ holdPrice: rules?.holdPrice,
1586
1829
  };
1587
1830
  }
1588
1831
  function sameExternalPayload(row, params, turnaroundMs = 0) {
@@ -1594,6 +1837,29 @@ function sameExternalPayload(row, params, turnaroundMs = 0) {
1594
1837
  Number(row.revision) === params.revision &&
1595
1838
  row.source === (params.source ?? null));
1596
1839
  }
1840
+ /**
1841
+ * What a holder holds in the tenant, live: its own holds, and those of approvals it asked for that
1842
+ * a person has yet to decide (RFC 0004 A4), which hold time for it just the same. Counted for its
1843
+ * caps under the holder's lock. `except` leaves out a hold about to give way to an approval.
1844
+ */
1845
+ async function holderHeld(tx, tenantRef, holderRef, except) {
1846
+ const [held] = await tx `
1847
+ SELECT count(*)::int AS count,
1848
+ COALESCE(sum(extract(epoch FROM
1849
+ ends_at + (buffer_after_ms + buffer_before_ms) * interval '1 millisecond'
1850
+ - starts_at)), 0)::float8
1851
+ AS seconds
1852
+ FROM slotlock.reservations
1853
+ WHERE tenant_ref IS NULL AND status = 'held'
1854
+ AND expires_at > statement_timestamp()
1855
+ AND resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef})
1856
+ AND (${except ?? null}::uuid IS NULL OR id <> ${except ?? null}::uuid)
1857
+ AND (holder_ref = ${holderRef}
1858
+ OR holder_ref IN (SELECT 'approval:' || approval.id FROM slotlock.approvals approval
1859
+ WHERE approval.tenant_ref = ${tenantRef} AND approval.status = 'pending'
1860
+ AND approval.requester ->> 'holder_ref' = ${holderRef}))`;
1861
+ return { count: held?.count ?? 0, seconds: held?.seconds ?? 0 };
1862
+ }
1597
1863
  async function lockExternalIdentity(tx, tenantRef, externalRef) {
1598
1864
  // JSON is unambiguous for this pair and has no NUL byte (Postgres text rejects U+0000).
1599
1865
  await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([tenantRef, externalRef])}, 0))`;
@@ -1982,6 +2248,24 @@ async function rollStoredSeries(tx, tenantRef, eventId, horizon) {
1982
2248
  cursor: row.recurrence_cursor ?? null,
1983
2249
  stored: true,
1984
2250
  });
2251
+ // Busy time a calendar reported (RFC 0004 C1) is refused to a rolled occurrence as an overlap is:
2252
+ // nothing is written, the series is put off, and the roll counts it a conflict.
2253
+ if ((event.transparency ?? 'opaque') === 'opaque' && occurrences.length > 0) {
2254
+ const beforeMs = Number(row.buffer_before_ms ?? 0);
2255
+ const afterMs = Number(row.buffer_after_ms ?? 0);
2256
+ const [met] = await tx `
2257
+ SELECT 1
2258
+ FROM slotlock.source_busy busy
2259
+ JOIN ROWS FROM (
2260
+ pg_catalog.unnest(${occurrences.map((each) => new Date(each.start.getTime() - beforeMs).toISOString())}::text[]::timestamptz[]),
2261
+ pg_catalog.unnest(${occurrences.map((each) => new Date(each.end.getTime() + afterMs).toISOString())}::text[]::timestamptz[]))
2262
+ AS occurrence(starts_at, ends_at)
2263
+ ON busy.starts_at < occurrence.ends_at AND busy.ends_at > occurrence.starts_at
2264
+ WHERE busy.resource_id IN (${compositeKin(tx, row.resource_id)})
2265
+ LIMIT 1`;
2266
+ if (met)
2267
+ throw Object.assign(new Error('slotlock: a rolled occurrence meets reported busy time'), { code: EXCLUSION_VIOLATION });
2268
+ }
1985
2269
  await replaceCalendarEventOccurrences(tx, {
1986
2270
  tenantRef,
1987
2271
  eventId: row.id,
@@ -2157,6 +2441,35 @@ async function rollSeriesBehind(executor, resourceId, start, end, exceptEventId)
2157
2441
  // Rolled back whole (its own transaction, or a savepoint of the caller's).
2158
2442
  }
2159
2443
  }
2444
+ /** An audit event as the API and `slotlock audit export` give it: snake_case, nulls kept. */
2445
+ export function slotlockAuditEventRecord(event) {
2446
+ return {
2447
+ id: event.id,
2448
+ occurred_at: event.occurredAt.toISOString(),
2449
+ action: event.action,
2450
+ actor_type: event.actorType,
2451
+ actor_ref: event.actorRef,
2452
+ actor_role: event.actorRole ?? null,
2453
+ target_type: event.targetType ?? null,
2454
+ target_ref: event.targetRef ?? null,
2455
+ outcome: event.outcome,
2456
+ reason: event.reason ?? null,
2457
+ ip_address: event.ipAddress ?? null,
2458
+ user_agent: event.userAgent ?? null,
2459
+ metadata: event.metadata,
2460
+ chain_index: event.chainIndex,
2461
+ hash: event.hash,
2462
+ };
2463
+ }
2464
+ /** Hours a tenant export is kept once ready, or since it was asked for if it never is. */
2465
+ export const SLOTLOCK_TENANT_EXPORT_HOURS = 24;
2466
+ /** The most a tenant export holds: past it, the export fails (`export_too_large`). */
2467
+ export const SLOTLOCK_TENANT_EXPORT_MAX_BYTES = 512 * 1024 * 1024;
2468
+ /** The most exports a tenant keeps at once. */
2469
+ export const SLOTLOCK_MAX_TENANT_EXPORTS = 5;
2470
+ /** A part of an export: one table's rows, at most this many, or about this many bytes. */
2471
+ export const SLOTLOCK_TENANT_EXPORT_PART_ROWS = 5_000;
2472
+ const TENANT_EXPORT_PART_BYTES = 1024 * 1024;
2160
2473
  const MAX_TENANT_LIMIT = 10_000_000;
2161
2474
  function tenantLimitsOption(limits) {
2162
2475
  const valid = {};
@@ -2448,7 +2761,7 @@ function sourceBusy(executor, resourceId, window) {
2448
2761
  return executor `
2449
2762
  SELECT busy.starts_at AS occupied_starts_at, busy.ends_at AS occupied_ends_at
2450
2763
  FROM slotlock.source_busy busy
2451
- WHERE busy.resource_id = ${resourceId}
2764
+ WHERE busy.resource_id IN (${compositeKin(executor, resourceId)})
2452
2765
  AND busy.starts_at < ${window.end} AND busy.ends_at > ${window.start}`;
2453
2766
  }
2454
2767
  /**
@@ -2560,6 +2873,24 @@ export function createTenantSlots(max) {
2560
2873
  function lockTimeoutError(cause) {
2561
2874
  return Object.assign(new Error('Slotlock tenant transaction waited too long for a lock: retry later', { cause }), { code: 'lock_timeout' });
2562
2875
  }
2876
+ /**
2877
+ * Build a pending tenant export (RFC 0004 D3) in its own tenant transaction, and mark it failed if
2878
+ * that fails (`export_too_large`, else `export_failed`). Null when it was not pending, or another
2879
+ * server is building it. Never throws: a server builds it after answering, and maintenance builds
2880
+ * one left pending.
2881
+ */
2882
+ export async function buildSlotlockTenantExport(store, tenantRef, exportId) {
2883
+ try {
2884
+ return await store.withTenant(tenantRef, (tenant) => tenant.buildTenantExport({ tenantRef, exportId }));
2885
+ }
2886
+ catch (error) {
2887
+ const reason = error?.code === 'export_too_large' ? 'export_too_large' : 'export_failed';
2888
+ await store
2889
+ .withTenant(tenantRef, (tenant) => tenant.failTenantExport({ tenantRef, exportId, error: reason }))
2890
+ .catch(() => undefined);
2891
+ return null;
2892
+ }
2893
+ }
2563
2894
  export function createSlotlockStore(sql, options = {}) {
2564
2895
  return buildSlotlockStore(sql, options, null);
2565
2896
  }
@@ -3977,7 +4308,7 @@ function buildSlotlockStore(sql, options, refusal) {
3977
4308
  await lockExternalIdentity(tx, params.tenantRef, params.externalRef);
3978
4309
  const existing = await tx `
3979
4310
  SELECT id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
3980
- buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes
4311
+ buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes, cutoff_anchor
3981
4312
  FROM slotlock.reservations
3982
4313
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
3983
4314
  AND calendar_event_id IS NULL
@@ -4011,6 +4342,7 @@ function buildSlotlockStore(sql, options, refusal) {
4011
4342
  found.ends_at.getTime() !== params.end.getTime() ||
4012
4343
  found.resource_id !== params.resourceId;
4013
4344
  let overrideNote = null;
4345
+ let cutoffAnchor = found.cutoff_anchor;
4014
4346
  if (moved) {
4015
4347
  const judged = await rulesNow(tx, found.resource_id);
4016
4348
  if (cutoffRuleBroken(judged.rules, 'reschedule', found.starts_at, judged.now)) {
@@ -4019,6 +4351,10 @@ function buildSlotlockStore(sql, options, refusal) {
4019
4351
  }
4020
4352
  overrideNote = params.overrideReason;
4021
4353
  }
4354
+ // Moved when cancelling would already be late: it stays as late as it was.
4355
+ const from = cancellationStart(found.starts_at, cutoffAnchor);
4356
+ if (cancellationLate(judged.rules, from, judged.now))
4357
+ cutoffAnchor = from;
4022
4358
  }
4023
4359
  const resourceState = await tenantResourceState(tx, params.resourceId, params.tenantRef);
4024
4360
  if (resourceState === 'missing')
@@ -4072,7 +4408,8 @@ function buildSlotlockStore(sql, options, refusal) {
4072
4408
  checked_in_at = CASE
4073
4409
  WHEN starts_at = ${params.start} AND resource_id = ${params.resourceId}
4074
4410
  THEN checked_in_at
4075
- END
4411
+ END,
4412
+ cutoff_anchor = ${cutoffAnchor ?? null}::timestamptz
4076
4413
  WHERE id = ${found.id}
4077
4414
  RETURNING id, resource_id, tenant_ref, external_ref, starts_at, ends_at,
4078
4415
  buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
@@ -4125,7 +4462,7 @@ function buildSlotlockStore(sql, options, refusal) {
4125
4462
  : { ok: false, code: 'reservation_cancelled' };
4126
4463
  }
4127
4464
  const existing = await tx `
4128
- SELECT id, revision, resource_id, starts_at FROM slotlock.reservations
4465
+ SELECT id, revision, resource_id, starts_at, cutoff_anchor FROM slotlock.reservations
4129
4466
  WHERE tenant_ref = ${params.tenantRef} AND external_ref = ${params.externalRef}
4130
4467
  AND calendar_event_id IS NULL
4131
4468
  FOR UPDATE`;
@@ -4142,13 +4479,15 @@ function buildSlotlockStore(sql, options, refusal) {
4142
4479
  return { ok: false, code: 'revision_conflict' };
4143
4480
  }
4144
4481
  const judged = await rulesNow(tx, found.resource_id);
4145
- if (cutoffRuleBroken(judged.rules, 'cancel', found.starts_at, judged.now)) {
4482
+ // From the start it had when a late move kept it, if one did.
4483
+ const start = cancellationStart(found.starts_at, found.cutoff_anchor);
4484
+ if (cutoffRuleBroken(judged.rules, 'cancel', start, judged.now)) {
4146
4485
  if (params.overrideReason === undefined) {
4147
4486
  return { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' };
4148
4487
  }
4149
4488
  overrideNote = params.overrideReason;
4150
4489
  }
4151
- fee = cancellationFeeDue(judged.rules, found.starts_at, judged.now);
4490
+ fee = cancellationFeeDue(judged.rules, start, judged.now);
4152
4491
  }
4153
4492
  if (!found) {
4154
4493
  // This reference is a calendar event's occupation, not a booking: nothing to cancel or
@@ -4296,6 +4635,24 @@ function buildSlotlockStore(sql, options, refusal) {
4296
4635
  return { ok: false, code: 'resource_archived' };
4297
4636
  if (holdLimit !== null)
4298
4637
  return { ok: false, code: 'hold_limit_reached', limit: holdLimit };
4638
+ // An agent's hold takes the tenant's price, never its own (RFC 0004 C3).
4639
+ let holdPaymentRequired = paymentRequired;
4640
+ let holdPayment = params.payment;
4641
+ let holdTtlSeconds = ttlSeconds;
4642
+ // The resource's rules, read once: its price here, the rest judged below.
4643
+ const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
4644
+ if (params.priceFromRules === true) {
4645
+ const price = ruling.holdPrice;
4646
+ if (params.payment !== undefined &&
4647
+ (!price || price.amountMinor !== params.payment.amountMinor || price.currency !== params.payment.currency)) {
4648
+ return { ok: false, code: 'payment_price_mismatch' };
4649
+ }
4650
+ if (price) {
4651
+ holdPaymentRequired = true;
4652
+ holdPayment = { amountMinor: price.amountMinor, currency: price.currency };
4653
+ holdTtlSeconds = Math.min(ttlSeconds, SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS / 1000);
4654
+ }
4655
+ }
4299
4656
  let heldSeconds = null;
4300
4657
  if (holderRef !== undefined) {
4301
4658
  // Counted under a lock on the holder, so racing holds stop at the cap: an agent
@@ -4305,23 +4662,13 @@ function buildSlotlockStore(sql, options, refusal) {
4305
4662
  tenantRef,
4306
4663
  holderRef,
4307
4664
  ])}, 0))`;
4308
- const [held] = await tx `
4309
- SELECT count(*)::int AS count,
4310
- COALESCE(sum(extract(epoch FROM
4311
- ends_at + (buffer_after_ms + buffer_before_ms) * interval '1 millisecond'
4312
- - starts_at)), 0)::float8
4313
- AS seconds
4314
- FROM slotlock.reservations
4315
- WHERE holder_ref = ${holderRef} AND tenant_ref IS NULL AND status = 'held'
4316
- AND expires_at > statement_timestamp()
4317
- AND resource_id IN (SELECT id FROM slotlock.resources WHERE tenant_ref = ${tenantRef ?? null})`;
4318
- if ((held?.count ?? 0) >= holdCap) {
4665
+ const held = await holderHeld(tx, tenantRef ?? null, holderRef);
4666
+ if (held.count >= holdCap) {
4319
4667
  return { ok: false, code: 'hold_limit_reached', limit: holdCap };
4320
4668
  }
4321
4669
  if (heldHoursCap !== undefined)
4322
- heldSeconds = held?.seconds ?? 0;
4670
+ heldSeconds = held.seconds;
4323
4671
  }
4324
- const ruling = await bookingRuling(tx, params.resourceId, params, defaultHours);
4325
4672
  if (ruling.rule)
4326
4673
  return { ok: false, code: 'booking_rule_violated', rule: ruling.rule };
4327
4674
  written = Math.max(bufferAfterMs, ruling.turnaroundMs);
@@ -4358,9 +4705,9 @@ function buildSlotlockStore(sql, options, refusal) {
4358
4705
  payment_amount_minor, payment_currency
4359
4706
  ) VALUES (
4360
4707
  ${params.resourceId}, ${params.start}, ${params.end}, ${written}, ${writtenBefore}, 'held',
4361
- statement_timestamp() + make_interval(secs => ${ttlSeconds}), ${params.source ?? null},
4362
- ${holderRef ?? null}, ${params.groupId ?? null}, ${paymentRequired},
4363
- ${params.payment?.amountMinor ?? null}, ${params.payment?.currency ?? null}
4708
+ statement_timestamp() + make_interval(secs => ${holdTtlSeconds}), ${params.source ?? null},
4709
+ ${holderRef ?? null}, ${params.groupId ?? null}, ${holdPaymentRequired},
4710
+ ${holdPayment?.amountMinor ?? null}, ${holdPayment?.currency ?? null}
4364
4711
  )
4365
4712
  RETURNING id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at, source,
4366
4713
  hold_group_id, ${tx.unsafe(HOLD_PAYMENT_COLUMNS)}`;
@@ -4494,6 +4841,382 @@ function buildSlotlockStore(sql, options, refusal) {
4494
4841
  return { deleted: deleted.length > 0, bookings: bookings.length };
4495
4842
  }, 'read committed');
4496
4843
  },
4844
+ async exportSubject(params) {
4845
+ if (!isValidIdentity(params.tenantRef)) {
4846
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4847
+ }
4848
+ const key = subjectKey(params.subject);
4849
+ if (!key)
4850
+ throw invalidSubjectError();
4851
+ const limit = params.limit ?? 1_000;
4852
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000) {
4853
+ throw Object.assign(new RangeError('Slotlock subject export limit must be 1-10000'), { code: 'invalid_limit' });
4854
+ }
4855
+ const tenantRef = params.tenantRef;
4856
+ const value = key.value;
4857
+ const take = limit + 1;
4858
+ return inTransaction(sql, async (tx) => {
4859
+ // The contacts an email or a contact reference names; the bookings, changes and approvals
4860
+ // that name those contacts are theirs too.
4861
+ const contactRefs = key.kind === 'email'
4862
+ ? (await tx `
4863
+ SELECT ref FROM slotlock.contacts WHERE tenant_ref = ${tenantRef} AND lower(email) = ${value}`).map((row) => row.ref)
4864
+ : key.kind === 'contact'
4865
+ ? [value]
4866
+ : [];
4867
+ const records = Object.fromEntries(SLOTLOCK_SUBJECT_TABLES.map((table) => [table, []]));
4868
+ const read = async (table, rows) => {
4869
+ records[table] = (await rows).map((entry) => entry.row);
4870
+ };
4871
+ if (contactRefs.length > 0) {
4872
+ await read('contacts', tx `
4873
+ SELECT to_jsonb(contact) - 'tenant_ref' AS row FROM slotlock.contacts contact
4874
+ WHERE contact.tenant_ref = ${tenantRef} AND contact.ref = ANY(${contactRefs}::text[])
4875
+ ORDER BY contact.ref LIMIT ${take}`);
4876
+ await read('reservations', tx `
4877
+ SELECT to_jsonb(booking) - 'tenant_ref' AS row FROM slotlock.reservations booking
4878
+ WHERE booking.tenant_ref = ${tenantRef} AND booking.contact_ref = ANY(${contactRefs}::text[])
4879
+ ORDER BY booking.created_at, booking.id LIMIT ${take}`);
4880
+ await read('outbox', tx `
4881
+ SELECT jsonb_build_object('id', change.id::text, 'type', change.type, 'subject', change.subject,
4882
+ 'data', change.data, 'occurred_at', change.occurred_at) AS row
4883
+ FROM slotlock.outbox change
4884
+ WHERE change.tenant_ref = ${tenantRef} AND change.data ->> 'contact_ref' = ANY(${contactRefs}::text[])
4885
+ ORDER BY change.id LIMIT ${take}`);
4886
+ }
4887
+ if (key.kind === 'email') {
4888
+ await read('calendar_event_attendees', tx `
4889
+ SELECT to_jsonb(attendee) - 'tenant_ref' AS row FROM slotlock.calendar_event_attendees attendee
4890
+ WHERE attendee.tenant_ref = ${tenantRef} AND lower(attendee.email) = ${value}
4891
+ ORDER BY attendee.event_id, attendee.ordinal LIMIT ${take}`);
4892
+ }
4893
+ if (key.kind === 'email' || key.kind === 'reference') {
4894
+ // An event's time and title, and its organizer only when that is them: never the others
4895
+ // it names.
4896
+ await read('calendar_events', tx `
4897
+ SELECT jsonb_strip_nulls(jsonb_build_object(
4898
+ 'id', event.id, 'external_ref', event.external_ref, 'resource_id', event.resource_id,
4899
+ 'starts_at', event.starts_at, 'ends_at', event.ends_at, 'timezone', event.timezone,
4900
+ 'summary', event.summary, 'status', event.status,
4901
+ 'recurring', event.recurrence_rule IS NOT NULL, 'created_at', event.created_at,
4902
+ 'organizer', CASE WHEN lower(event.organizer ->> 'email') = ${value} THEN event.organizer END)) AS row
4903
+ FROM slotlock.calendar_events event
4904
+ WHERE event.tenant_ref = ${tenantRef}
4905
+ AND ${key.kind === 'email'
4906
+ ? tx `(lower(event.organizer ->> 'email') = ${value}
4907
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(event.attendees) AS attendee(value)
4908
+ WHERE lower(attendee.value ->> 'email') = ${value}))`
4909
+ : tx `event.external_ref = ${value}`}
4910
+ ORDER BY event.created_at, event.id LIMIT ${take}`);
4911
+ }
4912
+ if (key.kind === 'holder') {
4913
+ // A hold may keep no tenant of its own: its resource's is its.
4914
+ await read('reservations', tx `
4915
+ SELECT to_jsonb(booking) - 'tenant_ref' AS row FROM slotlock.reservations booking
4916
+ WHERE booking.holder_ref = ${value}
4917
+ AND (booking.tenant_ref = ${tenantRef}
4918
+ OR (booking.tenant_ref IS NULL AND booking.resource_id IN (
4919
+ SELECT resource.id FROM slotlock.resources resource WHERE resource.tenant_ref = ${tenantRef})))
4920
+ ORDER BY booking.created_at, booking.id LIMIT ${take}`);
4921
+ await read('waitlist_entries', tx `
4922
+ SELECT to_jsonb(entry) - 'tenant_ref' AS row FROM slotlock.waitlist_entries entry
4923
+ WHERE entry.tenant_ref = ${tenantRef} AND entry.holder_ref = ${value}
4924
+ ORDER BY entry.created_at, entry.id LIMIT ${take}`);
4925
+ await read('outbox', tx `
4926
+ SELECT jsonb_build_object('id', change.id::text, 'type', change.type, 'subject', change.subject,
4927
+ 'data', change.data, 'occurred_at', change.occurred_at) AS row
4928
+ FROM slotlock.outbox change
4929
+ WHERE change.tenant_ref = ${tenantRef} AND change.data ->> 'holder_ref' = ${value}
4930
+ ORDER BY change.id LIMIT ${take}`);
4931
+ }
4932
+ if (key.kind === 'reference') {
4933
+ await read('reservations', tx `
4934
+ SELECT to_jsonb(booking) - 'tenant_ref' AS row FROM slotlock.reservations booking
4935
+ WHERE booking.tenant_ref = ${tenantRef} AND booking.external_ref = ${value}
4936
+ AND booking.calendar_event_id IS NULL
4937
+ ORDER BY booking.created_at, booking.id LIMIT ${take}`);
4938
+ for (const table of [
4939
+ 'reservation_tombstones',
4940
+ 'reservation_conflict_archive',
4941
+ 'calendar_event_tombstones',
4942
+ 'calendar_event_commands',
4943
+ ]) {
4944
+ await read(table, tx `
4945
+ SELECT to_jsonb(kept) - 'tenant_ref' AS row FROM ${tx(`slotlock.${table}`)} kept
4946
+ WHERE kept.tenant_ref = ${tenantRef} AND kept.external_ref = ${value}
4947
+ LIMIT ${take}`);
4948
+ }
4949
+ await read('outbox', tx `
4950
+ SELECT jsonb_build_object('id', change.id::text, 'type', change.type, 'subject', change.subject,
4951
+ 'data', change.data, 'occurred_at', change.occurred_at) AS row
4952
+ FROM slotlock.outbox change
4953
+ WHERE change.tenant_ref = ${tenantRef} AND change.data ->> 'reference' = ${value}
4954
+ ORDER BY change.id LIMIT ${take}`);
4955
+ }
4956
+ // An approval's decision, never its request: that may name others.
4957
+ await read('approvals', tx `
4958
+ SELECT jsonb_strip_nulls(jsonb_build_object(
4959
+ 'id', approval.id, 'policy_ref', approval.policy_ref, 'status', approval.status,
4960
+ 'operation', approval.operation, 'starts_at', approval.starts_at,
4961
+ 'ends_at', approval.ends_at, 'expires_at', approval.expires_at,
4962
+ 'decided_at', approval.decided_at, 'created_at', approval.created_at)) AS row
4963
+ FROM slotlock.approvals approval
4964
+ WHERE approval.tenant_ref = ${tenantRef} AND ${subjectApprovalSql(tx, key, contactRefs)}
4965
+ ORDER BY approval.created_at, approval.id LIMIT ${take}`);
4966
+ let truncated = false;
4967
+ for (const table of SLOTLOCK_SUBJECT_TABLES) {
4968
+ if (records[table].length > limit) {
4969
+ records[table] = records[table].slice(0, limit);
4970
+ truncated = true;
4971
+ }
4972
+ }
4973
+ return { records, truncated };
4974
+ }, 'read committed');
4975
+ },
4976
+ async eraseSubject(params) {
4977
+ if (!isValidIdentity(params.tenantRef)) {
4978
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
4979
+ }
4980
+ const key = subjectKey(params.subject);
4981
+ if (!key)
4982
+ throw invalidSubjectError();
4983
+ const tenantRef = params.tenantRef;
4984
+ const value = key.value;
4985
+ // One name for what the erasure reached, so what was one holder or booking still reads as one.
4986
+ const replacement = `erased-${randomBytes(16).toString('hex')}`;
4987
+ return inTransaction(sql, async (tx) => {
4988
+ const erased = emptySubjectCounts();
4989
+ const count = async (table, rows) => {
4990
+ erased[table] += (await rows).length;
4991
+ };
4992
+ const changes = async (kind, name) => {
4993
+ const [row] = await tx `
4994
+ SELECT slotlock.erase_subject_changes(${tenantRef}::text, ${kind}::text, ${name}::text,
4995
+ ${kind === 'contact' ? null : replacement}::text) AS changed`;
4996
+ erased.outbox += Number(row?.changed ?? 0);
4997
+ };
4998
+ const contactRefs = key.kind === 'email'
4999
+ ? (await tx `
5000
+ SELECT ref FROM slotlock.contacts WHERE tenant_ref = ${tenantRef} AND lower(email) = ${value}
5001
+ ORDER BY ref`).map((row) => row.ref)
5002
+ : key.kind === 'contact'
5003
+ ? [value]
5004
+ : [];
5005
+ // As deleteContact: a contact written meanwhile waits, then finds itself gone or kept.
5006
+ for (const ref of contactRefs)
5007
+ await lockExternalIdentity(tx, tenantRef, `contact:${ref}`);
5008
+ if (key.kind === 'reference')
5009
+ await lockExternalIdentity(tx, tenantRef, value);
5010
+ // Approvals first, while their requests still match what is being erased.
5011
+ await count('approvals', tx `
5012
+ UPDATE slotlock.approvals approval
5013
+ SET request = ${subjectScrubbedRequestSql(tx, key, contactRefs, replacement)}
5014
+ WHERE approval.tenant_ref = ${tenantRef} AND ${subjectApprovalSql(tx, key, contactRefs)}
5015
+ RETURNING 1`);
5016
+ if (contactRefs.length > 0) {
5017
+ await count('contacts', tx `
5018
+ DELETE FROM slotlock.contacts
5019
+ WHERE tenant_ref = ${tenantRef} AND ref = ANY(${contactRefs}::text[])
5020
+ RETURNING 1`);
5021
+ // The bookings stay; they no longer name whom to tell.
5022
+ await count('reservations', tx `
5023
+ UPDATE slotlock.reservations SET contact_ref = NULL
5024
+ WHERE tenant_ref = ${tenantRef} AND contact_ref = ANY(${contactRefs}::text[])
5025
+ RETURNING 1`);
5026
+ for (const ref of contactRefs)
5027
+ await changes('contact', ref);
5028
+ }
5029
+ if (key.kind === 'email') {
5030
+ await count('calendar_event_attendees', tx `
5031
+ DELETE FROM slotlock.calendar_event_attendees
5032
+ WHERE tenant_ref = ${tenantRef} AND lower(email) = ${value}
5033
+ RETURNING 1`);
5034
+ // The event keeps its revision: nothing it books changed, and no one is told of it again.
5035
+ await count('calendar_events', tx `
5036
+ UPDATE slotlock.calendar_events event
5037
+ SET attendees = coalesce((
5038
+ SELECT jsonb_agg(attendee.value ORDER BY attendee.ordinality)
5039
+ FROM jsonb_array_elements(event.attendees) WITH ORDINALITY AS attendee(value, ordinality)
5040
+ WHERE lower(attendee.value ->> 'email') IS DISTINCT FROM ${value}), '[]'::jsonb),
5041
+ organizer = CASE WHEN lower(event.organizer ->> 'email') = ${value} THEN NULL ELSE event.organizer END
5042
+ WHERE event.tenant_ref = ${tenantRef}
5043
+ AND (lower(event.organizer ->> 'email') = ${value}
5044
+ OR EXISTS (SELECT 1 FROM jsonb_array_elements(event.attendees) AS attendee(value)
5045
+ WHERE lower(attendee.value ->> 'email') = ${value}))
5046
+ RETURNING 1`);
5047
+ }
5048
+ if (key.kind === 'holder') {
5049
+ await count('waitlist_entries', tx `
5050
+ DELETE FROM slotlock.waitlist_entries
5051
+ WHERE tenant_ref = ${tenantRef} AND holder_ref = ${value}
5052
+ RETURNING 1`);
5053
+ // Holds and bookings stay as time taken, held by a name that is no one's.
5054
+ await count('reservations', tx `
5055
+ UPDATE slotlock.reservations booking SET holder_ref = ${replacement}
5056
+ WHERE booking.holder_ref = ${value}
5057
+ AND (booking.tenant_ref = ${tenantRef}
5058
+ OR (booking.tenant_ref IS NULL AND booking.resource_id IN (
5059
+ SELECT resource.id FROM slotlock.resources resource WHERE resource.tenant_ref = ${tenantRef})))
5060
+ RETURNING 1`);
5061
+ await changes('holder', value);
5062
+ }
5063
+ if (key.kind === 'reference') {
5064
+ // A booking keeps its time under a name that is no one's; so do its ended bookings and
5065
+ // events. The reference is free to book again.
5066
+ await count('reservations', tx `
5067
+ UPDATE slotlock.reservations SET external_ref = ${replacement}
5068
+ WHERE tenant_ref = ${tenantRef} AND external_ref = ${value} AND calendar_event_id IS NULL
5069
+ RETURNING 1`);
5070
+ for (const table of [
5071
+ 'reservation_tombstones',
5072
+ 'reservation_conflict_archive',
5073
+ 'calendar_events',
5074
+ 'calendar_event_tombstones',
5075
+ 'calendar_event_commands',
5076
+ ]) {
5077
+ await count(table, tx `
5078
+ UPDATE ${tx(`slotlock.${table}`)} SET external_ref = ${replacement}
5079
+ WHERE tenant_ref = ${tenantRef} AND external_ref = ${value}
5080
+ RETURNING 1`);
5081
+ }
5082
+ await changes('reference', value);
5083
+ }
5084
+ return { erased };
5085
+ }, 'read committed');
5086
+ },
5087
+ async createTenantExport(params) {
5088
+ if (!isValidIdentity(params.tenantRef)) {
5089
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
5090
+ }
5091
+ const tenantRef = params.tenantRef;
5092
+ return inTransaction(sql, async (tx) => {
5093
+ await lockExternalIdentity(tx, tenantRef, 'tenant-export');
5094
+ const [kept] = await tx `
5095
+ SELECT count(*) FILTER (WHERE status = 'pending')::int AS pending, count(*)::int AS kept
5096
+ FROM slotlock.tenant_exports
5097
+ WHERE tenant_ref = ${tenantRef} AND expires_at > statement_timestamp()`;
5098
+ if ((kept?.pending ?? 0) > 0)
5099
+ throw tenantExportRefusal('export_in_progress');
5100
+ if ((kept?.kept ?? 0) >= SLOTLOCK_MAX_TENANT_EXPORTS)
5101
+ throw tenantExportRefusal('export_limit_reached');
5102
+ const requestedBy = params.requestedBy === undefined ? null : cutBytes(params.requestedBy, 200) ?? null;
5103
+ const [row] = await tx `
5104
+ INSERT INTO slotlock.tenant_exports (tenant_ref, requested_by, expires_at)
5105
+ VALUES (${tenantRef}, ${requestedBy},
5106
+ statement_timestamp() + make_interval(hours => ${SLOTLOCK_TENANT_EXPORT_HOURS}))
5107
+ RETURNING *, row_count::text AS row_count, bytes::text AS bytes`;
5108
+ if (!row)
5109
+ throw new Error('slotlock: tenant export insert returned no row');
5110
+ return toTenantExport(row);
5111
+ }, 'read committed');
5112
+ },
5113
+ async buildTenantExport(params) {
5114
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId))
5115
+ return null;
5116
+ const tenantRef = params.tenantRef;
5117
+ const exportId = params.exportId;
5118
+ return inTransaction(sql, async (tx) => {
5119
+ // Held to the end: another server building it skips it rather than waiting.
5120
+ const [claimed] = await tx `
5121
+ SELECT id FROM slotlock.tenant_exports
5122
+ WHERE id = ${exportId} AND tenant_ref = ${tenantRef} AND status = 'pending'
5123
+ FOR UPDATE SKIP LOCKED`;
5124
+ if (!claimed)
5125
+ return null;
5126
+ let part = 0;
5127
+ let written = 0;
5128
+ const totals = await readTenantExport(tx, tenantRef, async (read) => {
5129
+ // Refused as soon as it is too large, so nothing past the limit is read or kept.
5130
+ written += read.bytes;
5131
+ if (written > SLOTLOCK_TENANT_EXPORT_MAX_BYTES)
5132
+ throw tenantExportRefusal('export_too_large');
5133
+ part += 1;
5134
+ await tx `
5135
+ INSERT INTO slotlock.tenant_export_parts (export_id, tenant_ref, part, table_name, row_count, content)
5136
+ VALUES (${exportId}, ${tenantRef}, ${part}, ${read.table}, ${read.lines.length}, ${read.lines.join('\n')})`;
5137
+ });
5138
+ const [row] = await tx `
5139
+ UPDATE slotlock.tenant_exports
5140
+ SET status = 'ready', finished_at = statement_timestamp(),
5141
+ expires_at = statement_timestamp() + make_interval(hours => ${SLOTLOCK_TENANT_EXPORT_HOURS}),
5142
+ parts = ${part}, row_count = ${totals.rows}, bytes = ${totals.bytes},
5143
+ tables = ${tx.json(totals.tables)}
5144
+ WHERE id = ${exportId} AND tenant_ref = ${tenantRef}
5145
+ RETURNING *, row_count::text AS row_count, bytes::text AS bytes`;
5146
+ return row ? toTenantExport(row) : null;
5147
+ }, 'read committed');
5148
+ },
5149
+ async failTenantExport(params) {
5150
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId))
5151
+ return;
5152
+ await sql `
5153
+ UPDATE slotlock.tenant_exports
5154
+ SET status = 'failed', finished_at = statement_timestamp(), error = ${cutBytes(params.error, 200) ?? 'export_failed'}
5155
+ WHERE id = ${params.exportId} AND tenant_ref = ${params.tenantRef} AND status = 'pending'`;
5156
+ },
5157
+ async getTenantExport(params) {
5158
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId))
5159
+ return null;
5160
+ const [row] = await sql `
5161
+ SELECT *, row_count::text AS row_count, bytes::text AS bytes FROM slotlock.tenant_exports
5162
+ WHERE id = ${params.exportId} AND tenant_ref = ${params.tenantRef}`;
5163
+ return row ? toTenantExport(row) : null;
5164
+ },
5165
+ async listTenantExports(params) {
5166
+ if (!isValidIdentity(params.tenantRef))
5167
+ return [];
5168
+ const rows = await sql `
5169
+ SELECT *, row_count::text AS row_count, bytes::text AS bytes FROM slotlock.tenant_exports
5170
+ WHERE tenant_ref = ${params.tenantRef}
5171
+ ORDER BY created_at DESC, id DESC`;
5172
+ return rows.map(toTenantExport);
5173
+ },
5174
+ async getTenantExportPart(params) {
5175
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId)) {
5176
+ return { ok: false, code: 'export_not_found' };
5177
+ }
5178
+ const [found] = await sql `
5179
+ SELECT made.status, part.table_name, part.content
5180
+ FROM slotlock.tenant_exports made
5181
+ LEFT JOIN slotlock.tenant_export_parts part
5182
+ ON part.export_id = made.id AND part.tenant_ref = made.tenant_ref AND part.part = ${params.part}
5183
+ WHERE made.id = ${params.exportId} AND made.tenant_ref = ${params.tenantRef}`;
5184
+ if (!found)
5185
+ return { ok: false, code: 'export_not_found' };
5186
+ if (found.status !== 'ready')
5187
+ return { ok: false, code: 'export_not_ready' };
5188
+ if (found.table_name === null || found.content === null)
5189
+ return { ok: false, code: 'export_part_not_found' };
5190
+ return { ok: true, table: found.table_name, lines: found.content.split('\n') };
5191
+ },
5192
+ async deleteTenantExport(params) {
5193
+ if (!isValidIdentity(params.tenantRef) || !isValidResourceId(params.exportId))
5194
+ return false;
5195
+ const deleted = await sql `
5196
+ DELETE FROM slotlock.tenant_exports WHERE id = ${params.exportId} AND tenant_ref = ${params.tenantRef}
5197
+ RETURNING 1`;
5198
+ return deleted.length > 0;
5199
+ },
5200
+ async pruneTenantExports(params) {
5201
+ if (!isValidIdentity(params.tenantRef))
5202
+ return { deleted: 0, pending: [] };
5203
+ const deleted = await sql `
5204
+ DELETE FROM slotlock.tenant_exports
5205
+ WHERE tenant_ref = ${params.tenantRef} AND expires_at <= statement_timestamp()
5206
+ RETURNING 1`;
5207
+ const pending = await sql `
5208
+ SELECT id FROM slotlock.tenant_exports
5209
+ WHERE tenant_ref = ${params.tenantRef} AND status = 'pending'
5210
+ AND created_at < statement_timestamp() - interval '2 minutes'
5211
+ ORDER BY created_at`;
5212
+ return { deleted: deleted.length, pending: pending.map((row) => row.id) };
5213
+ },
5214
+ async streamTenantExport(params) {
5215
+ if (!isValidIdentity(params.tenantRef)) {
5216
+ throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');
5217
+ }
5218
+ return inTransaction(sql, (tx) => readTenantExport(tx, params.tenantRef, ({ table, lines }) => params.onPart({ table, lines })), 'read committed');
5219
+ },
4497
5220
  async markHoldPaid(params) {
4498
5221
  if (!isValidIdentity(params.tenantRef))
4499
5222
  return { ok: false, code: 'invalid_identity' };
@@ -4511,13 +5234,19 @@ function buildSlotlockStore(sql, options, refusal) {
4511
5234
  const [recorded] = await tx `
4512
5235
  SELECT hold_id AS id, resource_id, starts_at, ends_at, buffer_after_ms, expires_at,
4513
5236
  NULL::text AS source, true AS payment_required, payment_ref, paid_at,
4514
- payment_amount_minor, payment_currency, paid_amount_minor, paid_currency
5237
+ payment_amount_minor, payment_currency, paid_amount_minor, paid_currency,
5238
+ booked_at, approval_id
4515
5239
  FROM slotlock.hold_payments
4516
5240
  WHERE hold_id = ${params.holdId} AND tenant_ref = ${params.tenantRef}`;
4517
5241
  if (recorded) {
4518
- return recorded.payment_ref === params.paymentRef
4519
- ? { ok: true, hold: toHold(recorded), idempotent: true }
4520
- : { ok: false, code: 'payment_conflict' };
5242
+ if (recorded.payment_ref !== params.paymentRef) {
5243
+ // Named, so an adapter refunds only a payment it can tell is another.
5244
+ return { ok: false, code: 'payment_conflict', paymentRef: recorded.payment_ref };
5245
+ }
5246
+ // Its hold ended without a booking: the payment was never applied, and is refunded.
5247
+ if (await paidHoldEnded(tx, params.tenantRef, recorded))
5248
+ return { ok: false, code: 'hold_ended' };
5249
+ return { ok: true, hold: toHold(recorded), idempotent: true };
4521
5250
  }
4522
5251
  const [row] = await tx `
4523
5252
  SELECT hold.id, hold.resource_id, hold.starts_at, hold.ends_at, hold.buffer_after_ms,
@@ -4537,7 +5266,7 @@ function buildSlotlockStore(sql, options, refusal) {
4537
5266
  if (row.payment_ref !== null && row.payment_ref !== undefined) {
4538
5267
  return row.payment_ref === params.paymentRef
4539
5268
  ? { ok: true, hold: toHold(row), idempotent: true }
4540
- : { ok: false, code: 'payment_conflict' };
5269
+ : { ok: false, code: 'payment_conflict', paymentRef: row.payment_ref };
4541
5270
  }
4542
5271
  // Paid after it lapsed: the time may be someone else's by now. The integrator refunds.
4543
5272
  if (!row.live)
@@ -4786,13 +5515,43 @@ function buildSlotlockStore(sql, options, refusal) {
4786
5515
  if ((pending?.tenant ?? 0) >= SLOTLOCK_MAX_PENDING_APPROVALS) {
4787
5516
  return { ok: false, code: 'approval_limit_reached', limit: SLOTLOCK_MAX_PENDING_APPROVALS };
4788
5517
  }
5518
+ // The agent's caps, with its other pending approvals and without the hold that gives way
5519
+ // to this one; under its lock, taken before the resources' as a hold takes it.
5520
+ const holder = params.holder;
5521
+ if (holder && isValidIdentity(holder.ref)) {
5522
+ await tx `SELECT pg_advisory_xact_lock(hashtextextended(${JSON.stringify([
5523
+ 'slotlock-holder',
5524
+ tenantRef,
5525
+ holder.ref,
5526
+ ])}, 0))`;
5527
+ const consumed = params.consumeHold && isValidResourceId(params.consumeHold.holdId) && params.consumeHold.holderRef === holder.ref
5528
+ ? params.consumeHold.holdId
5529
+ : undefined;
5530
+ const held = await holderHeld(tx, tenantRef, holder.ref, consumed);
5531
+ const holdCap = Math.min(maxHoldsPerHolder, holder.caps?.maxHolds ?? Number.POSITIVE_INFINITY);
5532
+ if (held.count + params.holds.length > holdCap) {
5533
+ return { ok: false, code: 'hold_limit_reached', limit: holdCap };
5534
+ }
5535
+ const heldHoursCap = holder.caps?.maxHeldHours !== undefined
5536
+ ? Math.min(maxHeldHoursPerHolder ?? Number.POSITIVE_INFINITY, holder.caps.maxHeldHours)
5537
+ : maxHeldHoursPerHolder;
5538
+ const asking = params.holds.reduce((seconds, hold) => seconds + (hold.end.getTime() - hold.start.getTime()) / 1000, 0);
5539
+ if (heldHoursCap !== undefined && held.seconds + asking > heldHoursCap * 3_600) {
5540
+ return { ok: false, code: 'held_time_limit_reached', limit: heldHoursCap };
5541
+ }
5542
+ }
4789
5543
  const id = randomUUID();
4790
5544
  // The requester's own hold of the time gives way to the approval's.
4791
5545
  if (params.consumeHold && isValidResourceId(params.consumeHold.holdId)) {
5546
+ // Taken over, not ended: its payment, if any, goes with the approval (booked if granted).
5547
+ await tx `SELECT set_config('slotlock.ending', 'approval', true)`;
4792
5548
  await tx `
4793
5549
  DELETE FROM slotlock.reservations
4794
5550
  WHERE id = ${params.consumeHold.holdId} AND status = 'held'
4795
5551
  AND holder_ref = ${params.consumeHold.holderRef}`;
5552
+ await tx `SELECT set_config('slotlock.ending', '', true)`;
5553
+ await tx `UPDATE slotlock.hold_payments SET approval_id = ${id}::uuid
5554
+ WHERE hold_id = ${params.consumeHold.holdId} AND tenant_ref = ${tenantRef}`;
4796
5555
  }
4797
5556
  const holds = [...params.holds].sort((left, right) => left.resourceId.localeCompare(right.resourceId) || left.start.getTime() - right.start.getTime());
4798
5557
  // Locked as any booking write locks them, in id order, so a racing booking takes turns.
@@ -4819,7 +5578,8 @@ function buildSlotlockStore(sql, options, refusal) {
4819
5578
  starts_at, ends_at, request_key, expires_at
4820
5579
  ) VALUES (
4821
5580
  ${id}, ${tenantRef}, ${params.policyRef}, ${params.operation}, ${tx.json(params.request)},
4822
- ${tx.json(params.requester)}, ${held.map((hold) => hold.id)}::uuid[],
5581
+ ${tx.json((holder ? { ...params.requester, holder_ref: holder.ref } : params.requester))},
5582
+ ${held.map((hold) => hold.id)}::uuid[],
4823
5583
  ${[...new Set(holds.map((hold) => hold.resourceId))]}::uuid[], ${params.start}, ${params.end},
4824
5584
  ${params.requestKey}, ${params.expiresAt}
4825
5585
  )
@@ -5027,6 +5787,11 @@ function buildSlotlockStore(sql, options, refusal) {
5027
5787
  return { ok: false, code: 'payment_required' };
5028
5788
  if (resource?.archived !== false)
5029
5789
  return { ok: false, code: 'resource_archived' };
5790
+ // Busy time a calendar reported since the hold was made (RFC 0004 C1) is refused, as an
5791
+ // agent's confirmation of its hold refuses it: the source's time is not to be booked.
5792
+ if (await sourceBusyLoss(tx, row.resource_id, row.starts_at, new Date(row.ends_at.getTime() + Number(row.buffer_after_ms)))) {
5793
+ return { ok: false, code: 'overlap' };
5794
+ }
5030
5795
  if (bookingTenant !== null) {
5031
5796
  const [retired] = await tx `
5032
5797
  SELECT revision FROM slotlock.reservation_tombstones
@@ -5054,6 +5819,9 @@ function buildSlotlockStore(sql, options, refusal) {
5054
5819
  buffer_after_ms, revision, source, contact_ref, notify, reminder_minutes`;
5055
5820
  if (!confirmed)
5056
5821
  throw new Error('slotlock: hold confirmation returned no row');
5822
+ // Its payment, if any, is applied: never refunded as a hold that ended unbooked.
5823
+ await tx `UPDATE slotlock.hold_payments SET booked_at = statement_timestamp()
5824
+ WHERE hold_id = ${holdId} AND booked_at IS NULL`;
5057
5825
  // Its linked alternatives (one group, one holder) are released with it, as booking one
5058
5826
  // from an agent releases the rest.
5059
5827
  if (row.hold_group_id !== null && row.holder_ref !== null) {
@@ -5257,7 +6025,8 @@ function buildSlotlockStore(sql, options, refusal) {
5257
6025
  SELECT id, tenant_ref, owner_ref, external_ref, resource_id, starts_at, ends_at, timezone,
5258
6026
  summary, description, location, status, transparency, organizer, attendees,
5259
6027
  reminders, recurrence_rule, recurrence_exceptions, materialized_starts_at,
5260
- materialized_ends_at, recurrence_cursor, revision, source, created_at, updated_at
6028
+ materialized_ends_at, recurrence_cursor, revision, source, created_at, updated_at,
6029
+ cutoff_anchor
5261
6030
  FROM slotlock.calendar_events
5262
6031
  WHERE tenant_ref = ${params.tenantRef}
5263
6032
  AND owner_ref = ${ownerRef}
@@ -5273,17 +6042,32 @@ function buildSlotlockStore(sql, options, refusal) {
5273
6042
  if (params.expectedRevision !== currentRevision) {
5274
6043
  return { ok: false, code: 'revision_conflict', currentRevision };
5275
6044
  }
5276
- // A one-off event moved in time or to another resource, past its reschedule cutoff
5277
- // (RFC 0004 B5), is refused to a writer held to the booking rules.
5278
- if (found &&
5279
- params.enforceBookingRules === true &&
5280
- found.recurrence_rule === null &&
5281
- (found.starts_at.getTime() !== event.start.getTime() ||
6045
+ // A one-off event changed past its cutoffs (RFC 0004 B5) is refused to a writer held to
6046
+ // the booking rules: moved in time or to another resource, or made a series (whose
6047
+ // cancellation no cutoff holds), past its reschedule cutoff; made transparent, letting its
6048
+ // time go, past its cancel cutoff or once a cancellation fee is due, as a cancellation
6049
+ // would be (cancelling records the fee).
6050
+ let movedAnchor = null;
6051
+ if (found && params.enforceBookingRules === true && found.recurrence_rule === null) {
6052
+ const moved = found.starts_at.getTime() !== event.start.getTime() ||
5282
6053
  found.ends_at.getTime() !== event.end.getTime() ||
5283
- found.resource_id !== params.resourceId)) {
5284
- const judged = await rulesNow(tx, found.resource_id);
5285
- if (cutoffRuleBroken(judged.rules, 'reschedule', found.starts_at, judged.now)) {
5286
- return { ok: false, code: 'booking_rule_violated', rule: 'reschedule_cutoff' };
6054
+ found.resource_id !== params.resourceId ||
6055
+ (event.recurrence?.rrule ?? null) !== null;
6056
+ const letGo = found.transparency !== 'transparent' && (event.transparency ?? 'opaque') === 'transparent';
6057
+ if (moved || letGo) {
6058
+ const judged = await rulesNow(tx, found.resource_id);
6059
+ if (moved && cutoffRuleBroken(judged.rules, 'reschedule', found.starts_at, judged.now)) {
6060
+ return { ok: false, code: 'booking_rule_violated', rule: 'reschedule_cutoff' };
6061
+ }
6062
+ const from = cancellationStart(found.starts_at, found.cutoff_anchor);
6063
+ if (letGo &&
6064
+ (cutoffRuleBroken(judged.rules, 'cancel', from, judged.now) ||
6065
+ cancellationFeeDue(judged.rules, from, judged.now) !== null)) {
6066
+ return { ok: false, code: 'booking_rule_violated', rule: 'cancel_cutoff' };
6067
+ }
6068
+ // Moved when cancelling would already be late: it stays as late as it was.
6069
+ if (moved && cancellationLate(judged.rules, from, judged.now))
6070
+ movedAnchor = from;
5287
6071
  }
5288
6072
  }
5289
6073
  const series = expanded ?? resumeStoredSeries(event, materializationWindow, found);
@@ -5319,6 +6103,7 @@ function buildSlotlockStore(sql, options, refusal) {
5319
6103
  }
5320
6104
  const eventId = found?.id ?? randomUUID();
5321
6105
  const revision = currentRevision + 1;
6106
+ let heldPaid = false;
5322
6107
  if (params.fromHold) {
5323
6108
  const { holdId, holderRef } = params.fromHold;
5324
6109
  if (!isValidResourceId(holdId) || !isValidIdentity(holderRef)) {
@@ -5327,12 +6112,14 @@ function buildSlotlockStore(sql, options, refusal) {
5327
6112
  const [hold] = await tx `
5328
6113
  SELECT resource_id, starts_at, ends_at, hold_group_id,
5329
6114
  expires_at > statement_timestamp() AS live,
5330
- (payment_required AND paid_at IS NULL) AS unpaid
6115
+ (payment_required AND paid_at IS NULL) AS unpaid,
6116
+ (payment_required AND paid_at IS NOT NULL) AS paid
5331
6117
  FROM slotlock.reservations
5332
6118
  WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}
5333
6119
  FOR UPDATE`;
5334
6120
  if (!hold)
5335
6121
  return { ok: false, code: 'hold_not_found' };
6122
+ heldPaid = hold.paid;
5336
6123
  // The holder's own hold that lapsed and is not yet reaped: named as the application
5337
6124
  // API's confirm names it. Another holder's stays not found.
5338
6125
  if (!hold.live)
@@ -5353,6 +6140,9 @@ function buildSlotlockStore(sql, options, refusal) {
5353
6140
  await tx `SELECT set_config('slotlock.ending', 'confirmed', true)`;
5354
6141
  await tx `DELETE FROM slotlock.reservations WHERE id = ${holdId} AND status = 'held' AND holder_ref = ${holderRef}`;
5355
6142
  await tx `SELECT set_config('slotlock.ending', '', true)`;
6143
+ // Its payment, if any, is applied to this booking.
6144
+ await tx `UPDATE slotlock.hold_payments SET booked_at = statement_timestamp()
6145
+ WHERE hold_id = ${holdId} AND booked_at IS NULL`;
5356
6146
  // A set held together (RFC 0004 B7) is booked member by member: its other holds stay.
5357
6147
  await tx `
5358
6148
  DELETE FROM slotlock.reservations
@@ -5364,8 +6154,16 @@ function buildSlotlockStore(sql, options, refusal) {
5364
6154
  }
5365
6155
  let turnaroundMs = 0;
5366
6156
  let bufferBeforeMs = 0;
5367
- if (params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque') {
5368
- const judge = await bookingJudge(tx, params.resourceId, defaultHours);
6157
+ // The resource's rules, read once, for its price and the rest alike.
6158
+ const judge = params.enforceBookingRules === true && (event.transparency ?? 'opaque') === 'opaque'
6159
+ ? await bookingJudge(tx, params.resourceId, defaultHours)
6160
+ : null;
6161
+ // A resource with a hold price (RFC 0004 C3) is booked by an agent only from its own
6162
+ // hold, once paid: new time on it, or time moved onto it from another resource.
6163
+ if (judge && (!found || found.resource_id !== params.resourceId) && !heldPaid && judge.holdPrice !== undefined) {
6164
+ return { ok: false, code: 'payment_required' };
6165
+ }
6166
+ if (judge) {
5369
6167
  for (const [index, occurrence] of occurrences.entries()) {
5370
6168
  // A held time's notice and advance were judged when it was held.
5371
6169
  const rule = judge.broken(occurrence, { first: index === 0 && !params.fromHold });
@@ -5418,6 +6216,8 @@ function buildSlotlockStore(sql, options, refusal) {
5418
6216
  buffer_before_ms = ${bufferBeforeMs},
5419
6217
  -- Whether anyone should be told (RFC 0004 P3): as sent, like the rest.
5420
6218
  notify = ${params.notify !== false},
6219
+ -- Kept, or set by a late move (RFC 0004 B5).
6220
+ cutoff_anchor = COALESCE(${movedAnchor}::timestamptz, cutoff_anchor),
5421
6221
  updated_at = now()
5422
6222
  WHERE id = ${eventId}
5423
6223
  AND tenant_ref = ${params.tenantRef}
@@ -5791,7 +6591,7 @@ function buildSlotlockStore(sql, options, refusal) {
5791
6591
  };
5792
6592
  }
5793
6593
  const events = await tx `
5794
- SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring
6594
+ SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring, cutoff_anchor
5795
6595
  FROM slotlock.calendar_events
5796
6596
  WHERE tenant_ref = ${params.tenantRef}
5797
6597
  AND owner_ref = ${ownerRef}
@@ -5881,7 +6681,7 @@ function buildSlotlockStore(sql, options, refusal) {
5881
6681
  return { ok: false, code: 'event_not_found' };
5882
6682
  await lockCalendarEventIdentity(tx, params.tenantRef, identity.owner_ref, identity.external_ref);
5883
6683
  const [found] = await tx `
5884
- SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring
6684
+ SELECT id, resource_id, revision, starts_at, recurrence_rule IS NOT NULL AS recurring, cutoff_anchor
5885
6685
  FROM slotlock.calendar_events
5886
6686
  WHERE tenant_ref = ${params.tenantRef} AND id = ${params.eventId}
5887
6687
  FOR UPDATE`;
@@ -6267,6 +7067,18 @@ function buildSlotlockStore(sql, options, refusal) {
6267
7067
  )
6268
7068
  SELECT count(*)::int AS deleted FROM deleted`;
6269
7069
  const tombstonesDeleted = Number(tombstones?.deleted ?? 0);
7070
+ // Busy time a calendar reported (RFC 0004 C1) that ended a month ago keeps nothing out any
7071
+ // more: it goes, as the feed's changes do, so a person's schedule is not kept for good.
7072
+ const [busy] = await tx `
7073
+ WITH ended AS (
7074
+ SELECT ctid FROM slotlock.source_busy
7075
+ WHERE tenant_ref = ${params.tenantRef}
7076
+ AND ends_at < statement_timestamp() - make_interval(days => ${SOURCE_BUSY_RETENTION_DAYS})
7077
+ LIMIT ${limit}
7078
+ ), deleted AS (
7079
+ DELETE FROM slotlock.source_busy busy USING ended WHERE busy.ctid = ended.ctid RETURNING 1
7080
+ )
7081
+ SELECT count(*)::int AS deleted FROM deleted`;
6270
7082
  return {
6271
7083
  expiredHoldsDeleted,
6272
7084
  holdCommandsDeleted,
@@ -6274,7 +7086,8 @@ function buildSlotlockStore(sql, options, refusal) {
6274
7086
  hasMore: legacy.length === limit ||
6275
7087
  expiredHoldsDeleted === limit ||
6276
7088
  holdCommandsDeleted === limit ||
6277
- tombstonesDeleted === limit,
7089
+ tombstonesDeleted === limit ||
7090
+ Number(busy?.deleted ?? 0) === limit,
6278
7091
  };
6279
7092
  });
6280
7093
  },
@@ -6493,7 +7306,7 @@ function buildSlotlockStore(sql, options, refusal) {
6493
7306
  reported.starts_at AS busy_start, reported.ends_at AS busy_end
6494
7307
  FROM slotlock.reservations booking
6495
7308
  JOIN reported ON booking.starts_at < reported.ends_at AND booking.ends_at > reported.starts_at
6496
- WHERE booking.resource_id = ${params.resourceId}
7309
+ WHERE booking.resource_id IN (${compositeKin(tx, params.resourceId)})
6497
7310
  AND booking.status = 'confirmed'
6498
7311
  AND booking.starts_at < ${window.end} AND booking.ends_at > ${window.start}
6499
7312
  AND NOT EXISTS (
@@ -6664,8 +7477,11 @@ function buildSlotlockStore(sql, options, refusal) {
6664
7477
  throw new RangeError('limit must be a whole number from 1 to 5000');
6665
7478
  }
6666
7479
  const rows = await sql `
6667
- WITH due AS (
6668
- SELECT occurrence.event_id, occurrence.recurrence_id, reminder.ordinal, reminder.action,
7480
+ WITH candidates AS (
7481
+ -- Reminders due at the same time are said once (the first in the list).
7482
+ SELECT DISTINCT ON (occurrence.event_id, occurrence.recurrence_id,
7483
+ occurrence.starts_at - make_interval(mins => reminder.minutes_before_start))
7484
+ occurrence.event_id, occurrence.recurrence_id, reminder.ordinal, reminder.action,
6669
7485
  reminder.minutes_before_start, occurrence.starts_at, occurrence.ends_at,
6670
7486
  event.resource_id,
6671
7487
  occurrence.starts_at - make_interval(mins => reminder.minutes_before_start) AS due_at
@@ -6681,21 +7497,21 @@ function buildSlotlockStore(sql, options, refusal) {
6681
7497
  <= statement_timestamp()
6682
7498
  AND occurrence.starts_at - make_interval(mins => reminder.minutes_before_start)
6683
7499
  > statement_timestamp() - interval '1 day'
6684
- -- Said once per time it is due: an occurrence moved since is reminded again.
7500
+ -- Said once per time it is due, whatever its place in the list: one added or
7501
+ -- reordered says nothing again; an occurrence moved since is reminded again.
6685
7502
  AND NOT EXISTS (
6686
7503
  SELECT 1 FROM slotlock.reminder_dispatches sent
6687
7504
  WHERE sent.event_id = occurrence.event_id AND sent.recurrence_id = occurrence.recurrence_id
6688
- AND sent.ordinal = reminder.ordinal
6689
7505
  AND sent.due_at = occurrence.starts_at - make_interval(mins => reminder.minutes_before_start))
6690
- ORDER BY due_at, occurrence.event_id, occurrence.recurrence_id, reminder.ordinal
6691
- LIMIT ${limit}
7506
+ ORDER BY occurrence.event_id, occurrence.recurrence_id,
7507
+ occurrence.starts_at - make_interval(mins => reminder.minutes_before_start), reminder.ordinal
7508
+ ), due AS (
7509
+ SELECT * FROM candidates ORDER BY due_at, event_id, recurrence_id LIMIT ${limit}
6692
7510
  ), dispatched AS (
6693
7511
  INSERT INTO slotlock.reminder_dispatches (tenant_ref, event_id, recurrence_id, ordinal, due_at)
6694
7512
  SELECT ${params.tenantRef}, event_id, recurrence_id, ordinal, due_at FROM due
6695
- ON CONFLICT (event_id, recurrence_id, ordinal) DO UPDATE
6696
- SET due_at = EXCLUDED.due_at, dispatched_at = statement_timestamp()
6697
- WHERE slotlock.reminder_dispatches.due_at <> EXCLUDED.due_at
6698
- RETURNING event_id, recurrence_id, ordinal
7513
+ ON CONFLICT (event_id, recurrence_id, due_at) DO NOTHING
7514
+ RETURNING event_id, recurrence_id, due_at
6699
7515
  ), reported AS (
6700
7516
  INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
6701
7517
  SELECT ${params.tenantRef}, due.resource_id, 'reminder.due', due.event_id::text,
@@ -6705,7 +7521,7 @@ function buildSlotlockStore(sql, options, refusal) {
6705
7521
  'minutes_before_start', due.minutes_before_start, 'due_at', due.due_at,
6706
7522
  'start', due.starts_at, 'end', due.ends_at),
6707
7523
  'slotlock:maintenance'
6708
- FROM due JOIN dispatched USING (event_id, recurrence_id, ordinal)
7524
+ FROM due JOIN dispatched USING (event_id, recurrence_id, due_at)
6709
7525
  RETURNING 1
6710
7526
  )
6711
7527
  SELECT count(*)::int AS count FROM reported`;
@@ -6714,8 +7530,9 @@ function buildSlotlockStore(sql, options, refusal) {
6714
7530
  return events;
6715
7531
  // A booking's reminders too, named by its reference, with whom to tell.
6716
7532
  const [bookings] = await sql `
6717
- WITH due AS (
6718
- SELECT booking.id, booking.external_ref, booking.resource_id, booking.contact_ref,
7533
+ WITH candidates AS (
7534
+ SELECT DISTINCT ON (booking.id, booking.starts_at - make_interval(mins => reminder.minutes))
7535
+ booking.id, booking.external_ref, booking.resource_id, booking.contact_ref,
6719
7536
  booking.starts_at, booking.ends_at, (reminder.ordinal - 1)::int AS ordinal,
6720
7537
  reminder.minutes,
6721
7538
  booking.starts_at - make_interval(mins => reminder.minutes) AS due_at
@@ -6727,19 +7544,19 @@ function buildSlotlockStore(sql, options, refusal) {
6727
7544
  AND booking.starts_at > statement_timestamp()
6728
7545
  AND booking.starts_at - make_interval(mins => reminder.minutes) <= statement_timestamp()
6729
7546
  AND booking.starts_at - make_interval(mins => reminder.minutes) > statement_timestamp() - interval '1 day'
7547
+ -- Said once per time it is due, as an event's reminders are.
6730
7548
  AND NOT EXISTS (
6731
7549
  SELECT 1 FROM slotlock.reservation_reminder_dispatches sent
6732
- WHERE sent.reservation_id = booking.id AND sent.ordinal = reminder.ordinal - 1
7550
+ WHERE sent.reservation_id = booking.id
6733
7551
  AND sent.due_at = booking.starts_at - make_interval(mins => reminder.minutes))
6734
- ORDER BY due_at, booking.id, reminder.ordinal
6735
- LIMIT ${limit - events}
7552
+ ORDER BY booking.id, booking.starts_at - make_interval(mins => reminder.minutes), reminder.ordinal
7553
+ ), due AS (
7554
+ SELECT * FROM candidates ORDER BY due_at, id LIMIT ${limit - events}
6736
7555
  ), dispatched AS (
6737
7556
  INSERT INTO slotlock.reservation_reminder_dispatches (tenant_ref, reservation_id, ordinal, due_at)
6738
7557
  SELECT ${params.tenantRef}, id, ordinal, due_at FROM due
6739
- ON CONFLICT (reservation_id, ordinal) DO UPDATE
6740
- SET due_at = EXCLUDED.due_at, dispatched_at = statement_timestamp()
6741
- WHERE slotlock.reservation_reminder_dispatches.due_at <> EXCLUDED.due_at
6742
- RETURNING reservation_id AS id, ordinal
7558
+ ON CONFLICT (reservation_id, due_at) DO NOTHING
7559
+ RETURNING reservation_id AS id, due_at
6743
7560
  ), reported AS (
6744
7561
  INSERT INTO slotlock.outbox (tenant_ref, resource_id, type, subject, data, actor)
6745
7562
  SELECT ${params.tenantRef}, due.resource_id, 'reminder.due', due.external_ref,
@@ -6749,7 +7566,7 @@ function buildSlotlockStore(sql, options, refusal) {
6749
7566
  'ordinal', due.ordinal, 'minutes_before_start', due.minutes, 'due_at', due.due_at,
6750
7567
  'start', due.starts_at, 'end', due.ends_at)),
6751
7568
  'slotlock:maintenance'
6752
- FROM due JOIN dispatched USING (id, ordinal)
7569
+ FROM due JOIN dispatched USING (id, due_at)
6753
7570
  RETURNING 1
6754
7571
  )
6755
7572
  SELECT count(*)::int AS count FROM reported`;
@@ -7388,6 +8205,14 @@ function buildSlotlockStore(sql, options, refusal) {
7388
8205
  SELECT slotlock.sweep_assistant_conversations(${limit}::integer) AS deleted`;
7389
8206
  return Number(row?.deleted ?? 0);
7390
8207
  },
8208
+ async sweepDashboardRecords() {
8209
+ // Each its own statement: an erasure that runs long never takes the prunes with it.
8210
+ const [pruned] = await sql `
8211
+ SELECT slotlock.prune_workspace_invitations() + slotlock.prune_dashboard_sessions()
8212
+ + slotlock.prune_workspace_domains() + slotlock.prune_sso() AS deleted`;
8213
+ const [erased] = await sql `SELECT slotlock.erase_deleted_teams() AS erased`;
8214
+ return Number(pruned?.deleted ?? 0) + Number(erased?.erased ?? 0);
8215
+ },
7391
8216
  async deleteAssistantChat(params) {
7392
8217
  if (!isValidIdentity(params.tenantRef) ||
7393
8218
  !isValidIdentity(params.memberRef) ||
@@ -7400,6 +8225,93 @@ function buildSlotlockStore(sql, options, refusal) {
7400
8225
  AND chat_id = ${params.chatId} RETURNING 1`;
7401
8226
  return rows.length > 0;
7402
8227
  },
8228
+ async recordAuditEvent(params) {
8229
+ if (!isValidIdentity(params.tenantRef)) {
8230
+ throw invalidIdentityError('Slotlock tenant references must be 1-500 UTF-8 bytes');
8231
+ }
8232
+ const event = params.event;
8233
+ if (!AUDIT_ACTION.test(event.action) || !AUDIT_ACTOR_TYPES.has(event.actorType) || !isValidIdentity(event.actorRef)) {
8234
+ throw invalidIdentityError('Slotlock audit events need an action (area.verb), an actor type and an actor reference');
8235
+ }
8236
+ if (event.targetType !== undefined && !AUDIT_TARGET_TYPE.test(event.targetType)) {
8237
+ throw invalidIdentityError('Slotlock audit event target types are lowercase words');
8238
+ }
8239
+ if (event.actorRole !== undefined && event.actorRole !== null && !isSlotlockWorkspaceRole(event.actorRole)) {
8240
+ throw invalidIdentityError('Slotlock audit event roles are workspace roles');
8241
+ }
8242
+ await sql `
8243
+ INSERT INTO slotlock.audit_events
8244
+ (tenant_ref, action, actor_type, actor_ref, actor_role, target_type, target_ref, outcome,
8245
+ reason, ip_address, user_agent, metadata)
8246
+ VALUES (${params.tenantRef}, ${event.action}, ${event.actorType}, ${event.actorRef},
8247
+ ${event.actorRole ?? null}, ${event.targetType ?? null}, ${cutBytes(event.targetRef, 500)},
8248
+ ${event.outcome === 'denied' ? 'denied' : 'success'}, ${cutBytes(event.reason, 200)},
8249
+ ${cutBytes(event.ipAddress, 64)}, ${cutBytes(event.userAgent, 256)},
8250
+ ${sql.json(boundAuditMetadata(event.metadata ?? {}))})`;
8251
+ },
8252
+ async listAuditEvents(params) {
8253
+ if (!isValidIdentity(params.tenantRef))
8254
+ return [];
8255
+ const limit = Math.min(Math.max(Math.trunc(params.limit ?? 50), 1), 200);
8256
+ const before = params.before;
8257
+ const action = params.action !== undefined && AUDIT_ACTION.test(params.action) ? params.action : null;
8258
+ const actor = params.actorRef !== undefined && isValidIdentity(params.actorRef) ? params.actorRef : null;
8259
+ const beforeId = params.beforeId !== undefined && isValidResourceId(params.beforeId) ? params.beforeId : null;
8260
+ if (params.beforeId !== undefined && beforeId === null)
8261
+ return [];
8262
+ const rows = await sql `
8263
+ SELECT id, occurred_at, action, actor_type, actor_ref, actor_role, target_type, target_ref,
8264
+ outcome, reason, ip_address, user_agent, metadata, chain_index, hash
8265
+ FROM slotlock.audit_events
8266
+ WHERE tenant_ref = ${params.tenantRef}
8267
+ AND (${action}::text IS NULL OR action = ${action}::text)
8268
+ AND (${actor}::text IS NULL OR actor_ref = ${actor}::text)
8269
+ AND (${before?.occurredAt ?? null}::timestamptz IS NULL
8270
+ OR (occurred_at, id) < (${before?.occurredAt ?? null}::timestamptz, ${before?.id ?? null}::uuid))
8271
+ AND (${beforeId}::uuid IS NULL
8272
+ OR (occurred_at, id) < (SELECT kept.occurred_at, kept.id FROM slotlock.audit_events kept
8273
+ WHERE kept.tenant_ref = ${params.tenantRef} AND kept.id = ${beforeId}::uuid))
8274
+ ORDER BY occurred_at DESC, id DESC
8275
+ LIMIT ${limit}`;
8276
+ return rows.map((row) => ({
8277
+ id: row.id,
8278
+ occurredAt: row.occurred_at,
8279
+ action: row.action,
8280
+ actorType: row.actor_type,
8281
+ actorRef: row.actor_ref,
8282
+ actorRole: row.actor_role,
8283
+ ...(row.target_type !== null ? { targetType: row.target_type } : {}),
8284
+ ...(row.target_ref !== null ? { targetRef: row.target_ref } : {}),
8285
+ outcome: row.outcome,
8286
+ ...(row.reason !== null ? { reason: row.reason } : {}),
8287
+ ...(row.ip_address !== null ? { ipAddress: row.ip_address } : {}),
8288
+ ...(row.user_agent !== null ? { userAgent: row.user_agent } : {}),
8289
+ chainIndex: row.chain_index === null ? null : Number(row.chain_index),
8290
+ hash: row.hash === null ? null : row.hash.toString('hex'),
8291
+ metadata: row.metadata,
8292
+ }));
8293
+ },
8294
+ async tenantPlan(params) {
8295
+ if (!isValidIdentity(params.tenantRef))
8296
+ return null;
8297
+ const [row] = await sql `
8298
+ SELECT plan, status FROM slotlock.tenant_plan(${params.tenantRef}::text) LIMIT 1`;
8299
+ return row ? { plan: row.plan, status: row.status } : null;
8300
+ },
8301
+ async pruneAuditEvents(params) {
8302
+ if (!isValidIdentity(params.tenantRef))
8303
+ return 0;
8304
+ const [row] = await sql `
8305
+ SELECT slotlock.prune_audit_events(${params.tenantRef}::text) AS pruned`;
8306
+ return Number(row?.pruned ?? 0);
8307
+ },
8308
+ async verifyAuditChain(params) {
8309
+ if (!isValidIdentity(params.tenantRef))
8310
+ return { checked: 0, brokenAt: null };
8311
+ const [row] = await sql `
8312
+ SELECT checked, broken_at FROM slotlock.verify_audit_chain(${params.tenantRef}::text)`;
8313
+ return { checked: Number(row?.checked ?? 0), brokenAt: row?.broken_at === null || row === undefined ? null : Number(row.broken_at) };
8314
+ },
7403
8315
  async offerWaitlist(params) {
7404
8316
  if (!isValidIdentity(params.tenantRef)) {
7405
8317
  throw invalidIdentityError('Slotlock tenant reference must be 1-500 UTF-8 bytes');