slotlock 0.2.0 → 0.5.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 (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
@@ -1,6 +1,7 @@
1
1
  // The application API: what an application, rather than an agent, does over HTTP that the TypeScript
2
2
  // store does in process. It creates resources and sets their bookable hours, holds and reserves time,
3
- // records how much of an external calendar it has read, and runs maintenance. Any language can call it.
3
+ // records how much of an external calendar it has read, reaches any of the tenant's events whichever
4
+ // key wrote them, erases the tenant when allowed, and runs maintenance. Any language can call it.
4
5
  //
5
6
  // The agent server serves these operations as REST under /v1, beside the agent tools, when it is
6
7
  // given an `application` backend and `rest` is on, and describes them in the same OpenAPI document.
@@ -8,12 +9,16 @@
8
9
  //
9
10
  // This module holds only the contract (names, schemas, routes); it imports nothing from the agent
10
11
  // server at runtime. The store-backed implementation is createSlotlockStoreApplicationBackend.
12
+ import { SLOTLOCK_CHANGE_TYPES } from './event-feed.js';
11
13
  import { z } from 'zod';
14
+ import { SLOTLOCK_EVENT_OUTPUT } from './event-schema.js';
15
+ import { SLOTLOCK_CALENDAR_HORIZON_DAYS } from './rules.js';
12
16
  /** The scope an application operation needs, whichever operation it is. */
13
17
  export const SLOTLOCK_APPLICATION_SCOPE = 'admin';
14
18
  const DATE_TIME = z.string().datetime({ offset: true });
15
19
  const RESOURCE_ID = z.string().trim().min(1).max(200);
16
20
  const HOLD_ID = z.string().trim().min(1).max(200);
21
+ const EVENT_ID = z.string().trim().min(1).max(200);
17
22
  /** Matches the store's identities: not blank, at most 500 UTF-8 bytes, no control characters. */
18
23
  const IDENTITY = z
19
24
  .string()
@@ -38,6 +43,32 @@ const boundedSpan = (value) => {
38
43
  return span > 0 && span <= MAX_SPAN_MS;
39
44
  };
40
45
  const SPAN_MESSAGE = { message: 'end must be after start, by at most 3,660 days' };
46
+ /** The longest window one list or busy read covers: the store's calendar horizon. */
47
+ const MAX_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60 * 60 * 1_000;
48
+ const boundedWindow = (value) => {
49
+ const span = Date.parse(value.end) - Date.parse(value.start);
50
+ return span > 0 && span <= MAX_WINDOW_MS;
51
+ };
52
+ const WINDOW_MESSAGE = {
53
+ message: `end must be after start, by at most ${SLOTLOCK_CALENDAR_HORIZON_DAYS} days`,
54
+ };
55
+ const PAGE_LIMIT = z.number().int().min(1).max(100).default(50);
56
+ /** The `next_cursor` of the page before; opaque. */
57
+ const CURSOR = z.string().min(1).max(500);
58
+ const NEXT_CURSOR = z.string().max(500).nullable();
59
+ const VISIBILITY = z.enum(['shared', 'private']);
60
+ /**
61
+ * A retry identity, as the `Idempotency-Key` request header or this field (the same value, if both
62
+ * are sent): the same key with the same request answers what the first did, and with another
63
+ * request is 422 idempotency_conflict.
64
+ */
65
+ const IDEMPOTENCY_KEY = IDENTITY.describe('A retry identity, also accepted as the Idempotency-Key header: the same key with the same request answers what the first did; with another request, 422 idempotency_conflict.');
66
+ /** Minutes after the end kept busy as well, for turnaround: at most 30 days. */
67
+ const BUFFER_AFTER_MINUTES = z
68
+ .number()
69
+ .int()
70
+ .min(0)
71
+ .max(30 * 24 * 60);
41
72
  const AVAILABILITY_RULE = z
42
73
  .object({
43
74
  rrule: z.string().min(1).max(500),
@@ -46,6 +77,19 @@ const AVAILABILITY_RULE = z
46
77
  })
47
78
  .strict();
48
79
  const AVAILABILITY_RULES = z.array(AVAILABILITY_RULE).max(50);
80
+ /** What a booking on a resource must satisfy; each rule optional (`SlotlockBookingRules`). */
81
+ const BOOKING_RULES = z
82
+ .object({
83
+ min_notice_minutes: z.number().int().min(0).max(525_600).optional(),
84
+ max_advance_days: z.number().int().min(1).max(3_660).optional(),
85
+ slot_step_minutes: z.number().int().min(1).max(1_440).optional(),
86
+ turnaround_minutes: z.number().int().min(0).max(1_440).optional(),
87
+ enforce_hours: z.boolean().optional(),
88
+ check_in_minutes: z.number().int().min(1).max(1_440).optional(),
89
+ require_certain: z.boolean().optional(),
90
+ })
91
+ .strict();
92
+ const POOL = z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/);
49
93
  const RESOURCE = z
