slotlock 0.6.3 → 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 (70) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1551
  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 +559 -44
  8. package/agent-store-backend.d.ts +34 -1
  9. package/agent-store-backend.js +1399 -38
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +217 -28
  12. package/application-api.d.ts +56 -3
  13. package/application-api.js +826 -15
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +40 -0
  17. package/assistant.js +115 -0
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/config.d.ts +25 -4
  21. package/config.js +72 -3
  22. package/dashboard-icon.d.ts +1 -0
  23. package/dashboard-icon.js +5 -0
  24. package/dashboard.d.ts +203 -7
  25. package/dashboard.js +1545 -64
  26. package/ddl.d.ts +51 -12
  27. package/ddl.js +2566 -54
  28. package/event-feed.d.ts +30 -4
  29. package/event-feed.js +171 -19
  30. package/index.d.ts +15 -4
  31. package/index.js +8 -2
  32. package/maintenance.d.ts +10 -0
  33. package/maintenance.js +16 -2
  34. package/metrics.d.ts +41 -0
  35. package/metrics.js +22 -0
  36. package/openapi.json +14675 -5957
  37. package/otel.d.ts +86 -0
  38. package/otel.js +273 -0
  39. package/overage.d.ts +33 -0
  40. package/overage.js +115 -0
  41. package/package.json +1 -1
  42. package/partner-billing.d.ts +81 -0
  43. package/partner-billing.js +333 -0
  44. package/partner-names.d.ts +8 -0
  45. package/partner-names.js +10 -0
  46. package/partners.d.ts +147 -0
  47. package/partners.js +375 -0
  48. package/plans.d.ts +92 -0
  49. package/plans.js +115 -0
  50. package/quotas.d.ts +108 -0
  51. package/quotas.js +258 -0
  52. package/rate-limit.d.ts +21 -1
  53. package/rate-limit.js +82 -15
  54. package/resource-profile.d.ts +135 -0
  55. package/resource-profile.js +263 -0
  56. package/rest-api.d.ts +2 -0
  57. package/rest-api.js +68 -3
  58. package/sandbox.d.ts +70 -0
  59. package/sandbox.js +295 -0
  60. package/self-host.d.ts +33 -5
  61. package/self-host.js +950 -22
  62. package/slotlock.manifest.json +1 -1
  63. package/store.d.ts +373 -4
  64. package/store.js +1212 -118
  65. package/teams.d.ts +25 -0
  66. package/teams.js +63 -0
  67. package/timezone.js +13 -1
  68. package/types.d.ts +95 -1
  69. package/usage.d.ts +106 -0
  70. package/usage.js +238 -0
@@ -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,12 +11,40 @@ 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
31
  * Writes run under READ COMMITTED whatever the role's default: tenant limits are counted under a
14
32
  * lock that needs that level.
15
33
  */
16
34
  const WRITE = { isolation: 'read committed' };
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
+ }
17
48
  // The UUIDs withTenant takes as a keyId (versions 1-5); any other from a custom authenticator is
18
49
  // left out, not refused.
19
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;
@@ -35,15 +66,51 @@ async function withTenant(store, context, callback, options) {
35
66
  try {
36
67
  const actor = actorOf(context);
37
68
  const keyId = context.principal.keyId;
38
- 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, {
39
100
  ...options,
40
101
  signal: context.signal,
41
102
  ...(actor !== undefined ? { actor } : {}),
42
103
  ...(context.origin !== undefined ? { origin: context.origin } : {}),
43
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 } : {}),
44
107
  });
45
108
  }
