slotlock 0.2.0 → 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 (51) hide show
  1. package/CHANGELOG.md +330 -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 +277 -53
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +12 -3
  10. package/agent-server.js +108 -78
  11. package/agent-store-backend.d.ts +6 -0
  12. package/agent-store-backend.js +355 -36
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +14 -1
  16. package/application-api.js +274 -12
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +6 -0
  20. package/dashboard.js +17 -1
  21. package/ddl.d.ts +45 -3
  22. package/ddl.js +538 -12
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/ical-worker-thread.js +4 -2
  27. package/ical-worker.d.ts +8 -5
  28. package/ical-worker.js +9 -2
  29. package/index.d.ts +5 -5
  30. package/index.js +3 -3
  31. package/maintenance.d.ts +15 -5
  32. package/maintenance.js +59 -27
  33. package/node-server.d.ts +9 -1
  34. package/node-server.js +7 -2
  35. package/openapi.json +6024 -0
  36. package/package.json +3 -2
  37. package/rate-limit.d.ts +71 -0
  38. package/rate-limit.js +718 -0
  39. package/rest-api.js +10 -0
  40. package/rules.js +6 -3
  41. package/self-host.d.ts +22 -8
  42. package/self-host.js +307 -34
  43. package/slotlock-manifest.schema.json +6 -1
  44. package/slotlock.manifest.json +7 -2
  45. package/store.d.ts +334 -24
  46. package/store.js +2029 -290
  47. package/sync.d.ts +43 -2
  48. package/sync.js +1515 -323
  49. package/timezone.d.ts +58 -3
  50. package/timezone.js +249 -20
  51. package/types.d.ts +213 -9
package/api-keys.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // cannot reach another tenant's keys, read a digest, or bring back a revoked, rotated-out or erased
9
9
  // key.
10
10
  import { createHash, randomBytes } from 'node:crypto';
11
+ import { SlotlockAgentOperationError, } from './agent-server.js';
11
12
  import { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT } from './ddl.js';
12
13
  export { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT };
13
14
  /**
@@ -41,6 +42,10 @@ const DAY_MS = 86_400_000;
41
42
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
43
  const DEFAULT_MAX_CONCURRENT_LOOKUPS = 2;
43
44
  const DEFAULT_MAX_WAITING_LOOKUPS = 256;
45
+ /** How long a key that authenticated keeps its place ahead of unknown keys in the lookup queue. */
46
+ const RECENTLY_VALID_MS = 15 * 60_000;
47
+ /** Keys the authenticator remembers as recently valid, by digest; past this the oldest go first. */
48
+ const MAX_RECENTLY_VALID = 10_000;
44
49
  function invalid(code, message) {
45
50
  return Object.assign(new Error(message), { code });
46
51
  }
@@ -230,14 +235,21 @@ export function createSlotlockApiKeyStore(sql) {
230
235
  },
231
236
  };
232
237
  }
238
+ /** What the authenticator throws when its lookup queue is full: retry later, the key may be fine. */
239
+ const LOOKUP_BUSY = 'server_busy';
233
240
  /**
234
241
  * Bearer authentication by API key: a request carrying an active key is that key's principal,
235
242
  * `api_key:<id>` in the key's own tenant with its scopes, which the agent server enforces before
236
243
  * `authorize`. A value that is not a well-formed key is anonymous without a lookup.
237
244
  *
238
245
  * Anyone can make well-formed keys, so lookups are bounded: a flood of made-up keys waits for at
239
- * most `maxConcurrentLookups` connections and is refused past `maxWaitingLookups`, and the rest of
240
- * the pool stays free for authenticated work. Rate-limit a public server in front of it as well.
246
+ * most `maxConcurrentLookups` connections, and past `maxWaitingLookups` a request is refused with a
247
+ * `SlotlockAgentOperationError` (`server_busy`, 503) rather than answered anonymous, which would
248
+ * tell a client holding a valid key that its key is bad. A key that authenticated in the last 15
249
+ * minutes waits in a queue of its own that is served first, so made-up keys, which never
250
+ * authenticate, cannot keep it waiting. Every request is still looked up, so a revoked key stops
251
+ * at once. Rate-limit a public server in front of it as well (`slotlock serve` does, per client
252
+ * address, before any lookup).
241
253
  */