50
94
  .object({
51
95
  id: RESOURCE_ID,
@@ -53,6 +97,76 @@ const RESOURCE = z
53
97
  timezone: TIMEZONE,
54
98
  /** The resource's own weekly hours; `null` when it uses the default. */
55
99
  availability: AVAILABILITY_RULES.nullable(),
100
+ /** What a booking on it must satisfy; `null` when it has no rules. */
101
+ booking_rules: BOOKING_RULES.nullable(),
102
+ /** `private` resources are never shown to agents; fixed when the resource is created. */
103
+ visibility: VISIBILITY,
104
+ /** When it was archived; `null` while it is in use. */
105
+ archived_at: DATE_TIME.nullable(),
106
+ /** The pool it is a member of (a vehicle class, a room type); `null` when none. */
107
+ pool: POOL.nullable(),
108
+ })
109
+ .strict();
110
+ const HISTORY_ENTRY = z
111
+ .object({
112
+ id: z.string().max(40),
113
+ type: z.string().max(100),
114
+ time: DATE_TIME,
115
+ /** Who made it: an API key, an agent's subject; `null` when not recorded. */
116
+ actor: z.string().max(200).nullable(),
117
+ /** The record as the change left it, with `before` for a change of an existing one. */
118
+ data: z.record(z.string(), z.unknown()),
119
+ })
120
+ .strict();
121
+ const HISTORY_OUTPUT = z
122
+ .object({ history: z.array(HISTORY_ENTRY).max(100), next_before: z.string().max(40).nullable() })
123
+ .strict();
124
+ const HISTORY_PAGE = {
125
+ limit: z.number().int().min(1).max(100).default(50),
126
+ before: z.string().regex(/^msg_[0-9]{1,19}$/).optional(),
127
+ };
128
+ const CHANGE = z
129
+ .object({
130
+ specversion: z.literal('1.0'),
131
+ id: z.string().max(40),
132
+ source: z.string().max(300),
133
+ type: z.string().max(100),
134
+ subject: z.string().max(500),
135
+ time: DATE_TIME,
136
+ datacontenttype: z.literal('application/json'),
137
+ data: z.record(z.string(), z.unknown()),
138
+ })
139
+ .strict();
140
+ const WEBHOOK = z
141
+ .object({
142
+ id: z.string().uuid(),
143
+ url: z.string().max(2048),
144
+ event_types: z.array(z.enum(SLOTLOCK_CHANGE_TYPES)).nullable(),
145
+ created_at: DATE_TIME,
146
+ attempts: z.number().int().min(0),
147
+ failing_since: DATE_TIME.nullable(),
148
+ last_status: z.number().int().nullable(),
149
+ last_error: z.string().max(500).nullable(),
150
+ disabled_at: DATE_TIME.nullable(),
151
+ disabled_reason: z.enum(['gone', 'failing']).nullable(),
152
+ })
153
+ .strict();
154
+ const WAITLIST_ENTRY = z
155
+ .object({
156
+ id: z.string().uuid(),
157
+ /** The resource it waits on; `null` when it waits on a pool. */
158
+ resource_id: RESOURCE_ID.nullable(),
159
+ pool: POOL.nullable(),
160
+ start: DATE_TIME,
161
+ end: DATE_TIME,
162
+ holder_ref: z.string().min(1).max(500),
163
+ offer_ttl_seconds: z.number().int().min(60).max(86_400),
164
+ /** waiting; offered (hold_id holds the window on offered_resource_id); expired (it started first). */
165
+ status: z.enum(['waiting', 'offered', 'expired']),
166
+ hold_id: z.string().uuid().nullable(),
167
+ offered_resource_id: RESOURCE_ID.nullable(),
168
+ offered_at: DATE_TIME.nullable(),
169
+ created_at: DATE_TIME,
56
170
  })
57
171
  .strict();
58
172
  const HOLD = z
@@ -77,8 +191,35 @@ const RESERVATION = z
77
191
  source: z.string().max(200).nullable(),
78
192
  })
79
193
  .strict();
194
+ /**
195
+ * Any of the tenant's events, whichever key wrote it: `id` is the store's id for it, which the
196
+ * tenant-event routes take, `event_ref` the id its owner reads it by, and `owner_ref` who that is
197
+ * (`internal` for the tenant's own code, `agent:<digest>` for an API key).
198
+ */
199
+ /** A JSCalendar Event (RFC 8984 §5.1); interop.ts's JSCalendarEvent names every property. */
200
+ const JSCALENDAR_EVENT = z
201
+ .object({
202
+ '@type': z.literal('Event'),
203
+ uid: z.string().max(200),
204
+ updated: DATE_TIME,
205
+ sequence: z.number().int().min(0),
206
+ title: z.string(),
207
+ start: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/),
208
+ timeZone: TIMEZONE,
209
+ duration: z.string().regex(/^PT/),
210
+ status: z.enum(['confirmed', 'tentative']),
211
+ freeBusyStatus: z.enum(['free', 'busy']),
212
+ })
213
+ .passthrough();
214
+ const TENANT_EVENT = SLOTLOCK_EVENT_OUTPUT.extend({
215
+ event_ref: z.string().max(500),
216
+ owner_ref: z.string().max(500),
217
+ }).strict();
218
+ const BUSY_INTERVAL = z.object({ start: DATE_TIME, end: DATE_TIME }).strict();
80
219
  const EXAMPLE_RESOURCE_ID = '0f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b';
81
220
  const EXAMPLE_HOLD_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