46
109
  catch (error) {
110
+ if (error instanceof DryRunRollback) {
111
+ assertActive(context);
112
+ return error.value;
113
+ }
47
114
  const code = error?.code;
48
115
  // A suspended tenant may read; a write it asks for is forbidden, not a server failure.
49
116
  if (code === 'tenant_suspended')
@@ -240,15 +307,38 @@ const BOOKING_RULE_CODES = Object.freeze({
240
307
  outside_hours: 'booking_rule_outside_hours',
241
308
  uncertain: 'booking_rule_uncertain',
242
309
  });
243
- function operationError(refusal) {
310
+ function operationError(refusal, details) {
244
311
  const code = typeof refusal === 'string'
245
312
  ? refusal
246
313
  : refusal.code === 'booking_rule_violated' && refusal.rule
247
314
  ? (BOOKING_RULE_CODES[refusal.rule] ?? refusal.code)
248
315
  : refusal.code;
249
316
  const status = slotlockAgentErrorStatus(code);
250
- // A gone hold (expired or another holder's) is named, so the agent holds again.
251
- 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
+ };
252
342
  }
253
343
  /** Resource and event ids: the UUIDs the store issues and validates. */
254
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;
@@ -329,11 +419,22 @@ export function createSlotlockStoreChangeSource(store, sql) {
329
419
  }
330
420
  /** Bind the protocol server to the production store without giving a request control of tenancy. */
331
421
  export function createSlotlockStoreAgentBackend(store, options) {
332
- 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 = {
333
427
  async holdSlots(context, input) {
334
428
  const slots = input.slots;
335
429
  const key = input.idempotency_key;
336
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;
337
438
  // One group per agent and key, so a retry answers with the same holds and group.
338
439
  const groupId = uuidFromDigest(`slotlock-hold-group\0${holderRef}\0${key}`);
339
440
  return withTenant(store, context, async (tenantStore) => {
@@ -360,6 +461,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
360
461
  end: new Date(slot.end),
361
462
  ttlMs: input.ttl_seconds * 1_000,
362
463
  source: 'agent-server',
464
+ // The key's own caps, below the server's (RFC 0004 A5).
465
+ ...(holderCaps ? { holderCaps } : {}),
363
466
  });
364
467
  if (result.ok) {
365
468
  holds.push({
@@ -419,15 +522,24 @@ export function createSlotlockStoreAgentBackend(store, options) {
419
522
  throw operationError(replay);
420
523
  return answer(replay);
421
524
  }
422
- 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
+ });
423
533
  if (members.length === 0)
424
534
  throw new SlotlockAgentOperationError('pool_not_found', 404);
425
535
  // Each member in turn, each in its own savepoint: one taken or refused by its rules is
426
536
  // passed over; the exclusion constraint still decides.
427
537
  for (const member of members) {
428
538
  const result = await put(member.id);
429
- if (result.ok)
539
+ if (result.ok) {
540
+ await tenantStore.notePoolBooking({ tenantRef, pool: input.pool, resourceId: member.id });
430
541
  return answer(result);
542
+ }
431
543
  if (result.code !== 'overlap' && result.code !== 'booking_rule_violated') {
432
544
  throw operationError(result);
433
545
  }
@@ -572,7 +684,10 @@ export function createSlotlockStoreAgentBackend(store, options) {
572
684
  // Thrown, so the whole transaction rolls back with the events already put.
573
685
  if (!result.ok) {
574
686
  const refusal = operationError(result);
575
- 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
+ });
576
691
  }
577
692
  const created = await tenantStore.getCalendarEvent({
578
693
  tenantRef: context.principal.tenantRef,
@@ -587,6 +702,61 @@ export function createSlotlockStoreAgentBackend(store, options) {
587
702
  return { events, replayed };
588
703
  }, WRITE);
589
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
+ },
590
760
  async releaseSlots(context, input) {
591
761
  const id = input.hold_id;
592
762
  if (!STORE_ID_PATTERN.test(id))
@@ -596,7 +766,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
596
766
  holderRef: agentOwnerRef(context),
597
767
  }).then(({ released }) => ({ released })));
598
768
  if (result.released)
599
- await offerFreedTime(store, context.principal.tenantRef);
769
+ await offerFreedTime(store, context);
600
770
  return result;
601
771
  },
602
772
  async describeResource(context, resourceId) {
@@ -608,28 +778,87 @@ export function createSlotlockStoreAgentBackend(store, options) {
608
778
  id: resourceId,
609
779
  visibility: 'shared',
610
780
  });
781
+ // Its profile's name when it has one, as a person reading a confirmation knows it.
611
782
  return resource
612
- ? { name: resource.externalRef ?? null, timezone: resource.timezone }
783
+ ? { name: resource.name ?? resource.externalRef ?? null, timezone: resource.timezone }
613
784
  : null;
614
785
  });
615
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
+ },
616
812
  async listResources(context, input) {
617
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.
618
820
  const output = (resource) => ({
619
821
  id: resource.id,
620
822
  external_ref: resource.externalRef,
621
823
  timezone: resource.timezone,
622
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 } : {}),
623
841
  });