242
254
  export function createSlotlockApiKeyAuthenticator(keys, options = {}) {
243
255
  const maxActive = options.maxConcurrentLookups ?? DEFAULT_MAX_CONCURRENT_LOOKUPS;
@@ -250,26 +262,37 @@ export function createSlotlockApiKeyAuthenticator(keys, options = {}) {
250
262
  }
251
263
  let active = 0;
252
264
  const waiting = [];
265
+ const waitingKnown = [];
266
+ /** Digests of keys that recently authenticated, oldest first, with when they stop counting. */
267
+ const recentlyValid = new Map();
253
268
  return async (request) => {
254
269
  const presented = slotlockBearerCredential(request);
255
270
  if (presented === undefined || !isSlotlockApiKey(presented))
256
271
  return null;
272
+ const digest = slotlockApiKeyDigest(presented).toString('hex');
273
+ const known = (recentlyValid.get(digest) ?? 0) > Date.now();
274
+ const queue = known ? waitingKnown : waiting;
257
275
  if (active < maxActive) {
258
276
  active += 1;
259
277
  }
260
- else if (waiting.length < maxWaiting) {
278
+ else if (queue.length < maxWaiting) {
261
279
  // A finishing lookup hands its slot straight to the next waiter, so `active` is unchanged.
262
- await new Promise((resolve) => waiting.push(resolve));
280
+ await new Promise((resolve) => queue.push(resolve));
263
281
  }
264
282
  else {
265
- return null;
283
+ throw new SlotlockAgentOperationError(LOOKUP_BUSY, 503);
266
284
  }
267
285
  try {
268
286
  if (request.signal.aborted)
269
287
  return null;
270
288
  const identity = await keys.authenticate(presented);
289
+ recentlyValid.delete(digest);
271
290
  if (!identity)
272
291
  return null;
292
+ recentlyValid.set(digest, Date.now() + RECENTLY_VALID_MS);
293
+ if (recentlyValid.size > MAX_RECENTLY_VALID) {
294
+ recentlyValid.delete(recentlyValid.keys().next().value);
295
+ }
273
296
  return {
274
297
  subject: `api_key:${identity.id}`,
275
298
  tenantRef: identity.tenantRef,
@@ -277,7 +300,7 @@ export function createSlotlockApiKeyAuthenticator(keys, options = {}) {
277
300
  };
278
301
  }
279
302
  finally {
280
- const next = waiting.shift();
303
+ const next = waitingKnown.shift() ?? waiting.shift();
281
304
  if (next)
282
305
  next();
283
306
  else
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import type { SlotlockAgentPrincipal } from './agent-server.js';
3
3
  import type { SlotlockTraceContext } from './mcp-modern.js';
4
- export type SlotlockApplicationOperation = 'slotlock_create_resource' | 'slotlock_get_resource' | 'slotlock_set_resource_availability' | 'slotlock_set_tenant_availability' | 'slotlock_acquire_hold' | 'slotlock_confirm_hold' | 'slotlock_release_hold' | 'slotlock_put_reservation' | 'slotlock_cancel_reservation' | 'slotlock_record_coverage' | 'slotlock_run_maintenance';
4
+ export type SlotlockApplicationOperation = 'slotlock_create_resource' | 'slotlock_get_resource' | 'slotlock_archive_resource' | 'slotlock_restore_resource' | 'slotlock_delete_resource' | 'slotlock_list_busy' | 'slotlock_set_resource_availability' | 'slotlock_set_tenant_availability' | 'slotlock_acquire_hold' | 'slotlock_get_hold' | 'slotlock_list_holds' | 'slotlock_confirm_hold' | 'slotlock_release_hold' | 'slotlock_get_reservation' | 'slotlock_list_reservations' | 'slotlock_put_reservation' | 'slotlock_cancel_reservation' | 'slotlock_record_coverage' | 'slotlock_list_tenant_events' | 'slotlock_get_tenant_event' | 'slotlock_cancel_tenant_event' | 'slotlock_get_tenant' | 'slotlock_erase_tenant' | 'slotlock_run_maintenance';
5
5
  /** The scope an application operation needs, whichever operation it is. */
6
6
  export declare const SLOTLOCK_APPLICATION_SCOPE = "admin";
7
7
  export interface SlotlockApplicationInvocationContext {
@@ -20,14 +20,27 @@ type Handler = (context: SlotlockApplicationInvocationContext, input: Record<str
20
20
  export interface SlotlockApplicationBackend {
21
21
  createResource: Handler;
22
22
  getResource: Handler;
23
+ archiveResource: Handler;
24
+ restoreResource: Handler;
25
+ deleteResource: Handler;
26
+ listBusy: Handler;
23
27
  setResourceAvailability: Handler;
24
28
  setTenantAvailability: Handler;
25
29
  acquireHold: Handler;
30
+ getHold: Handler;
31
+ listHolds: Handler;
26
32
  confirmHold: Handler;
27
33
  releaseHold: Handler;
34
+ getReservation: Handler;
35
+ listReservations: Handler;
28
36
  putReservation: Handler;
29
37
  cancelReservation: Handler;
30
38
  recordCoverage: Handler;
39
+ listTenantEvents: Handler;
40
+ getTenantEvent: Handler;
41
+ cancelTenantEvent: Handler;
42
+ getTenant: Handler;
43
+ eraseTenant: Handler;
31
44
  runMaintenance: Handler;
32
45
  }
33
46
  export interface SlotlockApplicationOperationDefinition {
@@ -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.
@@ -9,11 +10,14 @@
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.
11
12
  import { z } from 'zod';
13
+ import { SLOTLOCK_EVENT_OUTPUT } from './event-schema.js';
14
+ import { SLOTLOCK_CALENDAR_HORIZON_DAYS } from './rules.js';
12
15
  /** The scope an application operation needs, whichever operation it is. */
13
16
  export const SLOTLOCK_APPLICATION_SCOPE = 'admin';
14
17
  const DATE_TIME = z.string().datetime({ offset: true });
15
18
  const RESOURCE_ID = z.string().trim().min(1).max(200);
16
19
  const HOLD_ID = z.string().trim().min(1).max(200);
20
+ const EVENT_ID = z.string().trim().min(1).max(200);
17
21
  /** Matches the store's identities: not blank, at most 500 UTF-8 bytes, no control characters. */
18
22
  const IDENTITY = z
19
23
  .string()
@@ -38,6 +42,32 @@ const boundedSpan = (value) => {
38
42
  return span > 0 && span <= MAX_SPAN_MS;
39
43
  };
40
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);
41
71
  const AVAILABILITY_RULE = z
42
72
  .object({
43
73
  rrule: z.string().min(1).max(500),
@@ -53,6 +83,10 @@ const RESOURCE = z
53
83
  timezone: TIMEZONE,
54
84
  /** The resource's own weekly hours; `null` when it uses the default. */
55
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(),
56
90
  })
57
91
  .strict();
58
92
  const HOLD = z
@@ -77,8 +111,20 @@ const RESERVATION = z
77
111
  source: z.string().max(200).nullable(),
78
112
  })
79
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();
80
124
  const EXAMPLE_RESOURCE_ID = '0f8b6c1e-2d4a-4c7e-9b1a-3e5f7a9c2d4b';
81
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' };
82
128
  const EXAMPLE_RULES = [
83
129
  { rrule: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', start_minutes: 540, duration_minutes: 480 },
84
130
  ];
@@ -86,10 +132,16 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
86
132
  {
87
133
  name: 'slotlock_create_resource',
88
134
  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.',
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.',
90
136
  method: 'POST',
91
137
  path: '/v1/resources',
92
- input: z.object({ external_ref: IDENTITY, timezone: TIMEZONE.default('UTC') }).strict(),
138
+ input: z
139
+ .object({
140
+ external_ref: IDENTITY,
141
+ timezone: TIMEZONE.default('UTC'),
142
+ visibility: VISIBILITY.optional(),
143
+ })
144
+ .strict(),
93
145
  output: z.object({ resource: RESOURCE }).strict(),
94
146
  risk: { readOnly: false, destructive: false, idempotent: true },
95
147
  invoke: 'createResource',
@@ -98,7 +150,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
98
150
  {
99
151
  name: 'slotlock_get_resource',
100
152
  title: 'Get a resource',
101
- description: 'One resource of the tenant, with its own weekly bookable hours.',
153
+ description: 'One resource of the tenant, archived or not, with its own weekly bookable hours and its visibility.',
102
154
  method: 'GET',
103
155
  path: '/v1/resources/{resource_id}',
104
156
  input: z.object({ resource_id: RESOURCE_ID }).strict(),
@@ -107,6 +159,57 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
107
159
  invoke: 'getResource',
108
160
  example: { resource_id: EXAMPLE_RESOURCE_ID },
109
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
+ },
110
213
  {
111
214
  name: 'slotlock_set_resource_availability',
112
215
  title: "Set a resource's bookable hours",
@@ -134,7 +237,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
134
237
  {
135
238
  name: 'slotlock_acquire_hold',
136
239
  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.',
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.',
138
241
  method: 'POST',
139
242
  path: '/v1/holds',
140
243
  input: z
@@ -148,6 +251,9 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
148
251
  .min(1)
149
252
  .max(7 * 24 * 60 * 60),
150
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(),
151
257
  })
152
258
  .strict()
153
259
  .refine(boundedSpan, SPAN_MESSAGE),
@@ -160,12 +266,39 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
160
266
  end: '2027-03-29T10:00:00Z',
161
267
  ttl_seconds: 600,
162
268
  source: 'checkout',
269
+ idempotency_key: 'checkout-7f3a9c',
163
270
  },
164
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
+ },
165
298
  {
166
299
  name: 'slotlock_confirm_hold',
167
300
  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.',
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.",
169
302
  method: 'POST',
170
303
  path: '/v1/holds/{hold_id}/confirm',
171
304
  input: z.object({ hold_id: HOLD_ID }).strict(),
@@ -186,6 +319,41 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
186
319
  invoke: 'releaseHold',
187
320
  example: { hold_id: EXAMPLE_HOLD_ID },
188
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
+ },
189
357
  {
190
358
  name: 'slotlock_put_reservation',
191
359
  title: 'Book or move a reservation',
@@ -198,12 +366,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
198
366
  resource_id: RESOURCE_ID,
199
367
  start: DATE_TIME,
200
368
  end: DATE_TIME,
201
- buffer_after_minutes: z
202
- .number()
203
- .int()
204
- .min(0)
205
- .max(30 * 24 * 60)
206
- .default(0),
369
+ buffer_after_minutes: BUFFER_AFTER_MINUTES.default(0),
207
370
  revision: REVISION,
208
371
  source: SOURCE.optional(),
209
372
  })
@@ -260,6 +423,105 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
260
423
  revision: 7,
261
424
  },
262
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
+ },
263
525
  {
264
526
  name: 'slotlock_run_maintenance',
265
527
  title: 'Run maintenance',
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 {};