slotlock 0.6.1 → 0.6.3

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 (63) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +13 -6
  3. package/SECURITY.md +15 -4
  4. package/agent-server.js +1 -5
  5. package/agent-store-backend.d.ts +5 -6
  6. package/agent-store-backend.js +41 -67
  7. package/api-keys.js +1 -1
  8. package/application-api.d.ts +3 -3
  9. package/application-api.js +16 -24
  10. package/assistant.d.ts +18 -27
  11. package/assistant.js +36 -53
  12. package/booking-rules.d.ts +30 -41
  13. package/booking-rules.js +17 -25
  14. package/change-watch.d.ts +1 -0
  15. package/change-watch.js +1 -1
  16. package/cli.js +3 -4
  17. package/config.d.ts +17 -2
  18. package/config.js +44 -6
  19. package/dashboard.js +14 -1
  20. package/ddl.d.ts +20 -27
  21. package/ddl.js +166 -108
  22. package/engine.d.ts +6 -9
  23. package/engine.js +5 -9
  24. package/event-feed.d.ts +23 -31
  25. package/event-feed.js +30 -45
  26. package/event-schema.js +6 -9
  27. package/hooks.d.ts +2 -4
  28. package/hooks.js +2 -4
  29. package/ical-worker-thread.js +3 -4
  30. package/ical-worker.d.ts +8 -10
  31. package/ical-worker.js +7 -9
  32. package/interop.js +1 -4
  33. package/maintenance.d.ts +14 -10
  34. package/maintenance.js +43 -26
  35. package/mcp-app.d.ts +7 -9
  36. package/mcp-app.js +7 -9
  37. package/mcp-modern.d.ts +14 -16
  38. package/mcp-modern.js +23 -27
  39. package/metrics.js +0 -1
  40. package/node-server.d.ts +9 -9
  41. package/node-server.js +25 -34
  42. package/package-version.js +2 -4
  43. package/package.json +1 -1
  44. package/rate-limit.d.ts +6 -1
  45. package/rate-limit.js +24 -8
  46. package/rest-api.d.ts +13 -14
  47. package/rest-api.js +11 -12
  48. package/rules.d.ts +6 -7
  49. package/rules.js +30 -42
  50. package/self-host.d.ts +16 -2
  51. package/self-host.js +42 -21
  52. package/slotlock.manifest.json +1 -1
  53. package/store.d.ts +109 -161
  54. package/store.js +253 -371
  55. package/sync.d.ts +44 -59
  56. package/sync.js +325 -469
  57. package/timezone.d.ts +25 -33
  58. package/timezone.js +37 -52
  59. package/types.d.ts +35 -56
  60. package/webhooks.d.ts +22 -25
  61. package/webhooks.js +35 -40
  62. package/weekly-hours.d.ts +3 -0
  63. package/weekly-hours.js +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,40 @@
3
3
  All notable changes follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and semantic
4
4
  versioning.
5
5
 