624
842
  if (typeof input.pool === 'string') {
625
- return withTenant(store, context, async (tenantStore) => ({
626
- resources: (await tenantStore.listPoolMembers({
627
- tenantRef: context.principal.tenantRef,
628
- pool: input.pool,
629
- visibility: 'shared',
630
- })).map(output),
631
- next_cursor: null,
632
- }));
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
+ });
633
862
  }
634
863
  return withTenant(store, context, async (tenantStore) => {
635
864
  const limit = input.limit;
@@ -638,6 +867,10 @@ export function createSlotlockStoreAgentBackend(store, options) {
638
867
  visibility: 'shared',
639
868
  limit: limit + 1,
640
869
  ...(cursor !== undefined ? { after: cursor } : {}),
870
+ ...(kind !== undefined ? { kind } : {}),
871
+ ...(labels !== undefined ? { labels } : {}),
872
+ ...(location !== undefined ? { location } : {}),
873
+ ...(group !== undefined ? { group } : {}),
641
874
  });
642
875
  const page = resources.slice(0, limit);
643
876
  return {
@@ -677,6 +910,53 @@ export function createSlotlockStoreAgentBackend(store, options) {
677
910
  return { resources };
678
911
  });
679
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
+ },
680
960
  async findNextAvailable(context, input) {
681
961
  const window = interval(input);
682
962
  const resourceIds = distinctResourceIds(input);
@@ -788,7 +1068,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
788
1068
  : {}),
789
1069
  });
790
1070
  if (!result.ok)
791
- throw operationError(result);
1071
+ throw operationError(result, await overlapDetails(tenantStore, context, result));
792
1072
  const created = await tenantStore.getCalendarEvent({
793
1073
  tenantRef: context.principal.tenantRef,
794
1074
  ownerRef: agentOwnerRef(context),
@@ -926,7 +1206,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
926
1206
  enforceBookingRules: true,
927
1207
  });
928
1208
  if (!result.ok)
929
- throw operationError(result);
1209
+ throw operationError(result, await overlapDetails(tenantStore, context, result));
930
1210
  const updated = await tenantStore.getCalendarEvent({
931
1211
  tenantRef: context.principal.tenantRef,
932
1212
  ownerRef: agentOwnerRef(context),
@@ -937,7 +1217,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
937
1217
  return { event: projectSlotlockCalendarEvent(updated), replayed: result.idempotent };
938
1218
  }, WRITE);
939
1219
  if (!moved.replayed)
940
- await offerFreedTime(store, context.principal.tenantRef);
1220
+ await offerFreedTime(store, context);
941
1221
  return moved;
942
1222
  },
943
1223
  async deleteEvent(context, input) {
@@ -948,13 +1228,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
948
1228
  ownerRef: agentOwnerRef(context),
949
1229
  externalRef,
950
1230
  });
951
- if (!current)
952
- 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.
953
1233
  const result = await tenantStore.cancelCalendarEvent({
954
1234
  tenantRef: context.principal.tenantRef,
955
1235
  ownerRef: agentOwnerRef(context),
956
1236
  externalRef,
957
- expectedResourceId: current.resourceId,
1237
+ ...(current ? { expectedResourceId: current.resourceId } : {}),
958
1238
  idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
959
1239
  expectedRevision: input.expected_revision,
960
1240
  });
@@ -968,18 +1248,39 @@ export function createSlotlockStoreAgentBackend(store, options) {
968
1248
  };
969
1249
  }, WRITE);
970
1250
  if (!deleted.replayed)
971
- await offerFreedTime(store, context.principal.tenantRef);
1251
+ await offerFreedTime(store, context);
972
1252
  return deleted;
973
1253
  },
974
1254
  };
1255
+ return withApprovalPolicies(store, options, maxApprovalMinutes, backend);
975
1256
  }
976
1257
  function storeRefusal(error) {
977
1258
  const code = error?.code;
978
1259
  if (typeof code !== 'string')
979
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
+ }
980
1277
  if (code === 'resource_limit_reached' ||
981
1278
  code === 'resource_visibility_conflict' ||
982
- 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') {
983
1284
  return new SlotlockAgentOperationError(code, 409);
984
1285
  }
985
1286
  if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
@@ -987,6 +1288,88 @@ function storeRefusal(error) {
987
1288
  }