221
+ const EXAMPLE_EVENT_ID = '3d6f0a2b-8c4e-4f1a-9b7d-5e2c8a1f4b6d';
222
+ const EXAMPLE_WINDOW = { start: '2027-03-01T00:00:00Z', end: '2027-04-01T00:00:00Z' };
82
223
  const EXAMPLE_RULES = [
83
224
  { rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', start_minutes: 540, duration_minutes: 480 },
84
225
  ];
@@ -86,10 +227,16 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
86
227
  {
87
228
  name: 'slotlock_create_resource',
88
229
  title: 'Create or update a resource',
89
- description: 'Create the resource with this external reference, or update its time zone if the tenant already has it. Repeating the call changes nothing more.',
230
+ description: 'Create the resource with this external reference, or update its time zone if the tenant already has it. Repeating the call changes nothing more. visibility is shared (the default: agents list and book it) or private (agents never see it), fixed at creation: naming another for an existing resource answers 409 resource_visibility_conflict.',
90
231
  method: 'POST',
91
232
  path: '/v1/resources',
92
- input: z.object({ external_ref: IDENTITY, timezone: TIMEZONE.default('UTC') }).strict(),
233
+ input: z
234
+ .object({
235
+ external_ref: IDENTITY,
236
+ timezone: TIMEZONE.default('UTC'),
237
+ visibility: VISIBILITY.optional(),
238
+ })
239
+ .strict(),
93
240
  output: z.object({ resource: RESOURCE }).strict(),
94
241
  risk: { readOnly: false, destructive: false, idempotent: true },
95
242
  invoke: 'createResource',
@@ -98,7 +245,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
98
245
  {
99
246
  name: 'slotlock_get_resource',
100
247
  title: 'Get a resource',
101
- description: 'One resource of the tenant, with its own weekly bookable hours.',
248
+ description: 'One resource of the tenant, archived or not, with its own weekly bookable hours and its visibility.',
102
249
  method: 'GET',
103
250
  path: '/v1/resources/{resource_id}',
104
251
  input: z.object({ resource_id: RESOURCE_ID }).strict(),
@@ -107,6 +254,57 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
107
254
  invoke: 'getResource',
108
255
  example: { resource_id: EXAMPLE_RESOURCE_ID },
109
256
  },
257
+ {
258
+ name: 'slotlock_archive_resource',
259
+ title: 'Archive a resource',
260
+ description: 'Take a resource out of use: no list shows it and it takes no new booking, hold or event (409 resource_archived), while what is on it stays and can still be cancelled or released. Archiving it again keeps the first time.',
261
+ method: 'POST',
262
+ path: '/v1/resources/{resource_id}/archive',
263
+ input: z.object({ resource_id: RESOURCE_ID }).strict(),
264
+ output: z.object({ resource: RESOURCE }).strict(),
265
+ risk: { readOnly: false, destructive: false, idempotent: true },
266
+ invoke: 'archiveResource',
267
+ example: { resource_id: EXAMPLE_RESOURCE_ID },
268
+ },
269
+ {
270
+ name: 'slotlock_restore_resource',
271
+ title: 'Restore a resource',
272
+ description: 'Put an archived resource back in use. Restoring one in use changes nothing.',
273
+ method: 'POST',
274
+ path: '/v1/resources/{resource_id}/restore',
275
+ input: z.object({ resource_id: RESOURCE_ID }).strict(),
276
+ output: z.object({ resource: RESOURCE }).strict(),
277
+ risk: { readOnly: false, destructive: false, idempotent: true },
278
+ invoke: 'restoreResource',
279
+ example: { resource_id: EXAMPLE_RESOURCE_ID },
280
+ },
281
+ {
282
+ name: 'slotlock_delete_resource',
283
+ title: 'Delete a resource',
284
+ description: "Delete a resource with its history (past bookings, coverage, cancelled events), freeing its place under the tenant's resource limit. Refused with 409 resource_in_use while a booking on it has not ended, a hold on it is live, or a calendar event is on it: cancel or move those first. deleted is false when the tenant has no such resource, deleted already, say.",
285
+ method: 'DELETE',
286
+ path: '/v1/resources/{resource_id}',
287
+ input: z.object({ resource_id: RESOURCE_ID }).strict(),
288
+ output: z.object({ deleted: z.boolean() }).strict(),
289
+ risk: { readOnly: false, destructive: true, idempotent: true },
290
+ invoke: 'deleteResource',
291
+ example: { resource_id: EXAMPLE_RESOURCE_ID },
292
+ },
293
+ {
294
+ name: 'slotlock_list_busy',
295
+ title: "List a resource's busy time",
296
+ description: `The time a resource of the tenant, private ones included, is occupied in a window of at most ${SLOTLOCK_CALENDAR_HORIZON_DAYS} days, by start: bookings with their turnaround, live holds, and calendar events. Adjacent intervals are not merged. More than 10,000 answers 400 calendar_work_limit: narrow the window. Free/busy with coverage, for the resources agents see, is slotlock_get_free_busy.`,
297
+ method: 'GET',
298
+ path: '/v1/resources/{resource_id}/busy',
299
+ input: z
300
+ .object({ resource_id: RESOURCE_ID, start: DATE_TIME, end: DATE_TIME })
301
+ .strict()
302
+ .refine(boundedWindow, WINDOW_MESSAGE),
303
+ output: z.object({ busy: z.array(BUSY_INTERVAL).max(10_000) }).strict(),
304
+ risk: { readOnly: true, destructive: false, idempotent: true },
305
+ invoke: 'listBusy',
306
+ example: { resource_id: EXAMPLE_RESOURCE_ID, ...EXAMPLE_WINDOW },
307
+ },
110
308
  {
111
309
  name: 'slotlock_set_resource_availability',
112
310
  title: "Set a resource's bookable hours",
@@ -119,6 +317,112 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
119
317
  invoke: 'setResourceAvailability',
120
318
  example: { resource_id: EXAMPLE_RESOURCE_ID, rules: EXAMPLE_RULES },
121
319
  },
320
+ {
321
+ name: 'slotlock_export_resource_availability',
322
+ title: "Export a resource's bookable hours as iCalendar",
323
+ description: "One resource's own weekly bookable hours as an iCalendar VAVAILABILITY (RFC 7953) for a calendar or scheduling tool: an AVAILABLE per rule, its DTSTART's TZID the resource's IANA time zone. `null` when the resource uses the server's default.",
324
+ method: 'GET',
325
+ path: '/v1/resources/{resource_id}/availability/ical',
326
+ input: z.object({ resource_id: RESOURCE_ID }).strict(),
327
+ output: z.object({ icalendar: z.string().max(200_000).nullable() }).strict(),
328
+ risk: { readOnly: true, destructive: false, idempotent: true },
329
+ invoke: 'exportResourceAvailability',
330
+ example: { resource_id: EXAMPLE_RESOURCE_ID },
331
+ },
332
+ {
333
+ name: 'slotlock_import_resource_availability',
334
+ title: "Import a resource's bookable hours from iCalendar",
335
+ description: "Set one resource's own weekly bookable hours from an iCalendar VAVAILABILITY (RFC 7953), as slotlock_set_resource_availability does from rules: each AVAILABLE a weekly RRULE with a start time and DTEND or DURATION. Times must be in the resource's time zone (its TZID) or floating. 400 invalid_availability names what weekly hours cannot say: a bounded VAVAILABILITY, RDATE, EXDATE, a single window, another zone.",
336
+ method: 'PUT',
337
+ path: '/v1/resources/{resource_id}/availability/ical',
338
+ input: z
339
+ .object({ resource_id: RESOURCE_ID, icalendar: z.string().min(1).max(65_536) })
340
+ .strict(),
341
+ output: z.object({ resource: RESOURCE }).strict(),
342
+ risk: { readOnly: false, destructive: false, idempotent: true },
343
+ invoke: 'importResourceAvailability',
344
+ example: {
345
+ resource_id: EXAMPLE_RESOURCE_ID,
346
+ icalendar: 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Example//EN\r\nBEGIN:VAVAILABILITY\r\nUID:hours-1\r\nDTSTAMP:20260101T000000Z\r\nBEGIN:AVAILABLE\r\nUID:hours-1-0\r\nDTSTAMP:20260101T000000Z\r\nDTSTART;TZID=Europe/London:20260105T090000\r\nDTEND;TZID=Europe/London:20260105T170000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR\r\nEND:AVAILABLE\r\nEND:VAVAILABILITY\r\nEND:VCALENDAR\r\n',
347
+ },
348
+ },
349
+ {
350
+ name: 'slotlock_set_resource_booking_rules',
351
+ title: "Set a resource's booking rules",
352
+ description: "Set what a booking on one resource must satisfy from now on: minimum notice, how far ahead, the step starts fall on (from local midnight in the resource's time zone), the turnaround kept free after each booking, and whether bookings must lie inside its hours. `null` clears them. Holds, reservations, moves and agents' events are refused with the rule they break; searches offer only what they allow.",
353
+ method: 'PUT',
354
+ path: '/v1/resources/{resource_id}/booking-rules',
355
+ input: z.object({ resource_id: RESOURCE_ID, rules: BOOKING_RULES.nullable() }).strict(),
356
+ output: z.object({ resource: RESOURCE }).strict(),
357
+ risk: { readOnly: false, destructive: false, idempotent: true },
358
+ invoke: 'setResourceBookingRules',
359
+ example: {
360
+ resource_id: EXAMPLE_RESOURCE_ID,
361
+ rules: { min_notice_minutes: 120, slot_step_minutes: 30, turnaround_minutes: 30, enforce_hours: true },
362
+ },
363
+ },
364
+ {
365
+ name: 'slotlock_set_resource_pool',
366
+ title: "Set a resource's pool",
367
+ description: "Make a resource a member of a pool, a class of interchangeable resources such as a vehicle class or a room type, or take it out (`null`). Agents book the class with slotlock_book_pool, which takes the first member free; the class's capacity is its number of members. A resource is in one pool at most; pool names are 1-100 of a-z, 0-9, '.', '_' and '-'.",
368
+ method: 'PUT',
369
+ path: '/v1/resources/{resource_id}/pool',
370
+ input: z.object({ resource_id: RESOURCE_ID, pool: POOL.nullable() }).strict(),
371
+ output: z.object({ resource: RESOURCE }).strict(),
372
+ risk: { readOnly: false, destructive: false, idempotent: true },
373
+ invoke: 'setResourcePool',
374
+ example: { resource_id: EXAMPLE_RESOURCE_ID, pool: 'van' },
375
+ },
376
+ {
377
+ name: 'slotlock_list_pools',
378
+ title: 'List pools',
379
+ description: "The tenant's pools by name, each with its capacity: how many members it has that are not archived.",
380
+ method: 'GET',
381
+ path: '/v1/pools',
382
+ input: z.object({}).strict(),
383
+ output: z
384
+ .object({ pools: z.array(z.object({ pool: POOL, capacity: z.number().int().min(1) }).strict()).max(1000) })
385
+ .strict(),
386
+ risk: { readOnly: true, destructive: false, idempotent: true },
387
+ invoke: 'listPools',
388
+ example: {},
389
+ },
390
+ {
391
+ name: 'slotlock_get_resource_history',
392
+ title: "A resource's history",
393
+ description: "What happened on one of the tenant's resources, newest first: each hold, booking and event made, moved, confirmed, released or cancelled, when, by whom (the API key or agent), and for a change what it was before. Kept 30 days. Pass next_before as before for older entries.",
394
+ method: 'GET',
395
+ path: '/v1/resources/{resource_id}/history',
396
+ input: z.object({ resource_id: RESOURCE_ID, ...HISTORY_PAGE }).strict(),
397
+ output: HISTORY_OUTPUT,
398
+ risk: { readOnly: true, destructive: false, idempotent: true },
399
+ invoke: 'getResourceHistory',
400
+ example: { resource_id: EXAMPLE_RESOURCE_ID, limit: 20 },
401
+ },
402
+ {
403
+ name: 'slotlock_get_reservation_history',
404
+ title: "A booking's history",
405
+ description: 'What happened to one booking, by its reference, newest first: made, moved, cancelled, when and by whom, as slotlock_get_resource_history.',
406
+ method: 'GET',
407
+ path: '/v1/reservations/{reservation_ref}/history',
408
+ input: z.object({ reservation_ref: z.string().min(1).max(500), ...HISTORY_PAGE }).strict(),
409
+ output: HISTORY_OUTPUT,
410
+ risk: { readOnly: true, destructive: false, idempotent: true },
411
+ invoke: 'getReservationHistory',
412
+ example: { reservation_ref: 'BK-42', limit: 20 },
413
+ },
414
+ {
415
+ name: 'slotlock_get_tenant_event_history',
416
+ title: "An event's history",
417
+ description: 'What happened to one calendar event of the tenant, by its id, newest first: created, changed, cancelled, when and by whom, as slotlock_get_resource_history.',
418
+ method: 'GET',
419
+ path: '/v1/tenant/events/{event_id}/history',
420
+ input: z.object({ event_id: z.string().uuid(), ...HISTORY_PAGE }).strict(),
421
+ output: HISTORY_OUTPUT,
422
+ risk: { readOnly: true, destructive: false, idempotent: true },
423
+ invoke: 'getTenantEventHistory',
424
+ example: { event_id: '5e8ff9bf-55ba-4508-9d22-e984129be6b5', limit: 20 },
425
+ },
122
426
  {
123
427
  name: 'slotlock_set_tenant_availability',
124
428
  title: "Set every resource's bookable hours",
@@ -134,7 +438,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
134
438
  {
135
439
  name: 'slotlock_acquire_hold',
136
440
  title: 'Hold time',
137
- description: 'Occupy a window without booking it, for example while a customer pays. The hold blocks every other booking until it expires (the database clock, ttl_seconds from now), is confirmed, or is released. A taken window answers 409 overlap.',
441
+ description: 'Occupy a window without booking it, for example while a customer pays. The hold blocks every other booking until it expires (the database clock, ttl_seconds from now), is confirmed, or is released. buffer_after_minutes keeps the time after it busy as well, and a confirmed hold keeps it. A taken window answers 409 overlap. With an idempotency key (the Idempotency-Key header or idempotency_key), a retry answers the hold the first request made, until that hold would have expired, is released or is confirmed; the same key with another request is 422 idempotency_conflict.',
138
442
  method: 'POST',
139
443
  path: '/v1/holds',
140
444
  input: z
@@ -148,6 +452,9 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
148
452
  .min(1)
149
453
  .max(7 * 24 * 60 * 60),
150
454
  source: SOURCE.optional(),
455
+ idempotency_key: IDEMPOTENCY_KEY.optional(),
456
+ // Last: a generated client's 0.2.0 positional arguments keep their places.
457
+ buffer_after_minutes: BUFFER_AFTER_MINUTES.optional(),
151
458
  })
152
459
  .strict()
153
460
  .refine(boundedSpan, SPAN_MESSAGE),
@@ -160,12 +467,39 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
160
467
  end: '2027-03-29T10:00:00Z',
161
468
  ttl_seconds: 600,
162
469
  source: 'checkout',
470
+ idempotency_key: 'checkout-7f3a9c',
163
471
  },
164
472
  },
