slotlock 0.1.1 → 0.3.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 (56) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/CONTRIBUTING.md +55 -19
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +403 -103
  7. package/SECURITY.md +70 -5
  8. package/SPEC.md +161 -21
  9. package/agent-server.d.ts +76 -10
  10. package/agent-server.js +381 -146
  11. package/agent-store-backend.d.ts +22 -0
  12. package/agent-store-backend.js +598 -24
  13. package/api-keys.d.ts +22 -7
  14. package/api-keys.js +38 -8
  15. package/application-api.d.ts +67 -0
  16. package/application-api.js +552 -0
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +10 -1
  20. package/dashboard.js +23 -5
  21. package/ddl.d.ts +47 -4
  22. package/ddl.js +679 -25
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/hooks.d.ts +7 -0
  27. package/hooks.js +13 -0
  28. package/ical-worker-thread.js +4 -2
  29. package/ical-worker.d.ts +8 -5
  30. package/ical-worker.js +9 -2
  31. package/index.d.ts +12 -8
  32. package/index.js +7 -5
  33. package/maintenance.d.ts +67 -0
  34. package/maintenance.js +213 -0
  35. package/node-server.d.ts +9 -1
  36. package/node-server.js +127 -6
  37. package/openapi.json +6024 -0
  38. package/package.json +3 -2
  39. package/rate-limit.d.ts +71 -0
  40. package/rate-limit.js +718 -0
  41. package/rest-api.d.ts +33 -18
  42. package/rest-api.js +141 -63
  43. package/rules.d.ts +19 -0
  44. package/rules.js +38 -7
  45. package/self-host.d.ts +26 -9
  46. package/self-host.js +427 -47
  47. package/slotlock-manifest.schema.json +6 -1
  48. package/slotlock.manifest.json +7 -2
  49. package/store.d.ts +359 -27
  50. package/store.js +2077 -296
  51. package/sync.d.ts +43 -2
  52. package/sync.js +1950 -277
  53. package/timezone.d.ts +77 -3
  54. package/timezone.js +265 -21
  55. package/types.d.ts +217 -10
  56. package/weekly-hours.js +2 -2
