slotlock 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +542 -0
  2. package/NOTICE +2 -0
  3. package/README.md +4 -2
  4. package/agent-server.d.ts +25 -5
  5. package/agent-server.js +152 -7
  6. package/agent-store-backend.d.ts +6 -1
  7. package/agent-store-backend.js +1068 -68
  8. package/application-api.d.ts +28 -1
  9. package/application-api.js +789 -21
  10. package/approvals.d.ts +2 -0
  11. package/approvals.js +12 -5
  12. package/assistant.d.ts +34 -8
  13. package/assistant.js +81 -26
  14. package/availability-store.d.ts +193 -0
  15. package/availability-store.js +566 -0
  16. package/booking-rules.d.ts +103 -5
  17. package/booking-rules.js +263 -15
  18. package/config.d.ts +50 -0
  19. package/config.js +107 -0
  20. package/dashboard-assistant-ui.d.ts +1 -1
  21. package/dashboard-assistant-ui.js +109 -8
  22. package/dashboard-scope.d.ts +7 -0
  23. package/dashboard-scope.js +21 -0
  24. package/dashboard.d.ts +152 -8
  25. package/dashboard.js +3868 -399
  26. package/ddl-identity.d.ts +25 -0
  27. package/ddl-identity.js +881 -0
  28. package/ddl-scim.d.ts +5 -0
  29. package/ddl-scim.js +740 -0
  30. package/ddl-sso.d.ts +15 -0
  31. package/ddl-sso.js +1033 -0
  32. package/ddl.d.ts +44 -9
  33. package/ddl.js +2872 -47
  34. package/domains.d.ts +117 -0
  35. package/domains.js +340 -0
  36. package/event-feed.d.ts +2 -2
  37. package/event-feed.js +24 -34
  38. package/holiday-import.d.ts +38 -0
  39. package/holiday-import.js +56 -0
  40. package/holidays.d.ts +38 -0
  41. package/holidays.js +209 -0
  42. package/hours.d.ts +125 -0
  43. package/hours.js +224 -0
  44. package/index.d.ts +24 -5
  45. package/index.js +17 -3
  46. package/interop.d.ts +25 -7
  47. package/interop.js +145 -17
  48. package/invitation-email.d.ts +26 -0
  49. package/invitation-email.js +73 -0
  50. package/lifecycle.d.ts +31 -0
  51. package/lifecycle.js +115 -0
  52. package/maintenance.d.ts +26 -2
  53. package/maintenance.js +70 -2
  54. package/metrics.js +3 -0
  55. package/oidc.d.ts +135 -0
  56. package/oidc.js +452 -0
  57. package/openapi.json +20172 -9850
  58. package/package.json +2 -1
  59. package/ranges.d.ts +28 -0
  60. package/ranges.js +135 -0
  61. package/resource-search.d.ts +19 -0
  62. package/resource-search.js +153 -0
  63. package/rest-api.js +7 -0
  64. package/rules.d.ts +2 -0
  65. package/rules.js +2 -0
  66. package/scim-server.d.ts +19 -0
  67. package/scim-server.js +243 -0
  68. package/scim.d.ts +180 -0
  69. package/scim.js +416 -0
  70. package/self-host.d.ts +19 -6
  71. package/self-host.js +483 -28
  72. package/slotlock.manifest.json +1 -1
  73. package/store.d.ts +637 -23
  74. package/store.js +2529 -147
  75. package/types.d.ts +230 -0
  76. package/types.js +68 -1
  77. package/workspace-domains.d.ts +146 -0
  78. package/workspace-domains.js +249 -0
  79. package/workspace-roles.d.ts +47 -0
  80. package/workspace-roles.js +304 -0
  81. package/workspace-scim.d.ts +89 -0
  82. package/workspace-scim.js +157 -0
  83. package/workspace-sso.d.ts +131 -0
  84. package/workspace-sso.js +180 -0
  85. package/workspaces.d.ts +240 -0
  86. package/workspaces.js +433 -0
@@ -5,11 +5,15 @@ import { slotlockCloudEvent } from './webhooks.js';
5
5
  import { createHash } from 'node:crypto';
6
6
  import { SLOTLOCK_DEFAULT_APPROVAL_MINUTES, SLOTLOCK_KEY_APPROVAL_POLICY, SLOTLOCK_MAX_APPROVAL_MINUTES, SlotlockApprovalPolicyError, } from './approvals.js';
7
7
  import { SlotlockAgentOperationError, } from './agent-server.js';
8
- import { bookingSearch, bookingSearchBlocked, bookingSearchReach, } from './booking-rules.js';
8
+ import { bookingSearch, hoursApplyOf, bookingSearchBlocked, bookingSearchReach, } from './booking-rules.js';
9
9
  import { findNextAvailable, mergeIntervals } from './engine.js';
10
- import { expandRules } from './rules.js';
10
+ import { applicableHours, coveredByOpenWindows, dayToDate, epochDay, exceptionDays, exceptionRanges, openWindows, } from './hours.js';
11
+ import { readSlotlockHolidayCalendar } from './holiday-import.js';
12
+ import { SLOTLOCK_OVERRIDE_RESOURCES, } from './availability-store.js';
13
+ import { findEarliestRange } from './ranges.js';
11
14
  import { emitVAvailability, parseVAvailability, toJSCalendarEvent } from './interop.js';
12
- import { calendarEventRollingHorizon, } from './store.js';
15
+ import { buildSlotlockTenantExport, slotlockAuditEventRecord, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, calendarEventRollingHorizon, } from './store.js';
16
+ import { slotlockResourceSearchWords, slotlockSearchResources } from './resource-search.js';
13
17
  import { maintainSlotlockTenant } from './maintenance.js';