473
+ {
474
+ name: 'slotlock_get_hold',
475
+ title: 'Get a hold',
476
+ description: 'One of the tenant\'s live holds. An expired, confirmed, released or unknown one answers 404 hold_not_found; a confirmed one is a reservation whose reference is the hold\'s id.',
477
+ method: 'GET',
478
+ path: '/v1/holds/{hold_id}',
479
+ input: z.object({ hold_id: HOLD_ID }).strict(),
480
+ output: z.object({ hold: HOLD }).strict(),
481
+ risk: { readOnly: true, destructive: false, idempotent: true },
482
+ invoke: 'getHold',
483
+ example: { hold_id: EXAMPLE_HOLD_ID },
484
+ },
485
+ {
486
+ name: 'slotlock_list_holds',
487
+ title: "List a resource's holds",
488
+ description: "The live holds on one of the tenant's resources, by start then id, a page at a time: pass next_cursor as cursor for the next page, until it is null.",
489
+ method: 'GET',
490
+ path: '/v1/resources/{resource_id}/holds',
491
+ input: z
492
+ .object({ resource_id: RESOURCE_ID, limit: PAGE_LIMIT, cursor: CURSOR.optional() })
493
+ .strict(),
494
+ output: z.object({ holds: z.array(HOLD).max(100), next_cursor: NEXT_CURSOR }).strict(),
495
+ risk: { readOnly: true, destructive: false, idempotent: true },
496
+ invoke: 'listHolds',
497
+ example: { resource_id: EXAMPLE_RESOURCE_ID, limit: 50 },
498
+ },
165
499
  {
166
500
  name: 'slotlock_confirm_hold',
167
501
  title: 'Confirm a hold',
168
- description: 'Turn an unexpired hold into a confirmed reservation. Confirming it again answers the same reservation, so a retry is safe. An expired hold answers 409 hold_expired; a released or unknown one, 404 hold_not_found.',
502
+ description: "Turn an unexpired hold into a confirmed reservation, whose reference is the hold's id at revision 1: move or cancel it with the reservation routes by that reference (DELETE /v1/reservations/{hold_id}?revision=2 cancels it). Confirming it again answers the same reservation, so a retry is safe. An expired hold answers 409 hold_expired; a released or unknown one, 404 hold_not_found.",
169
503
  method: 'POST',
170
504
  path: '/v1/holds/{hold_id}/confirm',
171
505
  input: z.object({ hold_id: HOLD_ID }).strict(),
@@ -186,6 +520,41 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
186
520
  invoke: 'releaseHold',
187
521
  example: { hold_id: EXAMPLE_HOLD_ID },
188
522
  },
