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
@@ -1,6 +1,9 @@
1
+ import { SLOTLOCK_RESOURCE_PROFILE_FIELDS, SLOTLOCK_RECONCILE_TOLERANCE_PERCENT, slotlockResourceHasLabels, } from './resource-profile.js';
2
+ import { SLOTLOCK_USAGE_RETENTION_DAYS, slotlockUsageDay, slotlockUsageMonthToDate, slotlockUsageSpanDays, summarizeSlotlockUsage, } from './usage.js';
1
3
  import { slotlockChangeFeedHead } from './event-feed.js';
2
4
  import { slotlockCloudEvent } from './webhooks.js';
3
5
  import { createHash } from 'node:crypto';
6
+ import { SLOTLOCK_DEFAULT_APPROVAL_MINUTES, SLOTLOCK_KEY_APPROVAL_POLICY, SLOTLOCK_MAX_APPROVAL_MINUTES, SlotlockApprovalPolicyError, } from './approvals.js';
4
7
  import { SlotlockAgentOperationError, } from './agent-server.js';
5
8
  import { bookingSearch, bookingSearchBlocked, bookingSearchReach, } from './booking-rules.js';
6
9
  import { findNextAvailable, mergeIntervals } from './engine.js';
@@ -8,15 +11,42 @@ import { expandRules } from './rules.js';
8
11
  import { emitVAvailability, parseVAvailability, toJSCalendarEvent } from './interop.js';
9
12
  import { calendarEventRollingHorizon, } from './store.js';
10
13
  import { maintainSlotlockTenant } from './maintenance.js';