14
18
  import { fastForwardSlotlockSandbox, raceSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
15
19
  import { SLOTLOCK_TEST_TENANT_SUFFIX } from './api-keys.js';
@@ -38,6 +42,20 @@ const WRITE = { isolation: 'read committed' };
38
42
  */
39
43
  const APPROVAL_GRANT = Symbol('slotlock.approvalGrant');
40
44
  const OCCUPANCY_PROBE = Symbol('slotlock.occupancyProbe');
45
+ /**
46
+ * A booking the tenant's own systems make through the application API (RFC 0004 B7), not an
47
+ * agent's: its events are the tenant's (owner `internal`), outside any key's quota and kept when the
48
+ * key is revoked, and only approval policies that include the application hold it.
49
+ */
50
+ const TENANT_ACTOR = Symbol('slotlock.tenantActor');
51
+ /** Whether the call books for the tenant's own systems (TENANT_ACTOR), not an agent. */
52
+ function forTenant(context) {
53
+ return context[TENANT_ACTOR] === true;
54
+ }
55
+ /** The tenant's own identity for a retry key: per tenant, so any of its keys replays it. */
56
+ function tenantDigest(context, idempotencyKey) {
57
+ return createHash('sha256').update(`${context.principal.tenantRef}\0tenant\0${idempotencyKey}`).digest('hex');
58
+ }
41
59
  /** Thrown out of a dry run's transaction, with its answer, so the transaction rolls back. */
42
60
  class DryRunRollback {
43
61
  value;
@@ -60,6 +78,52 @@ function assertActive(context) {
60
78
  if (context.signal.aborted)
61
79
  throw new SlotlockAgentOperationError('request_aborted', 503);
62
80
  }
81
+ /** A tenant export as the API gives it. */
82
+ function tenantExportOutput(made) {
83
+ return {
84
+ id: made.id,
85
+ status: made.status,
86
+ requested_by: made.requestedBy,
87
+ created_at: made.createdAt.toISOString(),
88
+ finished_at: made.finishedAt?.toISOString() ?? null,
89
+ expires_at: made.expiresAt.toISOString(),
90
+ parts: made.parts,
91
+ rows: made.rows,
92
+ bytes: made.bytes,
93
+ tables: made.tables,
94
+ error: made.error,
95
+ };
96
+ }
97
+ /** A key's own caps on what its agent holds (RFC 0004 A5), or undefined when it has none. */
98
+ function keyHoldCaps(context) {
99
+ const limits = context.principal.limits;
100
+ return limits?.maxHolds !== undefined || limits?.maxHeldHours !== undefined
101
+ ? {
102
+ ...(limits.maxHolds !== undefined ? { maxHolds: limits.maxHolds } : {}),
103
+ ...(limits.maxHeldHours !== undefined ? { maxHeldHours: limits.maxHeldHours } : {}),
104
+ }
105
+ : undefined;
106
+ }
107
+ /** The agent an approval holds time for, and its key's caps. */
108
+ function approvalHolder(context) {
109
+ const caps = keyHoldCaps(context);
110
+ return caps ? { ref: agentOwnerRef(context), caps } : { ref: agentOwnerRef(context) };
111
+ }
112
+ /** The person an access or erasure request names (RFC 0004 D3), as the schema checked it. */
113
+ function subjectOf(input) {
114
+ for (const kind of ['email', 'holder', 'contact', 'reference']) {
115
+ const value = input[kind];
116
+ if (typeof value === 'string')
117
+ return { [kind]: value };
118
+ }
119
+ throw new SlotlockAgentOperationError('invalid_subject', 400);
120
+ }
121
+ function subjectRefusal(error) {
122
+ if (error?.code === 'invalid_subject') {
123
+ throw new SlotlockAgentOperationError('invalid_subject', 400);
124
+ }
125
+ throw error;
126
+ }
63
127
  async function withTenant(store, context, callback, options) {
64
128
  assertActive(context);
65
129
  let value;
@@ -225,15 +289,21 @@ function uuidFromDigest(value) {
225
289
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
226
290
  }
227
291
  function agentOwnerRef(context) {
292
+ if (forTenant(context))
293
+ return SLOTLOCK_INTERNAL_EVENT_OWNER_REF;
228
294
  return `agent:${createHash('sha256')
229
295
  .update(`${context.principal.tenantRef}\0${context.principal.subject}`)
230
296
  .digest('hex')}`;
231
297
  }
232
298
  function agentExternalRef(context, idempotencyKey) {
299
+ if (forTenant(context))
300
+ return `tenant:${tenantDigest(context, idempotencyKey)}`;
233
301
  // Already a stable client-visible identity; keep it unchanged.
234
302
  return `agent:${agentPrincipalDigest(context, idempotencyKey)}`;
235
303
  }
236
304
  function agentCommandIdempotencyKey(context, idempotencyKey) {
305
+ if (forTenant(context))
306
+ return `tenant-command:${tenantDigest(context, idempotencyKey)}`;
237
307
  // The principal stays in the digest so this remains safe with older tenant-only stores; hashing
238
308
  // keeps the subject and raw retry key out of durable rows.
239
309
  return `agent-command:${agentPrincipalDigest(context, idempotencyKey)}`;
@@ -293,7 +363,7 @@ export function projectSlotlockCalendarEvent(event) {
293
363
  export function slotlockAgentErrorStatus(code) {
294
364
  if (code === 'resource_tenant_mismatch' || code.endsWith('_not_found'))
295
365
  return 404;
296
- if (code.startsWith('invalid_'))
366
+ if (code.startsWith('invalid_') || code === 'duration_required')
297
367
  return 400;
298
368
  if (code === 'owner_event_quota_exceeded' || code === 'owner_command_quota_exceeded')
299
369
  return 429;
@@ -306,6 +376,13 @@ const BOOKING_RULE_CODES = Object.freeze({
306
376
  slot_step: 'booking_rule_slot_step',
307
377
  outside_hours: 'booking_rule_outside_hours',
308
378
  uncertain: 'booking_rule_uncertain',
379
+ min_duration: 'booking_rule_min_duration',
380
+ max_duration: 'booking_rule_max_duration',
381
+ duration_not_allowed: 'booking_rule_duration_not_allowed',
382
+ pickup_time: 'booking_rule_pickup_time',
383
+ return_time: 'booking_rule_return_time',
384
+ cancel_cutoff: 'booking_rule_cancel_cutoff',
385
+ reschedule_cutoff: 'booking_rule_reschedule_cutoff',
309
386
  });
310
387
  function operationError(refusal, details) {
311
388
  const code = typeof refusal === 'string'
@@ -340,6 +417,43 @@ async function overlapDetails(tenantStore, context, refusal) {
340
417
  ...(booking.eventRef !== null && booking.eventOwnerRef === owner ? { conflicting_event_id: booking.eventRef } : {}),
341
418
  };
342
419
  }
420
+ /**
421
+ * Slots on a resource whose hours hold a hire only at its ends (`hoursApply`): up to `limit` of
422
+ * `durationMs` from `from` to the window's end, each the range search's earliest after the last,
423
+ * so a slot may run from one local day to another and is judged as a write of it is.
424
+ */
425
+ async function rangedSlots(tenantStore, context, resource, window, from, durationMs, busy, limit, availabilityRules) {
426
+ const fallback = resource.availabilityRules === undefined && resource.inheritedAvailability === undefined
427
+ ? await availabilityRules(context, resource)
428
+ : [];
429
+ const exceptions = (await tenantStore.availabilityExceptionsFor({
430
+ tenantRef: context.principal.tenantRef,
431
+ resourceIds: [resource.id],
432
+ window: { start: new Date(window.start.getTime() - 86_400_000), end: new Date(window.end.getTime() + 86_400_000) },
433
+ })).get(resource.id) ?? [];
434
+ const rules = { ...resource.effectiveBookingRules, allowedDurations: [Math.round(durationMs / 60_000)] };
435
+ const hours = applicableHours(resource, fallback).rules;
436
+ const now = new Date();
437
+ const lastStart = window.end.getTime() - durationMs;
438
+ const slots = [];
439
+ for (let start = from; slots.length < limit && start <= lastStart;) {
440
+ const range = findEarliestRange({
441
+ busy,
442
+ rules,
443
+ timezone: resource.timezone,
444
+ hours,
445
+ exceptions,
446
+ now,
447
+ pickup: { start: new Date(start), end: new Date(lastStart) },
448
+ return: { start: new Date(start + durationMs), end: window.end },
449
+ });
450
+ if (!range)
451
+ break;
452
+ slots.push(range);
453
+ start = range.end.getTime();
454
+ }
455
+ return slots;
456
+ }
343
457
  /** Resource and event ids: the UUIDs the store issues and validates. */
344
458
  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;
345
459
  function parseResourceCursor(value) {
@@ -400,8 +514,59 @@ async function requireResource(tenantStore, tenantRef, resourceId, visibility) {
400
514
  return resource;
401
515
  }
402
516
  /** The caller's shared resource with this id: what an agent may see and book. */
403
- function requireAgentResource(tenantStore, tenantRef, resourceId) {
404
- return requireResource(tenantStore, tenantRef, resourceId, 'shared');
517
+ /** The most resources a search or a not-found's candidates look through: more than a tenant may have. */
518
+ const MAX_SEARCHED_RESOURCES = 5_000;
519
+ /** A tenant's resources agents may see, in pages, up to MAX_SEARCHED_RESOURCES. */
520
+ async function sharedResources(tenantStore, tenantRef, filter = {}) {
521
+ const all = [];
522
+ let after;
523
+ while (all.length < MAX_SEARCHED_RESOURCES) {
524
+ const page = await tenantStore.listResources({
525
+ tenantRef,
526
+ visibility: 'shared',
527
+ limit: 1_000,
528
+ ...(after !== undefined ? { after } : {}),
529
+ ...filter,
530
+ });
531
+ all.push(...page);
532
+ if (page.length < 1_000)
533
+ break;
534
+ after = page.at(-1)?.id;
535
+ }
536
+ return all.slice(0, MAX_SEARCHED_RESOURCES);
537
+ }
538
+ /**
539
+ * The refusal for an id that names no resource the caller can see. When what was given is not an
540
+ * id at all (a name or a reference, as a model will pass), it names up to three resources that best
541
+ * match it, so the caller can try again with the right id.
542
+ */
543
+ async function resourceNotFound(tenantStore, tenantRef, given) {
544
+ if (STORE_ID_PATTERN.test(given) || typeof given !== 'string' || given.trim() === '') {
545
+ return new SlotlockAgentOperationError('resource_not_found', 404);
546
+ }
547
+ let words;
548
+ try {
549
+ words = slotlockResourceSearchWords(given);
550
+ }
551
+ catch {
552
+ return new SlotlockAgentOperationError('resource_not_found', 404);
553
+ }
554
+ const best = slotlockSearchResources(await sharedResources(tenantStore, tenantRef), words).slice(0, 3);
555
+ const details = Object.fromEntries(best.map((match, index) => [
556
+ `candidate_${String(index + 1)}`,
557
+ `${match.resource.id} ${match.resource.name ?? match.resource.externalRef ?? ''}`.trim().slice(0, 200),
558
+ ]));
559
+ return new SlotlockAgentOperationError('resource_not_found', 404, details);
560
+ }
561
+ async function requireAgentResource(tenantStore, tenantRef, resourceId) {
562
+ try {
563
+ return await requireResource(tenantStore, tenantRef, resourceId, 'shared');
564
+ }
565
+ catch (error) {
566
+ if (error.code !== 'resource_not_found')
567
+ throw error;
568
+ throw await resourceNotFound(tenantStore, tenantRef, resourceId);
569
+ }
405
570
  }
406
571
  /**
407
572
  * The store's change feed for live updates (`subscriptions.changes`): each tenant's changes read
@@ -418,6 +583,31 @@ export function createSlotlockStoreChangeSource(store, sql) {
418
583
  };
419
584
  }
420
585
  /** Bind the protocol server to the production store without giving a request control of tenancy. */
586
+ /**
587
+ * Each resource's open hours within `window` (RFC 0004 B1, B2): its own weekly hours, else what it
588
+ * inherits from its location or tenant, else the server's, with the exceptions that reach it applied.
589
+ * One query reads every resource's exceptions.
590
+ */
591
+ async function hoursWithin(tenantStore, context, resources, window, options) {
592
+ const exceptions = await tenantStore.availabilityExceptionsFor({
593
+ tenantRef: context.principal.tenantRef,
594
+ resourceIds: resources.map((resource) => resource.id),
595
+ window,
596
+ });
597
+ const open = new Map();
598
+ for (const resource of resources) {
599
+ const fallback = resource.availabilityRules === undefined && resource.inheritedAvailability === undefined
600
+ ? await options.availabilityRules(context, resource)
601
+ : [];
602
+ open.set(resource.id, openWindows({
603
+ hours: applicableHours(resource, fallback).rules,
604
+ exceptions: exceptions.get(resource.id) ?? [],
605
+ timezone: resource.timezone,
606
+ window,
607
+ }));
608
+ }
609
+ return open;
610
+ }
421
611
  export function createSlotlockStoreAgentBackend(store, options) {
422
612
  const maxApprovalMinutes = options.maxApprovalMinutes ?? SLOTLOCK_MAX_APPROVAL_MINUTES;
423
613
  if (!(Number.isSafeInteger(maxApprovalMinutes) && maxApprovalMinutes >= 1 && maxApprovalMinutes <= SLOTLOCK_MAX_APPROVAL_MINUTES)) {
@@ -463,6 +653,11 @@ export function createSlotlockStoreAgentBackend(store, options) {
463
653
  source: 'agent-server',
464
654
  // The key's own caps, below the server's (RFC 0004 A5).
465
655
  ...(holderCaps ? { holderCaps } : {}),
656
+ // Its price is the tenant's (holdPrice), never the agent's own (RFC 0004 C3).
657
+ priceFromRules: true,
658
+ // Booked only once your system records its payment (RFC 0004 C3).
659
+ ...(input.payment_required === true ? { paymentRequired: true } : {}),
660
+ ...(input.payment !== undefined ? { payment: amountInput(input.payment) } : {}),
466
661
  });
467
662
  if (result.ok) {
468
663
  holds.push({
@@ -471,6 +666,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
471
666
  start: result.hold.start.toISOString(),
472
667
  end: result.hold.end.toISOString(),
473
668
  expires_at: result.hold.expiresAt.toISOString(),
669
+ ...(result.hold.paymentRequired ? { payment_required: true, payment_amount: amountOutput(result.hold.paymentAmount) } : {}),
474
670
  });
475
671
  }
476
672
  else {
@@ -480,6 +676,74 @@ export function createSlotlockStoreAgentBackend(store, options) {
480
676
  return { group_id: groupId, holds, refused };
481
677
  });
482
678
  },
679
+ async holdTogether(context, rawInput) {
680
+ const input = rawInput;
681
+ const holderRef = agentOwnerRef(context);
682
+ const limits = context.principal.limits;
683
+ const holderCaps = limits?.maxHolds !== undefined || limits?.maxHeldHours !== undefined
684
+ ? {
685
+ ...(limits.maxHolds !== undefined ? { maxHolds: limits.maxHolds } : {}),
686
+ ...(limits.maxHeldHours !== undefined ? { maxHeldHours: limits.maxHeldHours } : {}),
687
+ }
688
+ : undefined;
689
+ // Sorted, so sets over the same resources lock in one order; one group per agent and key.
690
+ const resourceIds = [...new Set(input.resource_ids)].sort();
691
+ if (resourceIds.length < 2)
692
+ throw new SlotlockAgentOperationError('invalid_arguments', 400);
693
+ const groupId = uuidFromDigest(`slotlock-hold-together\0${holderRef}\0${input.idempotency_key}`);
694
+ return withTenant(store, context, async (tenantStore) => {
695
+ for (const resourceId of resourceIds) {
696
+ try {
697
+ await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
698
+ }
699
+ catch (error) {
700
+ if (!(error instanceof SlotlockAgentOperationError))
701
+ throw error;
702
+ throw new SlotlockAgentOperationError(error.code, error.status, { resource_id: resourceId });
703
+ }
704
+ const result = await tenantStore.acquireHold({
705
+ tenantRef: context.principal.tenantRef,
706
+ principalRef: holderRef,
707
+ holderRef,
708
+ idempotencyKey: agentCommandIdempotencyKey(context, `${input.idempotency_key}\0together\0${resourceId}`),
709
+ groupId,
710
+ resourceId,
711
+ start: new Date(input.start),
712
+ end: new Date(input.end),
713
+ ttlMs: input.ttl_seconds * 1_000,
714
+ source: 'slotlock:together',
715
+ ...(holderCaps ? { holderCaps } : {}),
716
+ // Its price is the tenant's (holdPrice), never the agent's own (RFC 0004 C3).
717
+ priceFromRules: true,
718
+ });
719
+ // All or nothing: thrown, so the holds already made roll back with it.
720
+ if (!result.ok) {
721
+ const refusal = operationError(result);
722
+ throw new SlotlockAgentOperationError(refusal.code, refusal.status, { resource_id: resourceId });
723
+ }
724
+ }
725
+ // One time to live for the set: each expires when the earliest does.
726
+ const holds = await tenantStore.holdGroup({
727
+ tenantRef: context.principal.tenantRef,
728
+ holderRef,
729
+ groupId,
730
+ alignExpiry: true,
731
+ });
732
+ if (holds.length !== resourceIds.length)
733
+ throw new SlotlockAgentOperationError('store_inconsistent', 503);
734
+ return {
735
+ group_id: groupId,
736
+ holds: holds.map((hold) => ({
737
+ hold_id: hold.holdId,
738
+ resource_id: hold.resourceId,
739
+ start: hold.start.toISOString(),
740
+ end: hold.end.toISOString(),
741
+ expires_at: hold.expiresAt.toISOString(),
742
+ })),
743
+ expires_at: holds[0].expiresAt.toISOString(),
744
+ };
745
+ });
746
+ },
483
747
  async bookPool(context, rawInput) {
484
748
  const input = rawInput;
485
749
  const content = {
@@ -564,6 +828,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
564
828
  [window.end.getTime(), 0],
565
829
  ]);
566
830
  let complete = true;
831
+ const enforced = members.filter((member) => member.effectiveBookingRules?.enforceHours === true);
832
+ const memberHours = enforced.length > 0 ? await hoursWithin(tenantStore, context, enforced, window, options) : new Map();
567
833
  for (const member of members) {
568
834
  const sources = await requiredSources(options, context, member);
569
835
  const freeBusy = await readFreeBusy(tenantStore, {
@@ -590,8 +856,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
590
856
  ? [{ start: search.window.start, end: new Date(Math.min(search.window.end.getTime(), latestStart)) }]
591
857
  : [];
592
858
  if (rules?.enforceHours === true && open.length > 0) {
593
- const hours = member.availabilityRules ?? (await options.availabilityRules(context, member));
594
- const windows = expandRules(hours, window, member.timezone);
859
+ const windows = memberHours.get(member.id) ?? [];
595
860
  windowsLeft -= windows.length;
596
861
  if (windowsLeft < 0)
597
862
  throw new SlotlockAgentOperationError('calendar_work_limit', 400);
@@ -665,11 +930,25 @@ export function createSlotlockStoreAgentBackend(store, options) {
665
930
  throw new SlotlockAgentOperationError(error.code, error.status, { resource_id: resourceId });
666
931
  }
667
932
  }
933
+ // A set held together (RFC 0004 B7) is booked from its holds: each must be the agent's, live,
934
+ // and for exactly this time.
935
+ const held = new Map();
936
+ if (input.hold_group_id !== undefined) {
937
+ const holds = await tenantStore.holdGroup({
938
+ tenantRef: context.principal.tenantRef,
939
+ holderRef: agentOwnerRef(context),
940
+ groupId: input.hold_group_id,
941
+ });
942
+ for (const hold of holds)
943
+ held.set(hold.resourceId, hold.holdId);
944
+ }
668
945
  const events = [];
669
946
  let replayed = true;
670
947
  for (const resourceId of resourceIds) {
671
948
  const key = `${input.idempotency_key}\0${resourceId}`;
949
+ const holdId = held.get(resourceId);
672
950
  const result = await tenantStore.putCalendarEvent({
951
+ ...(holdId !== undefined ? { fromHold: { holdId, holderRef: agentOwnerRef(context) } } : {}),
673
952
  tenantRef: context.principal.tenantRef,
674
953
  ownerRef: agentOwnerRef(context),
675
954
  externalRef: agentExternalRef(context, key),
@@ -815,6 +1094,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
815
1094
  const labels = input.labels;
816
1095
  const location = input.location;
817
1096
  const group = input.group;
1097
+ let words;
1098
+ try {
1099
+ words = typeof input.query === 'string' ? slotlockResourceSearchWords(input.query) : undefined;
1100
+ }
1101
+ catch {
1102
+ throw new SlotlockAgentOperationError('invalid_arguments', 400);
1103
+ }
818
1104
  // What a resource says about itself, for an agent to show and choose by; its attributes are
819
1105
  // the integrator's, and stay on the application API.
820
1106
  const output = (resource) => ({
@@ -845,19 +1131,34 @@ export function createSlotlockStoreAgentBackend(store, options) {
845
1131
  const places = location === undefined
846
1132
  ? null
847
1133
  : 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
- };
1134
+ const members = (await tenantStore.listPoolMembers({
1135
+ tenantRef: context.principal.tenantRef,
1136
+ pool: input.pool,
1137
+ visibility: 'shared',
1138
+ })).filter((resource) => (kind === undefined || (resource.kind ?? 'other') === kind) &&
1139
+ (labels === undefined || slotlockResourceHasLabels(resource.labels, labels)) &&
1140
+ (places === null || (resource.location !== undefined && places.has(resource.location))) &&
1141
+ (group === undefined || resource.groups?.includes(group) === true));
1142
+ // Searched, the best matches first; else in the order slotlock_book_pool tries them.
1143
+ const shown = words === undefined
1144
+ ? members
1145
+ : slotlockSearchResources(members, words)
1146
+ .slice(0, input.limit)
1147
+ .map((match) => match.resource);
1148
+ return { resources: shown.map(output), next_cursor: null };
1149
+ });
1150
+ }
1151
+ if (words !== undefined) {
1152
+ // A search ranks every resource the filters allow (as many as a tenant may have), best first.
1153
+ return withTenant(store, context, async (tenantStore) => {
1154
+ const candidates = await sharedResources(tenantStore, context.principal.tenantRef, {
1155
+ ...(kind !== undefined ? { kind } : {}),
1156
+ ...(labels !== undefined ? { labels } : {}),
1157
+ ...(location !== undefined ? { location } : {}),
1158
+ ...(group !== undefined ? { group } : {}),
1159
+ });
1160
+ const best = slotlockSearchResources(candidates, words).slice(0, input.limit);
1161
+ return { resources: best.map((match) => output(match.resource)), next_cursor: null };
861
1162
  });
862
1163
  }
863
1164
  return withTenant(store, context, async (tenantStore) => {
@@ -921,6 +1222,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
921
1222
  timezone: resource.timezone,
922
1223
  window,
923
1224
  now: new Date(),
1225
+ durationMs,
924
1226
  });
925
1227
  const sources = await requiredSources(options, context, resource);
926
1228
  const freeBusy = await readFreeBusy(tenantStore, {
@@ -935,8 +1237,13 @@ export function createSlotlockStoreAgentBackend(store, options) {
935
1237
  return { slots: [], certain: false };
936
1238
  if (!search)
937
1239
  return { slots: [], certain: true };
938
- const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
939
- const windows = expandRules(rules, search.window, resource.timezone);
1240
+ // Where the hours hold a hire only at its ends (hoursApply), a slot may run from one local
1241
+ // day to another: each from the range search, judged as a write of it is.
1242
+ if (hoursApplyOf(resource.effectiveBookingRules) === 'ends') {
1243
+ const ranged = await rangedSlots(tenantStore, context, resource, window, search.window.start.getTime(), durationMs, freeBusy.busy, input.limit, options.availabilityRules);
1244
+ return { slots: ranged.map((slot) => ({ start: slot.start.toISOString(), end: slot.end.toISOString() })), certain: true };
1245
+ }
1246
+ const windows = (await hoursWithin(tenantStore, context, [resource], search.window, options)).get(resource.id) ?? [];
940
1247
  if (windows.length > MAX_REQUEST_RULE_WINDOWS)
941
1248
  throw new SlotlockAgentOperationError('calendar_work_limit', 400);
942
1249
  const busy = bookingSearchBlocked(freeBusy.busy, search);
@@ -957,6 +1264,96 @@ export function createSlotlockStoreAgentBackend(store, options) {
957
1264
  return { slots, certain: true };
958
1265
  });
959
1266
  },
1267
+ async defaultDuration(context, resourceIds) {
1268
+ return withTenant(store, context, async (tenantStore) => {
1269
+ const lengths = new Set();
1270
+ for (const resourceId of resourceIds) {
1271
+ const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
1272
+ lengths.add(resource.effectiveBookingRules?.defaultDurationMinutes);
1273
+ }
1274
+ const [only] = [...lengths];
1275
+ return lengths.size === 1 && only !== undefined ? only : null;
1276
+ });
1277
+ },
1278
+ async findAvailableRanges(context, input) {
1279
+ const tenantRef = context.principal.tenantRef;
1280
+ const pickup = { start: new Date(input.pickup_start), end: new Date(input.pickup_end) };
1281
+ const days = input.days;
1282
+ const back = input.return_start !== undefined
1283
+ ? { start: new Date(input.return_start), end: new Date(input.return_end) }
1284
+ : undefined;
1285
+ const limit = input.limit;
1286
+ const charge = busyBudget();
1287
+ const now = new Date();
1288
+ return withTenant(store, context, async (tenantStore) => {
1289
+ let resources;
1290
+ if (typeof input.pool === 'string') {
1291
+ resources = await tenantStore.listPoolMembers({ tenantRef, pool: input.pool, visibility: 'shared', ordered: true });
1292
+ if (resources.length === 0)
1293
+ throw new SlotlockAgentOperationError('pool_not_found', 404);
1294
+ resources = resources.slice(0, 100);
1295
+ }
1296
+ else {
1297
+ resources = [];
1298
+ for (const resourceId of distinctResourceIds(input)) {
1299
+ resources.push(await requireAgentResource(tenantStore, tenantRef, resourceId));
1300
+ }
1301
+ }
1302
+ // What a range may reach: the pick-up window, the longest it may run, and its buffers.
1303
+ const lastEnd = back ? back.end.getTime() : pickup.end.getTime() + ((days ?? 1) + 1) * 86_400_000;
1304
+ const reach = { start: new Date(pickup.start.getTime() - 86_400_000), end: new Date(lastEnd + 8 * 86_400_000) };
1305
+ const exceptions = await tenantStore.availabilityExceptionsFor({
1306
+ tenantRef,
1307
+ resourceIds: resources.map((resource) => resource.id),
1308
+ window: reach,
1309
+ });
1310
+ const ranges = [];
1311
+ let complete = true;
1312
+ for (const resource of resources) {
1313
+ const sources = await requiredSources(options, context, resource);
1314
+ const freeBusy = await readFreeBusy(tenantStore, {
1315
+ tenantRef,
1316
+ resourceId: resource.id,
1317
+ window: reach,
1318
+ requiredSources: [...sources],
1319
+ ...(options.coverageMaxAgeMs !== undefined ? { coverageMaxAgeMs: options.coverageMaxAgeMs } : {}),
1320
+ });
1321
+ charge(freeBusy.busy.length);
1322
+ if (freeBusy.coverage.state !== 'complete') {
1323
+ complete = false;
1324
+ continue;
1325
+ }
1326
+ const fallback = resource.availabilityRules === undefined && resource.inheritedAvailability === undefined
1327
+ ? await options.availabilityRules(context, resource)
1328
+ : [];
1329
+ const found = findEarliestRange({
1330
+ busy: freeBusy.busy,
1331
+ rules: resource.effectiveBookingRules,
1332
+ timezone: resource.timezone,
1333
+ hours: applicableHours(resource, fallback).rules,
1334
+ exceptions: exceptions.get(resource.id) ?? [],
1335
+ now,
1336
+ pickup,
1337
+ ...(back ? { return: back } : {}),
1338
+ ...(days !== undefined ? { days } : {}),
1339
+ });
1340
+ if (found)
1341
+ ranges.push({ resource_id: resource.id, start: found.start.toISOString(), end: found.end.toISOString() });
1342
+ }
1343
+ const window = { start: pickup.start, end: new Date(lastEnd) };
1344
+ const answer = {
1345
+ ranges: complete
1346
+ ? ranges
1347
+ .sort((a, b) => Date.parse(a.start) - Date.parse(b.start) || a.resource_id.localeCompare(b.resource_id))
1348
+ .slice(0, limit)
1349
+ : [],
1350
+ coverage: coverageProjection(window, complete),
1351
+ };
1352
+ return typeof input.pool === 'string'
1353
+ ? { ...answer, selected_resource_ids: resources.map((resource) => resource.id) }
1354
+ : answer;
1355
+ });
1356
+ },
960
1357
  async findNextAvailable(context, input) {
961
1358
  const window = interval(input);
962
1359
  const resourceIds = distinctResourceIds(input);
@@ -974,6 +1371,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
974
1371
  timezone: resource.timezone,
975
1372
  window,
976
1373
  now: new Date(),
1374
+ durationMs,
977
1375
  });
978
1376
  const sources = await requiredSources(options, context, resource);
979
1377
  const freeBusy = await readFreeBusy(tenantStore, {
@@ -993,17 +1391,23 @@ export function createSlotlockStoreAgentBackend(store, options) {
993
1391
  }
994
1392
  if (!search)
995
1393
  continue;
996
- const rules = resource.availabilityRules ?? (await options.availabilityRules(context, resource));
997
- const windows = expandRules(rules, search.window, resource.timezone);
998
- windowsLeft -= windows.length;
999
- if (windowsLeft < 0)
1000
- throw new SlotlockAgentOperationError('calendar_work_limit', 400);
1001
- const candidate = findNextAvailable({
1002
- busy: bookingSearchBlocked(freeBusy.busy, search),
1003
- windows,
1004
- durationMs,
1005
- ...(search.align ? { align: search.align } : {}),
1006
- });
1394
+ let candidate;
1395
+ if (hoursApplyOf(resource.effectiveBookingRules) === 'ends') {
1396
+ // A slot may run from one local day to another: the range search's earliest.
1397
+ [candidate = null] = await rangedSlots(tenantStore, context, resource, window, search.window.start.getTime(), durationMs, freeBusy.busy, 1, options.availabilityRules);
1398
+ }
1399
+ else {
1400
+ const windows = (await hoursWithin(tenantStore, context, [resource], search.window, options)).get(resource.id) ?? [];
1401
+ windowsLeft -= windows.length;
1402
+ if (windowsLeft < 0)
1403
+ throw new SlotlockAgentOperationError('calendar_work_limit', 400);
1404
+ candidate = findNextAvailable({
1405
+ busy: bookingSearchBlocked(freeBusy.busy, search),
1406
+ windows,
1407
+ durationMs,
1408
+ ...(search.align ? { align: search.align } : {}),
1409
+ });
1410
+ }
1007
1411
  if (candidate &&
1008
1412
  (!best ||
1009
1413
  candidate.start.getTime() < best.interval.start.getTime() ||
@@ -1066,6 +1470,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
1066
1470
  ...(typeof input.hold_id === 'string'
1067
1471
  ? { fromHold: { holdId: input.hold_id, holderRef: agentOwnerRef(context) } }
1068
1472
  : {}),
1473
+ // Tell no one about it (RFC 0004 P3).
1474
+ ...(input.notify === false ? { notify: false } : {}),
1069
1475
  });
1070
1476
  if (!result.ok)
1071
1477
  throw operationError(result, await overlapDetails(tenantStore, context, result));
@@ -1237,6 +1643,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
1237
1643
  ...(current ? { expectedResourceId: current.resourceId } : {}),
1238
1644
  idempotencyKey: agentCommandIdempotencyKey(context, input.idempotency_key),
1239
1645
  expectedRevision: input.expected_revision,
1646
+ // An agent is held to the resource's cancel cutoff (RFC 0004 B5).
1647
+ enforceBookingRules: true,
1240
1648
  });
1241
1649
  if (!result.ok)
1242
1650
  throw operationError(result);
@@ -1254,6 +1662,42 @@ export function createSlotlockStoreAgentBackend(store, options) {
1254
1662
  };
1255
1663
  return withApprovalPolicies(store, options, maxApprovalMinutes, backend);
1256
1664
  }
1665
+ /** An exception as the API answers it. */
1666
+ function exceptionOutput(exception) {
1667
+ return {
1668
+ id: exception.id,
1669
+ location: exception.location ?? null,
1670
+ resource_id: exception.resourceId ?? null,
1671
+ starts_on: exception.startsOn,
1672
+ ends_on: exception.endsOn,
1673
+ outcome: exception.outcome,
1674
+ hours: exception.hours?.map((window) => ({ start_minutes: window.startMinutes, duration_minutes: window.durationMinutes })) ??
1675
+ null,
1676
+ label: exception.label,
1677
+ source: exception.source,
1678
+ calendar_id: exception.calendarId ?? null,
1679
+ created_at: exception.createdAt.toISOString(),
1680
+ };
1681
+ }
1682
+ function holidayCalendarOutput(calendar) {
1683
+ return {
1684
+ calendar_id: calendar.id,
1685
+ name: calendar.name,
1686
+ location: calendar.location ?? null,
1687
+ resource_id: calendar.resourceId ?? null,
1688
+ url: calendar.url ?? null,
1689
+ imported_at: calendar.importedAt?.toISOString() ?? null,
1690
+ next_import_at: calendar.nextImportAt?.toISOString() ?? null,
1691
+ last_error: calendar.lastError ?? null,
1692
+ closures: calendar.closures,
1693
+ created_at: calendar.createdAt.toISOString(),
1694
+ };
1695
+ }
1696
+ /** Days the exports and imports of hours reach: a year on from yesterday. */
1697
+ function exportDays() {
1698
+ const today = Math.floor(Date.now() / 86_400_000);
1699
+ return { from: dayToDate(today - 1), to: dayToDate(today + 366) };
1700
+ }
1257
1701
  function storeRefusal(error) {
1258
1702
  const code = error?.code;
1259
1703
  if (typeof code !== 'string')
@@ -1280,9 +1724,12 @@ function storeRefusal(error) {
1280
1724
  code === 'pool_order_limit_reached' ||
1281
1725
  code === 'composite_too_deep' ||
1282
1726
  code === 'composite_parts_limit' ||
1283
- code === 'composite_busy') {
1727
+ code === 'composite_busy' ||
1728
+ code === 'availability_exceptions_limit_reached') {
1284
1729
  return new SlotlockAgentOperationError(code, 409);
1285
1730
  }
1731
+ if (code === 'holiday_calendar_unreachable')
1732
+ return new SlotlockAgentOperationError(code, 400);
1286
1733
  if (code.startsWith('invalid_') && code !== 'invalid_transaction_isolation') {
1287
1734
  return new SlotlockAgentOperationError(code, 400);
1288
1735
  }
@@ -1331,6 +1778,7 @@ function approvalPolicyOutput(policy) {
1331
1778
  outcome: policy.outcome,
1332
1779
  approval_ttl_minutes: policy.approvalTtlMinutes,
1333
1780
  lead_minutes: policy.leadMinutes,
1781
+ include_application: policy.includeApplication,
1334
1782
  created_at: policy.createdAt.toISOString(),
1335
1783
  updated_at: policy.updatedAt.toISOString(),
1336
1784
  };
@@ -1370,6 +1818,16 @@ function approvalOutput(approval) {
1370
1818
  created_at: approval.createdAt.toISOString(),
1371
1819
  };
1372
1820
  }
1821
+ /** A store refusal as the application API answers it, a broken booking rule by its own code. */
1822
+ function applicationRuleRefusal(result) {
1823
+ return result.code === 'booking_rule_violated' && result.rule
1824
+ ? new SlotlockAgentOperationError(`booking_rule_${result.rule}`, 409)
1825
+ : applicationRefusal(result.code);
1826
+ }
1827
+ /** A recorded cancellation fee as the API answers it (RFC 0004 B5). */
1828
+ function feeOutput(fee) {
1829
+ return fee ? { amount_minor: fee.amountMinor, currency: fee.currency } : null;
1830
+ }
1373
1831
  function applicationRefusal(code) {
1374
1832
  if (code === 'resource_tenant_mismatch') {
1375
1833
  return new SlotlockAgentOperationError('resource_not_found', 404);
@@ -1393,6 +1851,8 @@ function waitlistOutput(entry) {
1393
1851
  hold_id: entry.holdId,
1394
1852
  offered_resource_id: entry.offeredResourceId,
1395
1853
  offered_at: entry.offeredAt?.toISOString() ?? null,
1854
+ duration_minutes: entry.durationMs === null ? null : Math.round(entry.durationMs / 60_000),
1855
+ decided_at: entry.decidedAt?.toISOString() ?? null,
1396
1856
  created_at: entry.createdAt.toISOString(),
1397
1857
  };
1398
1858
  }
@@ -1409,6 +1869,8 @@ function keyApprovalPolicy() {
1409
1869
  outcome: 'require_approval',
1410
1870
  approvalTtlMinutes: SLOTLOCK_DEFAULT_APPROVAL_MINUTES,
1411
1871
  leadMinutes: 0,
1872
+ // The key's own approval holds whatever it books, for the tenant too.
1873
+ includeApplication: true,
1412
1874
  createdAt: new Date(0),
1413
1875
  updatedAt: new Date(0),
1414
1876
  };
@@ -1477,10 +1939,26 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
1477
1939
  /** Whether [start, end) on the resource falls outside its bookable hours. */
1478
1940
  async function outsideHours(tenantStore, context, resourceId, start, end) {
1479
1941
  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);
1942
+ const reach = { start: new Date(start.getTime() - 86_400_000), end: new Date(end.getTime() + 86_400_000) };
1943
+ const windows = (await hoursWithin(tenantStore, context, [resource], reach, options)).get(resource.id) ?? [];
1944
+ return !coveredByOpenWindows(windows, { start, end });
1483
1945
  }
1946
+ /**
1947
+ * What an update may change without booking any time: everything else (time, resource, zone,
1948
+ * transparency, status, recurrence and its exceptions) is judged by what it would add.
1949
+ */
1950
+ const EVENT_DETAIL_FIELDS = new Set([
1951
+ 'event_id',
1952
+ 'expected_revision',
1953
+ 'expected_resource_id',
1954
+ 'idempotency_key',
1955
+ 'title',
1956
+ 'description',
1957
+ 'location',
1958
+ 'organizer',
1959
+ 'attendees',
1960
+ 'reminders',
1961
+ ]);
1484
1962
  /** The write's ask and the policies that may cover it, or null when none can. */
1485
1963
  async function plan(tenantStore, context, operation, input) {
1486
1964
  const tenantRef = context.principal.tenantRef;
@@ -1538,15 +2016,18 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
1538
2016
  const current = await tenantStore.getCalendarEvent({ tenantRef, ownerRef, externalRef: input.event_id });
1539
2017
  if (!current)
1540
2018
  return null;
2019
+ // Details alone book nothing. Any other change may: a move, or time the event did not hold
2020
+ // (made opaque or confirmed, given occurrences or fewer exceptions, another zone), judged
2021
+ // below by what the write would add to what the event holds.
2022
+ if (Object.keys(input).every((field) => EVENT_DETAIL_FIELDS.has(field)))
2023
+ return null;
1541
2024
  const resourceId = input.resource_id ?? current.resourceId;
1542
2025
  const start = input.starts_at ? new Date(input.starts_at) : current.start;
1543
2026
  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
- }
2027
+ const moved = resourceId !== current.resourceId || start.getTime() !== current.start.getTime() || end.getTime() !== current.end.getTime();
1548
2028
  asked = {
1549
- kind: 'move',
2029
+ // Time added in place is a booking of its own; a move is a move.
2030
+ kind: moved ? 'move' : 'single',
1550
2031
  stored: 'slotlock_update_event',
1551
2032
  resourceIds: [resourceId],
1552
2033
  externalRefs: [current.externalRef],
@@ -1558,7 +2039,10 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
1558
2039
  }
1559
2040
  const minutes = (asked.end.getTime() - asked.start.getTime()) / 60_000;
1560
2041
  // 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)) &&
2042
+ const policies = (await tenantStore.approvalPoliciesFor({ tenantRef, resourceIds: asked.resourceIds })).filter((policy) =>
2043
+ // The tenant's own bookings are held only by policies that include the application.
2044
+ (!forTenant(context) || policy.includeApplication === true) &&
2045
+ (policy.conditions.kinds === undefined || policy.conditions.kinds.includes(asked.kind)) &&
1562
2046
  (policy.conditions.longerThanMinutes === undefined || minutes > policy.conditions.longerThanMinutes));
1563
2047
  if (policies.length === 0 && !keyPolicy)
1564
2048
  return null;
@@ -1580,18 +2064,20 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
1580
2064
  });
1581
2065
  if (asked.prior) {
1582
2066
  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
2067
  const kept = { ...asked.prior.request };
1589
2068
  // A pool booking is kept as the member it would take.
1590
2069
  if (operation === 'slotlock_book_pool')
1591
2070
  delete kept.resource_id;
2071
+ // The key answers only the request a person was asked about, granted or not: a grant
2072
+ // approves that booking, never another made with its key (a bundle's other resources, say,
2073
+ // which the store would take as new).
1592
2074
  if (JSON.stringify(canonical(kept)) !== JSON.stringify(canonical(request))) {
1593
2075
  throw new SlotlockAgentOperationError('idempotency_conflict', 409);
1594
2076
  }
2077
+ // Granted: the booking was made with this key, so the write answers it as a replay.
2078
+ // Without the hold the approval took over, as the grant made it.
2079
+ if (asked.prior.status === 'granted')
2080
+ return method.call(this, context, request);
1595
2081
  throw approvalAnswer(asked.prior);
1596
2082
  }
1597
2083
  const ask = asked.plan;
@@ -1615,6 +2101,11 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
1615
2101
  if (probed.replayed === true || occupied.length === 0) {
1616
2102
  return context.dryRun ? probed : method.call(this, context, input);
1617
2103
  }
2104
+ // Only time the event did not already hold needs a person: an edit or a move within its own
2105
+ // time books nothing new.
2106
+ const holds = uncovered(occupied, ask.before);
2107
+ if (holds.length === 0)
2108
+ return context.dryRun ? probed : method.call(this, context, input);
1618
2109
  const written = [...new Set(occupied.map((occupation) => occupation.resourceId))];
1619
2110
  const decided = await withTenant(store, { ...context, dryRun: false }, async (tenantStore) => {
1620
2111
  // A pool's policies are those of the member the write would take, and its pool.
@@ -1662,9 +2153,6 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
1662
2153
  expires_at: expiresAt.toISOString(),
1663
2154
  });
1664
2155
  }
1665
- const holds = uncovered(occupied, ask.before);
1666
- if (holds.length === 0)
1667
- return method.call(this, context, input);
1668
2156
  const chosen = ask.pool ? written[0] : undefined;
1669
2157
  const { hold_id: _hold, ...request } = input;
1670
2158
  const principal = context.principal;
@@ -1682,6 +2170,8 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
1682
2170
  : {}),
1683
2171
  ...(context.origin !== undefined ? { origin: context.origin } : {}),
1684
2172
  ...(ask.pool !== undefined ? { pool: ask.pool } : {}),
2173
+ // Granted, it is made as the tenant's again, never as the key's agent.
2174
+ ...(forTenant(context) ? { for: 'tenant' } : {}),
1685
2175
  },
1686
2176
  requesterRef: principal.subject,
1687
2177
  requestKey,
@@ -1690,8 +2180,14 @@ function withApprovalPolicies(store, options, maxApprovalMinutes, backend) {
1690
2180
  end: ask.end,
1691
2181
  expiresAt,
1692
2182
  ...(ask.consumeHold ? { consumeHold: ask.consumeHold } : {}),
2183
+ // An agent's approvals hold time against its own caps (RFC 0004 A5); the tenant's own
2184
+ // systems have none.
2185
+ ...(forTenant(context) ? {} : { holder: approvalHolder(context) }),
1693
2186
  }), WRITE);
1694
2187
  if (!result.ok) {
2188
+ if (result.code === 'hold_limit_reached' || result.code === 'held_time_limit_reached') {
2189
+ throw new SlotlockAgentOperationError(result.code, 409, { limit: String(result.limit) });
2190
+ }
1695
2191
  if (result.code === 'approval_limit_reached') {
1696
2192
  throw new SlotlockAgentOperationError('approval_limit_reached', 429, { limit: String(result.limit ?? '') });
1697
2193
  }
@@ -1751,6 +2247,7 @@ export async function runSlotlockApprovalGrant(agentBackend, approval, grant) {
1751
2247
  decidedBy: grant.decidedBy,
1752
2248
  ...(typeof requester.pool === 'string' ? { pool: requester.pool } : {}),
1753
2249
  },
2250
+ ...(requester.for === 'tenant' ? { [TENANT_ACTOR]: true } : {}),
1754
2251
  };
1755
2252
  const method = approval.operation === 'slotlock_book_together'
1756
2253
  ? agentBackend.bookTogether
@@ -1838,8 +2335,34 @@ function bookingRulesOutput(rules) {
1838
2335
  ...(rules.enforceHours !== undefined ? { enforce_hours: rules.enforceHours } : {}),
1839
2336
  ...(rules.checkInMinutes !== undefined ? { check_in_minutes: rules.checkInMinutes } : {}),
1840
2337
  ...(rules.requireCertain !== undefined ? { require_certain: rules.requireCertain } : {}),
2338
+ ...(rules.minDurationMinutes !== undefined ? { min_duration_minutes: rules.minDurationMinutes } : {}),
2339
+ ...(rules.maxDurationMinutes !== undefined ? { max_duration_minutes: rules.maxDurationMinutes } : {}),
2340
+ ...(rules.allowedDurations !== undefined ? { allowed_durations: [...rules.allowedDurations] } : {}),
2341
+ ...(rules.defaultDurationMinutes !== undefined ? { default_duration_minutes: rules.defaultDurationMinutes } : {}),
2342
+ ...(rules.pickupTimes !== undefined ? { pickup_times: dayWindowsOutput(rules.pickupTimes) } : {}),
2343
+ ...(rules.returnTimes !== undefined ? { return_times: dayWindowsOutput(rules.returnTimes) } : {}),
2344
+ ...(rules.hoursApply !== undefined ? { hours_apply: rules.hoursApply } : {}),
2345
+ ...(rules.cancelCutoffMinutes !== undefined ? { cancel_cutoff_minutes: rules.cancelCutoffMinutes } : {}),
2346
+ ...(rules.rescheduleCutoffMinutes !== undefined ? { reschedule_cutoff_minutes: rules.rescheduleCutoffMinutes } : {}),
2347
+ ...(rules.cancellationFee !== undefined
2348
+ ? {
2349
+ cancellation_fee: {
2350
+ amount_minor: rules.cancellationFee.amountMinor,
2351
+ currency: rules.cancellationFee.currency,
2352
+ ...(rules.cancellationFee.withinMinutes !== undefined ? { within_minutes: rules.cancellationFee.withinMinutes } : {}),
2353
+ },
2354
+ }
2355
+ : {}),
2356
+ ...(rules.holdPrice !== undefined
2357
+ ? { hold_price: { amount_minor: rules.holdPrice.amountMinor, currency: rules.holdPrice.currency } }
2358
+ : {}),
1841
2359
  };
1842
2360
  }
2361
+ const dayWindowsOutput = (windows) => windows.map((window) => ({ start_minutes: window.startMinutes, duration_minutes: window.durationMinutes }));
2362
+ const dayWindowsInput = (value) => value.map((window) => ({
2363
+ startMinutes: window.start_minutes,
2364
+ durationMinutes: window.duration_minutes,
2365
+ }));
1843
2366
  function bookingRulesInput(value) {
1844
2367
  if (value === null || value === undefined)
1845
2368
  return null;
@@ -1864,6 +2387,38 @@ function bookingRulesInput(value) {
1864
2387
  rules.checkInMinutes = input.check_in_minutes;
1865
2388
  if (input.require_certain !== undefined)
1866
2389
  rules.requireCertain = input.require_certain;
2390
+ if (input.min_duration_minutes !== undefined)
2391
+ rules.minDurationMinutes = input.min_duration_minutes;
2392
+ if (input.max_duration_minutes !== undefined)
2393
+ rules.maxDurationMinutes = input.max_duration_minutes;
2394
+ if (input.allowed_durations !== undefined)
2395
+ rules.allowedDurations = [...input.allowed_durations];
2396
+ if (input.default_duration_minutes !== undefined) {
2397
+ rules.defaultDurationMinutes = input.default_duration_minutes;
2398
+ }
2399
+ if (input.pickup_times !== undefined)
2400
+ rules.pickupTimes = dayWindowsInput(input.pickup_times);
2401
+ if (input.return_times !== undefined)
2402
+ rules.returnTimes = dayWindowsInput(input.return_times);
2403
+ if (input.hours_apply === 'whole' || input.hours_apply === 'ends')
2404
+ rules.hoursApply = input.hours_apply;
2405
+ if (input.cancel_cutoff_minutes !== undefined)
2406
+ rules.cancelCutoffMinutes = input.cancel_cutoff_minutes;
2407
+ if (input.reschedule_cutoff_minutes !== undefined) {
2408
+ rules.rescheduleCutoffMinutes = input.reschedule_cutoff_minutes;
2409
+ }
2410
+ if (input.cancellation_fee !== undefined) {
2411
+ const fee = input.cancellation_fee;
2412
+ rules.cancellationFee = {
2413
+ amountMinor: fee.amount_minor,
2414
+ currency: fee.currency,
2415
+ ...(fee.within_minutes !== undefined ? { withinMinutes: fee.within_minutes } : {}),
2416
+ };
2417
+ }
2418
+ if (input.hold_price !== undefined) {
2419
+ const price = input.hold_price;
2420
+ rules.holdPrice = { amountMinor: price.amount_minor, currency: price.currency };
2421
+ }
1867
2422
  return rules;
1868
2423
  }
1869
2424
  function resourceOutput(resource) {
@@ -1886,6 +2441,13 @@ function resourceOutput(resource) {
1886
2441
  location: resource.location ?? null,
1887
2442
  groups: resource.groups ?? [],
1888
2443
  parent_id: resource.parentId ?? null,
2444
+ inherited_availability: resource.inheritedAvailability
2445
+ ? {
2446
+ source: resource.inheritedAvailability.source,
2447
+ location: resource.inheritedAvailability.location ?? null,
2448
+ rules: rulesOutput(resource.inheritedAvailability.rules) ?? [],
2449
+ }
2450
+ : null,
1889
2451
  part_index: resource.partIndex ?? null,
1890
2452
  };
1891
2453
  }
@@ -1941,6 +2503,36 @@ function holdOutput(hold) {
1941
2503
  end: hold.end.toISOString(),
1942
2504
  expires_at: hold.expiresAt.toISOString(),
1943
2505
  source: hold.source,
2506
+ ...(hold.paymentRequired
2507
+ ? {
2508
+ payment_required: true,
2509
+ payment_ref: hold.paymentRef ?? null,
2510
+ paid_at: hold.paidAt?.toISOString() ?? null,
2511
+ payment_amount: amountOutput(hold.paymentAmount),
2512
+ paid_amount: amountOutput(hold.paidAmount),
2513
+ }
2514
+ : {}),
2515
+ };
2516
+ }
2517
+ function amountOutput(amount) {
2518
+ return amount ? { amount_minor: amount.amountMinor, currency: amount.currency } : null;
2519
+ }
2520
+ function amountInput(value) {
2521
+ const amount = value;
2522
+ return amount ? { amountMinor: amount.amount_minor, currency: amount.currency } : undefined;
2523
+ }
2524
+ function contactOutput(contact) {
2525
+ return {
2526
+ contact_ref: contact.ref,
2527
+ name: contact.name,
2528
+ email: contact.email,
2529
+ phone: contact.phone,
2530
+ whatsapp: contact.whatsapp,
2531
+ consent: contact.consent,
2532
+ consent_at: Object.fromEntries(Object.entries(contact.consentAt).map(([channel, at]) => [channel, at.toISOString()])),
2533
+ locale: contact.locale,
2534
+ created_at: contact.createdAt.toISOString(),
2535
+ updated_at: contact.updatedAt.toISOString(),
1944
2536
  };
1945
2537
  }
1946
2538
  function reservationOutput(reservation) {
@@ -1954,6 +2546,9 @@ function reservationOutput(reservation) {
1954
2546
  buffer_after_minutes: Math.ceil(reservation.bufferAfterMs / 60_000),
1955
2547
  revision: reservation.revision,
1956
2548
  source: reservation.source,
2549
+ ...(reservation.contactRef ? { contact_ref: reservation.contactRef } : {}),
2550
+ ...(reservation.notify === false ? { notify: false } : {}),
2551
+ ...(reservation.reminderMinutes ? { reminder_minutes: reservation.reminderMinutes } : {}),
1957
2552
  };
1958
2553
  }
1959
2554
  /**
@@ -2288,15 +2883,29 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
2288
2883
  async exportResourceAvailability(context, input) {
2289
2884
  return withTenant(store, context, async (tenant) => {
2290
2885
  const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
2291
- if (resource.availabilityRules === undefined)
2886
+ const rules = resource.availabilityRules ?? resource.inheritedAvailability?.rules;
2887
+ if (rules === undefined)
2292
2888
  return { icalendar: null };
2293
- return {
2294
- icalendar: emitVAvailability({
2295
- rules: resource.availabilityRules,
2296
- timezone: resource.timezone,
2297
- uid: `${resource.id}-hours`,
2298
- }),
2889
+ // The year ahead's closures and special hours, as EXDATE and RDATE (RFC 0004 B1).
2890
+ const span = exportDays();
2891
+ const window = {
2892
+ start: new Date(epochDay(span.from) * 86_400_000),
2893
+ end: new Date((epochDay(span.to) + 1) * 86_400_000),
2299
2894
  };
2895
+ const exceptions = await tenant.availabilityExceptionsFor({
2896
+ tenantRef: tenantOf(context),
2897
+ resourceIds: [resource.id],
2898
+ window,
2899
+ });
2900
+ const icalendar = emitVAvailability({
2901
+ rules,
2902
+ timezone: resource.timezone,
2903
+ uid: `${resource.id}-hours`,
2904
+ days: exceptionDays(exceptions.get(resource.id) ?? [], span.from, span.to),
2905
+ });
2906
+ if (icalendar.length > 200_000)
2907
+ throw new SlotlockAgentOperationError('calendar_work_limit', 400);
2908
+ return { icalendar };
2300
2909
  });
2301
2910
  },
2302
2911
  async importResourceAvailability(context, input) {
@@ -2326,6 +2935,14 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
2326
2935
  id,
2327
2936
  rules: parsed.rules,
2328
2937
  });
2938
+ if (resource) {
2939
+ // The days it names replace those an earlier import of its hours made.
2940
+ await tenant.replaceImportedExceptions({
2941
+ tenantRef: tenantOf(context),
2942
+ resourceId: id,
2943
+ ranges: exceptionRanges(parsed.days),
2944
+ });
2945
+ }
2329
2946
  }
2330
2947
  catch (error) {
2331
2948
  throw storeRefusal(error);
@@ -2460,11 +3077,142 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
2460
3077
  const rules = rulesInput(input.rules);
2461
3078
  return withTenant(store, context, async (tenant) => {
2462
3079
  try {
3080
+ const result = await tenant.storeTenantAvailability({
3081
+ tenantRef: tenantOf(context),
3082
+ rules,
3083
+ overrideResources: SLOTLOCK_OVERRIDE_RESOURCES.includes(input.override_resources)
3084
+ ? input.override_resources
3085
+ : 'none',
3086
+ });
3087
+ return { resources_updated: result.inheriting, resources_with_own_hours: result.ownHours };
3088
+ }
3089
+ catch (error) {
3090
+ throw storeRefusal(error);
3091
+ }
3092
+ });
3093
+ },
3094
+ async getAvailabilityDefaults(context) {
3095
+ return withTenant(store, context, async (tenant) => {
3096
+ const defaults = await tenant.getAvailabilityDefaults({ tenantRef: tenantOf(context) });
3097
+ return {
3098
+ tenant: rulesOutput(defaults.tenant),
3099
+ locations: defaults.locations.map((entry) => ({
3100
+ location: entry.location,
3101
+ rules: rulesOutput(entry.rules) ?? [],
3102
+ })),
3103
+ };
3104
+ });
3105
+ },
3106
+ async setLocationAvailability(context, input) {
3107
+ const rules = rulesInput(input.rules);
3108
+ const location = input.location;
3109
+ return withTenant(store, context, async (tenant) => {
3110
+ let result;
3111
+ try {
3112
+ result = await tenant.setLocationAvailability({ tenantRef: tenantOf(context), location, rules });
3113
+ }
3114
+ catch (error) {
3115
+ throw storeRefusal(error);
3116
+ }
3117
+ if (!result)
3118
+ throw new SlotlockAgentOperationError('location_not_found', 404);
3119
+ return { location, resources_updated: result.inheriting, resources_with_own_hours: result.ownHours };
3120
+ });
3121
+ },
3122
+ async listAvailabilityExceptions(context, input) {
3123
+ const limit = input.limit ?? 100;
3124
+ return withTenant(store, context, async (tenant) => {
3125
+ let exceptions;
3126
+ try {
3127
+ exceptions = await tenant.listAvailabilityExceptions({
3128
+ tenantRef: tenantOf(context),
3129
+ ...(input.resource_id !== undefined ? { resourceId: input.resource_id } : {}),
3130
+ ...(input.location !== undefined ? { location: input.location } : {}),
3131
+ ...(input.from !== undefined ? { from: input.from } : {}),
3132
+ ...(input.to !== undefined ? { to: input.to } : {}),
3133
+ ...(input.after !== undefined ? { after: input.after } : {}),
3134
+ limit: limit + 1,
3135
+ });
3136
+ }
3137
+ catch (error) {
3138
+ throw storeRefusal(error);
3139
+ }
3140
+ const page = exceptions.slice(0, limit);
3141
+ return {
3142
+ exceptions: page.map(exceptionOutput),
3143
+ next: exceptions.length > limit ? (page.at(-1)?.id ?? null) : null,
3144
+ };
3145
+ });
3146
+ },
3147
+ async createAvailabilityException(context, input) {
3148
+ const hours = input.hours;
3149
+ return withTenant(store, context, async (tenant) => {
3150
+ try {
3151
+ const exception = await tenant.createAvailabilityException({
3152
+ tenantRef: tenantOf(context),
3153
+ ...(input.location !== undefined ? { location: input.location } : {}),
3154
+ ...(input.resource_id !== undefined ? { resourceId: input.resource_id } : {}),
3155
+ startsOn: input.starts_on,
3156
+ ...(input.ends_on !== undefined ? { endsOn: input.ends_on } : {}),
3157
+ outcome: input.outcome,
3158
+ ...(hours
3159
+ ? { hours: hours.map((window) => ({ startMinutes: window.start_minutes, durationMinutes: window.duration_minutes })) }
3160
+ : {}),
3161
+ label: input.label,
3162
+ });
3163
+ return { exception: exceptionOutput(exception) };
3164
+ }
3165
+ catch (error) {
3166
+ throw storeRefusal(error);
3167
+ }
3168
+ });
3169
+ },
3170
+ async deleteAvailabilityException(context, input) {
3171
+ return withTenant(store, context, async (tenant) => {
3172
+ const deleted = await tenant.deleteAvailabilityException({
3173
+ tenantRef: tenantOf(context),
3174
+ id: input.exception_id,
3175
+ });
3176
+ if (!deleted)
3177
+ throw new SlotlockAgentOperationError('availability_exception_not_found', 404);
3178
+ return { deleted: true };
3179
+ });
3180
+ },
3181
+ async importHolidays(context, input) {
3182
+ if (input.location !== undefined && input.resource_id !== undefined) {
3183
+ throw new SlotlockAgentOperationError('invalid_holiday_calendar', 400, { reason: 'a location or a resource, not both' });
3184
+ }
3185
+ // Read (a URL fetched) before the transaction opens.
3186
+ let read;
3187
+ try {
3188
+ read = await readSlotlockHolidayCalendar({
3189
+ ...(input.icalendar !== undefined ? { icalendar: input.icalendar } : {}),
3190
+ ...(input.url !== undefined ? { url: input.url } : {}),
3191
+ ...(input.name !== undefined ? { name: input.name } : {}),
3192
+ ...(input.subscribe !== undefined ? { subscribe: input.subscribe } : {}),
3193
+ ...(input.from !== undefined ? { from: input.from } : {}),
3194
+ ...(input.to !== undefined ? { to: input.to } : {}),
3195
+ }, options.holidayFetch);
3196
+ }
3197
+ catch (error) {
3198
+ throw storeRefusal(error);
3199
+ }
3200
+ return withTenant(store, context, async (tenant) => {
3201
+ try {
3202
+ const result = await tenant.importHolidayCalendar({
3203
+ tenantRef: tenantOf(context),
3204
+ ...(input.location !== undefined ? { location: input.location } : {}),
3205
+ ...(input.resource_id !== undefined ? { resourceId: input.resource_id } : {}),
3206
+ name: read.name,
3207
+ ...(read.url !== undefined ? { url: read.url } : {}),
3208
+ holidays: read.holidays,
3209
+ span: read.span,
3210
+ });
2463
3211
  return {
2464
- resources_updated: await tenant.setTenantAvailability({
2465
- tenantRef: tenantOf(context),
2466
- rules,
2467
- }),
3212
+ imported: result.imported,
3213
+ skipped: read.skipped,
3214
+ removed: result.removed,
3215
+ calendar: holidayCalendarOutput(result.calendar),
2468
3216
  };
2469
3217
  }
2470
3218
  catch (error) {
@@ -2472,6 +3220,17 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
2472
3220
  }
2473
3221
  });
2474
3222
  },
3223
+ async listHolidayCalendars(context) {
3224
+ return withTenant(store, context, async (tenant) => ({
3225
+ calendars: (await tenant.listHolidayCalendars({ tenantRef: tenantOf(context) })).map(holidayCalendarOutput),
3226
+ }));
3227
+ },
3228
+ async deleteHolidayCalendar(context, input) {
3229
+ return withTenant(store, context, async (tenant) => {
3230
+ const deleted = await tenant.deleteHolidayCalendar({ tenantRef: tenantOf(context), id: input.calendar_id });
3231
+ return { deleted: deleted !== null, closures: deleted?.closures ?? 0 };
3232
+ });
3233
+ },
2475
3234
  async acquireHold(context, input) {
2476
3235
  return withTenant(store, context, async (tenant) => {
2477
3236
  const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
@@ -2485,6 +3244,8 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
2485
3244
  ...(input.buffer_after_minutes !== undefined
2486
3245
  ? { bufferAfterMs: input.buffer_after_minutes * 60_000 }
2487
3246
  : {}),
3247
+ ...(input.payment_required === true ? { paymentRequired: true } : {}),
3248
+ ...(input.payment !== undefined ? { payment: amountInput(input.payment) } : {}),
2488
3249
  // A retried request with the caller's key answers the hold the first one made.
2489
3250
  ...(input.idempotency_key !== undefined
2490
3251
  ? {
@@ -2534,6 +3295,69 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
2534
3295
  return { reservation: reservationOutput(result.reservation) };
2535
3296
  }, WRITE);
2536
3297
  },
3298
+ async markHoldPaid(context, input) {
3299
+ const id = input.hold_id;
3300
+ if (!STORE_ID_PATTERN.test(id))
3301
+ throw new SlotlockAgentOperationError('hold_not_found', 404);
3302
+ return withTenant(store, context, async (tenant) => {
3303
+ const result = await tenant.markHoldPaid({
3304
+ tenantRef: tenantOf(context),
3305
+ holdId: id,
3306
+ paymentRef: input.payment_ref,
3307
+ ...(input.received !== undefined ? { received: amountInput(input.received) } : {}),
3308
+ });
3309
+ if (!result.ok) {
3310
+ if (result.code === 'payment_conflict') {
3311
+ throw new SlotlockAgentOperationError('payment_conflict', 409, { payment_ref: result.paymentRef });
3312
+ }
3313
+ throw applicationRefusal(result.code);
3314
+ }
3315
+ return { hold: holdOutput(result.hold), replayed: result.idempotent };
3316
+ }, WRITE);
3317
+ },
3318
+ async putContact(context, input) {
3319
+ return withTenant(store, context, async (tenant) => {
3320
+ const result = await tenant.putContact({
3321
+ tenantRef: tenantOf(context),
3322
+ ref: input.contact_ref,
3323
+ name: input.name ?? null,
3324
+ email: input.email ?? null,
3325
+ phone: input.phone ?? null,
3326
+ whatsapp: input.whatsapp ?? null,
3327
+ consent: input.consent ?? {},
3328
+ locale: input.locale ?? null,
3329
+ });
3330
+ if (!result.ok)
3331
+ throw applicationRefusal(result.code);
3332
+ return { contact: contactOutput(result.contact), created: result.created };
3333
+ }, WRITE);
3334
+ },
3335
+ async getContact(context, input) {
3336
+ return withTenant(store, context, async (tenant) => {
3337
+ const contact = await tenant.getContact({ tenantRef: tenantOf(context), ref: input.contact_ref });
3338
+ if (!contact)
3339
+ throw new SlotlockAgentOperationError('contact_not_found', 404);
3340
+ return { contact: contactOutput(contact) };
3341
+ });
3342
+ },
3343
+ async listContacts(context, input) {
3344
+ const limit = input.limit;
3345
+ return withTenant(store, context, async (tenant) => {
3346
+ const contacts = await tenant.listContacts({
3347
+ tenantRef: tenantOf(context),
3348
+ limit: limit + 1,
3349
+ ...(input.cursor !== undefined ? { after: input.cursor } : {}),
3350
+ });
3351
+ const shown = contacts.slice(0, limit);
3352
+ return {
3353
+ contacts: shown.map(contactOutput),
3354
+ next_cursor: contacts.length > limit ? (shown.at(-1)?.ref ?? null) : null,
3355
+ };
3356
+ });
3357
+ },
3358
+ async deleteContact(context, input) {
3359
+ return withTenant(store, context, async (tenant) => tenant.deleteContact({ tenantRef: tenantOf(context), ref: input.contact_ref }), WRITE);
3360
+ },
2537
3361
  async releaseHold(context, input) {
2538
3362
  const id = input.hold_id;
2539
3363
  if (!STORE_ID_PATTERN.test(id))
@@ -2583,12 +3407,17 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
2583
3407
  bufferAfterMs: input.buffer_after_minutes * 60_000,
2584
3408
  revision: input.revision,
2585
3409
  ...(input.source !== undefined ? { source: input.source } : {}),
3410
+ ...(input.override_reason !== undefined ? { overrideReason: input.override_reason } : {}),
3411
+ ...(input.local_start !== undefined ? { localStart: input.local_start } : {}),
3412
+ ...(input.contact_ref !== undefined ? { contactRef: input.contact_ref } : {}),
3413
+ ...(input.notify !== undefined ? { notify: input.notify } : {}),
3414
+ ...(input.reminder_minutes !== undefined ? { reminderMinutes: input.reminder_minutes } : {}),
2586
3415
  };
2587
3416
  const result = command.revision === 1
2588
3417
  ? await tenant.createExternalReservation(command)
2589
3418
  : await tenant.rescheduleExternalReservation(command);
2590
3419
  if (!result.ok)
2591
- throw applicationRefusal(result.code);
3420
+ throw applicationRuleRefusal(result);
2592
3421
  return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
2593
3422
  }, WRITE);
2594
3423
  if (input.revision > 1 && !put.replayed) {
@@ -2602,10 +3431,11 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
2602
3431
  tenantRef: tenantOf(context),
2603
3432
  externalRef: input.reservation_ref,
2604
3433
  revision: input.revision,
3434
+ ...(input.override_reason !== undefined ? { overrideReason: input.override_reason } : {}),
2605
3435
  });
2606
3436
  if (!result.ok)
2607
- throw applicationRefusal(result.code);
2608
- return { cancelled: result.cancelled, revision: result.revision };
3437
+ throw applicationRuleRefusal(result);
3438
+ return { cancelled: result.cancelled, revision: result.revision, cancellation_fee: feeOutput(result.cancellationFee) };
2609
3439
  }, WRITE);
2610
3440
  if (cancelled.cancelled)
2611
3441
  await offerFreedTime(store, context);
@@ -2633,6 +3463,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
2633
3463
  end: new Date(input.end),
2634
3464
  holderRef: input.holder_ref,
2635
3465
  ...(ttl !== undefined ? { offerTtlMs: ttl * 1000 } : {}),
3466
+ ...(input.duration_minutes !== undefined ? { durationMs: input.duration_minutes * 60_000 } : {}),
2636
3467
  ...(input.idempotency_key !== undefined ? { idempotencyKey: input.idempotency_key } : {}),
2637
3468
  });
2638
3469
  if (!result.ok)
@@ -2659,6 +3490,26 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
2659
3490
  await offerFreedTime(store, context);
2660
3491
  return left;
2661
3492
  },
3493
+ async tenantBookTogether(context, input) {
3494
+ if (!options.agentBackend?.bookTogether)
3495
+ throw new SlotlockAgentOperationError('bundles_unavailable', 409);
3496
+ // Through the agents' bundle (one transaction, the booking rules applied), as the tenant's:
3497
+ // owned by the tenant, not the key, and held only by policies that include the application.
3498
+ return options.agentBackend.bookTogether({ ...context, operation: 'slotlock_book_together', [TENANT_ACTOR]: true }, input);
3499
+ },
3500
+ async tenantBookPool(context, input) {
3501
+ if (!options.agentBackend?.bookPool)
3502
+ throw new SlotlockAgentOperationError('pools_unavailable', 409);
3503
+ return options.agentBackend.bookPool({ ...context, operation: 'slotlock_book_pool', [TENANT_ACTOR]: true }, input);
3504
+ },
3505
+ async declineWaitlistOffer(context, input) {
3506
+ const declined = await withTenant(store, context, (tenant) => tenant.declineWaitlistOffer({ tenantRef: tenantOf(context), id: input.entry_id }));
3507
+ if (!declined)
3508
+ throw new SlotlockAgentOperationError('waitlist_offer_not_found', 404);
3509
+ // The time it let go goes to the next who waits.
3510
+ await offerFreedTime(store, context);
3511
+ return { entry: waitlistOutput(declined) };
3512
+ },
2662
3513
  async getCoverage(context, input) {
2663
3514
  return withTenant(store, context, async (tenant) => {
2664
3515
  const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
@@ -2720,6 +3571,45 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
2720
3571
  return { revision: result.revision, replayed: result.idempotent };
2721
3572
  }, WRITE);
2722
3573
  },
3574
+ async putSourceBusy(context, input) {
3575
+ return withTenant(store, context, async (tenant) => {
3576
+ const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
3577
+ const result = await tenant.replaceSourceBusy({
3578
+ tenantRef: tenantOf(context),
3579
+ resourceId: resource.id,
3580
+ source: input.source,
3581
+ window: interval(input),
3582
+ revision: input.revision,
3583
+ busy: input.busy.map((busy) => ({
3584
+ start: new Date(busy.start),
3585
+ end: new Date(busy.end),
3586
+ })),
3587
+ });
3588
+ if (!result.ok)
3589
+ throw applicationRefusal(result.code);
3590
+ return { revision: result.revision, replayed: result.idempotent, busy: result.busy, conflicts: result.conflicts };
3591
+ }, WRITE);
3592
+ },
3593
+ async reportCoverageConflict(context, input) {
3594
+ return withTenant(store, context, async (tenant) => {
3595
+ const resource = await requireResource(tenant, tenantOf(context), input.resource_id);
3596
+ const result = await tenant.reportCoverageConflict({
3597
+ tenantRef: tenantOf(context),
3598
+ resourceId: resource.id,
3599
+ source: input.source,
3600
+ booking: input.event_id !== undefined
3601
+ ? { eventId: input.event_id }
3602
+ : { reservationRef: input.reservation_ref },
3603
+ reason: input.reason,
3604
+ ...(input.calendar_start !== undefined
3605
+ ? { calendar: interval({ start: input.calendar_start, end: input.calendar_end }) }
3606
+ : {}),
3607
+ });
3608
+ if (!result.ok)
3609
+ throw applicationRefusal(result.code);
3610
+ return { reported: true };
3611
+ }, WRITE);
3612
+ },
2723
3613
  async listTenantEvents(context, input) {
2724
3614
  const limit = input.limit;
2725
3615
  const after = parseEventCursor(input.cursor);
@@ -2772,14 +3662,16 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
2772
3662
  idempotencyKey: applicationCommandKey(context, input.idempotency_key),
2773
3663
  expectedRevision: input.expected_revision,
2774
3664
  ...(expectedResourceId !== undefined ? { expectedResourceId } : {}),
3665
+ ...(input.override_reason !== undefined ? { overrideReason: input.override_reason } : {}),
2775
3666
  });
2776
3667
  if (!result.ok)
2777
- throw applicationRefusal(result.code);
3668
+ throw applicationRuleRefusal(result);
2778
3669
  return {
2779
3670
  event_id: result.eventId,
2780
3671
  revision: result.revision,
2781
3672
  cancelled: true,
2782
3673
  replayed: result.idempotent,
3674
+ cancellation_fee: feeOutput(result.cancellationFee),
2783
3675
  };
2784
3676
  }, WRITE);
2785
3677
  if (!cancelled.replayed)
@@ -3056,6 +3948,110 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
3056
3948
  surfaces: summary.surfaces,
3057
3949
  };
3058
3950
  },
3951
+ async listAuditEvents(context, input) {
3952
+ const tenantRef = tenantOf(context);
3953
+ const limit = input.limit ?? 50;
3954
+ return withTenant(store, context, async (tenant) => {
3955
+ // As the dashboard's export: where hosted plans apply, the Team plan's.
3956
+ const plan = await tenant.tenantPlan({ tenantRef });
3957
+ if (plan && (plan.plan !== 'team' || plan.status === 'canceled')) {
3958
+ throw new SlotlockAgentOperationError('team_plan_required', 403);
3959
+ }
3960
+ const events = await tenant.listAuditEvents({
3961
+ tenantRef,
3962
+ limit,
3963
+ ...(typeof input.cursor === 'string' ? { beforeId: input.cursor } : {}),
3964
+ ...(typeof input.action === 'string' ? { action: input.action } : {}),
3965
+ ...(typeof input.actor_ref === 'string' ? { actorRef: input.actor_ref } : {}),
3966
+ });
3967
+ return {
3968
+ events: events.map(slotlockAuditEventRecord),
3969
+ // A full page may have more after it; the next then answers none.
3970
+ next_cursor: events.length === limit ? (events.at(-1)?.id ?? null) : null,
3971
+ };
3972
+ });
3973
+ },
3974
+ async verifyAuditLog(context) {
3975
+ const verified = await withTenant(store, context, (tenant) => tenant.verifyAuditChain({ tenantRef: tenantOf(context) }));
3976
+ return { checked: verified.checked, broken_at: verified.brokenAt };
3977
+ },
3978
+ async createTenantExport(context) {
3979
+ const tenantRef = tenantOf(context);
3980
+ const keyId = context.principal.keyId;
3981
+ const actor = typeof keyId === 'string' && /^[0-9a-f-]{36}$/i.test(keyId)
3982
+ ? { actorType: 'api_key', actorRef: `api_key:${keyId}` }
3983
+ : { actorType: 'system', actorRef: 'application' };
3984
+ const created = await withTenant(store, context, async (tenant) => {
3985
+ const made = await tenant.createTenantExport({ tenantRef, requestedBy: actor.actorRef }).catch((error) => {
3986
+ const code = error?.code;
3987
+ if (code === 'export_in_progress' || code === 'export_limit_reached') {
3988
+ throw new SlotlockAgentOperationError(code, 409);
3989
+ }
3990
+ throw error;
3991
+ });
3992
+ await tenant.recordAuditEvent({
3993
+ tenantRef,
3994
+ event: { action: 'export.created', ...actor, targetType: 'export', targetRef: made.id },
3995
+ });
3996
+ return made;
3997
+ }, WRITE);
3998
+ // Built after the answer; maintenance builds one a stopped server left pending.
3999
+ void buildSlotlockTenantExport(store, tenantRef, created.id);
4000
+ return { export: tenantExportOutput(created) };
4001
+ },
4002
+ async listTenantExports(context) {
4003
+ const exports = await withTenant(store, context, (tenant) => tenant.listTenantExports({ tenantRef: tenantOf(context) }));
4004
+ return { exports: exports.slice(0, 100).map(tenantExportOutput) };
4005
+ },
4006
+ async getTenantExport(context, input) {
4007
+ const found = await withTenant(store, context, (tenant) => tenant.getTenantExport({ tenantRef: tenantOf(context), exportId: input.export_id }));
4008
+ if (!found)
4009
+ throw new SlotlockAgentOperationError('export_not_found', 404);
4010
+ return { export: tenantExportOutput(found) };
4011
+ },
4012
+ async getTenantExportPart(context, input) {
4013
+ const part = Number(input.part);
4014
+ const read = await withTenant(store, context, (tenant) => tenant.getTenantExportPart({ tenantRef: tenantOf(context), exportId: input.export_id, part }));
4015
+ if (!read.ok)
4016
+ throw new SlotlockAgentOperationError(read.code, read.code === 'export_not_ready' ? 409 : 404);
4017
+ return {
4018
+ export_id: input.export_id,
4019
+ part,
4020
+ table: read.table,
4021
+ rows: read.lines.map((line) => JSON.parse(line)),
4022
+ };
4023
+ },
4024
+ async deleteTenantExport(context, input) {
4025
+ const deleted = await withTenant(store, context, (tenant) => tenant.deleteTenantExport({ tenantRef: tenantOf(context), exportId: input.export_id }), WRITE);
4026
+ return { deleted };
4027
+ },
4028
+ async exportSubject(context, input) {
4029
+ const subject = subjectOf(input);
4030
+ const exported = await withTenant(store, context, (tenant) => tenant.exportSubject({ tenantRef: tenantOf(context), subject, limit: 1_000 })).catch(subjectRefusal);
4031
+ return { records: exported.records, truncated: exported.truncated };
4032
+ },
4033
+ async eraseSubject(context, input) {
4034
+ const subject = subjectOf(input);
4035
+ const tenantRef = tenantOf(context);
4036
+ const keyId = context.principal.keyId;
4037
+ return withTenant(store, context, async (tenant) => {
4038
+ const { erased } = await tenant.eraseSubject({ tenantRef, subject }).catch(subjectRefusal);
4039
+ // Who erased whom is the workspace's to know, but never the person: how they were named,
4040
+ // and what went.
4041
+ await tenant.recordAuditEvent({
4042
+ tenantRef,
4043
+ event: {
4044
+ action: 'subject.erased',
4045
+ ...(typeof keyId === 'string' && /^[0-9a-f-]{36}$/i.test(keyId)
4046
+ ? { actorType: 'api_key', actorRef: `api_key:${keyId}` }
4047
+ : { actorType: 'system', actorRef: 'application' }),
4048
+ targetType: 'subject',
4049
+ metadata: { by: Object.keys(subject)[0], ...Object.fromEntries(Object.entries(erased).filter(([, rows]) => rows > 0)) },
4050
+ },
4051
+ });
4052
+ return { erased };
4053
+ }, WRITE);
4054
+ },
3059
4055
  async enableWebhook(context, input) {
3060
4056
  return withTenant(store, context, async (tenant) => {
3061
4057
  const enabled = await tenant.enableWebhookEndpoint({
@@ -3087,6 +4083,7 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
3087
4083
  ...(input.conditions !== undefined ? { conditions: approvalConditions(input.conditions) } : {}),
3088
4084
  ...(input.approval_ttl_minutes !== undefined ? { approvalTtlMinutes: input.approval_ttl_minutes } : {}),
3089
4085
  ...(input.lead_minutes !== undefined ? { leadMinutes: input.lead_minutes } : {}),
4086
+ ...(input.include_application !== undefined ? { includeApplication: input.include_application } : {}),
3090
4087
  });
3091
4088
  }
3092
4089
  catch (error) {
@@ -3214,9 +4211,10 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
3214
4211
  async runMaintenance(context) {
3215
4212
  assertActive(context);
3216
4213
  try {
3217
- const { rollPending: _, zoneShifts, staleSources, noShows, waitlistOffers, approvalsExpired, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
4214
+ const { rollPending: _, holidayCalendarsImported: _holidays, zoneShifts, staleSources, noShows, waitlistOffers, approvalsExpired, bookingsCompleted, remindersDue, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
3218
4215
  batches,
3219
4216
  signal: context.signal,
4217
+ ...(options.holidayFetch ? { holidayFetch: options.holidayFetch } : {}),
3220
4218
  });
3221
4219
  return {
3222
4220
  ...result,
@@ -3225,6 +4223,8 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
3225
4223
  no_shows: noShows,
3226
4224
  waitlist_offers: waitlistOffers,
3227
4225
  approvals_expired: approvalsExpired,
4226
+ bookings_completed: bookingsCompleted,
4227
+ reminders_due: remindersDue,
3228
4228
  };
3229
4229
  }
3230
4230
  catch (error) {