523
+ {
524
+ name: 'slotlock_get_reservation',
525
+ title: 'Get a reservation',
526
+ description: "The tenant's booking with this reference as it now is, at its current revision: move it at that revision plus 1. A cancelled or unknown reference, or a calendar event's time, answers 404 reservation_not_found.",
527
+ method: 'GET',
528
+ path: '/v1/reservations/{reservation_ref}',
529
+ input: z.object({ reservation_ref: IDENTITY }).strict(),
530
+ output: z.object({ reservation: RESERVATION }).strict(),
531
+ risk: { readOnly: true, destructive: false, idempotent: true },
532
+ invoke: 'getReservation',
533
+ example: { reservation_ref: 'booking-BK-42' },
534
+ },
535
+ {
536
+ name: 'slotlock_list_reservations',
537
+ title: "List a resource's reservations",
538
+ description: `The tenant's bookings on one resource whose time, turnaround included, meets a window of at most ${SLOTLOCK_CALENDAR_HORIZON_DAYS} days, by start then id, a page at a time: pass next_cursor as cursor for the next page, until it is null. Calendar events are not bookings; slotlock_list_tenant_events lists them.`,
539
+ method: 'GET',
540
+ path: '/v1/resources/{resource_id}/reservations',
541
+ input: z
542
+ .object({
543
+ resource_id: RESOURCE_ID,
544
+ start: DATE_TIME,
545
+ end: DATE_TIME,
546
+ limit: PAGE_LIMIT,
547
+ cursor: CURSOR.optional(),
548
+ })
549
+ .strict()
550
+ .refine(boundedWindow, WINDOW_MESSAGE),
551
+ output: z
552
+ .object({ reservations: z.array(RESERVATION).max(100), next_cursor: NEXT_CURSOR })
553
+ .strict(),
554
+ risk: { readOnly: true, destructive: false, idempotent: true },
555
+ invoke: 'listReservations',
556
+ example: { resource_id: EXAMPLE_RESOURCE_ID, ...EXAMPLE_WINDOW, limit: 50 },
557
+ },
189
558
  {
190
559
  name: 'slotlock_put_reservation',
191
560
  title: 'Book or move a reservation',
@@ -198,12 +567,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
198
567
  resource_id: RESOURCE_ID,
199
568
  start: DATE_TIME,
200
569
  end: DATE_TIME,
201
- buffer_after_minutes: z
202
- .number()
203
- .int()
204
- .min(0)
205
- .max(30 * 24 * 60)
206
- .default(0),
570
+ buffer_after_minutes: BUFFER_AFTER_MINUTES.default(0),
207
571
  revision: REVISION,
208
572
  source: SOURCE.optional(),
209
573
  })
@@ -233,6 +597,78 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
233
597
  invoke: 'cancelReservation',
234
598
  example: { reservation_ref: 'booking-BK-42', revision: 2 },
235
599
  },