14
+ import { fastForwardSlotlockSandbox, raceSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
15
+ import { SLOTLOCK_TEST_TENANT_SUFFIX } from './api-keys.js';
16
+ import { slotlockPartnerKeyOutput, slotlockPrincipalPartner, } from './partners.js';
17
+ /** A partner's account as the partner API shows it. */
18
+ function partnerAccountOutput(account) {
19
+ return {
20
+ account: account.account,
21
+ plan: account.plan,
22
+ status: account.status,
23
+ created_at: account.createdAt.toISOString(),
24
+ updated_at: account.updatedAt.toISOString(),
25
+ ...(account.resources !== undefined ? { resources: account.resources } : {}),
26
+ ...(account.apiKeys !== undefined ? { api_keys: account.apiKeys } : {}),
27
+ };
28
+ }
11
29
  import { parseAvailabilityRules } from './weekly-hours.js';
12
30
  /**
13
- * A write runs under READ COMMITTED whatever the role's default: a store given tenant limits (as
14
- * `slotlock serve`'s is) counts it against them under the tenant's lock, which needs that level,
15
- * and refuses it otherwise.
31
+ * Writes run under READ COMMITTED whatever the role's default: tenant limits are counted under a
32
+ * lock that needs that level.
16
33
  */
17
34
  const WRITE = { isolation: 'read committed' };
18
- // The UUIDs withTenant takes as a keyId (versions 1-5, as Slotlock's keys are): any other a custom
19
- // authenticator's principal carries is left out, not refused.
35
+ /**
36
+ * Keys a context carries only from inside this module: a request cannot set a symbol, and spreading
37
+ * a context (`{ ...context, operation }`) keeps them.
38
+ */
39
+ const APPROVAL_GRANT = Symbol('slotlock.approvalGrant');
40
+ const OCCUPANCY_PROBE = Symbol('slotlock.occupancyProbe');
41
+ /** Thrown out of a dry run's transaction, with its answer, so the transaction rolls back. */
42
+ class DryRunRollback {
43
+ value;
44
+ constructor(value) {
45
+ this.value = value;
46
+ }
47
+ }
48
+ // The UUIDs withTenant takes as a keyId (versions 1-5); any other from a custom authenticator is
49
+ // left out, not refused.
20
50
  const KEY_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
21
51
  /** Who a call's changes are recorded as in the history: the principal's subject, kept short. */
22
52
  function actorOf(context) {
@@ -36,16 +66,51 @@ async function withTenant(store, context, callback, options) {
36
66
  try {
37
67
  const actor = actorOf(context);
38
68
  const keyId = context.principal.keyId;
39
- value = await store.withTenant(context.principal.tenantRef, callback, {
69
+ // A granted approval's write: its holds give way and it is marked granted in the same
70
+ // transaction, so the time passes from the approval to the booking with no gap between.
71
+ const grant = context[APPROVAL_GRANT];
72
+ const write = grant
73
+ ? async (tenantStore) => {
74
+ const tenantRef = context.principal.tenantRef;
75
+ const begun = await tenantStore.beginApprovalGrant({ tenantRef, id: grant.id });
76
+ if (!begun.ok) {
77
+ throw new SlotlockAgentOperationError(begun.code, begun.code === 'approval_not_found' ? 404 : 409);
78
+ }
79
+ const value = await callback(tenantStore);
80
+ const booked = value.event?.resource_id;
81
+ if (grant.pool !== undefined && typeof booked === 'string') {
82
+ await tenantStore.notePoolBooking({ tenantRef, pool: grant.pool, resourceId: booked });
83
+ }
84
+ await tenantStore.finishApprovalGrant({ tenantRef, id: grant.id, decidedBy: grant.decidedBy });
85
+ return value;
86
+ }
87
+ : callback;
88
+ // A dry run's transaction does everything, constraints included, then is undone: its answer
89
+ // is carried out of it by the rollback.
90
+ const probe = context[OCCUPANCY_PROBE];
91
+ const run = context.dryRun
92
+ ? async (tenantStore) => {
93
+ const value = await write(tenantStore);
94
+ if (probe)
95
+ await probe(tenantStore);
96
+ throw new DryRunRollback(value);
97
+ }
98
+ : write;
99
+ value = await store.withTenant(context.principal.tenantRef, run, {
40
100
  ...options,
41
101
  signal: context.signal,
42
102
  ...(actor !== undefined ? { actor } : {}),
43
103
  ...(context.origin !== undefined ? { origin: context.origin } : {}),
44
- // An authenticator's own principal may carry something else there: then it is left out.
45
104
  ...(typeof keyId === 'string' && KEY_ID.test(keyId) ? { keyId } : {}),
105
+ // A key limited to part of the tenant reaches only its resources, by the database's policies.
106
+ ...(context.principal.resourceScope ? { resourceScope: context.principal.resourceScope } : {}),
46
107
  });
47
108
  }
48
109
  catch (error) {
110
+ if (error instanceof DryRunRollback) {
111
+ assertActive(context);
112
+ return error.value;
113
+ }
49
114
  const code = error?.code;
50
115
  // A suspended tenant may read; a write it asks for is forbidden, not a server failure.
51
116
  if (code === 'tenant_suspended')
@@ -60,22 +125,18 @@ async function withTenant(store, context, callback, options) {
60
125
  return value;
61
126
  }
62
127
  /**
63
- * Busy intervals one free/busy read or search may gather across all the resources it names: as many
64
- * as the store reads for one resource. Without it, a request naming 100 resources at that limit, or
65
- * one resource 100 times, did 100 times the work of one.
128
+ * Busy intervals one free/busy read or search may gather across all its resources: as many as the
129
+ * store reads for one, so naming many resources cannot multiply the work.
66
130
  */
67
131
  const MAX_REQUEST_BUSY_INTERVALS = 10_000;
68
132
  /**
69
- * Bookable windows one search may expand from its resources' availability rules in all: five
70
- * resources of 50 daily rules over a year, or a hundred of two. Without it, a search of 100 such
71
- * resources over a year expanded 1.8 million windows and held the process for seven seconds.
133
+ * Bookable windows one search may expand from its resources' rules in all (five resources of 50
134
+ * daily rules over a year, or a hundred of two), so a broad search cannot hold the process.
72
135
  */
73
136
  const MAX_REQUEST_RULE_WINDOWS = 100_000;
74
- /** The resources a request names, each once, in the order first named. */
75
137
  function distinctResourceIds(input) {
76
138
  return [...new Set(input.resource_ids)];
77
139
  }
78
- /** Counts a request's busy intervals against MAX_REQUEST_BUSY_INTERVALS, refusing past it. */
79
140
  function busyBudget() {
80
141
  let left = MAX_REQUEST_BUSY_INTERVALS;
81
142
  return (intervals) => {
@@ -169,12 +230,12 @@ function agentOwnerRef(context) {
169
230
  .digest('hex')}`;
170
231
  }
171
232
  function agentExternalRef(context, idempotencyKey) {
172
- // This format is already a stable client-visible identity; keep it unchanged.
233
+ // Already a stable client-visible identity; keep it unchanged.
173
234
  return `agent:${agentPrincipalDigest(context, idempotencyKey)}`;
174
235
  }
175
236
  function agentCommandIdempotencyKey(context, idempotencyKey) {
176
- // Keep the principal in the digest too so this remains safe with older tenant-only stores.
177
- // Hashing keeps the authenticated subject and caller's raw retry key out of durable command rows.
237
+ // The principal stays in the digest so this remains safe with older tenant-only stores; hashing
238
+ // keeps the subject and raw retry key out of durable rows.
178
239
  return `agent-command:${agentPrincipalDigest(context, idempotencyKey)}`;
179
240
  }
180
241
  function attendeeProjection(attendee) {
@@ -192,8 +253,8 @@ function attendeeProjection(attendee) {
192
253
  */
193
254
  export function projectSlotlockCalendarEvent(event) {
194
255
  return {
195
- // External references are the stable tenant-visible identity. Internal UUIDs never become an
196
- // unscoped lookup capability at the protocol boundary.
256
+ // External references are the tenant-visible identity: internal UUIDs never become an unscoped
257
+ // lookup at the protocol boundary.
197
258
  id: event.externalRef,
198
259
  resource_id: event.resourceId,
199
260
  starts_at: event.start.toISOString(),
@@ -246,20 +307,41 @@ const BOOKING_RULE_CODES = Object.freeze({
246
307
  outside_hours: 'booking_rule_outside_hours',
247
308
  uncertain: 'booking_rule_uncertain',
248
309
  });
249
- function operationError(refusal) {
250
- // A broken booking rule is named in the code, which is all an agent's error carries.
310
+ function operationError(refusal, details) {
251
311
  const code = typeof refusal === 'string'
252
312
  ? refusal
253
313
  : refusal.code === 'booking_rule_violated' && refusal.rule
254
314
  ? (BOOKING_RULE_CODES[refusal.rule] ?? refusal.code)
255
315
  : refusal.code;
256
316
  const status = slotlockAgentErrorStatus(code);
257
- // A gone hold is named, so the agent holds again; it covers expired and another holder's alike.
258
- return new SlotlockAgentOperationError(status === 404 && code !== 'hold_not_found' ? 'not_found' : code, status);
317
+ // A gone hold (another holder's, released or reaped) is named, so the agent holds again.
318
+ return new SlotlockAgentOperationError(status === 404 && code !== 'hold_not_found' ? 'not_found' : code, status, details);
319
+ }
320
+ /**
321
+ * What an agent may be told about the booking in the way of its write: when that time is busy
322
+ * (as free/busy shows anyone who may read the resource), and the booking itself only when it is
323
+ * the agent's own hold or event, so it can release or move it. Nothing for another refusal, or for
324
+ * a booking the key may not see (its scope, or another tenant's).
325
+ */
326
+ async function overlapDetails(tenantStore, context, refusal) {
327
+ if (refusal.code !== 'overlap' || typeof refusal.conflictingReservationId !== 'string')
328
+ return {};
329
+ const booking = await tenantStore.describeBooking({
330
+ tenantRef: context.principal.tenantRef,
331
+ id: refusal.conflictingReservationId,
332
+ });
333
+ if (!booking)
334
+ return {};
335
+ const owner = agentOwnerRef(context);
336
+ return {
337
+ busy_start: booking.start.toISOString(),
338
+ busy_end: booking.end.toISOString(),
339
+ ...(booking.status === 'held' && booking.holderRef === owner ? { conflicting_hold_id: booking.id } : {}),
340
+ ...(booking.eventRef !== null && booking.eventOwnerRef === owner ? { conflicting_event_id: booking.eventRef } : {}),
341
+ };
259
342
  }
260
343
  /** Resource and event ids: the UUIDs the store issues and validates. */
261
344
  const STORE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
262
- /** A resource page's cursor is the last resource id of the previous page. */
263
345
  function parseResourceCursor(value) {
264
346
  if (value === undefined)
265
347
  return undefined;
@@ -299,12 +381,10 @@ function page(rows, limit) {
299
381
  return { shown, next: rows.length > limit && last ? encodeEventCursor(last) : null };
300
382
  }
301
383
  /**
302
- * The caller's resource with this id, or `resource_not_found`. The tools accept any id an agent may
303
- * send (an external reference such as `vehicle-42`, the nil UUID, a version 7 UUID). The store
304
- * issues version 4 UUIDs (gen_random_uuid) and refuses an id that is not a version 1 to 5 UUID as an
305
- * invalid identity, so such an id names no resource: it is not found, like a well-formed id that
306
- * names nothing or another tenant's resource. With `visibility`, a resource of the other is not
307
- * found either: agents ask for `shared`, so a private resource is invisible to them.
384
+ * The caller's resource with this id, or `resource_not_found`. Agents may send any id; one that is
385
+ * not a store-issued (v1-5) UUID names no resource and is not found, like another tenant's. With
386
+ * `visibility`, a resource of the other visibility is not found either, so private ones are
387
+ * invisible to agents.
308
388
  */
309
389
  async function requireResource(tenantStore, tenantRef, resourceId, visibility) {
310
390
  if (!STORE_ID_PATTERN.test(resourceId)) {
@@ -325,9 +405,8 @@ function requireAgentResource(tenantStore, tenantRef, resourceId) {
325
405
  }
326
406
  /**
327
407
  * The store's change feed for live updates (`subscriptions.changes`): each tenant's changes read
328
- * under its row-level security, one indexed query per read. Readers poll it (change-watch.ts:
329
- * every `SLOTLOCK_FEED_BUSY_MS` after a read that found changes, backing off to
330
- * `SLOTLOCK_FEED_IDLE_MS`); `sql` is the store's client, used for the feed's head.
408
+ * under its RLS, one indexed query per read. Readers poll it (change-watch.ts); `sql` is the
409
+ * store's client, used for the feed's head.
331
410
  */
332
411
  export function createSlotlockStoreChangeSource(store, sql) {
333
412
  return {
@@ -340,11 +419,22 @@ export function createSlotlockStoreChangeSource(store, sql) {
340
419
  }
341
420
  /** Bind the protocol server to the production store without giving a request control of tenancy. */
342
421
  export function createSlotlockStoreAgentBackend(store, options) {
343
- return {
422
+ const maxApprovalMinutes = options.maxApprovalMinutes ?? SLOTLOCK_MAX_APPROVAL_MINUTES;
423
+ if (!(Number.isSafeInteger(maxApprovalMinutes) && maxApprovalMinutes >= 1 && maxApprovalMinutes <= SLOTLOCK_MAX_APPROVAL_MINUTES)) {
424
+ throw new RangeError(`maxApprovalMinutes must be a whole number from 1 to ${SLOTLOCK_MAX_APPROVAL_MINUTES}`);
425
+ }
426
+ const backend = {
344
427
  async holdSlots(context, input) {
345
428
  const slots = input.slots;
346
429
  const key = input.idempotency_key;
347
430
  const holderRef = agentOwnerRef(context);
431
+ const limits = context.principal.limits;
432
+ const holderCaps = limits?.maxHolds !== undefined || limits?.maxHeldHours !== undefined
433
+ ? {
434
+ ...(limits.maxHolds !== undefined ? { maxHolds: limits.maxHolds } : {}),
435
+ ...(limits.maxHeldHours !== undefined ? { maxHeldHours: limits.maxHeldHours } : {}),
436
+ }
437
+ : undefined;
348
438
  // One group per agent and key, so a retry answers with the same holds and group.
349
439
  const groupId = uuidFromDigest(`slotlock-hold-group\0${holderRef}\0${key}`);
350
440
  return withTenant(store, context, async (tenantStore) => {
@@ -371,6 +461,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
371
461
  end: new Date(slot.end),
372
462
  ttlMs: input.ttl_seconds * 1_000,
373
463
  source: 'agent-server',
464
+ // The key's own caps, below the server's (RFC 0004 A5).
465
+ ...(holderCaps ? { holderCaps } : {}),
374
466
  });
375
467
  if (result.ok) {
376
468
  holds.push({
@@ -430,15 +522,24 @@ export function createSlotlockStoreAgentBackend(store, options) {
430
522
  throw operationError(replay);
431
523
  return answer(replay);
432
524
  }
433
- const members = await tenantStore.listPoolMembers({ tenantRef, pool: input.pool, visibility: 'shared' });
525
+ // In the pool's order (stable, least booked first, or round robin).
526
+ const members = await tenantStore.listPoolMembers({
527
+ tenantRef,
528
+ pool: input.pool,
529
+ visibility: 'shared',
530
+ ordered: true,
531
+ forBooking: true,
532
+ });
434
533
  if (members.length === 0)
435
534
  throw new SlotlockAgentOperationError('pool_not_found', 404);
436
- // Each member in turn, each try its own savepoint: a member that is taken or that the
437
- // booking's rules refuse is passed over; the exclusion constraint still decides each one.
535
+ // Each member in turn, each in its own savepoint: one taken or refused by its rules is
536
+ // passed over; the exclusion constraint still decides.
438
537
  for (const member of members) {
439
538
  const result = await put(member.id);
440
- if (result.ok)
539
+ if (result.ok) {
540
+ await tenantStore.notePoolBooking({ tenantRef, pool: input.pool, resourceId: member.id });
441
541
  return answer(result);
542
+ }
442
543
  if (result.code !== 'overlap' && result.code !== 'booking_rule_violated') {
443
544
  throw operationError(result);
444
545
  }
@@ -457,7 +558,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
457
558
  const members = await tenantStore.listPoolMembers({ tenantRef, pool, visibility: 'shared' });
458
559
  if (members.length === 0)
459
560
  throw new SlotlockAgentOperationError('pool_not_found', 404);
460
- // Each member's busy time, as edges: a count of busy members that rises and falls.
561
+ // Busy time as edges: a count of busy members that rises and falls.
461
562
  const edges = new Map([
462
563
  [window.start.getTime(), 0],
463
564
  [window.end.getTime(), 0],
@@ -477,9 +578,9 @@ export function createSlotlockStoreAgentBackend(store, options) {
477
578
  charge(freeBusy.busy.length);
478
579
  if (freeBusy.coverage.state !== 'complete')
479
580
  complete = false;
480
- // Not free either where its booking rules refuse a booking: before its notice, past how
481
- // far ahead it books, and, when it enforces them, outside its bookable hours. Its slot
482
- // step is not counted: it moves when a booking may start, not whether the member is free.
581
+ // Also not free where its booking rules refuse a booking (notice, advance, enforced
582
+ // hours). Its slot step is not counted: it moves when a booking may start, not whether
583
+ // the member is free.
483
584
  const rules = member.effectiveBookingRules;
484
585
  const search = bookingSearch({ rules, timezone: member.timezone, window, now });
485
586
  const latestStart = rules?.maxAdvanceDays !== undefined
@@ -524,7 +625,6 @@ export function createSlotlockStoreAgentBackend(store, options) {
524
625
  break;
525
626
  const free = members.length - busy;
526
627
  const last = intervals.at(-1);
527
- // Parts with the same count are one.
528
628
  if (last && last.free === free)
529
629
  last.end = new Date(next).toISOString();
530
630
  else
@@ -540,8 +640,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
540
640
  },
541
641
  async bookTogether(context, rawInput) {
542
642
  const input = rawInput;
543
- // In one order for every bundle, so two over the same resources wait on each other in turn
544
- // and never deadlock.
643
+ // Sorted, so bundles over the same resources lock in one order and never deadlock.
545
644
  const resourceIds = [...new Set(input.resource_ids)].sort();
546
645
  if (resourceIds.length < 2)
547
646
  throw new SlotlockAgentOperationError('invalid_arguments', 400);
@@ -582,10 +681,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
582
681
  materializationWindow: materializationWindow(content),
583
682
  enforceBookingRules: true,
584
683
  });
585
- // Thrown, so the whole transaction rolls back: the events already put go with it.
684
+ // Thrown, so the whole transaction rolls back with the events already put.
586
685
  if (!result.ok) {
587
686
  const refusal = operationError(result);
588
- throw new SlotlockAgentOperationError(refusal.code, refusal.status, { resource_id: resourceId });
687
+ throw new SlotlockAgentOperationError(refusal.code, refusal.status, {
688
+ resource_id: resourceId,
689
+ ...(await overlapDetails(tenantStore, context, result)),
690
+ });
589
691
  }
590
692
  const created = await tenantStore.getCalendarEvent({
591
693
  tenantRef: context.principal.tenantRef,
@@ -600,6 +702,61 @@ export function createSlotlockStoreAgentBackend(store, options) {
600
702
  return { events, replayed };
601
703
  }, WRITE);
602
704
  },
705
+ async confirmHold(context, rawInput) {
706
+ const input = rawInput;
707
+ // The holder's own hold, lapsed or live, and its resource's zone; then the booking exactly
708
+ // as create_event with its hold_id makes it, which judges the hold again under its lock.
709
+ const found = await withTenant(store, context, async (tenantStore) => {
710
+ const held = await tenantStore.getHolderHold({
711
+ tenantRef: context.principal.tenantRef,
712
+ holdId: input.hold_id,
713
+ holderRef: agentOwnerRef(context),
714
+ });
715
+ if (!held)
716
+ return null;
717
+ const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, held.hold.resourceId);
718
+ return { ...held, timezone: resource.timezone };
719
+ });
720
+ if (!found) {
721
+ // Booked already: a retry answers the event this key made (the store compares the
722
+ // request, so another one with the same key is idempotency_conflict).
723
+ const booked = await withTenant(store, context, (tenantStore) => tenantStore.getCalendarEvent({
724
+ tenantRef: context.principal.tenantRef,
725
+ ownerRef: agentOwnerRef(context),
726
+ externalRef: agentExternalRef(context, input.idempotency_key),
727
+ }));
728
+ if (!booked)
729
+ throw new SlotlockAgentOperationError('hold_not_found', 404);
730
+ return this.createEvent({ ...context, operation: 'slotlock_create_event' }, {
731
+ resource_id: booked.resourceId,
732
+ starts_at: booked.start.toISOString(),
733
+ ends_at: booked.end.toISOString(),
734
+ timezone: input.timezone ?? booked.timezone,
735
+ ...(input.title !== undefined ? { title: input.title } : {}),
736
+ ...(input.description !== undefined ? { description: input.description } : {}),
737
+ ...(input.location !== undefined ? { location: input.location } : {}),
738
+ transparency: 'opaque',
739
+ status: 'confirmed',
740
+ hold_id: input.hold_id,
741
+ idempotency_key: input.idempotency_key,
742
+ });
743
+ }
744
+ if (!found.live)
745
+ throw new SlotlockAgentOperationError('hold_expired', 409);
746
+ return this.createEvent({ ...context, operation: 'slotlock_create_event' }, {
747
+ resource_id: found.hold.resourceId,
748
+ starts_at: found.hold.start.toISOString(),
749
+ ends_at: found.hold.end.toISOString(),
750
+ timezone: input.timezone ?? found.timezone,
751
+ ...(input.title !== undefined ? { title: input.title } : {}),
752
+ ...(input.description !== undefined ? { description: input.description } : {}),
753
+ ...(input.location !== undefined ? { location: input.location } : {}),
754
+ transparency: 'opaque',
755
+ status: 'confirmed',
756
+ hold_id: input.hold_id,
757
+ idempotency_key: input.idempotency_key,
758
+ });
759
+ },
603
760
  async releaseSlots(context, input) {
604
761
  const id = input.hold_id;
605
762
  if (!STORE_ID_PATTERN.test(id))
@@ -609,7 +766,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
609
766
  holderRef: agentOwnerRef(context),
610
767
  }).then(({ released }) => ({ released })));
611
768
  if (result.released)
612
- await offerFreedTime(store, context.principal.tenantRef);
769
+ await offerFreedTime(store, context);
613
770
  return result;
614
771
  },
615
772
  async describeResource(context, resourceId) {
@@ -621,29 +778,87 @@ export function createSlotlockStoreAgentBackend(store, options) {
621
778
  id: resourceId,
622
779
  visibility: 'shared',
623
780
  });
781
+ // Its profile's name when it has one, as a person reading a confirmation knows it.
624
782
  return resource
625
- ? { name: resource.externalRef ?? null, timezone: resource.timezone }
783
+ ? { name: resource.name ?? resource.externalRef ?? null, timezone: resource.timezone }
626
784
  : null;
627
785
  });
628
786
  },
787
+ async selectResources(context, selection) {
788
+ return withTenant(store, context, async (tenantStore) => {
789
+ let resources;
790
+ try {
791
+ // Shared and in use, as an agent may search them, in the store's stable order.
792
+ resources = await tenantStore.listResources({
793
+ tenantRef: context.principal.tenantRef,
794
+ visibility: 'shared',
795
+ limit: selection.limit + 1,
796
+ ...(selection.kind !== undefined ? { kind: selection.kind } : {}),
797
+ ...(selection.labels !== undefined ? { labels: selection.labels } : {}),
798
+ ...(selection.location !== undefined ? { location: selection.location } : {}),
799
+ ...(selection.group !== undefined ? { group: selection.group } : {}),
800
+ ...(selection.pool !== undefined ? { pool: selection.pool } : {}),
801
+ });
802
+ }
803
+ catch (error) {
804
+ throw storeRefusal(error);
805
+ }
806
+ if (resources.length > selection.limit) {
807
+ throw new SlotlockAgentOperationError('selection_too_large', 400, { limit: String(selection.limit) });
808
+ }
809
+ return resources.map((resource) => resource.id);
810
+ });
811
+ },
629
812
  async listResources(context, input) {
630
813
  const cursor = parseResourceCursor(input.cursor);
814
+ const kind = input.kind;
815
+ const labels = input.labels;
816
+ const location = input.location;
817
+ const group = input.group;
818
+ // What a resource says about itself, for an agent to show and choose by; its attributes are
819
+ // the integrator's, and stay on the application API.
631
820
  const output = (resource) => ({
632
821
  id: resource.id,
633
822
  external_ref: resource.externalRef,
634
823
  timezone: resource.timezone,
635
824
  ...(resource.pool !== undefined ? { pool: resource.pool } : {}),
825
+ ...(resource.name !== undefined ? { name: resource.name } : {}),
826
+ ...(resource.description !== undefined
827
+ ? {
828
+ description: [...resource.description].length > 500
829
+ ? `${[...resource.description].slice(0, 500).join('')}…`
830
+ : resource.description,
831
+ }
832
+ : {}),
833
+ ...(resource.kind !== undefined ? { kind: resource.kind } : {}),
834
+ ...(resource.capacity !== undefined ? { capacity: resource.capacity } : {}),
835
+ ...(resource.labels !== undefined ? { labels: resource.labels } : {}),
836
+ ...(resource.location !== undefined
837
+ ? { location: { ref: resource.location, name: resource.locationName ?? resource.location } }
838
+ : {}),
839
+ ...(resource.groups !== undefined ? { groups: resource.groups } : {}),
840
+ ...(resource.parentId !== undefined ? { part_of: resource.parentId } : {}),
636
841
  });
637
842
  if (typeof input.pool === 'string') {
638
- // A pool's members, in the order a pool booking tries them; at most 100, one page.
639
- return withTenant(store, context, async (tenantStore) => ({
640
- resources: (await tenantStore.listPoolMembers({
641
- tenantRef: context.principal.tenantRef,
642
- pool: input.pool,
643
- visibility: 'shared',
644
- })).map(output),
645
- next_cursor: null,
646
- }));
843
+ return withTenant(store, context, async (tenantStore) => {
844
+ // A pool's members are filtered here: a location filter takes the locations under it.
845
+ const places = location === undefined
846
+ ? null
847
+ : locationAndBelow(await tenantStore.listLocations({ tenantRef: context.principal.tenantRef }), location);
848
+ return {
849
+ resources: (await tenantStore.listPoolMembers({
850
+ tenantRef: context.principal.tenantRef,
851
+ pool: input.pool,
852
+ visibility: 'shared',
853
+ }))
854
+ .filter((resource) => (kind === undefined || (resource.kind ?? 'other') === kind) &&
855
+ (labels === undefined || slotlockResourceHasLabels(resource.labels, labels)) &&
856
+ (places === null || (resource.location !== undefined && places.has(resource.location))) &&
857
+ (group === undefined || resource.groups?.includes(group) === true))
858
+ .map(output),
859
+ next_cursor: null,
860
+ };
861
+ });
647
862
  }
648
863
  return withTenant(store, context, async (tenantStore) => {
649
864
  const limit = input.limit;
@@ -652,6 +867,10 @@ export function createSlotlockStoreAgentBackend(store, options) {
652
867
  visibility: 'shared',
653
868
  limit: limit + 1,
654
869
  ...(cursor !== undefined ? { after: cursor } : {}),
870
+ ...(kind !== undefined ? { kind } : {}),
871
+ ...(labels !== undefined ? { labels } : {}),
872
+ ...(location !== undefined ? { location } : {}),
873
+ ...(group !== undefined ? { group } : {}),
655
874
  });
656
875
  const page = resources.slice(0, limit);
657
876
  return {
@@ -691,6 +910,53 @@ export function createSlotlockStoreAgentBackend(store, options) {
691
910
  return { resources };
692
911
  });
693
912
  },
913
+ async findNextSlots(context, input) {
914
+ const window = interval(input);
915
+ const durationMs = input.duration_minutes * 60_000;
916
+ const charge = busyBudget();
917
+ return withTenant(store, context, async (tenantStore) => {
918
+ const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, input.resource_id);
919
+ const search = bookingSearch({
920
+ rules: resource.effectiveBookingRules,
921
+ timezone: resource.timezone,
922
+ window,
923
+ now: new Date(),
924
+ });
925
+ const sources = await requiredSources(options, context, resource);
926
+ const freeBusy = await readFreeBusy(tenantStore, {
927
+ tenantRef: context.principal.tenantRef,
928
+ resourceId: input.resource_id,
929
+ window: search ? bookingSearchReach({ ...search, window }) : window,
930
+ requiredSources: [...sources],
931
+ ...(options.coverageMaxAgeMs !== undefined ? { coverageMaxAgeMs: options.coverageMaxAgeMs } : {}),
932
+ });
933
+ charge(freeBusy.busy.length);
934
+ if (freeBusy.coverage.state !== 'complete')
935
+ return { slots: [], certain: false };
936
+ if (!search)
937
+ return { slots: [], certain: true };
938
+ const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
939
+ const windows = expandRules(rules, search.window, resource.timezone);
940
+ if (windows.length > MAX_REQUEST_RULE_WINDOWS)
941
+ throw new SlotlockAgentOperationError('calendar_work_limit', 400);
942
+ const busy = bookingSearchBlocked(freeBusy.busy, search);
943
+ // Each next slot as a search from where the last one ended would find it: the same busy
944
+ // time and hours, with the hours before that end left out.
945
+ const slots = [];
946
+ let from = search.window.start.getTime();
947
+ while (slots.length < input.limit) {
948
+ const open = windows
949
+ .map((hours) => ({ start: new Date(Math.max(hours.start.getTime(), from)), end: hours.end }))
950
+ .filter((hours) => hours.end.getTime() - hours.start.getTime() >= durationMs);
951
+ const found = findNextAvailable({ busy, windows: open, durationMs, ...(search.align ? { align: search.align } : {}) });
952
+ if (!found)
953
+ break;
954
+ slots.push({ start: found.start.toISOString(), end: found.end.toISOString() });
955
+ from = found.end.getTime();
956
+ }
957
+ return { slots, certain: true };
958
+ });
959
+ },
694
960
  async findNextAvailable(context, input) {
695
961
  const window = interval(input);
696
962
  const resourceIds = distinctResourceIds(input);
@@ -702,8 +968,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
702
968
  let incomplete = false;
703
969
  for (const resourceId of resourceIds) {
704
970
  const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
705
- // Only what a booking made now may take: the resource's booking rules bound the search,
706
- // as they bound the write that follows it.
971
+ // The resource's booking rules bound the search, as they bound the write that follows.
707
972
  const search = bookingSearch({
708
973
  rules: resource.effectiveBookingRules,
709
974
  timezone: resource.timezone,
@@ -747,8 +1012,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
747
1012
  best = { resourceId, interval: candidate };
748
1013
  }
749
1014
  }
750
- // A cross-resource answer is only certain when every requested resource is certain. A slot
751
- // from one healthy calendar must not mask a stale provider or recurrence horizon on another.
1015
+ // A cross-resource answer is only certain when every resource is: one healthy calendar must
1016
+ // not mask a stale one.
752
1017
  const complete = !incomplete;
753
1018
  const certainBest = complete ? best : null;
754
1019
  return {
@@ -803,7 +1068,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
803
1068
  : {}),
804
1069
  });
805
1070
  if (!result.ok)
806
- throw operationError(result);
1071
+ throw operationError(result, await overlapDetails(tenantStore, context, result));
807
1072
  const created = await tenantStore.getCalendarEvent({
808
1073
  tenantRef: context.principal.tenantRef,
809
1074
  ownerRef: agentOwnerRef(context),
@@ -855,7 +1120,6 @@ export function createSlotlockStoreAgentBackend(store, options) {
855
1120
  },
856
1121
  async updateEvent(context, rawInput) {
857
1122
  const input = rawInput;
858
- // No event is on a resource the store never issued (an external reference, say).
859
1123
  if (input.expected_resource_id !== undefined &&
860
1124
  !STORE_ID_PATTERN.test(input.expected_resource_id)) {
861
1125
  throw new SlotlockAgentOperationError('event_not_found', 404);
@@ -929,8 +1193,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
929
1193
  tenantRef: context.principal.tenantRef,
930
1194
  ownerRef: agentOwnerRef(context),
931
1195
  externalRef: current.externalRef,
932
- // The caller's fence when it sent one, checked under the event's lock; otherwise the
933
- // resource just read, so a move between that read and the write is not overwritten.
1196
+ // The caller's fence when sent, checked under the event's lock; otherwise the resource
1197
+ // just read, so a move in between is not overwritten.
934
1198
  expectedResourceId: input.expected_resource_id ?? current.resourceId,
935
1199
  resourceId,
936
1200
  expectedRevision: input.expected_revision,
@@ -942,7 +1206,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
942
1206
  enforceBookingRules: true,
943
1207
  });
944
1208
  if (!result.ok)
945
- throw operationError(result);
1209
+ throw operationError(result, await overlapDetails(tenantStore, context, result));
946
1210
  const updated = await tenantStore.getCalendarEvent({
947
1211
  tenantRef: context.principal.tenantRef,
948
1212
  ownerRef: agentOwnerRef(context),
@@ -952,9 +1216,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
952
1216
  throw new SlotlockAgentOperationError('store_inconsistent', 503);
953
1217
  return { event: projectSlotlockCalendarEvent(updated), replayed: result.idempotent };
954
1218
  }, WRITE);
955
- // A moved or shortened event may have freed the time it held.
956
1219
  if (!moved.replayed)
957
- await offerFreedTime(store, context.principal.tenantRef);
1220
+ await offerFreedTime(store, context);
958
1221
  return moved;
959
1222
  },
960
1223
  async deleteEvent(context, input) {
@@ -965,13 +1228,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
965
1228
  ownerRef: agentOwnerRef(context),
966
1229
  externalRef,
967
1230
  });
968
- if (!current)
969
- throw new SlotlockAgentOperationError('event_not_found', 404);
1231
+ // Gone already: a retry of a cancellation that succeeded, answered from its key, or
1232
+ // event_not_found from the store when there was none.
970
1233
  const result = await tenantStore.cancelCalendarEvent({
971
1234
  tenantRef: context.principal.tenantRef,
972
1235
  ownerRef: agentOwnerRef(context),
973
1236
  externalRef,
974
- expectedResourceId: current.resourceId,
1237
+ ...(current ? { expectedResourceId: current.resourceId } : {}),
975
1238
  idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
976
1239
  expectedRevision: input.expected_revision,
977
1240
  });
@@ -985,19 +1248,39 @@ export function createSlotlockStoreAgentBackend(store, options) {
985
1248
  };
986
1249
  }, WRITE);
987
1250
  if (!deleted.replayed)
988
- await offerFreedTime(store, context.principal.tenantRef);
1251
+ await offerFreedTime(store, context);
989
1252
  return deleted;
990
1253
  },
991
1254
  };
1255
+ return withApprovalPolicies(store, options, maxApprovalMinutes, backend);
992
1256
  }
993
- /** A store failure thrown with a `code` the caller may see, or the error itself to report. */
994
1257
  function storeRefusal(error) {
995
1258
  const code = error?.code;
996
1259
  if (typeof code !== 'string')
997
1260
  return error;
1261
+ if (code === 'invalid_resource_profile' || code === 'invalid_location' || code === 'invalid_group') {
1262
+ // Which field, so a client can say what to change.
1263
+ const field = error.field;
1264
+ return new SlotlockAgentOperationError(code, 400, typeof field === 'string' ? { field } : undefined);
1265
+ }
1266
+ if (code === 'location_not_found' || code === 'group_not_found' || code === 'resource_not_found' || code === 'parent_not_found') {
1267
+ return new SlotlockAgentOperationError(code, 404);
1268
+ }
1269
+ if (code === 'location_in_use' ||
1270
+ code === 'location_too_deep' ||
1271
+ code === 'location_cycle' ||
1272
+ code === 'location_limit_reached' ||
1273
+ code === 'group_limit_reached' ||
1274
+ code === 'resource_group_limit_reached') {
1275
+ return new SlotlockAgentOperationError(code, 409);
1276
+ }
998
1277
  if (code === 'resource_limit_reached' ||
999
1278
  code === 'resource_visibility_conflict' ||
1000
- code === 'pool_rules_limit_reached') {
1279
+ code === 'pool_rules_limit_reached' ||
1280
+ code === 'pool_order_limit_reached' ||
1281
+ code === 'composite_too_deep' ||
1282
+ code === 'composite_parts_limit' ||
1283
+ code === 'composite_busy') {
1001
1284
  return new SlotlockAgentOperationError(code, 409);
1002
1285
  }
1003
1286
  if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
@@ -1005,7 +1288,88 @@ function storeRefusal(error) {
1005
1288
  }
1006
1289
  return error;
1007
1290
  }
1008
- /** The status an application refusal answers with; the resource of another tenant is not found. */
1291
+ /** A key's limits as the API takes them (snake_case), as the key store takes them. */
1292
+ function keyLimitsFromInput(value) {
1293
+ const given = value;
1294
+ return {
1295
+ ...(given.max_holds !== undefined ? { maxHolds: given.max_holds } : {}),
1296
+ ...(given.max_held_hours !== undefined ? { maxHeldHours: given.max_held_hours } : {}),
1297
+ ...(given.requests_per_minute !== undefined ? { requestsPerMinute: given.requests_per_minute } : {}),
1298
+ ...(given.requires_approval === true ? { requiresApproval: true } : {}),
1299
+ };
1300
+ }
1301
+ /** A sandbox refusal as the API answers it: 403 for a live tenant, 409 for one not empty. */
1302
+ function sandboxRefusal(error) {
1303
+ const code = error?.code;
1304
+ if (code === 'sandbox_test_only')
1305
+ return new SlotlockAgentOperationError(code, 403);
1306
+ if (code === 'sandbox_not_empty')
1307
+ return new SlotlockAgentOperationError(code, 409);
1308
+ return error;
1309
+ }
1310
+ /** An approval decision refused: 404 for none, 409 for one decided or past its wait. */
1311
+ function approvalRefusal(code) {
1312
+ return new SlotlockAgentOperationError(code, code === 'approval_not_found' ? 404 : 409);
1313
+ }
1314
+ function approvalPolicyOutput(policy) {
1315
+ const applies = policy.appliesTo;
1316
+ const conditions = policy.conditions;
1317
+ return {
1318
+ policy_ref: policy.ref,
1319
+ name: policy.name,
1320
+ applies_to: {
1321
+ ...(applies.resourceIds ? { resource_ids: applies.resourceIds } : {}),
1322
+ ...(applies.pools ? { pools: applies.pools } : {}),
1323
+ ...(applies.locations ? { locations: applies.locations } : {}),
1324
+ ...(applies.groups ? { groups: applies.groups } : {}),
1325
+ },
1326
+ conditions: {
1327
+ ...(conditions.longerThanMinutes !== undefined ? { longer_than_minutes: conditions.longerThanMinutes } : {}),
1328
+ ...(conditions.outsideHours !== undefined ? { outside_hours: conditions.outsideHours } : {}),
1329
+ ...(conditions.kinds ? { kinds: conditions.kinds } : {}),
1330
+ },
1331
+ outcome: policy.outcome,
1332
+ approval_ttl_minutes: policy.approvalTtlMinutes,
1333
+ lead_minutes: policy.leadMinutes,
1334
+ created_at: policy.createdAt.toISOString(),
1335
+ updated_at: policy.updatedAt.toISOString(),
1336
+ };
1337
+ }
1338
+ function approvalAppliesTo(value) {
1339
+ const given = value;
1340
+ return {
1341
+ ...(given.resource_ids ? { resourceIds: given.resource_ids } : {}),
1342
+ ...(given.pools ? { pools: given.pools } : {}),
1343
+ ...(given.locations ? { locations: given.locations } : {}),
1344
+ ...(given.groups ? { groups: given.groups } : {}),
1345
+ };
1346
+ }
1347
+ function approvalConditions(value) {
1348
+ const given = value;
1349
+ return {
1350
+ ...(given.longer_than_minutes !== undefined ? { longerThanMinutes: given.longer_than_minutes } : {}),
1351
+ ...(given.outside_hours !== undefined ? { outsideHours: given.outside_hours } : {}),
1352
+ ...(given.kinds ? { kinds: given.kinds } : {}),
1353
+ };
1354
+ }
1355
+ function approvalOutput(approval) {
1356
+ return {
1357
+ approval_id: approval.id,
1358
+ policy_ref: approval.policyRef,
1359
+ status: approval.status,
1360
+ operation: approval.operation,
1361
+ request: approval.request,
1362
+ requested_by: approval.requestedBy,
1363
+ resource_ids: approval.resourceIds,
1364
+ starts_at: approval.start.toISOString(),
1365
+ ends_at: approval.end.toISOString(),
1366
+ expires_at: approval.expiresAt.toISOString(),
1367
+ decided_by: approval.decidedBy,
1368
+ decided_at: approval.decidedAt?.toISOString() ?? null,
1369
+ reason: approval.reason,
1370
+ created_at: approval.createdAt.toISOString(),
1371
+ };
1372
+ }
1009
1373
  function applicationRefusal(code) {
1010
1374
  if (code === 'resource_tenant_mismatch') {
1011
1375
  return new SlotlockAgentOperationError('resource_not_found', 404);
@@ -1033,11 +1397,379 @@ function waitlistOutput(entry) {
1033
1397
  };
1034
1398
  }
1035
1399
  /**
1036
- * Offer what a cancel or release just freed to the tenant's waitlist now, rather than at the next
1037
- * maintenance pass. Best effort, after the freeing transaction committed: maintenance offers
1038
- * whatever this misses.
1400
+ * The policy a key whose bookings wait for a person (`requiresApproval`, RFC 0004 A5) is held by:
1401
+ * every resource, a day's wait. A tenant cannot name a policy of its own `api-key`.
1402
+ */
1403
+ function keyApprovalPolicy() {
1404
+ return {
1405
+ ref: SLOTLOCK_KEY_APPROVAL_POLICY,
1406
+ name: 'Bookings by this API key',
1407
+ appliesTo: {},
1408
+ conditions: {},
1409
+ outcome: 'require_approval',
1410
+ approvalTtlMinutes: SLOTLOCK_DEFAULT_APPROVAL_MINUTES,
1411
+ leadMinutes: 0,
1412
+ createdAt: new Date(0),
1413
+ updatedAt: new Date(0),
1414
+ };
1415
+ }
1416
+ /** A JSON value with its object keys sorted, so two requests compare by content. */
1417
+ function canonical(value) {
1418
+ if (Array.isArray(value))
1419
+ return value.map(canonical);
1420
+ if (value !== null && typeof value === 'object') {
1421
+ return Object.fromEntries(Object.keys(value)
1422
+ .sort()
1423
+ .map((key) => [key, canonical(value[key])]));
1424
+ }
1425
+ return value;
1426
+ }
1427
+ /** Each interval of `wanted` less the parts `kept` already covers on the same resource. */
1428
+ function uncovered(wanted, kept) {
1429
+ const pieces = [];
1430
+ for (const want of wanted) {
1431
+ let open = [{ start: want.start, end: want.end }];
1432
+ for (const own of kept) {
1433
+ if (own.resourceId !== want.resourceId)
1434
+ continue;
1435
+ open = open.flatMap((piece) => {
1436
+ if (own.end <= piece.start || own.start >= piece.end)
1437
+ return [piece];
1438
+ return [
1439
+ ...(own.start > piece.start ? [{ start: piece.start, end: own.start }] : []),
1440
+ ...(own.end < piece.end ? [{ start: own.end, end: piece.end }] : []),
1441
+ ];
1442
+ });
1443
+ }
1444
+ pieces.push(...open.map((piece) => ({ ...piece, resourceId: want.resourceId })));
1445
+ }
1446
+ return pieces;
1447
+ }
1448
+ /** How a request already put to a person answers its retry: waiting, declined or expired. */
1449
+ function approvalAnswer(approval) {
1450
+ const id = { approval_id: approval.id, policy_ref: approval.policyRef };
1451
+ if (approval.status === 'declined') {
1452
+ return new SlotlockAgentOperationError('approval_declined', 403, {
1453
+ ...id,
1454
+ ...(approval.reason ? { reason: approval.reason.slice(0, 200) } : {}),
1455
+ });
1456
+ }
1457
+ if (approval.status === 'expired' || approval.expiresAt.getTime() <= Date.now()) {
1458
+ return new SlotlockAgentOperationError('approval_expired', 409, id);
1459
+ }
1460
+ return new SlotlockAgentOperationError('approval_pending', 409, {
1461
+ ...id,
1462
+ expires_at: approval.expiresAt.toISOString(),
1463
+ });
1464
+ }
1465
+ /**
1466
+ * Approval policies (RFC 0004 A4) in front of the backend's writes. A write a policy covers is
1467
+ * tried first as a dry run, so it meets every check and the database's constraints as it would;
1468
+ * a `refuse` policy then refuses it, and a `require_approval` one holds the exact time it would
1469
+ * occupy for the approval and answers `approval_pending`. A retry with the same idempotency key
1470
+ * answers as the request stands, and once granted, with the booking itself. Tenants with no policy
1471
+ * pay one indexed read per write.
1472
+ */
1473
+ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
1474
+ const requestKeyOf = (context, stored, key) => createHash('sha256')
1475
+ .update(JSON.stringify(['slotlock-approval', context.principal.tenantRef, agentOwnerRef(context), stored, key]))
1476
+ .digest('hex');
1477
+ /** Whether [start, end) on the resource falls outside its bookable hours. */
1478
+ async function outsideHours(tenantStore, context, resourceId, start, end) {
1479
+ const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
1480
+ const hours = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
1481
+ const windows = mergeIntervals(expandRules(hours, { start: new Date(start.getTime() - 86_400_000), end: new Date(end.getTime() + 86_400_000) }, resource.timezone));
1482
+ return !windows.some((window) => window.start <= start && window.end >= end);
1483
+ }
1484
+ /** The write's ask and the policies that may cover it, or null when none can. */
1485
+ async function plan(tenantStore, context, operation, input) {
1486
+ const tenantRef = context.principal.tenantRef;
1487
+ const ownerRef = agentOwnerRef(context);
1488
+ const key = input.idempotency_key;
1489
+ // A key whose bookings all wait for a person (RFC 0004 A5) is a policy of its own.
1490
+ const keyPolicy = context.principal.limits?.requiresApproval === true ? keyApprovalPolicy() : undefined;
1491
+ // Most tenants have none: one small indexed read, and nothing else looked up.
1492
+ if (!keyPolicy && (await tenantStore.listApprovalPolicies({ tenantRef })).length === 0)
1493
+ return null;
1494
+ let asked;
1495
+ if (operation === 'slotlock_create_event') {
1496
+ asked = {
1497
+ kind: 'single',
1498
+ stored: 'slotlock_create_event',
1499
+ resourceIds: [input.resource_id],
1500
+ externalRefs: [agentExternalRef(context, key)],
1501
+ start: new Date(input.starts_at),
1502
+ end: new Date(input.ends_at),
1503
+ series: typeof input.recurrence_rule === 'string',
1504
+ before: [],
1505
+ ...(typeof input.hold_id === 'string' ? { consumeHold: { holdId: input.hold_id, holderRef: ownerRef } } : {}),
1506
+ };
1507
+ }
1508
+ else if (operation === 'slotlock_book_pool') {
1509
+ const members = await tenantStore.listPoolMembers({ tenantRef, pool: input.pool, visibility: 'shared' });
1510
+ if (members.length === 0)
1511
+ return null;
1512
+ asked = {
1513
+ kind: 'pool',
1514
+ stored: 'slotlock_create_event',
1515
+ resourceIds: members.map((member) => member.id),
1516
+ externalRefs: [agentExternalRef(context, key)],
1517
+ start: new Date(input.starts_at),
1518
+ end: new Date(input.ends_at),
1519
+ series: false,
1520
+ before: [],
1521
+ pool: input.pool,
1522
+ };
1523
+ }
1524
+ else if (operation === 'slotlock_book_together') {
1525
+ const resourceIds = [...new Set(input.resource_ids)].sort();
1526
+ asked = {
1527
+ kind: 'bundle',
1528
+ stored: 'slotlock_book_together',
1529
+ resourceIds,
1530
+ externalRefs: resourceIds.map((resourceId) => agentExternalRef(context, `${key}\0${resourceId}`)),
1531
+ start: new Date(input.starts_at),
1532
+ end: new Date(input.ends_at),
1533
+ series: false,
1534
+ before: [],
1535
+ };
1536
+ }
1537
+ else {
1538
+ const current = await tenantStore.getCalendarEvent({ tenantRef, ownerRef, externalRef: input.event_id });
1539
+ if (!current)
1540
+ return null;
1541
+ const resourceId = input.resource_id ?? current.resourceId;
1542
+ const start = input.starts_at ? new Date(input.starts_at) : current.start;
1543
+ const end = input.ends_at ? new Date(input.ends_at) : current.end;
1544
+ // Only a move to another time or resource is a booking a policy can be about.
1545
+ if (resourceId === current.resourceId && start.getTime() === current.start.getTime() && end.getTime() === current.end.getTime()) {
1546
+ return null;
1547
+ }
1548
+ asked = {
1549
+ kind: 'move',
1550
+ stored: 'slotlock_update_event',
1551
+ resourceIds: [resourceId],
1552
+ externalRefs: [current.externalRef],
1553
+ start,
1554
+ end,
1555
+ series: input.recurrence_rule !== undefined ? typeof input.recurrence_rule === 'string' : current.recurrence !== undefined,
1556
+ before: await tenantStore.eventOccupancy({ tenantRef, ownerRef, externalRefs: [current.externalRef] }),
1557
+ };
1558
+ }
1559
+ const minutes = (asked.end.getTime() - asked.start.getTime()) / 60_000;
1560
+ // What the ask alone decides; hours wait for the resource the write takes.
1561
+ const policies = (await tenantStore.approvalPoliciesFor({ tenantRef, resourceIds: asked.resourceIds })).filter((policy) => (policy.conditions.kinds === undefined || policy.conditions.kinds.includes(asked.kind)) &&
1562
+ (policy.conditions.longerThanMinutes === undefined || minutes > policy.conditions.longerThanMinutes));
1563
+ if (policies.length === 0 && !keyPolicy)
1564
+ return null;
1565
+ return { ...asked, policies, ...(keyPolicy ? { keyPolicy } : {}) };
1566
+ }
1567
+ function gated(operation, method) {
1568
+ return async function (context, input) {
1569
+ // The grant itself, or a write a person already approved and its retries.
1570
+ if (context[APPROVAL_GRANT])
1571
+ return method.call(this, context, input);
1572
+ const tenantRef = context.principal.tenantRef;
1573
+ const stored = operation === 'slotlock_book_together' || operation === 'slotlock_update_event' ? operation : 'slotlock_create_event';
1574
+ const requestKey = requestKeyOf(context, stored, input.idempotency_key);
1575
+ const asked = await withTenant(store, { ...context, dryRun: false }, async (tenantStore) => {
1576
+ const prior = await tenantStore.findApproval({ tenantRef, requestKey });
1577
+ if (prior)
1578
+ return { prior };
1579
+ return { plan: await plan(tenantStore, context, operation, input) };
1580
+ });
1581
+ if (asked.prior) {
1582
+ const { hold_id: _held, ...request } = input;
1583
+ // Granted: the booking was made with this key, so the write answers it as a replay (and
1584
+ // the store refuses another request with it). Without the hold the approval took over,
1585
+ // as the grant made it.
1586
+ if (asked.prior.status === 'granted')
1587
+ return method.call(this, context, request);
1588
+ const kept = { ...asked.prior.request };
1589
+ // A pool booking is kept as the member it would take.
1590
+ if (operation === 'slotlock_book_pool')
1591
+ delete kept.resource_id;
1592
+ if (JSON.stringify(canonical(kept)) !== JSON.stringify(canonical(request))) {
1593
+ throw new SlotlockAgentOperationError('idempotency_conflict', 409);
1594
+ }
1595
+ throw approvalAnswer(asked.prior);
1596
+ }
1597
+ const ask = asked.plan;
1598
+ if (!ask)
1599
+ return method.call(this, context, input);
1600
+ // Tried as a dry run, reading what it would occupy before it is undone: a write that would be
1601
+ // refused is refused as it would be.
1602
+ let occupied = [];
1603
+ const probed = await method.call(this, {
1604
+ ...context,
1605
+ dryRun: true,
1606
+ [OCCUPANCY_PROBE]: async (tenantStore) => {
1607
+ occupied = await tenantStore.eventOccupancy({
1608
+ tenantRef,
1609
+ ownerRef: agentOwnerRef(context),
1610
+ externalRefs: ask.externalRefs,
1611
+ });
1612
+ },
1613
+ }, input);
1614
+ // Made already (before the policy, say), or takes no time (a transparent event): nothing to hold.
1615
+ if (probed.replayed === true || occupied.length === 0) {
1616
+ return context.dryRun ? probed : method.call(this, context, input);
1617
+ }
1618
+ const written = [...new Set(occupied.map((occupation) => occupation.resourceId))];
1619
+ const decided = await withTenant(store, { ...context, dryRun: false }, async (tenantStore) => {
1620
+ // A pool's policies are those of the member the write would take, and its pool.
1621
+ const candidates = [
1622
+ ...(ask.pool
1623
+ ? (await tenantStore.approvalPoliciesFor({ tenantRef, resourceIds: written })).filter((policy) => ask.policies.some((known) => known.ref === policy.ref))
1624
+ : ask.policies),
1625
+ ...(ask.keyPolicy ? [ask.keyPolicy] : []),
1626
+ ];
1627
+ const applying = [];
1628
+ for (const policy of candidates) {
1629
+ if (policy.conditions.outsideHours === true) {
1630
+ let outside = false;
1631
+ for (const resourceId of written) {
1632
+ if (await outsideHours(tenantStore, context, resourceId, ask.start, ask.end))
1633
+ outside = true;
1634
+ }
1635
+ if (!outside)
1636
+ continue;
1637
+ }
1638
+ applying.push(policy);
1639
+ }
1640
+ return applying;
1641
+ });
1642
+ if (decided.length === 0)
1643
+ return context.dryRun ? probed : method.call(this, context, input);
1644
+ const refusing = decided.find((policy) => policy.outcome === 'refuse');
1645
+ if (refusing)
1646
+ throw new SlotlockAgentOperationError('approval_policy_refused', 403, { policy_ref: refusing.ref });
1647
+ // The strictest wait: the earliest a person must decide by, under each policy's own wait, its
1648
+ // lead before the start, and the server's ceiling.
1649
+ const now = Date.now();
1650
+ const deadline = (policy) => Math.min(now + Math.min(policy.approvalTtlMinutes, maxApprovalMinutes) * 60_000, ask.start.getTime() - policy.leadMinutes * 60_000);
1651
+ const policy = [...decided].sort((left, right) => deadline(left) - deadline(right) || left.ref.localeCompare(right.ref))[0];
1652
+ const expiresAt = new Date(deadline(policy));
1653
+ if (ask.series || occupied.some((occupation) => occupation.recurring)) {
1654
+ throw new SlotlockAgentOperationError('approval_series_unsupported', 409, { policy_ref: policy.ref });
1655
+ }
1656
+ if (expiresAt.getTime() < now + 60_000) {
1657
+ throw new SlotlockAgentOperationError('approval_too_late', 409, { policy_ref: policy.ref });
1658
+ }
1659
+ if (context.dryRun) {
1660
+ throw new SlotlockAgentOperationError('approval_required', 409, {
1661
+ policy_ref: policy.ref,
1662
+ expires_at: expiresAt.toISOString(),
1663
+ });
1664
+ }
1665
+ const holds = uncovered(occupied, ask.before);
1666
+ if (holds.length === 0)
1667
+ return method.call(this, context, input);
1668
+ const chosen = ask.pool ? written[0] : undefined;
1669
+ const { hold_id: _hold, ...request } = input;
1670
+ const principal = context.principal;
1671
+ const result = await withTenant(store, context, (tenantStore) => tenantStore.requestApproval({
1672
+ tenantRef,
1673
+ policyRef: policy.ref,
1674
+ operation: ask.stored,
1675
+ request: canonical(chosen ? { ...request, resource_id: chosen } : request),
1676
+ requester: {
1677
+ subject: principal.subject,
1678
+ ...(principal.scopes ? { scopes: [...principal.scopes] } : {}),
1679
+ ...(principal.keyId !== undefined ? { key_id: principal.keyId } : {}),
1680
+ ...(principal.resourceScope
1681
+ ? { resource_scope: { locations: [...principal.resourceScope.locations], groups: [...principal.resourceScope.groups] } }
1682
+ : {}),
1683
+ ...(context.origin !== undefined ? { origin: context.origin } : {}),
1684
+ ...(ask.pool !== undefined ? { pool: ask.pool } : {}),
1685
+ },
1686
+ requesterRef: principal.subject,
1687
+ requestKey,
1688
+ holds,
1689
+ start: ask.start,
1690
+ end: ask.end,
1691
+ expiresAt,
1692
+ ...(ask.consumeHold ? { consumeHold: ask.consumeHold } : {}),
1693
+ }), WRITE);
1694
+ if (!result.ok) {
1695
+ if (result.code === 'approval_limit_reached') {
1696
+ throw new SlotlockAgentOperationError('approval_limit_reached', 429, { limit: String(result.limit ?? '') });
1697
+ }
1698
+ throw new SlotlockAgentOperationError(result.code, 409);
1699
+ }
1700
+ throw approvalAnswer(result.approval);
1701
+ };
1702
+ }
1703
+ const wrapped = { ...backend };
1704
+ wrapped.createEvent = gated('slotlock_create_event', backend.createEvent);
1705
+ wrapped.updateEvent = gated('slotlock_update_event', backend.updateEvent);
1706
+ if (backend.bookPool)
1707
+ wrapped.bookPool = gated('slotlock_book_pool', backend.bookPool);
1708
+ if (backend.bookTogether)
1709
+ wrapped.bookTogether = gated('slotlock_book_together', backend.bookTogether);
1710
+ if (backend.confirmHold) {
1711
+ const confirm = backend.confirmHold;
1712
+ // A confirm already put to a person answers as it stands, before its hold (now the
1713
+ // approval's) is looked for; otherwise it books through createEvent, policies and all.
1714
+ wrapped.confirmHold = async function (context, input) {
1715
+ if (!context[APPROVAL_GRANT]) {
1716
+ const requestKey = requestKeyOf(context, 'slotlock_create_event', input.idempotency_key);
1717
+ const prior = await withTenant(store, { ...context, dryRun: false }, (tenantStore) => tenantStore.findApproval({ tenantRef: context.principal.tenantRef, requestKey }));
1718
+ if (prior && prior.status !== 'granted')
1719
+ throw approvalAnswer(prior);
1720
+ }
1721
+ return confirm.call(this, context, input);
1722
+ };
1723
+ }
1724
+ return wrapped;
1725
+ }
1726
+ /**
1727
+ * Run a granted approval's write as the agent that asked for it, in one transaction with the grant:
1728
+ * its holds give way, the write is made, and the approval is marked granted.
1729
+ */
1730
+ export async function runSlotlockApprovalGrant(agentBackend, approval, grant) {
1731
+ const requester = approval.requester;
1732
+ if (typeof requester.subject !== 'string')
1733
+ throw new SlotlockAgentOperationError('store_inconsistent', 503);
1734
+ const scope = requester.resource_scope;
1735
+ const context = {
1736
+ principal: {
1737
+ subject: requester.subject,
1738
+ tenantRef: grant.tenantRef,
1739
+ ...(Array.isArray(requester.scopes) ? { scopes: requester.scopes } : {}),
1740
+ ...(typeof requester.key_id === 'string' ? { keyId: requester.key_id } : {}),
1741
+ ...(scope && Array.isArray(scope.locations) && Array.isArray(scope.groups)
1742
+ ? { resourceScope: { locations: scope.locations, groups: scope.groups } }
1743
+ : {}),
1744
+ },
1745
+ operation: approval.operation,
1746
+ signal: grant.signal,
1747
+ ...(grant.origin !== undefined ? { origin: grant.origin } : {}),
1748
+ ...(grant.dryRun ? { dryRun: true } : {}),
1749
+ [APPROVAL_GRANT]: {
1750
+ id: approval.id,
1751
+ decidedBy: grant.decidedBy,
1752
+ ...(typeof requester.pool === 'string' ? { pool: requester.pool } : {}),
1753
+ },
1754
+ };
1755
+ const method = approval.operation === 'slotlock_book_together'
1756
+ ? agentBackend.bookTogether
1757
+ : approval.operation === 'slotlock_update_event'
1758
+ ? agentBackend.updateEvent
1759
+ : agentBackend.createEvent;
1760
+ if (!method)
1761
+ throw new SlotlockAgentOperationError('approvals_unavailable', 409);
1762
+ return method.call(agentBackend, context, approval.request);
1763
+ }
1764
+ /**
1765
+ * Offer what a cancel or release just freed to the waitlist now. Best effort, after commit:
1766
+ * maintenance offers whatever this misses.
1039
1767
  */
1040
- async function offerFreedTime(store, tenantRef) {
1768
+ async function offerFreedTime(store, context) {
1769
+ // A dry run freed nothing.
1770
+ if (context.dryRun)
1771
+ return;
1772
+ const tenantRef = context.principal.tenantRef;
1041
1773
  try {
1042
1774
  await store.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), {
1043
1775
  origin: 'maintenance',
@@ -1059,6 +1791,14 @@ function webhookOutput(endpoint) {
1059
1791
  last_error: endpoint.lastError,
1060
1792
  disabled_at: endpoint.disabledAt?.toISOString() ?? null,
1061
1793
  disabled_reason: endpoint.disabledReason,
1794
+ filter: endpoint.filter
1795
+ ? {
1796
+ ...(endpoint.filter.resourceIds ? { resource_ids: endpoint.filter.resourceIds } : {}),
1797
+ ...(endpoint.filter.pools ? { pools: endpoint.filter.pools } : {}),
1798
+ ...(endpoint.filter.locations ? { locations: endpoint.filter.locations } : {}),
1799
+ ...(endpoint.filter.groups ? { groups: endpoint.filter.groups } : {}),
1800
+ }
1801
+ : null,
1062
1802
  };
1063
1803
  }
1064
1804
  function rulesOutput(rules) {
@@ -1085,7 +1825,6 @@ function rulesInput(value) {
1085
1825
  throw storeRefusal(error);
1086
1826
  }
1087
1827
  }
1088
- /** Booking rules as the application API writes them, snake_case; `null` for none. */
1089
1828
  function bookingRulesOutput(rules) {
1090
1829
  if (!rules)
1091
1830
  return null;
@@ -1138,8 +1877,53 @@ function resourceOutput(resource) {
1138
1877
  visibility: resource.visibility ?? 'shared',
1139
1878
  archived_at: resource.archivedAt?.toISOString() ?? null,
1140
1879
  pool: resource.pool ?? null,
1880
+ name: resource.name ?? null,
1881
+ description: resource.description ?? null,
1882
+ kind: resource.kind ?? 'other',
1883
+ capacity: resource.capacity ?? null,
1884
+ labels: resource.labels ?? [],
1885
+ attributes: resource.attributes ?? {},
1886
+ location: resource.location ?? null,
1887
+ groups: resource.groups ?? [],
1888
+ parent_id: resource.parentId ?? null,
1889
+ part_index: resource.partIndex ?? null,
1141
1890
  };
1142
1891
  }
1892
+ function locationOutput(location) {
1893
+ return {
1894
+ ref: location.ref,
1895
+ name: location.name,
1896
+ parent_ref: location.parentRef,
1897
+ timezone: location.timezone,
1898
+ resources: location.resources,
1899
+ };
1900
+ }
1901
+ function groupOutput(group) {
1902
+ return { ref: group.ref, name: group.name, description: group.description, members: group.members };
1903
+ }
1904
+ /** The references of a location and every location under it, from the tenant's whole tree. */
1905
+ function locationAndBelow(locations, ref) {
1906
+ const found = new Set([ref]);
1907
+ for (let grew = true; grew;) {
1908
+ grew = false;
1909
+ for (const location of locations) {
1910
+ if (location.parentRef !== null && found.has(location.parentRef) && !found.has(location.ref)) {
1911
+ found.add(location.ref);
1912
+ grew = true;
1913
+ }
1914
+ }
1915
+ }
1916
+ return found;
1917
+ }
1918
+ /** The profile fields of an operation's input, as the store takes them; absent ones left out. */
1919
+ function profileInput(input) {
1920
+ const profile = {};
1921
+ for (const field of SLOTLOCK_RESOURCE_PROFILE_FIELDS) {
1922
+ if (input[field] !== undefined)
1923
+ profile[field] = input[field];
1924
+ }
1925
+ return profile;
1926
+ }
1143
1927
  /** Any of a tenant's events: the store's id, which the tenant-event routes take, and its owner's. */
1144
1928
  function tenantEventOutput(event) {
1145
1929
  return {
@@ -1166,7 +1950,7 @@ function reservationOutput(reservation) {
1166
1950
  reservation_ref: reservation.externalRef,
1167
1951
  start: reservation.start.toISOString(),
1168
1952
  end: reservation.end.toISOString(),
1169
- // The store keeps milliseconds; a buffer set in seconds through the library still shows as busy.
1953
+ // The store keeps milliseconds; round up so a sub-minute buffer still shows as busy.
1170
1954
  buffer_after_minutes: Math.ceil(reservation.bufferAfterMs / 60_000),
1171
1955
  revision: reservation.revision,
1172
1956
  source: reservation.source,
@@ -1193,9 +1977,9 @@ function applicationCommandKey(context, idempotencyKey) {
1193
1977
  /** The most busy intervals one slotlock_list_busy answers, as one free/busy request reads. */
1194
1978
  const MAX_LISTED_BUSY = MAX_REQUEST_BUSY_INTERVALS;
1195
1979
  /**
1196
- * Serve the application API (application-api.ts) from the production store, in the principal's
1197
- * tenant. Every resource an operation names must be the tenant's, or it is not found; holds are
1198
- * reservations under the tenant's row-level security, so another tenant's hold is not found either.
1980
+ * Serve the application API (application-api.ts) from the store in the principal's tenant. Every
1981
+ * resource named must be the tenant's, or it is not found; holds are under the tenant's RLS, so
1982
+ * another tenant's is not found either.
1199
1983
  */
1200
1984
  export function createSlotlockStoreApplicationBackend(store, options = {}) {
1201
1985
  const max = options.maxTenantResources;
@@ -1239,6 +2023,23 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1239
2023
  };
1240
2024
  });
1241
2025
  }
2026
+ /** The partner store and the partner a partner key names; any other caller is forbidden. */
2027
+ function partnerOf(context) {
2028
+ assertActive(context);
2029
+ const partners = options.partners;
2030
+ if (!partners)
2031
+ throw new SlotlockAgentOperationError('partner_api_not_served', 404);
2032
+ return { partners, partner: slotlockPrincipalPartner(context.principal) };
2033
+ }
2034
+ /** As partnerOf, with the account's tenant: 404 account_not_found for one the partner lacks. */
2035
+ async function partnerAccount(context, input) {
2036
+ const { partners, partner } = partnerOf(context);
2037
+ const tenantRef = await partners.tenantOf(partner, input.account);
2038
+ if (!tenantRef)
2039
+ throw new SlotlockAgentOperationError('account_not_found', 404);
2040
+ // Its test environment (RFC 0003 G9): a tenant of its own, on the same plan, never billed.
2041
+ return { partners, partner, tenantRef: input.environment === 'test' ? `${tenantRef}${SLOTLOCK_TEST_TENANT_SUFFIX}` : tenantRef };
2042
+ }
1242
2043
  return {
1243
2044
  async createResource(context, input) {
1244
2045
  // A capped create counts under READ COMMITTED; withTenant opens it for the call.
@@ -1247,11 +2048,15 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1247
2048
  const resource = await tenant.createResource({
1248
2049
  tenantRef: tenantOf(context),
1249
2050
  externalRef: input.external_ref,
1250
- timezone: input.timezone,
2051
+ ...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
2052
+ ...(input.location !== undefined ? { location: input.location } : {}),
1251
2053
  ...(input.visibility !== undefined
1252
2054
  ? { visibility: input.visibility }
1253
2055
  : {}),
1254
2056
  ...(max !== undefined ? { maxTenantResources: max } : {}),
2057
+ ...(SLOTLOCK_RESOURCE_PROFILE_FIELDS.some((field) => input[field] !== undefined)
2058
+ ? { profile: profileInput(input) }
2059
+ : {}),
1255
2060
  });
1256
2061
  return { resource: resourceOutput(resource) };
1257
2062
  }
@@ -1260,6 +2065,151 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1260
2065
  }
1261
2066
  }, max !== undefined ? { isolation: 'read committed' } : undefined);
1262
2067
  },
2068
+ async updateResource(context, input) {
2069
+ const id = input.resource_id;
2070
+ if (!STORE_ID_PATTERN.test(id))
2071
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
2072
+ return withTenant(store, context, async (tenant) => {
2073
+ let resource;
2074
+ try {
2075
+ resource = await tenant.updateResourceProfile({
2076
+ tenantRef: tenantOf(context),
2077
+ id,
2078
+ profile: profileInput(input),
2079
+ ...(input.location !== undefined ? { location: input.location } : {}),
2080
+ });
2081
+ }
2082
+ catch (error) {
2083
+ throw storeRefusal(error);
2084
+ }
2085
+ if (!resource)
2086
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
2087
+ return { resource: resourceOutput(resource) };
2088
+ }, WRITE);
2089
+ },
2090
+ async listLocations(context) {
2091
+ return withTenant(store, context, async (tenant) => ({
2092
+ locations: (await tenant.listLocations({ tenantRef: tenantOf(context) })).map(locationOutput),
2093
+ }));
2094
+ },
2095
+ async createLocation(context, input) {
2096
+ return withTenant(store, context, async (tenant) => {
2097
+ try {
2098
+ const location = await tenant.putLocation({
2099
+ tenantRef: tenantOf(context),
2100
+ ref: input.ref,
2101
+ name: input.name,
2102
+ ...(input.parent_ref !== undefined ? { parentRef: input.parent_ref } : {}),
2103
+ ...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
2104
+ });
2105
+ return { location: locationOutput(location) };
2106
+ }
2107
+ catch (error) {
2108
+ throw storeRefusal(error);
2109
+ }
2110
+ }, WRITE);
2111
+ },
2112
+ async updateLocation(context, input) {
2113
+ return withTenant(store, context, async (tenant) => {
2114
+ let location;
2115
+ try {
2116
+ location = await tenant.updateLocation({
2117
+ tenantRef: tenantOf(context),
2118
+ ref: input.location_ref,
2119
+ ...(input.name !== undefined ? { name: input.name } : {}),
2120
+ ...(input.parent_ref !== undefined ? { parentRef: input.parent_ref } : {}),
2121
+ ...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
2122
+ });
2123
+ }
2124
+ catch (error) {
2125
+ throw storeRefusal(error);
2126
+ }
2127
+ if (!location)
2128
+ throw new SlotlockAgentOperationError('location_not_found', 404);
2129
+ return { location: locationOutput(location) };
2130
+ }, WRITE);
2131
+ },
2132
+ async deleteLocation(context, input) {
2133
+ return withTenant(store, context, async (tenant) => {
2134
+ try {
2135
+ return {
2136
+ deleted: await tenant.deleteLocation({ tenantRef: tenantOf(context), ref: input.location_ref }),
2137
+ };
2138
+ }
2139
+ catch (error) {
2140
+ throw storeRefusal(error);
2141
+ }
2142
+ }, WRITE);
2143
+ },
2144
+ async listGroups(context) {
2145
+ return withTenant(store, context, async (tenant) => ({
2146
+ groups: (await tenant.listGroups({ tenantRef: tenantOf(context) })).map(groupOutput),
2147
+ }));
2148
+ },
2149
+ async createGroup(context, input) {
2150
+ return withTenant(store, context, async (tenant) => {
2151
+ try {
2152
+ const group = await tenant.putGroup({
2153
+ tenantRef: tenantOf(context),
2154
+ ref: input.ref,
2155
+ name: input.name,
2156
+ ...(input.description !== undefined ? { description: input.description } : {}),
2157
+ });
2158
+ return { group: groupOutput(group) };
2159
+ }
2160
+ catch (error) {
2161
+ throw storeRefusal(error);
2162
+ }
2163
+ }, WRITE);
2164
+ },
2165
+ async deleteGroup(context, input) {
2166
+ return withTenant(store, context, async (tenant) => {
2167
+ try {
2168
+ return { deleted: await tenant.deleteGroup({ tenantRef: tenantOf(context), ref: input.group_ref }) };
2169
+ }
2170
+ catch (error) {
2171
+ throw storeRefusal(error);
2172
+ }
2173
+ }, WRITE);
2174
+ },
2175
+ async addGroupMember(context, input) {
2176
+ const resourceId = input.resource_id;
2177
+ if (!STORE_ID_PATTERN.test(resourceId))
2178
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
2179
+ return withTenant(store, context, async (tenant) => {
2180
+ try {
2181
+ return {
2182
+ added: await tenant.addGroupMember({
2183
+ tenantRef: tenantOf(context),
2184
+ group: input.group_ref,
2185
+ resourceId,
2186
+ }),
2187
+ };
2188
+ }
2189
+ catch (error) {
2190
+ throw storeRefusal(error);
2191
+ }
2192
+ }, WRITE);
2193
+ },
2194
+ async removeGroupMember(context, input) {
2195
+ const resourceId = input.resource_id;
2196
+ if (!STORE_ID_PATTERN.test(resourceId))
2197
+ return { removed: false };
2198
+ return withTenant(store, context, async (tenant) => {
2199
+ try {
2200
+ return {
2201
+ removed: await tenant.removeGroupMember({
2202
+ tenantRef: tenantOf(context),
2203
+ group: input.group_ref,
2204
+ resourceId,
2205
+ }),
2206
+ };
2207
+ }
2208
+ catch (error) {
2209
+ throw storeRefusal(error);
2210
+ }
2211
+ }, WRITE);
2212
+ },
1263
2213
  async getResource(context, input) {
1264
2214
  return withTenant(store, context, async (tenant) => ({
1265
2215
  resource: resourceOutput(await requireResource(tenant, tenantOf(context), input.resource_id)),
@@ -1289,7 +2239,6 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1289
2239
  },
1290
2240
  async deleteResource(context, input) {
1291
2241
  const id = input.resource_id;
1292
- // An id the store never issues names no resource: there is nothing to delete.
1293
2242
  if (!STORE_ID_PATTERN.test(id))
1294
2243
  return { deleted: false };
1295
2244
  return withTenant(store, context, async (tenant) => {
@@ -1421,6 +2370,26 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1421
2370
  return { resource: resourceOutput(resource) };
1422
2371
  }, WRITE);
1423
2372
  },
2373
+ async setResourceParent(context, input) {
2374
+ const id = input.resource_id;
2375
+ const parentId = input.parent_id;
2376
+ if (!STORE_ID_PATTERN.test(id))
2377
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
2378
+ if (parentId !== null && !STORE_ID_PATTERN.test(parentId))
2379
+ throw new SlotlockAgentOperationError('parent_not_found', 404);
2380
+ return withTenant(store, context, async (tenant) => {
2381
+ let resource;
2382
+ try {
2383
+ resource = await tenant.setResourceParent({ tenantRef: tenantOf(context), id, parentId });
2384
+ }
2385
+ catch (error) {
2386
+ throw storeRefusal(error);
2387
+ }
2388
+ if (!resource)
2389
+ throw new SlotlockAgentOperationError('resource_not_found', 404);
2390
+ return { resource: resourceOutput(resource) };
2391
+ }, WRITE);
2392
+ },
1424
2393
  async getResourceHistory(context, input) {
1425
2394
  return history(context, input);
1426
2395
  },
@@ -1435,6 +2404,21 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1435
2404
  pools: await tenant.listPools({ tenantRef: tenantOf(context) }),
1436
2405
  }));
1437
2406
  },
2407
+ async setPoolOrder(context, input) {
2408
+ return withTenant(store, context, async (tenant) => {
2409
+ try {
2410
+ const order = await tenant.setPoolOrder({
2411
+ tenantRef: tenantOf(context),
2412
+ pool: input.pool,
2413
+ order: input.order,
2414
+ });
2415
+ return { pool: input.pool, order };
2416
+ }
2417
+ catch (error) {
2418
+ throw storeRefusal(error);
2419
+ }
2420
+ }, WRITE);
2421
+ },
1438
2422
  async getBookingRuleDefaults(context) {
1439
2423
  return withTenant(store, context, async (tenant) => {
1440
2424
  const defaults = await tenant.getBookingRuleDefaults({ tenantRef: tenantOf(context) });
@@ -1552,14 +2536,13 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1552
2536
  },
1553
2537
  async releaseHold(context, input) {
1554
2538
  const id = input.hold_id;
1555
- // An id that is not one the store issues names no hold: there is nothing to release.
1556
2539
  if (!STORE_ID_PATTERN.test(id))
1557
2540
  return { released: false };
1558
2541
  const released = await withTenant(store, context, async (tenant) => ({
1559
2542
  released: (await tenant.releaseHold(id, { tenantRef: tenantOf(context) })).released,
1560
2543
  }));
1561
2544
  if (released.released)
1562
- await offerFreedTime(store, tenantOf(context));
2545
+ await offerFreedTime(store, context);
1563
2546
  return released;
1564
2547
  },
1565
2548
  async getReservation(context, input) {
@@ -1608,9 +2591,8 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1608
2591
  throw applicationRefusal(result.code);
1609
2592
  return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
1610
2593
  }, WRITE);
1611
- // A move frees the time the booking held.
1612
2594
  if (input.revision > 1 && !put.replayed) {
1613
- await offerFreedTime(store, tenantOf(context));
2595
+ await offerFreedTime(store, context);
1614
2596
  }
1615
2597
  return put;
1616
2598
  },
@@ -1626,7 +2608,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1626
2608
  return { cancelled: result.cancelled, revision: result.revision };
1627
2609
  }, WRITE);
1628
2610
  if (cancelled.cancelled)
1629
- await offerFreedTime(store, tenantOf(context));
2611
+ await offerFreedTime(store, context);
1630
2612
  return cancelled;
1631
2613
  },
1632
2614
  async checkInReservation(context, input) {
@@ -1651,10 +2633,11 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1651
2633
  end: new Date(input.end),
1652
2634
  holderRef: input.holder_ref,
1653
2635
  ...(ttl !== undefined ? { offerTtlMs: ttl * 1000 } : {}),
2636
+ ...(input.idempotency_key !== undefined ? { idempotencyKey: input.idempotency_key } : {}),
1654
2637
  });
1655
2638
  if (!result.ok)
1656
2639
  throw applicationRefusal(result.code);
1657
- return { entry: waitlistOutput(result.entry) };
2640
+ return { entry: waitlistOutput(result.entry), replayed: result.replayed };
1658
2641
  });
1659
2642
  },
1660
2643
  async listWaitlist(context, input) {
@@ -1673,7 +2656,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1673
2656
  left: await tenant.leaveWaitlist({ tenantRef: tenantOf(context), id: input.entry_id }),
1674
2657
  }));
1675
2658
  if (left.left)
1676
- await offerFreedTime(store, tenantOf(context));
2659
+ await offerFreedTime(store, context);
1677
2660
  return left;
1678
2661
  },
1679
2662
  async getCoverage(context, input) {
@@ -1778,7 +2761,6 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1778
2761
  async cancelTenantEvent(context, input) {
1779
2762
  const id = input.event_id;
1780
2763
  const expectedResourceId = input.expected_resource_id;
1781
- // An id the store never issues names no event, and no event is on a resource it never issued.
1782
2764
  if (!STORE_ID_PATTERN.test(id) ||
1783
2765
  (expectedResourceId !== undefined && !STORE_ID_PATTERN.test(expectedResourceId))) {
1784
2766
  throw new SlotlockAgentOperationError('event_not_found', 404);
@@ -1801,7 +2783,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1801
2783
  };
1802
2784
  }, WRITE);
1803
2785
  if (!cancelled.replayed)
1804
- await offerFreedTime(store, tenantOf(context));
2786
+ await offerFreedTime(store, context);
1805
2787
  return cancelled;
1806
2788
  },
1807
2789
  async getTenant(context) {
@@ -1820,12 +2802,18 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1820
2802
  throw new SlotlockAgentOperationError('invalid_confirmation', 400);
1821
2803
  }
1822
2804
  return withTenant(store, context, async (tenant) => {
1823
- // Read under the tenant's lock, which a suspension waits for: a suspended tenant's data is
1824
- // kept for its operator, who erases it (slotlock tenant erase) when they decide to.
2805
+ // Read under the tenant's lock: a suspended tenant's data is kept for its operator to
2806
+ // erase.
1825
2807
  if (await tenant.isTenantSuspended({ tenantRef: tenantOf(context) })) {
1826
2808
  throw new SlotlockAgentOperationError('tenant_suspended', 403);
1827
2809
  }
1828
- const erased = await tenant.eraseTenant({ tenantRef: tenantOf(context) });
2810
+ const erased = await tenant.eraseTenant({ tenantRef: tenantOf(context) }).catch((error) => {
2811
+ // A paid hosted plan is cancelled first, so it does not renew for an erased tenant.
2812
+ if (error?.code === 'billing_active') {
2813
+ throw new SlotlockAgentOperationError('billing_active', 409);
2814
+ }
2815
+ throw error;
2816
+ });
1829
2817
  return { total: erased.total, rows: erased.rows };
1830
2818
  }, WRITE);
1831
2819
  },
@@ -1848,11 +2836,26 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1848
2836
  tenantRef: tenantOf(context),
1849
2837
  url: input.url,
1850
2838
  ...(input.event_types !== undefined ? { eventTypes: input.event_types } : {}),
2839
+ ...(['resource_ids', 'pools', 'locations', 'groups'].some((field) => input[field] !== undefined)
2840
+ ? {
2841
+ filter: {
2842
+ ...(input.resource_ids !== undefined ? { resourceIds: input.resource_ids } : {}),
2843
+ ...(input.pools !== undefined ? { pools: input.pools } : {}),
2844
+ ...(input.locations !== undefined ? { locations: input.locations } : {}),
2845
+ ...(input.groups !== undefined ? { groups: input.groups } : {}),
2846
+ },
2847
+ }
2848
+ : {}),
2849
+ ...(input.idempotency_key !== undefined ? { idempotencyKey: input.idempotency_key } : {}),
1851
2850
  });
1852
2851
  if (!created.ok) {
1853
- throw new SlotlockAgentOperationError(created.code, created.code === 'webhook_limit_reached' ? 409 : 400);
2852
+ throw new SlotlockAgentOperationError(created.code, created.code === 'webhook_limit_reached' || created.code === 'idempotency_conflict' ? 409 : 400);
1854
2853
  }
1855
- return { webhook: webhookOutput(created.endpoint), secret: created.secret };
2854
+ return {
2855
+ webhook: webhookOutput(created.endpoint),
2856
+ secret: created.secret,
2857
+ replayed: created.replayed === true,
2858
+ };
1856
2859
  }, WRITE);
1857
2860
  },
1858
2861
  async listWebhooks(context) {
@@ -1863,6 +2866,196 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1863
2866
  async deleteWebhook(context, input) {
1864
2867
  return withTenant(store, context, async (tenant) => tenant.deleteWebhookEndpoint({ tenantRef: tenantOf(context), id: input.webhook_id }), WRITE);
1865
2868
  },
2869
+ // The partner API (RFC 0003 P3): the partner the key names, its own accounts only.
2870
+ async partnerPutAccount(context, input) {
2871
+ const { partners, partner } = partnerOf(context);
2872
+ const put = await partners.putAccount(partner, input.account, input.plan);
2873
+ if (!put)
2874
+ throw new SlotlockAgentOperationError('forbidden', 403);
2875
+ return { account: partnerAccountOutput(put.account), created: put.created };
2876
+ },
2877
+ async partnerListAccounts(context) {
2878
+ const { partners, partner } = partnerOf(context);
2879
+ return { accounts: (await partners.listAccounts(partner)).map(partnerAccountOutput) };
2880
+ },
2881
+ async partnerGetAccount(context, input) {
2882
+ const { partners, partner } = partnerOf(context);
2883
+ const found = await partners.getAccount(partner, input.account);
2884
+ if (!found)
2885
+ throw new SlotlockAgentOperationError('account_not_found', 404);
2886
+ return { account: partnerAccountOutput(found) };
2887
+ },
2888
+ async partnerListKeys(context, input) {
2889
+ const { partners, tenantRef } = await partnerAccount(context, input);
2890
+ return { keys: (await partners.keys.list({ tenantRef })).map(slotlockPartnerKeyOutput) };
2891
+ },
2892
+ async partnerCreateKey(context, input) {
2893
+ const { partners, partner, tenantRef } = await partnerAccount(context, input);
2894
+ const contract = await partners.contract(partner);
2895
+ if (!contract)
2896
+ throw new SlotlockAgentOperationError('forbidden', 403);
2897
+ const scopes = input.scopes ?? [...contract.keyScopes];
2898
+ if (scopes.some((scope) => !contract.keyScopes.includes(scope))) {
2899
+ throw new SlotlockAgentOperationError('scope_not_in_contract', 403);
2900
+ }
2901
+ const days = input.expires_in_days;
2902
+ try {
2903
+ const created = await partners.keys.create({
2904
+ tenantRef,
2905
+ name: input.name,
2906
+ scopes,
2907
+ createdBy: `partner:${partner}`,
2908
+ actor: context.principal.subject,
2909
+ origin: 'application',
2910
+ ...(days !== undefined ? { expiresAt: new Date(Date.now() + days * 86_400_000) } : {}),
2911
+ ...(input.limits !== undefined ? { limits: keyLimitsFromInput(input.limits) } : {}),
2912
+ });
2913
+ return { key: created.key, api_key: slotlockPartnerKeyOutput(created.apiKey) };
2914
+ }
2915
+ catch (error) {
2916
+ const code = error.code;
2917
+ if (code === 'api_key_limit_reached')
2918
+ throw new SlotlockAgentOperationError(code, 409);
2919
+ if (code === 'tenant_suspended')
2920
+ throw new SlotlockAgentOperationError(code, 403);
2921
+ throw error;
2922
+ }
2923
+ },
2924
+ async partnerRotateKey(context, input) {
2925
+ const { partners, tenantRef } = await partnerAccount(context, input);
2926
+ const rotated = await partners.keys
2927
+ .rotate({ tenantRef, id: input.key_id, actor: context.principal.subject, origin: 'application' })
2928
+ .catch((error) => {
2929
+ if (error.code === 'tenant_suspended') {
2930
+ throw new SlotlockAgentOperationError('tenant_suspended', 403);
2931
+ }
2932
+ throw error;
2933
+ });
2934
+ if (!rotated)
2935
+ throw new SlotlockAgentOperationError('key_not_found', 404);
2936
+ return { key: rotated.key, api_key: slotlockPartnerKeyOutput(rotated.apiKey) };
2937
+ },
2938
+ async partnerRevokeKey(context, input) {
2939
+ const { partners, tenantRef } = await partnerAccount(context, input);
2940
+ const revoked = await partners.keys.revoke({
2941
+ tenantRef,
2942
+ id: input.key_id,
2943
+ actor: context.principal.subject,
2944
+ origin: 'application',
2945
+ });
2946
+ if (!revoked)
2947
+ throw new SlotlockAgentOperationError('key_not_found', 404);
2948
+ return { api_key: slotlockPartnerKeyOutput(revoked) };
2949
+ },
2950
+ async partnerSuspendAccount(context, input) {
2951
+ const { partners, partner } = partnerOf(context);
2952
+ const changed = await partners.setSuspended(partner, input.account, true, input.reason);
2953
+ if (changed === null)
2954
+ throw new SlotlockAgentOperationError('account_not_found', 404);
2955
+ return { suspended: changed };
2956
+ },
2957
+ async partnerUnsuspendAccount(context, input) {
2958
+ const { partners, partner } = partnerOf(context);
2959
+ const changed = await partners.setSuspended(partner, input.account, false);
2960
+ if (changed === null)
2961
+ throw new SlotlockAgentOperationError('account_not_found', 404);
2962
+ return { unsuspended: changed };
2963
+ },
2964
+ async partnerEraseAccount(context, input) {
2965
+ const { partners, partner } = partnerOf(context);
2966
+ if (input.confirm !== input.account)
2967
+ throw new SlotlockAgentOperationError('confirmation_mismatch', 400);
2968
+ const erased = await partners.eraseAccount(partner, input.account);
2969
+ if (!erased.erased)
2970
+ throw new SlotlockAgentOperationError('account_not_found', 404);
2971
+ return { erased: true, rows: erased.total };
2972
+ },
2973
+ async partnerGetUsage(context, input) {
2974
+ const { partners, partner } = partnerOf(context);
2975
+ const now = Date.now();
2976
+ const range = slotlockUsageMonthToDate(now);
2977
+ const from = input.from ?? range.from;
2978
+ const to = input.to ?? range.to;
2979
+ const span = slotlockUsageSpanDays(from, to);
2980
+ if (span < 0 || span > SLOTLOCK_USAGE_RETENTION_DAYS || slotlockUsageSpanDays(to, slotlockUsageDay(now)) < 0) {
2981
+ throw new SlotlockAgentOperationError('invalid_usage_range', 400);
2982
+ }
2983
+ const rows = await partners.usage(partner, from, to, input.account);
2984
+ return { from, to, usage: rows };
2985
+ },
2986
+ async partnerReconcileUsage(context, input) {
2987
+ const { partners, partner } = partnerOf(context);
2988
+ const day = input.day;
2989
+ // An ended day: today's counts are still arriving.
2990
+ if (slotlockUsageSpanDays(day, slotlockUsageDay(Date.now())) < 1) {
2991
+ throw new SlotlockAgentOperationError('invalid_usage_range', 400);
2992
+ }
2993
+ const theirs = new Map();
2994
+ for (const row of input.accounts) {
2995
+ theirs.set(row.account, { operations: row.operations, ...(row.bookings !== undefined ? { bookings: row.bookings } : {}) });
2996
+ }
2997
+ const ours = new Map();
2998
+ for (const row of await partners.usage(partner, day, day)) {
2999
+ if (row.metric !== 'operations' && row.metric !== 'bookings')
3000
+ continue;
3001
+ const counted = ours.get(row.account) ?? { operations: 0, bookings: 0 };
3002
+ counted[row.metric] += row.count;
3003
+ ours.set(row.account, counted);
3004
+ }
3005
+ const accounts = [...new Set([...ours.keys(), ...theirs.keys()])].sort();
3006
+ const lines = [];
3007
+ const mismatched = new Set();
3008
+ let worst = 0;
3009
+ for (const account of accounts) {
3010
+ for (const metric of ['operations', 'bookings']) {
3011
+ const mine = ours.get(account)?.[metric] ?? 0;
3012
+ const told = theirs.get(account)?.[metric];
3013
+ if (told === undefined && (metric === 'bookings' || !theirs.has(account)) && mine === 0)
3014
+ continue;
3015
+ // Not sent by them, where we counted some: a difference of all of it.
3016
+ const theirCount = told ?? null;
3017
+ const difference = theirCount === null
3018
+ ? mine === 0 ? 0 : 100
3019
+ : Math.max(mine, theirCount) === 0 ? 0 : (Math.abs(mine - theirCount) * 100) / Math.max(mine, theirCount);
3020
+ if (theirCount === null && metric === 'bookings') {
3021
+ lines.push({ account, metric, ours: mine, theirs: null, difference_percent: null });
3022
+ continue;
3023
+ }
3024
+ const rounded = Math.round(difference * 100) / 100;
3025
+ if (difference > SLOTLOCK_RECONCILE_TOLERANCE_PERCENT)
3026
+ mismatched.add(account);
3027
+ worst = Math.max(worst, rounded);
3028
+ lines.push({ account, metric, ours: mine, theirs: theirCount, difference_percent: rounded });
3029
+ }
3030
+ }
3031
+ await partners.recordReconciliation(partner, day, accounts.length, mismatched.size, worst);
3032
+ return {
3033
+ day,
3034
+ within_tolerance: mismatched.size === 0,
3035
+ tolerance_percent: SLOTLOCK_RECONCILE_TOLERANCE_PERCENT,
3036
+ accounts: lines,
3037
+ };
3038
+ },
3039
+ async getUsage(context, input) {
3040
+ const now = Date.now();
3041
+ const range = slotlockUsageMonthToDate(now);
3042
+ const from = input.from ?? range.from;
3043
+ const to = input.to ?? range.to;
3044
+ const span = slotlockUsageSpanDays(from, to);
3045
+ if (span < 0 || span > SLOTLOCK_USAGE_RETENTION_DAYS || slotlockUsageSpanDays(to, slotlockUsageDay(now)) < 0) {
3046
+ throw new SlotlockAgentOperationError('invalid_usage_range', 400);
3047
+ }
3048
+ const rows = await withTenant(store, context, (tenant) => tenant.readUsage({ tenantRef: tenantOf(context), from, to }));
3049
+ const summary = summarizeSlotlockUsage(rows);
3050
+ return {
3051
+ from,
3052
+ to,
3053
+ totals: summary.totals,
3054
+ days: summary.days,
3055
+ keys: summary.keys.map(({ keyId, ...totals }) => ({ key_id: keyId, ...totals })),
3056
+ surfaces: summary.surfaces,
3057
+ };
3058
+ },
1866
3059
  async enableWebhook(context, input) {
1867
3060
  return withTenant(store, context, async (tenant) => {
1868
3061
  const enabled = await tenant.enableWebhookEndpoint({
@@ -1876,11 +3069,152 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1876
3069
  return { webhook: webhookOutput(enabled) };
1877
3070
  }, WRITE);
1878
3071
  },
3072
+ async listApprovalPolicies(context) {
3073
+ return withTenant(store, context, async (tenant) => ({
3074
+ policies: (await tenant.listApprovalPolicies({ tenantRef: tenantOf(context) })).map(approvalPolicyOutput),
3075
+ }));
3076
+ },
3077
+ async putApprovalPolicy(context, input) {
3078
+ return withTenant(store, context, async (tenant) => {
3079
+ let policy;
3080
+ try {
3081
+ policy = await tenant.putApprovalPolicy({
3082
+ tenantRef: tenantOf(context),
3083
+ ref: input.policy_ref,
3084
+ name: input.name,
3085
+ outcome: input.outcome,
3086
+ ...(input.applies_to !== undefined ? { appliesTo: approvalAppliesTo(input.applies_to) } : {}),
3087
+ ...(input.conditions !== undefined ? { conditions: approvalConditions(input.conditions) } : {}),
3088
+ ...(input.approval_ttl_minutes !== undefined ? { approvalTtlMinutes: input.approval_ttl_minutes } : {}),
3089
+ ...(input.lead_minutes !== undefined ? { leadMinutes: input.lead_minutes } : {}),
3090
+ });
3091
+ }
3092
+ catch (error) {
3093
+ if (error instanceof SlotlockApprovalPolicyError) {
3094
+ throw new SlotlockAgentOperationError(error.code, 400, { reason: error.message.slice(0, 200) });
3095
+ }
3096
+ throw error;
3097
+ }
3098
+ if (!policy)
3099
+ throw new SlotlockAgentOperationError('approval_policy_limit_reached', 409);
3100
+ return { policy: approvalPolicyOutput(policy) };
3101
+ }, WRITE);
3102
+ },
3103
+ async deleteApprovalPolicy(context, input) {
3104
+ return withTenant(store, context, (tenant) => tenant.deleteApprovalPolicy({ tenantRef: tenantOf(context), ref: input.policy_ref }));
3105
+ },
3106
+ async listApprovals(context, input) {
3107
+ return withTenant(store, context, async (tenant) => ({
3108
+ approvals: (await tenant.listApprovals({
3109
+ tenantRef: tenantOf(context),
3110
+ limit: input.limit,
3111
+ ...(input.status !== undefined ? { status: input.status } : {}),
3112
+ })).map(approvalOutput),
3113
+ }));
3114
+ },
3115
+ async getApproval(context, input) {
3116
+ return withTenant(store, context, async (tenant) => {
3117
+ const approval = await tenant.getApproval({ tenantRef: tenantOf(context), id: input.approval_id });
3118
+ if (!approval)
3119
+ throw new SlotlockAgentOperationError('approval_not_found', 404);
3120
+ return { approval: approvalOutput(approval) };
3121
+ });
3122
+ },
3123
+ async decideApproval(context, input) {
3124
+ const tenantRef = tenantOf(context);
3125
+ const id = input.approval_id;
3126
+ const decidedBy = actorOf(context) ?? 'unknown';
3127
+ if (input.decision === 'decline') {
3128
+ const declined = await withTenant(store, context, (tenant) => tenant.declineApproval({
3129
+ tenantRef,
3130
+ id,
3131
+ decidedBy,
3132
+ ...(input.reason !== undefined ? { reason: input.reason } : {}),
3133
+ }), WRITE);
3134
+ if (!declined.ok)
3135
+ throw approvalRefusal(declined.code);
3136
+ await offerFreedTime(store, context);
3137
+ return { approval: approvalOutput(declined.approval), booking: null };
3138
+ }
3139
+ if (!options.agentBackend)
3140
+ throw new SlotlockAgentOperationError('approvals_unavailable', 409);
3141
+ const approval = await withTenant(store, context, (tenant) => tenant.getApproval({ tenantRef, id }));
3142
+ if (!approval)
3143
+ throw approvalRefusal('approval_not_found');
3144
+ if (approval.status !== 'pending')
3145
+ throw approvalRefusal('approval_not_pending');
3146
+ // A dry run's grant is a dry run of the booking: rolled back with it.
3147
+ const booking = await runSlotlockApprovalGrant(options.agentBackend, approval, {
3148
+ tenantRef,
3149
+ decidedBy,
3150
+ signal: context.signal,
3151
+ ...(context.origin !== undefined ? { origin: context.origin } : {}),
3152
+ ...(context.dryRun ? { dryRun: true } : {}),
3153
+ });
3154
+ const granted = context.dryRun
3155
+ ? { ...approval, status: 'granted', decidedBy, decidedAt: new Date() }
3156
+ : await withTenant(store, context, (tenant) => tenant.getApproval({ tenantRef, id }));
3157
+ if (!granted)
3158
+ throw new SlotlockAgentOperationError('store_inconsistent', 503);
3159
+ return { approval: approvalOutput(granted), booking };
3160
+ },
3161
+ async sandboxSeed(context, input) {
3162
+ try {
3163
+ return { ...(await seedSlotlockSandbox(store, tenantOf(context), input.scenario)) };
3164
+ }
3165
+ catch (error) {
3166
+ throw sandboxRefusal(error);
3167
+ }
3168
+ },
3169
+ async sandboxReset(context) {
3170
+ try {
3171
+ return { removed: await resetSlotlockSandbox(store, tenantOf(context)) };
3172
+ }
3173
+ catch (error) {
3174
+ throw sandboxRefusal(error);
3175
+ }
3176
+ },
3177
+ async sandboxFastForward(context, input) {
3178
+ try {
3179
+ const forward = await fastForwardSlotlockSandbox(store, tenantOf(context), input.minutes);
3180
+ const { rollPending: _, ...maintenance } = forward.maintenance;
3181
+ return { minutes: forward.minutes, holds: forward.holds, approvals: forward.approvals, maintenance };
3182
+ }
3183
+ catch (error) {
3184
+ throw sandboxRefusal(error);
3185
+ }
3186
+ },
3187
+ async sandboxRace(context, input) {
3188
+ if (!options.agentBackend)
3189
+ throw new SlotlockAgentOperationError('sandbox_unavailable', 409);
3190
+ try {
3191
+ const raced = await raceSlotlockSandbox({
3192
+ backend: options.agentBackend,
3193
+ tenantRef: tenantOf(context),
3194
+ resourceId: input.resource_id,
3195
+ start: new Date(input.starts_at),
3196
+ end: new Date(input.ends_at),
3197
+ ...(input.agents !== undefined ? { agents: input.agents } : {}),
3198
+ });
3199
+ return {
3200
+ agents: raced.agents,
3201
+ booked: raced.booked,
3202
+ refused: raced.refused,
3203
+ with_alternatives: raced.withAlternatives,
3204
+ server_errors: raced.serverErrors,
3205
+ p50_ms: raced.p50Ms,
3206
+ p99_ms: raced.p99Ms,
3207
+ max_ms: raced.maxMs,
3208
+ };
3209
+ }
3210
+ catch (error) {
3211
+ throw sandboxRefusal(error);
3212
+ }
3213
+ },
1879
3214
  async runMaintenance(context) {
1880
3215
  assertActive(context);
1881
3216
  try {
1882
- // A caller goes round again while `capped`; when to do so at once is the scheduler's.
1883
- const { rollPending: _, zoneShifts, staleSources, noShows, waitlistOffers, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
3217
+ const { rollPending: _, zoneShifts, staleSources, noShows, waitlistOffers, approvalsExpired, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
1884
3218
  batches,
1885
3219
  signal: context.signal,
1886
3220
  });
@@ -1890,6 +3224,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1890
3224
  stale_sources: staleSources,
1891
3225
  no_shows: noShows,
1892
3226
  waitlist_offers: waitlistOffers,
3227
+ approvals_expired: approvalsExpired,
1893
3228
  };
1894
3229
  }
1895
3230
  catch (error) {