@@ -0,0 +1,552 @@
1
+ // The application API: what an application, rather than an agent, does over HTTP that the TypeScript
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, 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.
5
+ //
6
+ // The agent server serves these operations as REST under /v1, beside the agent tools, when it is
7
+ // given an `application` backend and `rest` is on, and describes them in the same OpenAPI document.
8
+ // They need the `admin` scope. They are never MCP tools or A2A skills: no agent is offered them.
9
+ //
10
+ // This module holds only the contract (names, schemas, routes); it imports nothing from the agent
11
+ // server at runtime. The store-backed implementation is createSlotlockStoreApplicationBackend.
12
+ import { z } from 'zod';
13
+ import { SLOTLOCK_EVENT_OUTPUT } from './event-schema.js';
14
+ import { SLOTLOCK_CALENDAR_HORIZON_DAYS } from './rules.js';
15
+ /** The scope an application operation needs, whichever operation it is. */
16
+ export const SLOTLOCK_APPLICATION_SCOPE = 'admin';
17
+ const DATE_TIME = z.string().datetime({ offset: true });
18
+ const RESOURCE_ID = z.string().trim().min(1).max(200);
19
+ const HOLD_ID = z.string().trim().min(1).max(200);
20
+ const EVENT_ID = z.string().trim().min(1).max(200);
21
+ /** Matches the store's identities: not blank, at most 500 UTF-8 bytes, no control characters. */
22
+ const IDENTITY = z
23
+ .string()
24
+ .min(1)
25
+ .max(500)
26
+ .refine((value) => value.trim().length > 0 &&
27
+ Buffer.byteLength(value, 'utf8') <= 500 &&
28
+ !/[\u0000-\u001f\u007f]/.test(value), { message: 'must be 1-500 UTF-8 bytes, not blank, without control characters' });
29
+ const SOURCE = z
30
+ .string()
31
+ .min(1)
32
+ .max(200)
33
+ .refine((value) => value.trim().length > 0 && !/[\u0000-\u001f\u007f]/.test(value), {
34
+ message: 'must not be blank or hold control characters',
35
+ });
36
+ const TIMEZONE = z.string().min(1).max(100);
37
+ const REVISION = z.number().int().positive();
38
+ /** The longest span one hold or reservation may cover: the longest event Slotlock stores. */
39
+ const MAX_SPAN_MS = 3_660 * 24 * 60 * 60 * 1_000;
40
+ const boundedSpan = (value) => {
41
+ const span = Date.parse(value.end) - Date.parse(value.start);
42
+ return span > 0 && span <= MAX_SPAN_MS;
43
+ };
44
+ const SPAN_MESSAGE = { message: 'end must be after start, by at most 3,660 days' };
45
+ /** The longest window one list or busy read covers: the store's calendar horizon. */
46
+ const MAX_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * 24 * 60 * 60 * 1_000;
47
+ const boundedWindow = (value) => {
48
+ const span = Date.parse(value.end) - Date.parse(value.start);
49
+ return span > 0 && span <= MAX_WINDOW_MS;
50
+ };
51
+ const WINDOW_MESSAGE = {
52
+ message: `end must be after start, by at most ${SLOTLOCK_CALENDAR_HORIZON_DAYS} days`,
53
+ };
54
+ const PAGE_LIMIT = z.number().int().min(1).max(100).default(50);
55
+ /** The `next_cursor` of the page before; opaque. */
56
+ const CURSOR = z.string().min(1).max(500);
57
+ const NEXT_CURSOR = z.string().max(500).nullable();
58
+ const VISIBILITY = z.enum(['shared', 'private']);
59
+ /**
60
+ * A retry identity, as the `Idempotency-Key` request header or this field (the same value, if both
61
+ * are sent): the same key with the same request answers what the first did, and with another
62
+ * request is 409 idempotency_conflict.
63
+ */
64
+ 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, 409 idempotency_conflict.');
65
+ /** Minutes after the end kept busy as well, for turnaround: at most 30 days. */
66
+ const BUFFER_AFTER_MINUTES = z
67
+ .number()
68
+ .int()
69
+ .min(0)
70
+ .max(30 * 24 * 60);
71
+ const AVAILABILITY_RULE = z
72
+ .object({
73
+ rrule: z.string().min(1).max(500),
74
+ start_minutes: z.number().int().min(0).max(1_439),
75
+ duration_minutes: z.number().int().min(1).max(10_080),
76
+ })
77
+ .strict();
78
+ const AVAILABILITY_RULES = z.array(AVAILABILITY_RULE).max(50);
79
+ const RESOURCE = z
80
+ .object({
81
+ id: RESOURCE_ID,
82
+ external_ref: z.string().max(500).nullable(),
83
+ timezone: TIMEZONE,
84
+ /** The resource's own weekly hours; `null` when it uses the default. */
85
+ availability: AVAILABILITY_RULES.nullable(),
86
+ /** `private` resources are never shown to agents; fixed when the resource is created. */
87
+ visibility: VISIBILITY,
88
+ /** When it was archived; `null` while it is in use. */
89
+ archived_at: DATE_TIME.nullable(),
90
+ })
91
+ .strict();
92
+ const HOLD = z
93
+ .object({
94
+ id: HOLD_ID,
95
+ resource_id: RESOURCE_ID,
96
+ start: DATE_TIME,
97
+ end: DATE_TIME,
98
+ expires_at: DATE_TIME,
99
+ source: z.string().max(200).nullable(),
100
+ })
101
+ .strict();
102
+ const RESERVATION = z
103
+ .object({
104
+ id: z.string().max(200),
105
+ resource_id: RESOURCE_ID,
106
+ reservation_ref: z.string().max(500).nullable(),
107
+ start: DATE_TIME,
108
+ end: DATE_TIME,
109
+ buffer_after_minutes: z.number().int().min(0),
110
+ revision: REVISION,
111
+ source: z.string().max(200).nullable(),
112
+ })
113
+ .strict();
114
+ /**
115
+ * Any of the tenant's events, whichever key wrote it: `id` is the store's id for it, which the
116
+ * tenant-event routes take, `event_ref` the id its owner reads it by, and `owner_ref` who that is
117
+ * (`internal` for the tenant's own code, `agent:<digest>` for an API key).
118
+ */
119
+ const TENANT_EVENT = SLOTLOCK_EVENT_OUTPUT.extend({
120
+ event_ref: z.string().max(500),
121
+ owner_ref: z.string().max(500),
122
+ }).strict();
123
+ const BUSY_INTERVAL = z.object({ start: DATE_TIME, end: DATE_TIME }).strict();
124
+ const EXAMPLE_RESOURCE_ID = '0f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b';
125
+ const EXAMPLE_HOLD_ID = '7c9e6679-7425-40de-944b-e07fc1f90ae7';
126
+ const EXAMPLE_EVENT_ID = '3d6f0a2b-8c4e-4f1a-9b7d-5e2c8a1f4b6d';
127
+ const EXAMPLE_WINDOW = { start: '2027-03-01T00:00:00Z', end: '2027-04-01T00:00:00Z' };
128
+ const EXAMPLE_RULES = [
129
+ { rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', start_minutes: 540, duration_minutes: 480 },
130
+ ];
131
+ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
132
+ {
133
+ name: 'slotlock_create_resource',
134
+ title: 'Create or update a resource',
135
+ 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.',
136
+ method: 'POST',
137
+ path: '/v1/resources',
138
+ input: z
139
+ .object({
140
+ external_ref: IDENTITY,
141
+ timezone: TIMEZONE.default('UTC'),
142
+ visibility: VISIBILITY.optional(),
143
+ })
144
+ .strict(),
145
+ output: z.object({ resource: RESOURCE }).strict(),
146
+ risk: { readOnly: false, destructive: false, idempotent: true },
147
+ invoke: 'createResource',
148
+ example: { external_ref: 'vehicle-42', timezone: 'Europe/London' },
149
+ },
150
+ {
151
+ name: 'slotlock_get_resource',
152
+ title: 'Get a resource',
153
+ description: 'One resource of the tenant, archived or not, with its own weekly bookable hours and its visibility.',
154
+ method: 'GET',
155
+ path: '/v1/resources/{resource_id}',
156
+ input: z.object({ resource_id: RESOURCE_ID }).strict(),
157
+ output: z.object({ resource: RESOURCE }).strict(),
158
+ risk: { readOnly: true, destructive: false, idempotent: true },
159
+ invoke: 'getResource',
160
+ example: { resource_id: EXAMPLE_RESOURCE_ID },
161
+ },
162
+ {
163
+ name: 'slotlock_archive_resource',
164
+ title: 'Archive a resource',
165
+ 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.',
166
+ method: 'POST',
167
+ path: '/v1/resources/{resource_id}/archive',
168
+ input: z.object({ resource_id: RESOURCE_ID }).strict(),
169
+ output: z.object({ resource: RESOURCE }).strict(),
170
+ risk: { readOnly: false, destructive: false, idempotent: true },
171
+ invoke: 'archiveResource',
172
+ example: { resource_id: EXAMPLE_RESOURCE_ID },
173
+ },
174
+ {
175
+ name: 'slotlock_restore_resource',
176
+ title: 'Restore a resource',
177
+ description: 'Put an archived resource back in use. Restoring one in use changes nothing.',
178
+ method: 'POST',
179
+ path: '/v1/resources/{resource_id}/restore',
180
+ input: z.object({ resource_id: RESOURCE_ID }).strict(),
181
+ output: z.object({ resource: RESOURCE }).strict(),
182
+ risk: { readOnly: false, destructive: false, idempotent: true },
183
+ invoke: 'restoreResource',
184
+ example: { resource_id: EXAMPLE_RESOURCE_ID },
185
+ },
186
+ {
187
+ name: 'slotlock_delete_resource',
188
+ title: 'Delete a resource',
189
+ 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.",
190
+ method: 'DELETE',
191
+ path: '/v1/resources/{resource_id}',
192
+ input: z.object({ resource_id: RESOURCE_ID }).strict(),
193
+ output: z.object({ deleted: z.boolean() }).strict(),
194
+ risk: { readOnly: false, destructive: true, idempotent: true },
195
+ invoke: 'deleteResource',
196
+ example: { resource_id: EXAMPLE_RESOURCE_ID },
197
+ },
198
+ {
199
+ name: 'slotlock_list_busy',
200
+ title: "List a resource's busy time",
201
+ 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.`,
202
+ method: 'GET',
203
+ path: '/v1/resources/{resource_id}/busy',
204
+ input: z
205
+ .object({ resource_id: RESOURCE_ID, start: DATE_TIME, end: DATE_TIME })
206
+ .strict()
207
+ .refine(boundedWindow, WINDOW_MESSAGE),
208
+ output: z.object({ busy: z.array(BUSY_INTERVAL).max(10_000) }).strict(),
209
+ risk: { readOnly: true, destructive: false, idempotent: true },
210
+ invoke: 'listBusy',
211
+ example: { resource_id: EXAMPLE_RESOURCE_ID, ...EXAMPLE_WINDOW },
212
+ },
213
+ {
214
+ name: 'slotlock_set_resource_availability',
215
+ title: "Set a resource's bookable hours",
216
+ description: "Give one resource its own weekly bookable hours. `null` makes it use the server's default; `[]` makes it never bookable. Each rule is weekly with BYDAY, in the resource's time zone: start_minutes after midnight, lasting duration_minutes.",
217
+ method: 'PUT',
218
+ path: '/v1/resources/{resource_id}/availability',
219
+ input: z.object({ resource_id: RESOURCE_ID, rules: AVAILABILITY_RULES.nullable() }).strict(),
220
+ output: z.object({ resource: RESOURCE }).strict(),
221
+ risk: { readOnly: false, destructive: false, idempotent: true },
222
+ invoke: 'setResourceAvailability',
223
+ example: { resource_id: EXAMPLE_RESOURCE_ID, rules: EXAMPLE_RULES },
224
+ },
225
+ {
226
+ name: 'slotlock_set_tenant_availability',
227
+ title: "Set every resource's bookable hours",
228
+ description: "Set the weekly bookable hours of every resource the tenant has, as slotlock_set_resource_availability does for one. `null` makes them all use the server's default. Returns how many resources changed.",
229
+ method: 'PUT',
230
+ path: '/v1/availability',
231
+ input: z.object({ rules: AVAILABILITY_RULES.nullable() }).strict(),
232
+ output: z.object({ resources_updated: z.number().int().min(0) }).strict(),
233
+ risk: { readOnly: false, destructive: false, idempotent: true },
234
+ invoke: 'setTenantAvailability',
235
+ example: { rules: EXAMPLE_RULES },
236
+ },
237
+ {
238
+ name: 'slotlock_acquire_hold',
239
+ title: 'Hold time',
240
+ 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 409 idempotency_conflict.',
241
+ method: 'POST',
242
+ path: '/v1/holds',
243
+ input: z
244
+ .object({
245
+ resource_id: RESOURCE_ID,
246
+ start: DATE_TIME,
247
+ end: DATE_TIME,
248
+ ttl_seconds: z
249
+ .number()
250
+ .int()
251
+ .min(1)
252
+ .max(7 * 24 * 60 * 60),
253
+ source: SOURCE.optional(),
254
+ idempotency_key: IDEMPOTENCY_KEY.optional(),
255
+ // Last: a generated client's 0.2.0 positional arguments keep their places.
256
+ buffer_after_minutes: BUFFER_AFTER_MINUTES.optional(),
257
+ })
258
+ .strict()
259
+ .refine(boundedSpan, SPAN_MESSAGE),
260
+ output: z.object({ hold: HOLD }).strict(),
261
+ risk: { readOnly: false, destructive: false, idempotent: false },
262
+ invoke: 'acquireHold',
263
+ example: {
264
+ resource_id: EXAMPLE_RESOURCE_ID,
265
+ start: '2027-03-29T09:00:00Z',
266
+ end: '2027-03-29T10:00:00Z',
267
+ ttl_seconds: 600,
268
+ source: 'checkout',
269
+ idempotency_key: 'checkout-7f3a9c',
270
+ },
271
+ },
272
+ {
273
+ name: 'slotlock_get_hold',
274
+ title: 'Get a hold',
275
+ 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.',
276
+ method: 'GET',
277
+ path: '/v1/holds/{hold_id}',
278
+ input: z.object({ hold_id: HOLD_ID }).strict(),
279
+ output: z.object({ hold: HOLD }).strict(),
280
+ risk: { readOnly: true, destructive: false, idempotent: true },
281
+ invoke: 'getHold',
282
+ example: { hold_id: EXAMPLE_HOLD_ID },
283
+ },
284
+ {
285
+ name: 'slotlock_list_holds',
286
+ title: "List a resource's holds",
287
+ 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.",
288
+ method: 'GET',
289
+ path: '/v1/resources/{resource_id}/holds',
290
+ input: z
291
+ .object({ resource_id: RESOURCE_ID, limit: PAGE_LIMIT, cursor: CURSOR.optional() })
292
+ .strict(),
293
+ output: z.object({ holds: z.array(HOLD).max(100), next_cursor: NEXT_CURSOR }).strict(),
294
+ risk: { readOnly: true, destructive: false, idempotent: true },
295
+ invoke: 'listHolds',
296
+ example: { resource_id: EXAMPLE_RESOURCE_ID, limit: 50 },
297
+ },
298
+ {
299
+ name: 'slotlock_confirm_hold',
300
+ title: 'Confirm a hold',
301
+ 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.",
302
+ method: 'POST',
303
+ path: '/v1/holds/{hold_id}/confirm',
304
+ input: z.object({ hold_id: HOLD_ID }).strict(),
305
+ output: z.object({ reservation: RESERVATION }).strict(),
306
+ risk: { readOnly: false, destructive: false, idempotent: false },
307
+ invoke: 'confirmHold',
308
+ example: { hold_id: EXAMPLE_HOLD_ID },
309
+ },
310
+ {
311
+ name: 'slotlock_release_hold',
312
+ title: 'Release a hold',
313
+ description: 'Free a held window. released is false when the hold was already gone: expired, confirmed or released.',
314
+ method: 'DELETE',
315
+ path: '/v1/holds/{hold_id}',
316
+ input: z.object({ hold_id: HOLD_ID }).strict(),
317
+ output: z.object({ released: z.boolean() }).strict(),
318
+ risk: { readOnly: false, destructive: true, idempotent: true },
319
+ invoke: 'releaseHold',
320
+ example: { hold_id: EXAMPLE_HOLD_ID },
321
+ },
322
+ {
323
+ name: 'slotlock_get_reservation',
324
+ title: 'Get a reservation',
325
+ 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.",
326
+ method: 'GET',
327
+ path: '/v1/reservations/{reservation_ref}',
328
+ input: z.object({ reservation_ref: IDENTITY }).strict(),
329
+ output: z.object({ reservation: RESERVATION }).strict(),
330
+ risk: { readOnly: true, destructive: false, idempotent: true },
331
+ invoke: 'getReservation',
332
+ example: { reservation_ref: 'booking-BK-42' },
333
+ },
334
+ {
335
+ name: 'slotlock_list_reservations',
336
+ title: "List a resource's reservations",
337
+ 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.`,
338
+ method: 'GET',
339
+ path: '/v1/resources/{resource_id}/reservations',
340
+ input: z
341
+ .object({
342
+ resource_id: RESOURCE_ID,
343
+ start: DATE_TIME,
344
+ end: DATE_TIME,
345
+ limit: PAGE_LIMIT,
346
+ cursor: CURSOR.optional(),
347
+ })
348
+ .strict()
349
+ .refine(boundedWindow, WINDOW_MESSAGE),
350
+ output: z
351
+ .object({ reservations: z.array(RESERVATION).max(100), next_cursor: NEXT_CURSOR })
352
+ .strict(),
353
+ risk: { readOnly: true, destructive: false, idempotent: true },
354
+ invoke: 'listReservations',
355
+ example: { resource_id: EXAMPLE_RESOURCE_ID, ...EXAMPLE_WINDOW, limit: 50 },
356
+ },
357
+ {
358
+ name: 'slotlock_put_reservation',
359
+ title: 'Book or move a reservation',
360
+ description: 'Book the reservation with this reference (revision 1), or move it (the next revision: the current one plus 1). Sending the same reservation at its current revision again changes nothing (replayed: true). A taken window answers 409 overlap and leaves a moved reservation where it was. buffer_after_minutes keeps the time after it busy as well.',
361
+ method: 'PUT',
362
+ path: '/v1/reservations/{reservation_ref}',
363
+ input: z
364
+ .object({
365
+ reservation_ref: IDENTITY,
366
+ resource_id: RESOURCE_ID,
367
+ start: DATE_TIME,
368
+ end: DATE_TIME,
369
+ buffer_after_minutes: BUFFER_AFTER_MINUTES.default(0),
370
+ revision: REVISION,
371
+ source: SOURCE.optional(),
372
+ })
373
+ .strict()
374
+ .refine(boundedSpan, SPAN_MESSAGE),
375
+ output: z.object({ reservation: RESERVATION, replayed: z.boolean() }).strict(),
376
+ risk: { readOnly: false, destructive: false, idempotent: true },
377
+ invoke: 'putReservation',
378
+ example: {
379
+ reservation_ref: 'booking-BK-42',
380
+ resource_id: EXAMPLE_RESOURCE_ID,
381
+ start: '2027-03-29T09:00:00Z',
382
+ end: '2027-03-31T09:00:00Z',
383
+ buffer_after_minutes: 60,
384
+ revision: 1,
385
+ },
386
+ },
387
+ {
388
+ name: 'slotlock_cancel_reservation',
389
+ title: 'Cancel a reservation',
390
+ description: 'Cancel the reservation with this reference at the next revision (the current one plus 1). Cancelling leaves a tombstone, so a delayed booking cannot bring it back; repeating the cancel answers cancelled: false. Use a new reference for a new reservation.',
391
+ method: 'DELETE',
392
+ path: '/v1/reservations/{reservation_ref}',
393
+ input: z.object({ reservation_ref: IDENTITY, revision: REVISION }).strict(),
394
+ output: z.object({ cancelled: z.boolean(), revision: REVISION }).strict(),
395
+ risk: { readOnly: false, destructive: true, idempotent: true },
396
+ invoke: 'cancelReservation',
397
+ example: { reservation_ref: 'booking-BK-42', revision: 2 },
398
+ },
399
+ {
400
+ name: 'slotlock_record_coverage',
401
+ title: 'Record calendar coverage',
402
+ description: "Record that the events of an external calendar (source) are complete for a window of a resource, at that source's revision. Free/busy calls time unproven, not free, until every source a resource requires covers it. Revisions only go forward; repeating one changes nothing.",
403
+ method: 'PUT',
404
+ path: '/v1/resources/{resource_id}/coverage/{source}',
405
+ input: z
406
+ .object({
407
+ resource_id: RESOURCE_ID,
408
+ source: SOURCE,
409
+ start: DATE_TIME,
410
+ end: DATE_TIME,
411
+ revision: REVISION,
412
+ })
413
+ .strict()
414
+ .refine((value) => Date.parse(value.end) > Date.parse(value.start), SPAN_MESSAGE),
415
+ output: z.object({ revision: REVISION, replayed: z.boolean() }).strict(),
416
+ risk: { readOnly: false, destructive: false, idempotent: true },
417
+ invoke: 'recordCoverage',
418
+ example: {
419
+ resource_id: EXAMPLE_RESOURCE_ID,
420
+ source: 'google:fleet@example.com',
421
+ start: '2027-03-01T00:00:00Z',
422
+ end: '2027-06-01T00:00:00Z',
423
+ revision: 7,
424
+ },
425
+ },
426
+ {
427
+ name: 'slotlock_list_tenant_events',
428
+ title: "List a resource's events, every key's",
429
+ 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.`,
430
+ method: 'GET',
431
+ path: '/v1/tenant/events',
432
+ input: z
433
+ .object({
434
+ resource_id: RESOURCE_ID,
435
+ start: DATE_TIME,
436
+ end: DATE_TIME,
437
+ limit: PAGE_LIMIT,
438
+ cursor: CURSOR.optional(),
439
+ })
440
+ .strict()
441
+ .refine(boundedWindow, WINDOW_MESSAGE),
442
+ output: z
443
+ .object({ events: z.array(TENANT_EVENT).max(100), next_cursor: NEXT_CURSOR })
444
+ .strict(),
445
+ risk: { readOnly: true, destructive: false, idempotent: true },
446
+ invoke: 'listTenantEvents',
447
+ example: { resource_id: EXAMPLE_RESOURCE_ID, ...EXAMPLE_WINDOW, limit: 50 },
448
+ },
449
+ {
450
+ name: 'slotlock_get_tenant_event',
451
+ title: 'Get any event of the tenant',
452
+ 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.",
453
+ method: 'GET',
454
+ path: '/v1/tenant/events/{event_id}',
455
+ input: z.object({ event_id: EVENT_ID }).strict(),
456
+ output: z.object({ event: TENANT_EVENT }).strict(),
457
+ risk: { readOnly: true, destructive: false, idempotent: true },
458
+ invoke: 'getTenantEvent',
459
+ example: { event_id: EXAMPLE_EVENT_ID },
460
+ },
461
+ {
462
+ name: 'slotlock_cancel_tenant_event',
463
+ title: 'Cancel any event of the tenant',
464
+ 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).",
465
+ method: 'DELETE',
466
+ path: '/v1/tenant/events/{event_id}',
467
+ input: z
468
+ .object({
469
+ event_id: EVENT_ID,
470
+ expected_revision: REVISION,
471
+ expected_resource_id: RESOURCE_ID.optional(),
472
+ // Required, so a client always sends it; the Idempotency-Key header may stand in for
473
+ // it, since the server reads the header into it before the schema checks the request.
474
+ idempotency_key: IDEMPOTENCY_KEY,
475
+ })
476
+ .strict(),
477
+ output: z
478
+ .object({
479
+ event_id: EVENT_ID,
480
+ revision: REVISION,
481
+ cancelled: z.literal(true),
482
+ replayed: z.boolean(),
483
+ })
484
+ .strict(),
485
+ risk: { readOnly: false, destructive: true, idempotent: true },
486
+ invoke: 'cancelTenantEvent',
487
+ example: {
488
+ event_id: EXAMPLE_EVENT_ID,
489
+ expected_revision: 2,
490
+ idempotency_key: 'cancel-3d6f0a2b',
491
+ },
492
+ },
493
+ {
494
+ name: 'slotlock_get_tenant',
495
+ title: 'Get the tenant',
496
+ 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).",
497
+ method: 'GET',
498
+ path: '/v1/tenant',
499
+ input: z.object({}).strict(),
500
+ output: z
501
+ .object({ tenant: z.object({ ref: IDENTITY, suspended: z.boolean() }).strict() })
502
+ .strict(),
503
+ risk: { readOnly: true, destructive: false, idempotent: true },
504
+ invoke: 'getTenant',
505
+ example: {},
506
+ },
507
+ {
508
+ name: 'slotlock_erase_tenant',
509
+ title: 'Erase the tenant',
510
+ 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.",
511
+ method: 'POST',
512
+ path: '/v1/tenant/erase',
513
+ input: z.object({ confirm_tenant_ref: IDENTITY }).strict(),
514
+ output: z
515
+ .object({
516
+ total: z.number().int().min(0),
517
+ /** Rows deleted, by table. */
518
+ rows: z.record(z.string(), z.number().int().min(0)),
519
+ })
520
+ .strict(),
521
+ risk: { readOnly: false, destructive: true, idempotent: true },
522
+ invoke: 'eraseTenant',
523
+ example: { confirm_tenant_ref: 'fleet-ops' },
524
+ },
525
+ {
526
+ name: 'slotlock_run_maintenance',
527
+ title: 'Run maintenance',
528
+ description: "Extend recurring events to the rolling horizon and prune what the retention window no longer needs, for the caller's tenant. Run it at least daily (slotlock serve does). capped is true when there was more than one call does: call again.",
529
+ method: 'POST',
530
+ path: '/v1/maintenance',
531
+ input: z.object({}).strict(),
532
+ output: z
533
+ .object({
534
+ extended: z.number().int().min(0),
535
+ conflicts: z.number().int().min(0),
536
+ refused: z.number().int().min(0),
537
+ pruned: z.number().int().min(0),
538
+ capped: z.boolean(),
539
+ })
540
+ .strict(),
541
+ risk: { readOnly: false, destructive: false, idempotent: true },
542
+ invoke: 'runMaintenance',
543
+ example: {},
544
+ },
545
+ ]);
546
+ const BY_NAME = new Map(SLOTLOCK_APPLICATION_OPERATIONS.map((operation) => [operation.name, operation]));
547
+ export function isSlotlockApplicationOperation(name) {
548
+ return typeof name === 'string' && BY_NAME.has(name);
549
+ }
550
+ export function slotlockApplicationOperation(name) {
551
+ return BY_NAME.get(name);
552
+ }
package/config.d.ts CHANGED
@@ -33,3 +33,96 @@ export interface SlotlockServeDashboardConfig {
33
33
  * dashboard, so the rest are then required: a half-configured sign-in is a startup error.
34
34
  */
35
35
  export declare function readDashboardConfig(env: SlotlockEnv): SlotlockServeDashboardConfig | undefined;
36
+ /** A token bucket: `perSecond` tokens come back each second, and it holds at most `burst`. */
37
+ export interface SlotlockRate {
38
+ perSecond: number;
39
+ burst: number;
40
+ }
41
+ /** The token buckets `slotlock serve` keeps (SLOTLOCK_LIMITS), each counted per its own key. */
42
+ export declare const SLOTLOCK_RATE_NAMES: readonly ["address", "key_read", "key_write", "tenant_read", "tenant_write", "global_read", "global_write", "app_read", "app_write", "sign_in"];
43
+ export type SlotlockRateName = (typeof SLOTLOCK_RATE_NAMES)[number];
44
+ /** The counts `slotlock serve` caps (SLOTLOCK_LIMITS), with the range each may be set to. */
45
+ declare const CAP_RANGES: Readonly<{
46
+ readonly tenant_series_writes: readonly [1, 100];
47
+ readonly series_writes: readonly [1, 100];
48
+ readonly maintenance_runs: readonly [1, 100];
49
+ readonly subscriptions: readonly [1, 10000];
50
+ readonly tenant_subscriptions: readonly [1, 10000];
51
+ readonly key_subscriptions: readonly [1, 64];
52
+ readonly key_lookups: readonly [1, 8];
53
+ readonly key_lookup_queue: readonly [0, 10000];
54
+ readonly tenant_transactions: readonly [1, 10];
55
+ readonly tenant_holds: readonly [1, 10000000];
56
+ readonly tenant_reservations: readonly [1, 10000000];
57
+ readonly tenant_events: readonly [1, 10000000];
58
+ readonly tenant_coverage: readonly [1, 10000000];
59
+ }>;
60
+ export type SlotlockCapName = keyof typeof CAP_RANGES;
61
+ export declare const SLOTLOCK_CAP_NAMES: readonly ("key_lookup_queue" | "key_lookups" | "key_subscriptions" | "maintenance_runs" | "series_writes" | "subscriptions" | "tenant_coverage" | "tenant_events" | "tenant_holds" | "tenant_reservations" | "tenant_series_writes" | "tenant_subscriptions" | "tenant_transactions")[];
62
+ export interface SlotlockLimits {
63
+ /** Each rate, or null where SLOTLOCK_LIMITS turns it off. */
64
+ rates: Readonly<Record<SlotlockRateName, SlotlockRate | null>>;
65
+ caps: Readonly<Record<SlotlockCapName, number>>;
66
+ }
67
+ /**
68
+ * What `slotlock serve` allows unless SLOTLOCK_LIMITS says otherwise, sized for one public server on
69
+ * one CPU (the 2026-10-24 readiness audit measured the database at about 900 writes a second):
70
+ * - `address`: every request from one client address, before any credential is looked up.
71
+ * - `key_*`, `tenant_*`, `global_*`: an authenticated request takes a read token from its key, its
72
+ * tenant and the server; a write (a REST method other than GET or HEAD, or a write tool) takes a
73
+ * write token from each as well.
74
+ * - `app_*`: a request to the application API (application-api.ts) from an admin key takes its
75
+ * read and write tokens from its tenant's application buckets instead of the key and tenant ones,
76
+ * so the application and the tenant's agents cannot use up each other's budget; one from any
77
+ * other key takes them from that key's own. `global_*` counts it as well.
78
+ * - `sign_in`: dashboard sign-ins and their callbacks from one client network: an IPv4 address,
79
+ * or an IPv6 client's /48 (slotlockClientNetwork in rate-limit.ts), as the dashboard Worker
80
+ * counts them.
81
+ * - `tenant_series_writes` and `series_writes`: writes that may expand a recurring series (a
82
+ * recurring create, any update) running at once in a tenant and in each server process.
83
+ * - `maintenance_runs`: the application API's maintenance runs at once in each server process, one
84
+ * a tenant, in slots of their own. A freed slot of either kind rests for as long as the event
85
+ * loop was busy while its write held it, up to a second, then goes to the tenant with the fewest
86
+ * writes in flight and, among those, the least use lately. That keeps a tenant sending one write
87
+ * at a time from waiting behind tenants with more, not behind many tenants with one each
88
+ * (rate-limit.ts).
89
+ * - `subscriptions`: MCP live-update streams the server holds open. When it is full, a tenant
90
+ * holding fewer than the tenant holding most, less one, ends that tenant's oldest stream.
91
+ * - `tenant_subscriptions`: of those, how many one tenant may hold.
92
+ * - `key_subscriptions`: of those, how many one key (or the server token) may hold, and always
93
+ * fewer than its tenant's, so one key cannot lock the tenant's other keys out
94
+ * (slotlockKeySubscriptions).
95
+ * - `key_lookups` and `key_lookup_queue`: API key lookups running at once and waiting.
96
+ * - `tenant_transactions`: store transactions one tenant runs at once on the server's pool of 10
97
+ * connections; the rest wait their turn in the process, so a tenant's writes waiting on its own
98
+ * locks leave the other connections to other tenants and key lookups. A server with one tenant
99
+ * can set 10.
100
+ * - `tenant_holds`, `tenant_reservations`, `tenant_events` and `tenant_coverage`: what one tenant
101
+ * may store, counted as the store's tenant limits count it (SlotlockTenantLimits), whichever key,
102
+ * agent or application writes it. Every write surface of the server shares them, so no admin
103
+ * key can grow the shared database without end; a write past one answers 409 with its
104
+ * `*_limit_reached` code.
105
+ */
106
+ export declare const SLOTLOCK_DEFAULT_LIMITS: SlotlockLimits;
107
+ /**
108
+ * The live-update streams one key may hold open: `key_subscriptions`, but at least one fewer than
109
+ * its tenant may (unless the tenant may hold only one), so a key at its own limit leaves its
110
+ * tenant's other keys a stream however the two caps are set.
111
+ */
112
+ export declare function slotlockKeySubscriptions(caps: Readonly<Record<SlotlockCapName, number>>): number;
113
+ /** A rate as SLOTLOCK_LIMITS and the `listening` log line write it: `<count>/<s|m|h>:<burst>`. */
114
+ export declare function formatSlotlockRate(rate: SlotlockRate | null): string;
115
+ /**
116
+ * SLOTLOCK_LIMITS: comma-separated `name=value` overrides of SLOTLOCK_DEFAULT_LIMITS. A rate is
117
+ * `<count>/<s|m|h>`, optionally `:<burst>` (default: the count), or `off`; a cap is a whole number.
118
+ * For example `tenant_write=2/s:10,global_write=30/s,address=off`.
119
+ */
120
+ export declare function readLimits(env: SlotlockEnv): SlotlockLimits;
121
+ /**
122
+ * SLOTLOCK_TRUSTED_PROXY_HEADER: the request field holding the client's address, set (and
123
+ * overwritten) by a proxy every request passes through, such as `CF-Connecting-IP` behind
124
+ * Cloudflare. Unset, the client address is the connection's. A client can send any field, so set it
125
+ * only when nothing reaches the server except through that proxy.
126
+ */
127
+ export declare function readTrustedProxyHeader(env: SlotlockEnv): string | undefined;
128
+ export {};