600
+ {
601
+ name: 'slotlock_check_in_reservation',
602
+ title: 'Check in a booking',
603
+ description: "Claim a booking by its reference: the customer has arrived or picked up. On a resource whose booking rules set check_in_minutes, a booking not checked in that long after its start is released by maintenance (reservation.no_show on the feed) and offered to the waitlist. Check-in opens an hour before the start (409 check_in_not_open before then) and moving the booking to another start clears it. Checking in again keeps the first time; a booking that has ended answers 409 reservation_ended.",
604
+ method: 'POST',
605
+ path: '/v1/reservations/{reservation_ref}/check-in',
606
+ input: z.object({ reservation_ref: IDENTITY }).strict(),
607
+ output: z.object({ reference: IDENTITY, checked_in_at: DATE_TIME }).strict(),
608
+ risk: { readOnly: false, destructive: false, idempotent: true },
609
+ invoke: 'checkInReservation',
610
+ example: { reservation_ref: 'booking-BK-42' },
611
+ },
612
+ {
613
+ name: 'slotlock_join_waitlist',
614
+ title: 'Join the waitlist',
615
+ description: "Wait for a window on one resource (resource_id) or on any member of a pool (pool) that is taken now. When it comes free (a booking cancelled, a hold released or expired, a no-show), the entries waiting for it are offered in the order they joined: the first gets a hold of it for holder_ref, for offer_ttl_seconds (default 900), and the feed says so (waitlist.offered, with hold_id): confirm the hold to book it. An entry whose window starts first expires (waitlist.expired). A holder may wait for 20 windows at once (409 waitlist_limit_reached); a window is at most 31 days.",
616
+ method: 'POST',
617
+ path: '/v1/waitlist',
618
+ input: z
619
+ .object({
620
+ resource_id: RESOURCE_ID.optional(),
621
+ pool: POOL.optional(),
622
+ start: DATE_TIME,
623
+ end: DATE_TIME,
624
+ holder_ref: z.string().trim().min(1).max(500),
625
+ offer_ttl_seconds: z.number().int().min(60).max(86_400).optional(),
626
+ })
627
+ .strict()
628
+ .refine((input) => (input.resource_id === undefined) !== (input.pool === undefined), {
629
+ message: 'Name resource_id or pool, not both',
630
+ }),
631
+ output: z.object({ entry: WAITLIST_ENTRY }).strict(),
632
+ risk: { readOnly: false, destructive: false, idempotent: false },
633
+ invoke: 'joinWaitlist',
634
+ example: {
635
+ pool: 'van',
636
+ start: '2027-03-29T09:00:00Z',
637
+ end: '2027-03-29T17:00:00Z',
638
+ holder_ref: 'customer-42',
639
+ },
640
+ },
641
+ {
642
+ name: 'slotlock_list_waitlist',
643
+ title: 'List the waitlist',
644
+ description: "The tenant's waitlist entries in the order they are offered (oldest first), with what each was offered: optionally only those of one resource, pool or holder.",
645
+ method: 'GET',
646
+ path: '/v1/waitlist',
647
+ input: z
648
+ .object({
649
+ resource_id: RESOURCE_ID.optional(),
650
+ pool: POOL.optional(),
651
+ holder_ref: z.string().trim().min(1).max(500).optional(),
652
+ limit: z.number().int().min(1).max(1_000).default(100),
653
+ })
654
+ .strict(),
655
+ output: z.object({ entries: z.array(WAITLIST_ENTRY).max(1_000) }).strict(),
656
+ risk: { readOnly: true, destructive: false, idempotent: true },
657
+ invoke: 'listWaitlist',
658
+ example: { pool: 'van' },
659
+ },
660
+ {
661
+ name: 'slotlock_leave_waitlist',
662
+ title: 'Leave the waitlist',
663
+ description: 'Stop waiting. A hold offered and not yet confirmed is released with the entry, and the time offered to the next who waits; one confirmed is a booking and stays. left is false when there was no such entry.',
664
+ method: 'DELETE',
665
+ path: '/v1/waitlist/{entry_id}',
666
+ input: z.object({ entry_id: z.string().uuid() }).strict(),
667
+ output: z.object({ left: z.boolean() }).strict(),
668
+ risk: { readOnly: false, destructive: true, idempotent: true },
669
+ invoke: 'leaveWaitlist',
670
+ example: { entry_id: '1b4e28ba-2fa1-41d2-883f-0016d3cca427' },
671
+ },
236
672
  {
237
673
  name: 'slotlock_record_coverage',
238
674
  title: 'Record calendar coverage',
@@ -260,6 +696,170 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
260
696
  revision: 7,
261
697
  },
262
698
  },