6
+ ## 0.6.3 - 2026-11-08
7
+
8
+ ### Added
9
+
10
+ - `SLOTLOCK_TRUSTED_PROXY_SECRET`: with it set, `slotlock serve` believes
11
+ `SLOTLOCK_TRUSTED_PROXY_HEADER` only on a request whose `X-Slotlock-Proxy-Proof` carries that
12
+ exact value (compared in constant time), and counts any other by its connection's address. A
13
+ process that reaches the server without passing the proxy, such as another container on
14
+ kamal-proxy's network, can then no longer pick its own rate-limit bucket. Unset, nothing changes.
15
+ The `listening` line says `client_address_proof`.
16
+
17
+ ### Changed
18
+
19
+ - Site: the landing page's Slotlock Cloud endpoints list only what an agent connects to (MCP, A2A,
20
+ REST, OpenAPI, the agent card). The dashboard row is gone: people reach it through Sign in.
21
+
22
+ ### Fixed
23
+
24
+ - `slotlock serve` maintains every tenant with data every hour, not only the ones whose keys it has
25
+ served since it started: a tenant gone quiet has its expired holds, ended waitlist entries and
26
+ feed past retention deleted too. Tenants are listed by name in `slotlock.maintenance_tenants`
27
+ (owner only, filled by triggers as a tenant first writes, and from existing rows when `migrate`
28
+ creates it); the serving role reads the names through `slotlock.maintenance_tenants(text)` and
29
+ maintains each in its own tenant context. Run `slotlock migrate`.
30
+
31
+ ## 0.6.2 - 2026-11-08
32
+
33
+ ### Changed
34
+
35
+ - Dashboard: pages use the width of a wide screen, up to 1600px, and centre past it, instead of
36
+ stopping at 1200px against the sidebar with empty space to their right.
37
+ - SECURITY.md: the hosted service is open, and security research on it is welcome against your own
38
+ account, within the rules it gives (no load testing, no other accounts' data).
39
+
6
40
  ## 0.6.1 - 2026-11-08
7
41
 
8
42
  ### Changed
package/README.md CHANGED
@@ -86,7 +86,11 @@ docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/
86
86
  when such a field arrives without the setting. Behind two proxies, such as Cloudflare and then
87
87
  kamal-proxy, name the first one's field: the server reads the last address of a list field such
88
88
  as `X-Forwarded-For`, which the second proxy writes as Cloudflare's. A client can send that field
89
- itself, so let nothing reach the server except through the proxy.
89
+ itself, so let nothing reach the server except through the proxy. Where something else can reach
90
+ it (another container on kamal-proxy's network), also set `SLOTLOCK_TRUSTED_PROXY_SECRET` and have
91
+ the proxy set `X-Slotlock-Proxy-Proof` to it on every request (a Cloudflare request header
92
+ transform rule): the field is then believed only beside that exact value, and any other request
93
+ is counted by its connection's address.
90
94
  - The server also bounds what one tenant may store, whichever key, agent or application writes
91
95
  it: 10,000 holds, and 100,000 each of bookings, calendar events and coverage rows, counted as
92
96
  the store's `tenantLimits` count them, besides 10,000 resources. A write past one answers 409
@@ -395,9 +399,10 @@ this one and read its session secret, which signs in as any tenant.
395
399
  - With organizations, set `SLOTLOCK_DASHBOARD_ORGS` on both the Worker and the server: the server
396
400
  takes an organization's keys only while its own list names it, whether or not it serves the
397
401
  dashboard.
398
- - The server checks a dashboard tenant against `SLOTLOCK_DASHBOARD_USERS` only when it serves the
399
- dashboard itself. With the dashboard on Workers, removing a person from the Worker's list signs
400
- them out of the dashboard; to stop their keys, revoke them or erase the tenant.
402
+ - Set `SLOTLOCK_DASHBOARD_USERS` on both the Worker and the server too: the server takes a person's
403
+ keys only while its own list allows them, whether or not it serves the dashboard, so removing a
404
+ person from both lists signs them out and stops their keys. A server without it takes every
405
+ person's keys.
401
406
  - A missing or weak secret makes the Worker answer 503 and log which variable, never its value.
402
407
 
403
408
  ## Connect an MCP client
@@ -1527,8 +1532,10 @@ a 12h/24h switch.
1527
1532
  Every tenant needs maintenance at least daily: it extends recurring events to the rolling horizon
1528
1533
  and prunes what the retention window no longer needs, which is what returns agent quota.
1529
1534
 
1530
- - `slotlock serve` maintains `SLOTLOCK_TENANT` when it starts and every tenant whose key it serves,
1531
- then each hourly, one tenant at a time. It logs a `maintenance` line with counts.
1535
+ - `slotlock serve` maintains `SLOTLOCK_TENANT` and every tenant with data when it starts (whether
1536
+ or not its keys call), then each hourly, one tenant at a time. It logs a `maintenance` line with
1537
+ counts. It finds the tenants through `slotlock.maintenance_tenants(text)`, which lists names
1538
+ only; each pass runs in its own tenant's context, under row-level security.
1532
1539
  - `slotlock maintain` runs one tenant's maintenance now, for cron or after a bulk import. An admin
1533
1540
  key can call `POST /v1/maintenance`.
1534
1541
  - Each pass holds a per-tenant PostgreSQL advisory lock, so servers and cron jobs never repeat each
package/SECURITY.md CHANGED
@@ -24,10 +24,21 @@ are published as advisories at <https://github.com/PILOTAAI/slotlock/security/ad
24
24
 
25
25
  ## Hosted Slotlock
26
26
 
27
- The hosted service is not open; self-hosting is the only supported path today. Do not test
28
- `slotlock-api.pylota.io` or `app.slotlock.pylota.io`: they are not in scope until this section
29
- says the service is open and gives the rules for testing it. Test Slotlock by running it yourself,
30
- and report what you find as above.
27
+ The hosted service is open: any GitHub account can sign in at
28
+ <https://app.slotlock.pylota.io/dashboard> and use `https://slotlock-api.pylota.io` with its own
29
+ keys. Self-hosting stays fully supported. Security research on the hosted service is welcome within
30
+ these rules:
31
+
32
+ - Test only against an account you signed in with yourself, and only with its own keys. Stop as
33
+ soon as you reach another account's data, keep none of it, and report it.
34
+ - No traffic meant to slow or stop the service: no floods, load tests, or recurrence rules written
35
+ to expand without end. A request over a limit gets `429`; do not try to get around the limits.
36
+ Run Slotlock yourself to load test it.
37
+ - No social engineering, and no attacks on Pylota's people, offices, GitHub, Cloudflare, or other
38
+ providers.
39
+ - Report privately as above, never in a public issue, and give us time to fix it before disclosure.
40
+
41
+ The [Acceptable Use Policy](ACCEPTABLE-USE.md) permits research within these rules. Abuse of the service by others goes to [abuse@pylota.io](mailto:abuse@pylota.io).
31
42
 
32
43
  ## Security model
33
44
 
package/agent-server.js CHANGED
@@ -4,7 +4,7 @@ import { z } from 'zod';
4
4
  import { SLOTLOCK_MCP_APPS_EXTENSION, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, clientSupportsSlotlockMcpApp, } from './mcp-app.js';
5
5
  import { MCP_META_PROTOCOL_VERSION, MCP_META_SERVER_INFO, MCP_META_SUBSCRIPTION_ID, MCP_MISSING_REQUIRED_CLIENT_CAPABILITY, canonicalJson, checkModernMcpRequest, isRecord, openMcpEventStream, openRequestState, principalTag, sealRequestState, sha256Hex, stateSecrets, traceContextFrom, } from './mcp-modern.js';
6
6
  import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlockApplicationOperation, slotlockApplicationOperation, } from './application-api.js';
7
- import { createSlotlockChangeReaders, } from './change-watch.js';
7
+ import { createSlotlockChangeReaders, unref, } from './change-watch.js';
8
8
  import { SLOTLOCK_EVENT_OUTPUT as EVENT } from './event-schema.js';
9
9
  import { ignoreRejection } from './hooks.js';
10
10
  import { slotlockPackageVersion } from './package-version.js';
@@ -1539,10 +1539,6 @@ function parseSubscriptionFilter(value) {
1539
1539
  return { ok: true, resourceIds };
1540
1540
  }
1541
1541
  /** A timer that does not by itself keep a Node process alive (a no-op on other runtimes). */
1542
- function unref(timer) {
1543
- timer.unref?.();
1544
- return timer;
1545
- }
1546
1542
  /** Natural-language guidance `server/discover` gives the model on how to use these tools well. */
1547
1543
  const SLOTLOCK_MCP_INSTRUCTIONS = [
1548
1544
  'Slotlock is a tenant-isolated resource calendar (vehicles, rooms, equipment).',
@@ -27,9 +27,8 @@ export declare function projectSlotlockCalendarEvent(event: SlotlockCalendarEven
27
27
  export declare function slotlockAgentErrorStatus(code: string): 400 | 404 | 409 | 429;
28
28
  /**
29
29
  * The store's change feed for live updates (`subscriptions.changes`): each tenant's changes read
30
- * under its row-level security, one indexed query per read. Readers poll it (change-watch.ts:
31
- * every `SLOTLOCK_FEED_BUSY_MS` after a read that found changes, backing off to
32
- * `SLOTLOCK_FEED_IDLE_MS`); `sql` is the store's client, used for the feed's head.
30
+ * under its RLS, one indexed query per read. Readers poll it (change-watch.ts); `sql` is the
31
+ * store's client, used for the feed's head.
33
32
  */
34
33
  export declare function createSlotlockStoreChangeSource(store: SlotlockStore, sql: Sql): SlotlockAgentChangeSource;
35
34
  /** Bind the protocol server to the production store without giving a request control of tenancy. */
@@ -50,8 +49,8 @@ export interface SlotlockStoreApplicationBackendOptions {
50
49
  tenantErasure?: boolean;
51
50
  }
52
51
  /**
53
- * Serve the application API (application-api.ts) from the production store, in the principal's
54
- * tenant. Every resource an operation names must be the tenant's, or it is not found; holds are
55
- * reservations under the tenant's row-level security, so another tenant's hold is not found either.
52
+ * Serve the application API (application-api.ts) from the store in the principal's tenant. Every
53
+ * resource named must be the tenant's, or it is not found; holds are under the tenant's RLS, so
54
+ * another tenant's is not found either.
56
55
  */
57
56
  export declare function createSlotlockStoreApplicationBackend(store: SlotlockStore, options?: SlotlockStoreApplicationBackendOptions): SlotlockApplicationBackend;
@@ -10,13 +10,12 @@ import { calendarEventRollingHorizon, } from './store.js';
10
10
  import { maintainSlotlockTenant } from './maintenance.js';
11
11
  import { parseAvailabilityRules } from './weekly-hours.js';
12
12
  /**
13
- * A write runs under READ COMMITTED whatever the role's default: a store given tenant limits (as
14
- * `slotlock serve`'s is) counts it against them under the tenant's lock, which needs that level,
15
- * and refuses it otherwise.
13
+ * Writes run under READ COMMITTED whatever the role's default: tenant limits are counted under a
14
+ * lock that needs that level.
16
15
  */
17
16
  const WRITE = { isolation: 'read committed' };
18
- // The UUIDs withTenant takes as a keyId (versions 1-5, as Slotlock's keys are): any other a custom
19
- // authenticator's principal carries is left out, not refused.
17
+ // The UUIDs withTenant takes as a keyId (versions 1-5); any other from a custom authenticator is
18
+ // left out, not refused.
20
19
  const KEY_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
21
20
  /** Who a call's changes are recorded as in the history: the principal's subject, kept short. */
22
21
  function actorOf(context) {
@@ -41,7 +40,6 @@ async function withTenant(store, context, callback, options) {
41
40
  signal: context.signal,
42
41
  ...(actor !== undefined ? { actor } : {}),
43
42
  ...(context.origin !== undefined ? { origin: context.origin } : {}),
44
- // An authenticator's own principal may carry something else there: then it is left out.
45
43
  ...(typeof keyId === 'string' && KEY_ID.test(keyId) ? { keyId } : {}),
46
44
  });
47
45
  }
@@ -60,22 +58,18 @@ async function withTenant(store, context, callback, options) {
60
58
  return value;
61
59
  }
62
60
  /**
63
- * Busy intervals one free/busy read or search may gather across all the resources it names: as many
64
- * as the store reads for one resource. Without it, a request naming 100 resources at that limit, or
65
- * one resource 100 times, did 100 times the work of one.
61
+ * Busy intervals one free/busy read or search may gather across all its resources: as many as the
62
+ * store reads for one, so naming many resources cannot multiply the work.
66
63
  */
67
64
  const MAX_REQUEST_BUSY_INTERVALS = 10_000;
68
65
  /**
69
- * Bookable windows one search may expand from its resources' availability rules in all: five
70
- * resources of 50 daily rules over a year, or a hundred of two. Without it, a search of 100 such
71
- * resources over a year expanded 1.8 million windows and held the process for seven seconds.
66
+ * Bookable windows one search may expand from its resources' rules in all (five resources of 50
67
+ * daily rules over a year, or a hundred of two), so a broad search cannot hold the process.
72
68
  */
73
69
  const MAX_REQUEST_RULE_WINDOWS = 100_000;
74
- /** The resources a request names, each once, in the order first named. */
75
70
  function distinctResourceIds(input) {
76
71
  return [...new Set(input.resource_ids)];
77
72
  }
78
- /** Counts a request's busy intervals against MAX_REQUEST_BUSY_INTERVALS, refusing past it. */
79
73
  function busyBudget() {
80
74
  let left = MAX_REQUEST_BUSY_INTERVALS;
81
75
  return (intervals) => {
@@ -169,12 +163,12 @@ function agentOwnerRef(context) {
169
163
  .digest('hex')}`;
170
164
  }
171
165
  function agentExternalRef(context, idempotencyKey) {
172
- // This format is already a stable client-visible identity; keep it unchanged.
166
+ // Already a stable client-visible identity; keep it unchanged.
173
167
  return `agent:${agentPrincipalDigest(context, idempotencyKey)}`;
174
168
  }
175
169
  function agentCommandIdempotencyKey(context, idempotencyKey) {
176
- // Keep the principal in the digest too so this remains safe with older tenant-only stores.
177
- // Hashing keeps the authenticated subject and caller's raw retry key out of durable command rows.
170
+ // The principal stays in the digest so this remains safe with older tenant-only stores; hashing
171
+ // keeps the subject and raw retry key out of durable rows.
178
172
  return `agent-command:${agentPrincipalDigest(context, idempotencyKey)}`;
179
173
  }
180
174
  function attendeeProjection(attendee) {
@@ -192,8 +186,8 @@ function attendeeProjection(attendee) {
192
186
  */
193
187
  export function projectSlotlockCalendarEvent(event) {
194
188
  return {
195
- // External references are the stable tenant-visible identity. Internal UUIDs never become an
196
- // unscoped lookup capability at the protocol boundary.
189
+ // External references are the tenant-visible identity: internal UUIDs never become an unscoped
190
+ // lookup at the protocol boundary.
197
191
  id: event.externalRef,
198
192
  resource_id: event.resourceId,
199
193
  starts_at: event.start.toISOString(),
@@ -247,19 +241,17 @@ const BOOKING_RULE_CODES = Object.freeze({
247
241
  uncertain: 'booking_rule_uncertain',
248
242
  });
249
243
  function operationError(refusal) {
250
- // A broken booking rule is named in the code, which is all an agent's error carries.
251
244
  const code = typeof refusal === 'string'
252
245
  ? refusal
253
246
  : refusal.code === 'booking_rule_violated' && refusal.rule
254
247
  ? (BOOKING_RULE_CODES[refusal.rule] ?? refusal.code)
255
248
  : refusal.code;
256
249
  const status = slotlockAgentErrorStatus(code);
257
- // A gone hold is named, so the agent holds again; it covers expired and another holder's alike.
250
+ // A gone hold (expired or another holder's) is named, so the agent holds again.
258
251
  return new SlotlockAgentOperationError(status === 404 && code !== 'hold_not_found' ? 'not_found' : code, status);
259
252
  }
260
253
  /** Resource and event ids: the UUIDs the store issues and validates. */
261
254
  const STORE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
262
- /** A resource page's cursor is the last resource id of the previous page. */
263
255
  function parseResourceCursor(value) {
264
256
  if (value === undefined)
265
257
  return undefined;
@@ -299,12 +291,10 @@ function page(rows, limit) {
299
291
  return { shown, next: rows.length > limit && last ? encodeEventCursor(last) : null };
300
292
  }
301
293
  /**
302
- * The caller's resource with this id, or `resource_not_found`. The tools accept any id an agent may
303
- * send (an external reference such as `vehicle-42`, the nil UUID, a version 7 UUID). The store
304
- * issues version 4 UUIDs (gen_random_uuid) and refuses an id that is not a version 1 to 5 UUID as an
305
- * invalid identity, so such an id names no resource: it is not found, like a well-formed id that
306
- * names nothing or another tenant's resource. With `visibility`, a resource of the other is not
307
- * found either: agents ask for `shared`, so a private resource is invisible to them.
294
+ * The caller's resource with this id, or `resource_not_found`. Agents may send any id; one that is
295
+ * not a store-issued (v1-5) UUID names no resource and is not found, like another tenant's. With
296
+ * `visibility`, a resource of the other visibility is not found either, so private ones are
297
+ * invisible to agents.
308
298
  */
309
299
  async function requireResource(tenantStore, tenantRef, resourceId, visibility) {
310
300
  if (!STORE_ID_PATTERN.test(resourceId)) {
@@ -325,9 +315,8 @@ function requireAgentResource(tenantStore, tenantRef, resourceId) {
325
315
  }
326
316
  /**
327
317
  * The store's change feed for live updates (`subscriptions.changes`): each tenant's changes read
328
- * under its row-level security, one indexed query per read. Readers poll it (change-watch.ts:
329
- * every `SLOTLOCK_FEED_BUSY_MS` after a read that found changes, backing off to
330
- * `SLOTLOCK_FEED_IDLE_MS`); `sql` is the store's client, used for the feed's head.
318
+ * under its RLS, one indexed query per read. Readers poll it (change-watch.ts); `sql` is the
319
+ * store's client, used for the feed's head.
331
320
  */
332
321
  export function createSlotlockStoreChangeSource(store, sql) {
333
322
  return {
@@ -433,8 +422,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
433
422
  const members = await tenantStore.listPoolMembers({ tenantRef, pool: input.pool, visibility: 'shared' });
434
423
  if (members.length === 0)
435
424
  throw new SlotlockAgentOperationError('pool_not_found', 404);
436
- // Each member in turn, each try its own savepoint: a member that is taken or that the
437
- // booking's rules refuse is passed over; the exclusion constraint still decides each one.
425
+ // Each member in turn, each in its own savepoint: one taken or refused by its rules is
426
+ // passed over; the exclusion constraint still decides.
438
427
  for (const member of members) {
439
428
  const result = await put(member.id);
440
429
  if (result.ok)
@@ -457,7 +446,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
457
446
  const members = await tenantStore.listPoolMembers({ tenantRef, pool, visibility: 'shared' });
458
447
  if (members.length === 0)
459
448
  throw new SlotlockAgentOperationError('pool_not_found', 404);
460
- // Each member's busy time, as edges: a count of busy members that rises and falls.
449
+ // Busy time as edges: a count of busy members that rises and falls.
461
450
  const edges = new Map([
462
451
  [window.start.getTime(), 0],
463
452
  [window.end.getTime(), 0],
@@ -477,9 +466,9 @@ export function createSlotlockStoreAgentBackend(store, options) {
477
466
  charge(freeBusy.busy.length);
478
467
  if (freeBusy.coverage.state !== 'complete')
479
468
  complete = false;
480
- // Not free either where its booking rules refuse a booking: before its notice, past how
481
- // far ahead it books, and, when it enforces them, outside its bookable hours. Its slot
482
- // step is not counted: it moves when a booking may start, not whether the member is free.
469
+ // Also not free where its booking rules refuse a booking (notice, advance, enforced
470
+ // hours). Its slot step is not counted: it moves when a booking may start, not whether
471
+ // the member is free.
483
472
  const rules = member.effectiveBookingRules;
484
473
  const search = bookingSearch({ rules, timezone: member.timezone, window, now });
485
474
  const latestStart = rules?.maxAdvanceDays !== undefined
@@ -524,7 +513,6 @@ export function createSlotlockStoreAgentBackend(store, options) {
524
513
  break;
525
514
  const free = members.length - busy;
526
515
  const last = intervals.at(-1);
527
- // Parts with the same count are one.
528
516
  if (last && last.free === free)
529
517
  last.end = new Date(next).toISOString();
530
518
  else
@@ -540,8 +528,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
540
528
  },
541
529
  async bookTogether(context, rawInput) {
542
530
  const input = rawInput;
543
- // In one order for every bundle, so two over the same resources wait on each other in turn
544
- // and never deadlock.
531
+ // Sorted, so bundles over the same resources lock in one order and never deadlock.
545
532
  const resourceIds = [...new Set(input.resource_ids)].sort();
546
533
  if (resourceIds.length < 2)
547
534
  throw new SlotlockAgentOperationError('invalid_arguments', 400);
@@ -582,7 +569,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
582
569
  materializationWindow: materializationWindow(content),
583
570
  enforceBookingRules: true,
584
571
  });
585
- // Thrown, so the whole transaction rolls back: the events already put go with it.
572
+ // Thrown, so the whole transaction rolls back with the events already put.
586
573
  if (!result.ok) {
587
574
  const refusal = operationError(result);
588
575
  throw new SlotlockAgentOperationError(refusal.code, refusal.status, { resource_id: resourceId });
@@ -635,7 +622,6 @@ export function createSlotlockStoreAgentBackend(store, options) {
635
622
  ...(resource.pool !== undefined ? { pool: resource.pool } : {}),
636
623
  });
637
624
  if (typeof input.pool === 'string') {
638
- // A pool's members, in the order a pool booking tries them; at most 100, one page.
639
625
  return withTenant(store, context, async (tenantStore) => ({
640
626
  resources: (await tenantStore.listPoolMembers({
641
627
  tenantRef: context.principal.tenantRef,
@@ -702,8 +688,7 @@ export function createSlotlockStoreAgentBackend(store, options) {
702
688
  let incomplete = false;
703
689
  for (const resourceId of resourceIds) {
704
690
  const resource = await requireAgentResource(tenantStore, context.principal.tenantRef, resourceId);
705
- // Only what a booking made now may take: the resource's booking rules bound the search,
706
- // as they bound the write that follows it.
691
+ // The resource's booking rules bound the search, as they bound the write that follows.
707
692
  const search = bookingSearch({
708
693
  rules: resource.effectiveBookingRules,
709
694
  timezone: resource.timezone,
@@ -747,8 +732,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
747
732
  best = { resourceId, interval: candidate };
748
733
  }
749
734
  }
750
- // A cross-resource answer is only certain when every requested resource is certain. A slot
751
- // from one healthy calendar must not mask a stale provider or recurrence horizon on another.
735
+ // A cross-resource answer is only certain when every resource is: one healthy calendar must
736
+ // not mask a stale one.
752
737
  const complete = !incomplete;
753
738
  const certainBest = complete ? best : null;
754
739
  return {
@@ -855,7 +840,6 @@ export function createSlotlockStoreAgentBackend(store, options) {
855
840
  },
856
841
  async updateEvent(context, rawInput) {
857
842
  const input = rawInput;
858
- // No event is on a resource the store never issued (an external reference, say).
859
843
  if (input.expected_resource_id !== undefined &&
860
844
  !STORE_ID_PATTERN.test(input.expected_resource_id)) {
861
845
  throw new SlotlockAgentOperationError('event_not_found', 404);
@@ -929,8 +913,8 @@ export function createSlotlockStoreAgentBackend(store, options) {
929
913
  tenantRef: context.principal.tenantRef,
930
914
  ownerRef: agentOwnerRef(context),
931
915
  externalRef: current.externalRef,
932
- // The caller's fence when it sent one, checked under the event's lock; otherwise the
933
- // resource just read, so a move between that read and the write is not overwritten.
916
+ // The caller's fence when sent, checked under the event's lock; otherwise the resource
917
+ // just read, so a move in between is not overwritten.
934
918
  expectedResourceId: input.expected_resource_id ?? current.resourceId,
935
919
  resourceId,
936
920
  expectedRevision: input.expected_revision,
@@ -952,7 +936,6 @@ export function createSlotlockStoreAgentBackend(store, options) {
952
936
  throw new SlotlockAgentOperationError('store_inconsistent', 503);
953
937
  return { event: projectSlotlockCalendarEvent(updated), replayed: result.idempotent };
954
938
  }, WRITE);
955
- // A moved or shortened event may have freed the time it held.
956
939
  if (!moved.replayed)
957
940
  await offerFreedTime(store, context.principal.tenantRef);
958
941
  return moved;
@@ -990,7 +973,6 @@ export function createSlotlockStoreAgentBackend(store, options) {
990
973
  },
991
974
  };
992
975
  }
993
- /** A store failure thrown with a `code` the caller may see, or the error itself to report. */
994
976
  function storeRefusal(error) {
995
977
  const code = error?.code;
996
978
  if (typeof code !== 'string')
@@ -1005,7 +987,6 @@ function storeRefusal(error) {
1005
987
  }
1006
988
  return error;
1007
989
  }
1008
- /** The status an application refusal answers with; the resource of another tenant is not found. */
1009
990
  function applicationRefusal(code) {
1010
991
  if (code === 'resource_tenant_mismatch') {
1011
992
  return new SlotlockAgentOperationError('resource_not_found', 404);
@@ -1033,9 +1014,8 @@ function waitlistOutput(entry) {
1033
1014
  };
1034
1015
  }
1035
1016
  /**
1036
- * Offer what a cancel or release just freed to the tenant's waitlist now, rather than at the next
1037
- * maintenance pass. Best effort, after the freeing transaction committed: maintenance offers
1038
- * whatever this misses.
1017
+ * Offer what a cancel or release just freed to the waitlist now. Best effort, after commit:
1018
+ * maintenance offers whatever this misses.
1039
1019
  */
1040
1020
  async function offerFreedTime(store, tenantRef) {
1041
1021
  try {
@@ -1085,7 +1065,6 @@ function rulesInput(value) {
1085
1065
  throw storeRefusal(error);
1086
1066
  }
1087
1067
  }
1088
- /** Booking rules as the application API writes them, snake_case; `null` for none. */
1089
1068
  function bookingRulesOutput(rules) {
1090
1069
  if (!rules)
1091
1070
  return null;
@@ -1166,7 +1145,7 @@ function reservationOutput(reservation) {
1166
1145
  reservation_ref: reservation.externalRef,
1167
1146
  start: reservation.start.toISOString(),
1168
1147
  end: reservation.end.toISOString(),
1169
- // The store keeps milliseconds; a buffer set in seconds through the library still shows as busy.
1148
+ // The store keeps milliseconds; round up so a sub-minute buffer still shows as busy.
1170
1149
  buffer_after_minutes: Math.ceil(reservation.bufferAfterMs / 60_000),
1171
1150
  revision: reservation.revision,
1172
1151
  source: reservation.source,
@@ -1193,9 +1172,9 @@ function applicationCommandKey(context, idempotencyKey) {
1193
1172
  /** The most busy intervals one slotlock_list_busy answers, as one free/busy request reads. */
1194
1173
  const MAX_LISTED_BUSY = MAX_REQUEST_BUSY_INTERVALS;
1195
1174
  /**
1196
- * Serve the application API (application-api.ts) from the production store, in the principal's
1197
- * tenant. Every resource an operation names must be the tenant's, or it is not found; holds are
1198
- * reservations under the tenant's row-level security, so another tenant's hold is not found either.
1175
+ * Serve the application API (application-api.ts) from the store in the principal's tenant. Every
1176
+ * resource named must be the tenant's, or it is not found; holds are under the tenant's RLS, so
1177
+ * another tenant's is not found either.
1199
1178
  */
1200
1179
  export function createSlotlockStoreApplicationBackend(store, options = {}) {
1201
1180
  const max = options.maxTenantResources;
@@ -1289,7 +1268,6 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1289
1268
  },
1290
1269
  async deleteResource(context, input) {
1291
1270
  const id = input.resource_id;
1292
- // An id the store never issues names no resource: there is nothing to delete.
1293
1271
  if (!STORE_ID_PATTERN.test(id))
1294
1272
  return { deleted: false };
1295
1273
  return withTenant(store, context, async (tenant) => {
@@ -1552,7 +1530,6 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1552
1530
  },
1553
1531
  async releaseHold(context, input) {
1554
1532
  const id = input.hold_id;
1555
- // An id that is not one the store issues names no hold: there is nothing to release.
1556
1533
  if (!STORE_ID_PATTERN.test(id))
1557
1534
  return { released: false };
1558
1535
  const released = await withTenant(store, context, async (tenant) => ({
@@ -1608,7 +1585,6 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1608
1585
  throw applicationRefusal(result.code);
1609
1586
  return { reservation: reservationOutput(result.reservation), replayed: result.idempotent };
1610
1587
  }, WRITE);
1611
- // A move frees the time the booking held.
1612
1588
  if (input.revision > 1 && !put.replayed) {
1613
1589
  await offerFreedTime(store, tenantOf(context));
1614
1590
  }
@@ -1778,7 +1754,6 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1778
1754
  async cancelTenantEvent(context, input) {
1779
1755
  const id = input.event_id;
1780
1756
  const expectedResourceId = input.expected_resource_id;
1781
- // An id the store never issues names no event, and no event is on a resource it never issued.
1782
1757
  if (!STORE_ID_PATTERN.test(id) ||
1783
1758
  (expectedResourceId !== undefined && !STORE_ID_PATTERN.test(expectedResourceId))) {
1784
1759
  throw new SlotlockAgentOperationError('event_not_found', 404);
@@ -1820,8 +1795,8 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1820
1795
  throw new SlotlockAgentOperationError('invalid_confirmation', 400);
1821
1796
  }
1822
1797
  return withTenant(store, context, async (tenant) => {
1823
- // Read under the tenant's lock, which a suspension waits for: a suspended tenant's data is
1824
- // kept for its operator, who erases it (slotlock tenant erase) when they decide to.
1798
+ // Read under the tenant's lock: a suspended tenant's data is kept for its operator to
1799
+ // erase.
1825
1800
  if (await tenant.isTenantSuspended({ tenantRef: tenantOf(context) })) {
1826
1801
  throw new SlotlockAgentOperationError('tenant_suspended', 403);
1827
1802
  }
@@ -1879,7 +1854,6 @@ export function createSlotlockStoreApplicationBackend(store, options = {}) {
1879
1854
  async runMaintenance(context) {
1880
1855
  assertActive(context);
1881
1856
  try {
1882
- // A caller goes round again while `capped`; when to do so at once is the scheduler's.
1883
1857
  const { rollPending: _, zoneShifts, staleSources, noShows, waitlistOffers, ...result } = await maintainSlotlockTenant(store, tenantOf(context), {
1884
1858
  batches,
1885
1859
  signal: context.signal,
package/api-keys.js CHANGED
@@ -8,7 +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
+ import { SlotlockAgentOperationError } from './agent-server.js';
12
12
  import { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT } from './ddl.js';
13
13
  import { isSlotlockChangeOrigin } from './event-feed.js';
14
14
  export { SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_RETAINED_LIMIT };
@@ -16,9 +16,9 @@ export interface SlotlockApplicationInvocationContext {
16
16
  }
17
17
  type Handler = (context: SlotlockApplicationInvocationContext, input: Record<string, unknown>) => Promise<Record<string, unknown>>;
18
18
  /**
19
- * What serves the application operations. Every method receives the input its operation's schema
20
- * accepted, and returns what its output schema describes; a refusal is a thrown
21
- * `SlotlockAgentOperationError`. The tenant is the principal's, never an argument.
19
+ * What serves the application operations: each method gets schema-valid input and returns what its
20
+ * output schema describes; refusals throw `SlotlockAgentOperationError`. The tenant is the
21
+ * principal's, never an argument.
22
22
  */
23
23
  export interface SlotlockApplicationBackend {
24
24
  createResource: Handler;
@@ -1,14 +1,13 @@
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.
1
+ // The application API: what an application, rather than an agent, does over HTTP that the
2
+ // TypeScript store does in process (resources and hours, holds and reservations, coverage, any of
3
+ // the tenant's events, erasure when allowed, maintenance), for any language.
5
4
  //
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.
5
+ // Served as REST under /v1 beside the agent tools when the agent server has an `application`
6
+ // backend and `rest` is on, in the same OpenAPI document. Needs the `admin` scope; never MCP tools
7
+ // or A2A skills, so no agent is offered them.
9
8
  //
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.
9
+ // Contract only (names, schemas, routes); nothing from the agent server at runtime. The
10
+ // store-backed implementation is createSlotlockStoreApplicationBackend.
12
11
  import { SLOTLOCK_CHANGE_ORIGINS, SLOTLOCK_CHANGE_TYPES } from './event-feed.js';
13
12
  import { z } from 'zod';
14
13
  import { SLOTLOCK_EVENT_OUTPUT } from './event-schema.js';
@@ -58,9 +57,8 @@ const CURSOR = z.string().min(1).max(500);
58
57
  const NEXT_CURSOR = z.string().max(500).nullable();
59
58
  const VISIBILITY = z.enum(['shared', 'private']);
60
59
  /**
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.
60
+ * A retry identity, as the `Idempotency-Key` header or this field (the same value if both): the
61
+ * same key and request answer what the first did; another request is 422 idempotency_conflict.
64
62
  */
65
63
  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
64
  /** Minutes after the end kept busy as well, for turnaround: at most 30 days. */
@@ -104,8 +102,7 @@ const RESOURCE = z
104
102
  /** Its own booking rules; `null` when it has none. */
105
103
  booking_rules: BOOKING_RULES.nullable(),
106
104
  /**
107
- * What a booking on it must satisfy: the tenant's default rules, overlaid by its pool's,
108
- * overlaid by its own; `null` when no rule applies.
105
+ * Tenant defaults, overlaid by its pool's, overlaid by its own; `null` when no rule applies.
109
106
  */
110
107
  effective_booking_rules: BOOKING_RULES.nullable(),
111
108
  /** `private` resources are never shown to agents; fixed when the resource is created. */
@@ -208,11 +205,6 @@ const RESERVATION = z
208
205
  source: z.string().max(200).nullable(),
209
206
  })
210
207
  .strict();
211
- /**
212
- * Any of the tenant's events, whichever key wrote it: `id` is the store's id for it, which the
213
- * tenant-event routes take, `event_ref` the id its owner reads it by, and `owner_ref` who that is
214
- * (`internal` for the tenant's own code, `agent:<digest>` for an API key).
215
- */
216
208
  /** A JSCalendar Event (RFC 8984 §5.1); interop.ts's JSCalendarEvent names every property. */
217
209
  const JSCALENDAR_EVENT = z
218
210
  .object({
@@ -511,7 +503,7 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
511
503
  .max(7 * 24 * 60 * 60),
512
504
  source: SOURCE.optional(),
513
505
  idempotency_key: IDEMPOTENCY_KEY.optional(),
514
- // Last: a generated client's 0.2.0 positional arguments keep their places.
506
+ // Last, so a generated 0.2.0 client's positional arguments keep their places.
515
507
  buffer_after_minutes: BUFFER_AFTER_MINUTES.optional(),
516
508
  })
517
509
  .strict()
@@ -768,8 +760,8 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
768
760
  .object({
769
761
  source: SOURCE,
770
762
  /**
771
- * How the change feed names the source (coverage.recorded, .deleted, .stale): random,
772
- * made when it is first recorded for the resource, kept until it is deleted.
763
+ * How the change feed names the source: random, made when first recorded for the
764
+ * resource, kept until deleted.
773
765
  */
774
766
  source_id: z.string().regex(/^src_[0-9a-f]{32}$/),
775
767
  start: DATE_TIME,
@@ -870,8 +862,8 @@ export const SLOTLOCK_APPLICATION_OPERATIONS = Object.freeze([
870
862
  event_id: EVENT_ID,
871
863
  expected_revision: REVISION,
872
864
  expected_resource_id: RESOURCE_ID.optional(),
873
- // Required, so a client always sends it; the Idempotency-Key header may stand in for
874
- // it, since the server reads the header into it before the schema checks the request.
865
+ // Required, so clients always send it; the Idempotency-Key header may stand in, since the
866
+ // server copies it in before validation.
875
867
  idempotency_key: IDEMPOTENCY_KEY,
876
868
  })
877
869
  .strict(),