988
1289
  return error;
989
1290
  }
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
+ }
990
1373
  function applicationRefusal(code) {
991
1374
  if (code === 'resource_tenant_mismatch') {
992
1375
  return new SlotlockAgentOperationError('resource_not_found', 404);
@@ -1013,11 +1396,380 @@ function waitlistOutput(entry) {
1013
1396
  created_at: entry.createdAt.toISOString(),
1014
1397
  };
1015
1398
  }
1399
+ /**
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
+ }
1016
1764
  /**
1017
1765
  * Offer what a cancel or release just freed to the waitlist now. Best effort, after commit:
1018
1766
  * maintenance offers whatever this misses.
1019
1767
  */
1020
- 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;
1021
1773
  try {
1022
1774
  await store.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), {
1023
1775
  origin: 'maintenance',
@@ -1039,6 +1791,14 @@ function webhookOutput(endpoint) {
1039
1791
  last_error: endpoint.lastError,
1040
1792
  disabled_at: endpoint.disabledAt?.toISOString() ?? null,
1041
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,
1042
1802
  };
1043
1803
  }
1044
1804
  function rulesOutput(rules) {
@@ -1117,8 +1877,53 @@ function resourceOutput(resource) {
1117
1877
  visibility: resource.visibility ?? 'shared',
1118
1878
  archived_at: resource.archivedAt?.toISOString() ?? null,
1119
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,
1120
1890
  };
1121
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
+ }
1122
1927
  /** Any of a tenant's events: the store's id, which the tenant-event routes take, and its owner's. */
1123
1928
  function tenantEventOutput(event) {
1124
1929
  return {
@@ -1218,6 +2023,23 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1218
2023
  };
1219
2024
  });
1220
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
+ }
1221
2043
  return {
1222
2044
  async createResource(context, input) {
1223
2045
  // A capped create counts under READ COMMITTED; withTenant opens it for the call.
@@ -1226,11 +2048,15 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1226
2048
  const resource = await tenant.createResource({
1227
2049
  tenantRef: tenantOf(context),
1228
2050
  externalRef: input.external_ref,
1229
- timezone: input.timezone,
2051
+ ...(input.timezone !== undefined ? { timezone: input.timezone } : {}),
2052
+ ...(input.location !== undefined ? { location: input.location } : {}),
1230
2053
  ...(input.visibility !== undefined
1231
2054
  ? { visibility: input.visibility }
1232
2055
  : {}),
1233
2056
  ...(max !== undefined ? { maxTenantResources: max } : {}),
2057
+ ...(SLOTLOCK_RESOURCE_PROFILE_FIELDS.some((field) => input[field] !== undefined)
2058
+ ? { profile: profileInput(input) }
2059
+ : {}),
1234
2060
  });
1235
2061
  return { resource: resourceOutput(resource) };
1236
2062
  }
@@ -1239,6 +2065,151 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1239
2065
  }
1240
2066
  }, max !== undefined ? { isolation: 'read committed' } : undefined);
1241
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
+ },
1242
2213
  async getResource(context, input) {
1243
2214
  return withTenant(store, context, async (tenant) => ({
1244
2215
  resource: resourceOutput(await requireResource(tenant, tenantOf(context), input.resource_id)),
@@ -1399,6 +2370,26 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1399
2370
  return { resource: resourceOutput(resource) };
1400
2371
  }, WRITE);
1401
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
+ },
1402
2393
  async getResourceHistory(context, input) {
1403
2394
  return history(context, input);
1404
2395
  },
@@ -1413,6 +2404,21 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1413
2404
  pools: await tenant.listPools({ tenantRef: tenantOf(context) }),
1414
2405
  }));
1415
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
+ },
1416
2422
  async getBookingRuleDefaults(context) {
1417
2423
  return withTenant(store, context, async (tenant) => {
1418
2424
  const defaults = await tenant.getBookingRuleDefaults({ tenantRef: tenantOf(context) });
@@ -1536,7 +2542,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1536
2542
  released: (await tenant.releaseHold(id, { tenantRef: tenantOf(context) })).released,
1537
2543
  }));
1538
2544
  if (released.released)
1539
- await offerFreedTime(store, tenantOf(context));
2545
+ await offerFreedTime(store, context);
1540
2546
  return released;
1541
2547
  },