699
+ {
700
+ name: 'slotlock_get_coverage',
701
+ title: "A resource's coverage and its health",
702
+ description: "Each external calendar (source) that records coverage of a resource: the window it covers, when it was last read, its last error, and whether it is stale (not read for a day, so the time it covers is unproven, not free). A source that goes stale is also put on the change feed once, as coverage.stale, until it is read again.",
703
+ method: 'GET',
704
+ path: '/v1/resources/{resource_id}/coverage',
705
+ input: z.object({ resource_id: RESOURCE_ID }).strict(),
706
+ output: z
707
+ .object({
708
+ sources: z
709
+ .array(z
710
+ .object({
711
+ source: SOURCE,
712
+ start: DATE_TIME,
713
+ end: DATE_TIME,
714
+ revision: REVISION,
715
+ observed_at: DATE_TIME,
716
+ last_error: z.string().max(500).nullable(),
717
+ last_error_at: DATE_TIME.nullable(),
718
+ stale: z.boolean(),
719
+ })
720
+ .strict())
721
+ .max(100),
722
+ })
723
+ .strict(),
724
+ risk: { readOnly: true, destructive: false, idempotent: true },
725
+ invoke: 'getCoverage',
726
+ example: { resource_id: EXAMPLE_RESOURCE_ID },
727
+ },
728
+ {
729
+ name: 'slotlock_record_coverage_failure',
730
+ title: 'Record a failed read of a source',
731
+ description: "Record that reading an external calendar failed (it answered 500, a token expired): kept as the source's last error, shown by slotlock_get_coverage, while its coverage stays as it was. 404 coverage_not_found for a source that has never recorded coverage of the resource.",
732
+ method: 'POST',
733
+ path: '/v1/resources/{resource_id}/coverage/{source}/failure',
734
+ input: z.object({ resource_id: RESOURCE_ID, source: SOURCE, error: z.string().min(1).max(500) }).strict(),
735
+ output: z.object({ recorded: z.literal(true) }).strict(),
736
+ risk: { readOnly: false, destructive: false, idempotent: true },
737
+ invoke: 'recordCoverageFailure',
738
+ example: { resource_id: EXAMPLE_RESOURCE_ID, source: 'google:fleet@example.com', error: 'HTTP 401 from the calendar API' },
739
+ },
740
+ {
741
+ name: 'slotlock_delete_coverage',
742
+ title: 'Retire a source',
743
+ description: "Forget a source's coverage of the resource, for a calendar sync that is disconnected or replaced: free/busy and a resource whose booking rules require certainty stop waiting on it. 404 coverage_not_found for a source with no coverage of the resource.",
744
+ method: 'DELETE',
745
+ path: '/v1/resources/{resource_id}/coverage/{source}',
746
+ input: z.object({ resource_id: RESOURCE_ID, source: SOURCE }).strict(),
747
+ output: z.object({ deleted: z.literal(true) }).strict(),
748
+ risk: { readOnly: false, destructive: true, idempotent: false },
749
+ invoke: 'deleteCoverage',
750
+ example: { resource_id: EXAMPLE_RESOURCE_ID, source: 'google:fleet@example.com' },
751
+ },
752
+ {
753
+ name: 'slotlock_list_tenant_events',
754
+ title: "List a resource's events, every key's",
755
+ description: `The calendar events on one of the tenant's resources with an occurrence in a window of at most ${SLOTLOCK_CALENDAR_HORIZON_DAYS} days, whichever key wrote them (a revoked one's included), by start then id, a page at a time: pass next_cursor as cursor for the next page, until it is null.`,
756
+ method: 'GET',
757
+ path: '/v1/tenant/events',
758
+ input: z
759
+ .object({
760
+ resource_id: RESOURCE_ID,
761
+ start: DATE_TIME,
762
+ end: DATE_TIME,
763
+ limit: PAGE_LIMIT,
764
+ cursor: CURSOR.optional(),
765
+ })
766
+ .strict()
767
+ .refine(boundedWindow, WINDOW_MESSAGE),
768
+ output: z
769
+ .object({ events: z.array(TENANT_EVENT).max(100), next_cursor: NEXT_CURSOR })
770
+ .strict(),
771
+ risk: { readOnly: true, destructive: false, idempotent: true },
772
+ invoke: 'listTenantEvents',
773
+ example: { resource_id: EXAMPLE_RESOURCE_ID, ...EXAMPLE_WINDOW, limit: 50 },
774
+ },
775
+ {
776
+ name: 'slotlock_get_tenant_event',
777
+ title: 'Get any event of the tenant',
778
+ description: "One of the tenant's events by its id (the id slotlock_list_tenant_events gives), whichever key wrote it. A cancelled or unknown one answers 404 event_not_found.",
779
+ method: 'GET',
780
+ path: '/v1/tenant/events/{event_id}',
781
+ input: z.object({ event_id: EVENT_ID }).strict(),
782
+ output: z.object({ event: TENANT_EVENT }).strict(),
783
+ risk: { readOnly: true, destructive: false, idempotent: true },
784
+ invoke: 'getTenantEvent',
785
+ example: { event_id: EXAMPLE_EVENT_ID },
786
+ },
787
+ {
788
+ name: 'slotlock_export_tenant_event',
789
+ title: 'Export an event as JSCalendar',
790
+ description: "One of the tenant's events as a JSCalendar Event (RFC 8984), the JSON form of a calendar event: uid is its id, sequence its revision, start a local time in timeZone with an exact duration, its people participants, its reminders alerts, its recurrence recurrenceRules and recurrenceOverrides (and the RRULE verbatim as slotlock.pylota.io:rrule). A cancelled or unknown one answers 404 event_not_found.",
791
+ method: 'GET',
792
+ path: '/v1/tenant/events/{event_id}/jscalendar',
793
+ input: z.object({ event_id: EVENT_ID }).strict(),
794
+ output: z.object({ event: JSCALENDAR_EVENT }).strict(),
795
+ risk: { readOnly: true, destructive: false, idempotent: true },
796
+ invoke: 'exportTenantEvent',
797
+ example: { event_id: EXAMPLE_EVENT_ID },
798
+ },
799
+ {
800
+ name: 'slotlock_cancel_tenant_event',
801
+ title: 'Cancel any event of the tenant',
802
+ description: "Cancel one of the tenant's events by its id, whichever key wrote it (a revoked key's included, without charging its quota), at the revision you last read (expected_revision). Its owner cannot create it again. With expected_resource_id, an event since moved to another resource answers 404 event_not_found. Needs an idempotency key (the Idempotency-Key header or idempotency_key): a retry with it answers the first cancellation (replayed: true).",
803
+ method: 'DELETE',
804
+ path: '/v1/tenant/events/{event_id}',
805
+ input: z
806
+ .object({
807
+ event_id: EVENT_ID,
808
+ expected_revision: REVISION,
809
+ expected_resource_id: RESOURCE_ID.optional(),
810
+ // Required, so a client always sends it; the Idempotency-Key header may stand in for
811
+ // it, since the server reads the header into it before the schema checks the request.
812
+ idempotency_key: IDEMPOTENCY_KEY,
813
+ })
814
+ .strict(),
815
+ output: z
816
+ .object({
817
+ event_id: EVENT_ID,
818
+ revision: REVISION,
819
+ cancelled: z.literal(true),
820
+ replayed: z.boolean(),
821
+ })
822
+ .strict(),
823
+ risk: { readOnly: false, destructive: true, idempotent: true },
824
+ invoke: 'cancelTenantEvent',
825
+ example: {
826
+ event_id: EXAMPLE_EVENT_ID,
827
+ expected_revision: 2,
828
+ idempotency_key: 'cancel-3d6f0a2b',
829
+ },
830
+ },
831
+ {
832
+ name: 'slotlock_get_tenant',
833
+ title: 'Get the tenant',
834
+ description: "The key's tenant: its reference, and whether it is suspended. A suspended tenant may read and writes nothing (403 tenant_suspended); only the server's operator suspends a tenant or lifts a suspension (slotlock tenant suspend).",
835
+ method: 'GET',
836
+ path: '/v1/tenant',
837
+ input: z.object({}).strict(),
838
+ output: z
839
+ .object({ tenant: z.object({ ref: IDENTITY, suspended: z.boolean() }).strict() })
840
+ .strict(),
841
+ risk: { readOnly: true, destructive: false, idempotent: true },
842
+ invoke: 'getTenant',
843
+ example: {},
844
+ },
845
+ {
846
+ name: 'slotlock_erase_tenant',
847
+ title: 'Erase the tenant',
848
+ description: "Delete everything the key's tenant has, at once and for good: resources, holds, bookings, events, coverage and every API key, this one included. confirm_tenant_ref must be the tenant's reference (slotlock_get_tenant), or it answers 400 invalid_confirmation. Off unless the server allows it (403 tenant_erasure_disabled; slotlock serve does not: its operator runs slotlock tenant erase). A suspended tenant cannot erase itself (403 tenant_suspended). Erasing again deletes nothing.",
849
+ method: 'POST',
850
+ path: '/v1/tenant/erase',
851
+ input: z.object({ confirm_tenant_ref: IDENTITY }).strict(),
852
+ output: z
853
+ .object({
854
+ total: z.number().int().min(0),
855
+ /** Rows deleted, by table. */
856
+ rows: z.record(z.string(), z.number().int().min(0)),
857
+ })
858
+ .strict(),
859
+ risk: { readOnly: false, destructive: true, idempotent: true },
860
+ invoke: 'eraseTenant',
861
+ example: { confirm_tenant_ref: 'fleet-ops' },
862
+ },
263
863
  {
264
864
  name: 'slotlock_run_maintenance',
265
865
  title: 'Run maintenance',
@@ -273,6 +873,10 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
273
873
  conflicts: z.number().int().min(0),
274
874
  refused: z.number().int().min(0),
275
875
  pruned: z.number().int().min(0),
876
+ zone_shifts: z.number().int().min(0),
877
+ stale_sources: z.number().int().min(0),
878
+ no_shows: z.number().int().min(0),
879
+ waitlist_offers: z.number().int().min(0),
276
880
  capped: z.boolean(),
277
881
  })