1542
2548
  async getReservation(context, input) {
@@ -1586,7 +2592,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1586
2592
  return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
1587
2593
  }, WRITE);
1588
2594
  if (input.revision > 1 && !put.replayed) {
1589
- await offerFreedTime(store, tenantOf(context));
2595
+ await offerFreedTime(store, context);
1590
2596
  }
1591
2597
  return put;
1592
2598
  },
@@ -1602,7 +2608,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1602
2608
  return { cancelled: result.cancelled, revision: result.revision };
1603
2609
  }, WRITE);
1604
2610
  if (cancelled.cancelled)
1605
- await offerFreedTime(store, tenantOf(context));
2611
+ await offerFreedTime(store, context);
1606
2612
  return cancelled;
1607
2613
  },
1608
2614
  async checkInReservation(context, input) {
@@ -1627,10 +2633,11 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1627
2633
  end: new Date(input.end),
1628
2634
  holderRef: input.holder_ref,
1629
2635
  ...(ttl !== undefined ? { offerTtlMs: ttl * 1000 } : {}),
2636
+ ...(input.idempotency_key !== undefined ? { idempotencyKey: input.idempotency_key } : {}),
1630
2637
  });
1631
2638
  if (!result.ok)
1632
2639
  throw applicationRefusal(result.code);
1633
- return { entry: waitlistOutput(result.entry) };
2640
+ return { entry: waitlistOutput(result.entry), replayed: result.replayed };
1634
2641
  });
1635
2642
  },
1636
2643
  async listWaitlist(context, input) {
@@ -1649,7 +2656,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1649
2656
  left: await tenant.leaveWaitlist({ tenantRef: tenantOf(context), id: input.entry_id }),
1650
2657
  }));
1651
2658
  if (left.left)
1652
- await offerFreedTime(store, tenantOf(context));
2659
+ await offerFreedTime(store, context);
1653
2660
  return left;
1654
2661
  },
1655
2662
  async getCoverage(context, input) {
@@ -1776,7 +2783,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1776
2783
  };
1777
2784
  }, WRITE);
1778
2785
  if (!cancelled.replayed)
1779
- await offerFreedTime(store, tenantOf(context));
2786
+ await offerFreedTime(store, context);
1780
2787
  return cancelled;
1781
2788
  },
1782
2789
  async getTenant(context) {
@@ -1800,7 +2807,13 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1800
2807
  if (await tenant.isTenantSuspended({ tenantRef: tenantOf(context) })) {
1801
2808
  throw new SlotlockAgentOperationError('tenant_suspended', 403);
1802
2809
  }
1803
- 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
+ });
1804
2817
  return { total: erased.total, rows: erased.rows };
1805
2818
  }, WRITE);
1806
2819
  },
@@ -1823,11 +2836,26 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1823
2836
  tenantRef: tenantOf(context),
1824
2837
  url: input.url,
1825
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 } : {}),
1826
2850
  });
1827
2851
  if (!created.ok) {
1828
- 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);
1829
2853
  }
1830
- 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
+ };
1831
2859
  }, WRITE);
1832
2860
  },
1833
2861
  async listWebhooks(context) {
@@ -1838,6 +2866,196 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1838
2866
  async deleteWebhook(context, input) {
1839
2867
  return withTenant(store, context, async (tenant) => tenant.deleteWebhookEndpoint({ tenantRef: tenantOf(context), id: input.webhook_id }), WRITE);
1840
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
+ },
1841
3059
  async enableWebhook(context, input) {
1842
3060
  return withTenant(store, context, async (tenant) => {
1843
3061
  const enabled = await tenant.enableWebhookEndpoint({
@@ -1851,10 +3069,152 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1851
3069
  return { webhook: webhookOutput(enabled) };
1852
3070
  }, WRITE);
1853
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
+ },
1854
3214
  async runMaintenance(context) {
1855
3215
  assertActive(context);
1856
3216
  try {
1857
- 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), {
1858
3218
  batches,
1859
3219
  signal: context.signal,
1860
3220
  });
@@ -1864,6 +3224,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1864
3224
  stale_sources: staleSources,
1865
3225
  no_shows: noShows,
1866
3226
  waitlist_offers: waitlistOffers,
3227
+ approvals_expired: approvalsExpired,
1867
3228
  };
1868
3229
  }
1869
3230
  catch (error) {