278
882
  .strict(),
@@ -280,6 +884,73 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
280
884
  invoke: 'runMaintenance',
281
885
  example: {},
282
886
  },
887
+ {
888
+ name: 'slotlock_list_changes',
889
+ title: 'List changes',
890
+ description: "What changed in the tenant, oldest first, as CloudEvents: holds made, confirmed, released or expired; bookings made, moved or cancelled; calendar events created, changed or cancelled (ids, times and states, never titles or attendees). Pass next_cursor as cursor to read on; it is returned even when nothing is new, so poll with it. Only changes of transactions that have ended are listed, so following the cursor never skips one. Changes are kept 30 days: an older cursor answers 410 cursor_expired, so start again without one.",
891
+ method: 'GET',
892
+ path: '/v1/changes',
893
+ input: z
894
+ .object({ cursor: CURSOR.optional(), limit: z.number().int().min(1).max(500).default(100) })
895
+ .strict(),
896
+ output: z.object({ changes: z.array(CHANGE).max(500), next_cursor: z.string().max(500) }).strict(),
897
+ risk: { readOnly: true, destructive: false, idempotent: true },
898
+ invoke: 'listChanges',
899
+ example: { limit: 100 },
900
+ },
901
+ {
902
+ name: 'slotlock_create_webhook',
903
+ title: 'Add a webhook',
904
+ description: 'Send the changes slotlock_list_changes lists from now on to an https URL, each as a CloudEvent signed as Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature), in order, until a 2xx. A failure is retried with backoff; after 24 hours of failures, or a 410, the webhook is disabled and the feed records webhook.disabled. The secret is returned here only. At most 10 per tenant.',
905
+ method: 'POST',
906
+ path: '/v1/webhooks',
907
+ input: z
908
+ .object({
909
+ url: z.string().url().max(2048),
910
+ event_types: z.array(z.enum(SLOTLOCK_CHANGE_TYPES)).min(1).max(20).optional(),
911
+ })
912
+ .strict(),
913
+ output: z.object({ webhook: WEBHOOK, secret: z.string().regex(/^whsec_/) }).strict(),
914
+ risk: { readOnly: false, destructive: false, idempotent: false },
915
+ invoke: 'createWebhook',
916
+ example: { url: 'https://example.com/slotlock-webhook', event_types: ['reservation.created'] },
917
+ },
918
+ {
919
+ name: 'slotlock_list_webhooks',
920
+ title: 'List webhooks',
921
+ description: "The tenant's webhooks with how delivery is going: failed attempts, the last status, and whether and why one is disabled.",
922
+ method: 'GET',
923
+ path: '/v1/webhooks',
924
+ input: z.object({}).strict(),
925
+ output: z.object({ webhooks: z.array(WEBHOOK).max(10) }).strict(),
926
+ risk: { readOnly: true, destructive: false, idempotent: true },
927
+ invoke: 'listWebhooks',
928
+ example: {},
929
+ },
930
+ {
931
+ name: 'slotlock_delete_webhook',
932
+ title: 'Delete a webhook',
933
+ description: 'Stop sending to a webhook. deleted is false when the tenant has no such webhook.',
934
+ method: 'DELETE',
935
+ path: '/v1/webhooks/{webhook_id}',
936
+ input: z.object({ webhook_id: z.string().uuid() }).strict(),
937
+ output: z.object({ deleted: z.boolean() }).strict(),
938
+ risk: { readOnly: false, destructive: true, idempotent: true },
939
+ invoke: 'deleteWebhook',
940
+ example: { webhook_id: '9b2f4c1e-6d3a-4e8b-a1c7-5f0e2d4b6a8c' },
941
+ },
942
+ {
943
+ name: 'slotlock_enable_webhook',
944
+ title: 'Enable a webhook',
945
+ description: 'Send to a disabled webhook again, from the first change it did not accept. 404 webhook_not_found when the tenant has no such webhook; 409 changes_pruned when what it missed is older than the 30 days the feed keeps, since it would skip those unseen: delete it, add it again, and read what it missed from slotlock_list_changes.',
946
+ method: 'POST',
947
+ path: '/v1/webhooks/{webhook_id}/enable',
948
+ input: z.object({ webhook_id: z.string().uuid() }).strict(),
949
+ output: z.object({ webhook: WEBHOOK }).strict(),
950
+ risk: { readOnly: false, destructive: false, idempotent: true },
951
+ invoke: 'enableWebhook',
952
+ example: { webhook_id: '9b2f4c1e-6d3a-4e8b-a1c7-5f0e2d4b6a8c' },
953
+ },
283
954
  ]);
284
955
  const BY_NAME = new Map(SLOTLOCK_APPLICATION_OPERATIONS.map((operation) => [operation.name, operation]));
285
956
  export function isSlotlockApplicationOperation(name) {