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/README.md CHANGED
@@ -5,6 +5,42 @@ reservable, answers "when is this free?" from weekly availability rules, and let
5
5
  double bookings, however many writers race. Agents call it over MCP or A2A; your code calls it as a
6
6
  TypeScript library.
7
7
 
8
+ ## Known issues in 0.2.0
9
+
10
+ Slotlock 0.2.0 can occupy less time than an event was written with, so another booking can take the
11
+ rest: around daylight-saving changes, in any time zone that has them, and at the end of some series.
12
+ The library and `slotlock serve` are both affected (the last case below is in the library's
13
+ iCalendar readers only); the 0.1 releases are older still and unsupported. 0.3.0 fixes all six
14
+ cases; while you run 0.2.0, do not rely on it alone to refuse bookings in them.
15
+
16
+ - A recurring event whose first occurrence spans a daylight-saving change gets that occurrence an
17
+ hour short: in spring, an hour of the written time is free to book.
18
+ - Recurring events written through the store (the agent tools and the application API) lose the
19
+ repeated hour of an autumn change: times in it move an hour early, and a series whose first
20
+ occurrence spans it can store no occurrences, so all of its time is free.
21
+ - A stored recurring series cannot cancel or move an occurrence that falls in the skipped hour of a
22
+ spring change; the time it was moved to stays free.
23
+ - An occurrence that starts in the skipped hour and lasts longer than the skipped hour is cut short:
24
+ a 75-minute occurrence occupies 15 minutes.
25
+ - A timed recurring series with a date-only `UNTIL` ends at midnight UTC, so it can lose its last
26
+ day.
27
+ - An event read from iCalendar (`parseICalendarChanges`, `parseTrustedICalendarEvents` or the
28
+ worker pool) whose `DURATION` counts hours loses an hour across a spring change, whether it is a
29
+ single event, a series, an `RDATE` period or a moved occurrence: `DURATION:PT48H` occupies 47
30
+ hours.
31
+
32
+ 0.1.0, 0.1.1 and 0.2.0 also pin zod 4.6.5 exactly, which has a high advisory
33
+ ([GHSA-mrhx-c7xc-m3w5](https://github.com/advisories/GHSA-mrhx-c7xc-m3w5): validating an oversized
34
+ array can exhaust memory), which `npm audit` reports. While you run one of them, add this to your
35
+ application's `package.json` and run `npm install`:
36
+
37
+ ```json
38
+ "overrides": {"zod": "4.6.7"}
39
+ ```
40
+
41
+ The full notice, with the versions affected, is in
42
+ [docs/release/0.2.0-known-issues.md](https://github.com/PILOTAAI/slotlock/blob/main/docs/release/0.2.0-known-issues.md).
43
+
8
44
  ## Why it is built for agents
9
45
 
10
46
  - Resources own calendars, not users, and the tenant comes only from the authenticated caller.
@@ -20,15 +56,15 @@ TypeScript library.
20
56
  npm install slotlock postgres
21
57
  ```
22
58
 
23
- Node.js 22.12+, PostgreSQL 16 with `btree_gist` (created for you if missing), and `postgres` ^3.4.5
24
- as a peer dependency. TypeScript declarations are included.
59
+ Node.js 22.12+, PostgreSQL 16 or newer with `btree_gist` (created for you if missing; CI runs 16,
60
+ 17 and 18), and `postgres` ^3.4.5 as a peer dependency. TypeScript declarations are included.
25
61
 
26
62
  ## Self-host with Docker
27
63
 
28
64
  From a clone of this repository:
29
65
 
30
66
  ```sh
31
- cp .env.example .env # set the four empty secrets: openssl rand -hex 32
67
+ scripts/quickstart-env.sh > .env # .env.example, its four secrets set to openssl rand -hex 32
32
68
  docker compose up --build --detach
33
69
  docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/London
34
70
  ```
@@ -44,19 +80,60 @@ docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/
44
80
  `migrate` service applies the schema as the owner.
45
81
  - `SLOTLOCK_AVAILABILITY` sets the default bookable hours. A resource can have its own
46
82
  (`slotlock hours set`, or the dashboard); with neither, no slot is offered.
47
- `SLOTLOCK_CONFIRM_WRITES=all` (the default) makes every write wait for a person.
83
+ `SLOTLOCK_CONFIRM_WRITES=all` (as `.env.example` sets it; the server's default is `none`) makes
84
+ every write wait for a person.
48
85
  - To serve beyond this machine, put a TLS proxy in front and set `SLOTLOCK_PUBLIC_URL`.
86
+ - The server limits requests per client address (before any key is looked up), per key, per
87
+ tenant and for the whole server, and answers `429 rate_limited` with `Retry-After` and the
88
+ `RateLimit` fields. The application API counts an admin key's calls in its tenant's own
89
+ `app_read` and `app_write` budgets, apart from the agents'; another key's are its own. A recurring create and every event update (a new title
90
+ re-expands a recurring event's series too) takes its write token, then waits for a slot: one at
91
+ a time per tenant (`tenant_series_writes`) and per server (`series_writes`); each server process
92
+ counts its own. A freed slot rests for as long as the server was busy while the write before
93
+ held it (at most a second), so with one slot, writes that each hold it under a second keep the
94
+ server busy at most about half the time. It then goes to the waiting tenant with the fewest
95
+ writes in flight and, among those, the one whose writes have kept the server busy least lately
96
+ (halving every ten seconds), then to the first to ask. So a tenant sending one write at a time
97
+ waits for the write running, its rest, and the other tenants with one write in flight whose
98
+ writes kept the server less busy: many such tenants, new ones included, can each go ahead of it
99
+ once. Tenants keeping more writes waiting never go ahead of it. Four more of a tenant's writes
100
+ may wait and the rest get 429. When the server's 64 places are all taken, a write from a tenant
101
+ with fewer writes in flight than the tenant with the most takes the place of that tenant's last
102
+ write, which gets 503 `server_busy`; otherwise it gets 503 at once. An expansion runs on the
103
+ server's main thread, so any request may also wait for the one running. A write that has waited
104
+ two seconds gets 429 while its tenant's own write is still running, otherwise 503
105
+ `server_busy`. An application API maintenance run waits the same way for slots of its own: one
106
+ per tenant, `maintenance_runs` per server. The health check is not counted. One tenant's
107
+ requests hold at most `tenant_transactions` (2) of the server's 10 database connections at once
108
+ and the rest wait their turn, so a tenant's writes waiting on its own locks leave the others to
109
+ other tenants and key lookups; a server with one tenant can set `tenant_transactions=10`. A
110
+ request whose transaction waits more than 5 seconds for a lock gets 503 `server_busy`.
111
+ `SLOTLOCK_LIMITS` changes the limits; behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` to the field it puts the
112
+ client's address in (`CF-Connecting-IP` behind Cloudflare) before the server takes traffic, or
113
+ every client shares the proxy's budget: the server logs a `client_address_field_ignored` warning
114
+ when such a field arrives without the setting. Behind two proxies, such as Cloudflare and then
115
+ kamal-proxy, name the first one's field: the server reads the last address of a list field such
116
+ as `X-Forwarded-For`, which the second proxy writes as Cloudflare's. A client can send that field
117
+ itself, so let nothing reach the server except through the proxy.
118
+ - The server also bounds what one tenant may store, whichever key, agent or application writes
119
+ it: 10,000 holds, and 100,000 each of bookings, calendar events and coverage rows, counted as
120
+ the store's `tenantLimits` count them, besides 10,000 resources. A write past one answers 409
121
+ `hold_limit_reached`, `reservation_limit_reached`, `event_limit_reached` or
122
+ `coverage_limit_reached`; `SLOTLOCK_LIMITS` changes them (`tenant_holds`,
123
+ `tenant_reservations`, `tenant_events`, `tenant_coverage`).
49
124
 
50
125
  ```sh
51
126
  slotlock hours set '[{"rrule":"FREQ=WEEKLY;BYDAY=SA","startMinutes":600,"durationMinutes":240}]' --resource vehicle-42
52
127
  slotlock hours clear --all # back to SLOTLOCK_AVAILABILITY
53
128
  ```
54
129
 
55
- `slotlock --help` lists every setting.
130
+ A rule is `rrule`, `startMinutes` and `durationMinutes` here and in `SLOTLOCK_AVAILABILITY`. The
131
+ REST application API spells every field in snake_case, so there it is `rrule`, `start_minutes` and
132
+ `duration_minutes`. `slotlock --help` lists every setting.
56
133
 
57
134
  ### Without Docker
58
135
 
59
- With Node.js 22.12+ and PostgreSQL 16, run each command as `npx slotlock` after
136
+ With Node.js 22.12+ and PostgreSQL 16 or newer, run each command as `npx slotlock` after
60
137
  `npm install slotlock postgres`, or from a clone, built once, as `node dist/cli.js` (a clone does not
61
138
  put `slotlock` on your `PATH`). Use two roles, as Compose does:
62
139
  the server connects as a role that owns nothing and cannot bypass row-level security, and `migrate`
@@ -100,6 +177,25 @@ slotlock key revoke <id>
100
177
  - A tenant may hold 100 active keys. The last six characters are a checksum secret scanners can
101
178
  verify offline.
102
179
 
180
+ ### Retiring resources and tenants
181
+
182
+ ```sh
183
+ slotlock resource archive vehicle-42 # no new bookings, holds or events; history kept
184
+ slotlock resource restore vehicle-42
185
+ slotlock resource delete vehicle-42 # with its history; refused while anything on it is live
186
+ slotlock tenant status
187
+ slotlock tenant suspend --reason "chargeback review"
188
+ slotlock tenant unsuspend
189
+ slotlock tenant erase --confirm "$SLOTLOCK_TENANT"
190
+ ```
191
+
192
+ - The resource commands take a reference or an id, and act in `SLOTLOCK_TENANT`.
193
+ - A suspended tenant may read and writes nothing; its keys still authenticate, but it gets no new
194
+ key or secret. Only the schema owner suspends or lifts a suspension, so `suspend` and `unsuspend`
195
+ connect as `SLOTLOCK_MIGRATE_DATABASE_URL`'s role when it is set.
196
+ - `erase` deletes everything the tenant has, its API keys included, at once and for good;
197
+ `--confirm` must repeat the tenant. A suspension outlives it.
198
+
103
199
  ### Dashboard
104
200
 
105
201
  The server can also serve a dashboard where people sign in with GitHub and manage their own keys and
@@ -173,7 +269,7 @@ this one and read its session secret, which signs in as any tenant.
173
269
  - the configuration's id;
174
270
  - your hostname (`routes`);
175
271
  - `SLOTLOCK_PUBLIC_URL`, `SLOTLOCK_SERVICE_URL` and `SLOTLOCK_DASHBOARD_USERS` (`vars`);
176
- - the two rate limiting bindings, each with a `namespace_id` unique in your account.
272
+ - the three rate limiting bindings, each with a `namespace_id` unique in your account.
177
273
  3. Create the GitHub OAuth app as above, with the callback URL
178
274
  `<SLOTLOCK_PUBLIC_URL>/dashboard/callback` on the Worker's origin.
179
275
  4. Store the secrets on the Worker: `npx wrangler secret put` for `SLOTLOCK_GITHUB_CLIENT_ID`,
@@ -185,11 +281,20 @@ this one and read its session secret, which signs in as any tenant.
185
281
  response is sent; Hyperdrive keeps the pool.
186
282
  - Anyone may start a sign-in, and each callback costs a database write and a request to GitHub
187
283
  with the app's client secret. An isolate runs at most eight at once, but Workers run many
188
- isolates, so two rate limiting bindings bound them all:
189
- - `SLOTLOCK_CLIENT_RATE_LIMIT` counts every dashboard request per client address;
190
- - `SLOTLOCK_SIGN_IN_RATE_LIMIT` counts sign-ins and callbacks from every client together.
191
-
192
- Their counts are per Cloudflare location. Without either binding the Worker answers 503.
284
+ isolates, so three rate limiting bindings bound them all:
285
+ - `SLOTLOCK_CLIENT_RATE_LIMIT` counts every dashboard request per client address (an IPv6
286
+ client's /64, as `slotlock serve` counts it);
287
+ - `SLOTLOCK_SIGN_IN_RATE_LIMIT` counts sign-ins and callbacks per client network (an IPv6
288
+ client's /48, a site's allocation, as `slotlock serve` counts its sign-ins), so a site rotating
289
+ through its /64s cannot use up the ceiling;
290
+ - `SLOTLOCK_SIGN_IN_CEILING_RATE_LIMIT` counts them from every client together, set far above one
291
+ client's limit so a few clients cannot use it up for everyone.
292
+
293
+ Their counts are per Cloudflare location. Without either of the first two the Worker answers 503.
294
+ Without the ceiling, as in a configuration from before it, `SLOTLOCK_SIGN_IN_RATE_LIMIT` counts
295
+ every client together, and the Worker logs a warning: add the ceiling before lowering that
296
+ binding's limit to one client's. The example configuration also removes query strings (a
297
+ callback's OAuth code) from the URLs Workers Logs keep.
193
298
  - The server checks a dashboard tenant against `SLOTLOCK_DASHBOARD_USERS` only when it serves the
194
299
  dashboard itself. With the dashboard on Workers, removing a person from the Worker's list signs
195
300
  them out of the dashboard; to stop their keys, revoke them or erase the tenant.
@@ -359,7 +464,11 @@ callback call uses, supports nested savepoints, and restores the previous contex
359
464
  standalone `set_config(..., true)` through a pool: its transaction ends before the next call.
360
465
  `withTenant(tenantRef, callback, { isolation: 'read committed' })` runs the callback in a READ
361
466
  COMMITTED transaction whatever the role's default, and refuses inside a caller's transaction of
362
- another level.
467
+ another level. A store sharing one pool among tenants can bound each tenant's share:
468
+ `createSlotlockStore(sql, { maxTenantTransactions, tenantLockTimeoutMs })` runs at most that many
469
+ of a tenant's `withTenant` transactions at once (the rest wait in the process, and leave the line
470
+ when `withTenant`'s `signal` aborts), and refuses one that waits longer than that for a lock with
471
+ `lock_timeout`, safe to retry. `slotlock serve` sets `tenant_transactions` (2) and 5,000.
363
472
 
364
473
  `createResource({ ..., maxTenantResources })` refuses a new resource (`resource_limit_reached`) once
365
474
  the tenant has that many (1-100,000); an existing `externalRef` is still updated. Capped creates for
@@ -417,26 +526,67 @@ Occurrences are stored for a window of at most 367 days and 2,000 occurrences; m
417
526
  the window forward, never moving its end back. Outside it, before or after, free/busy reports the
418
527
  time as unproven.
419
528
 
420
- A rule is expanded within a budget: 50,000 steps and a second for a feed (the whole read, from its
421
- text), or for a stored series as maintenance rolls it; half of that for one event as it is written.
422
- A feed's own time zones (VTIMEZONE) count against the same budget. A feed may carry 100 time zones
423
- and name 100 it does not define, 1,000 observances and 2,000 RDATE values in the zones it carries,
424
- and 20,000 RDATE and EXDATE values in its events; a property's name and parameters may take 8 KB
425
- and list 100. A SECONDLY to WEEKLY step, a `DURATION`, or an event's length may be a century at
529
+ A rule is expanded within a budget of steps, a count of work that reads no clock, so the same input
530
+ is accepted or refused however loaded the machine: 500,000 for a feed (the whole read, from its
531
+ text) or for a stored series as maintenance rolls it, about half a second, and 50,000 for one event
532
+ as it is written, refused within a tenth of a second whatever its rule or time zone (on a laptop; a
533
+ 4-vCPU Linux server takes up to twice as long). A step is about a microsecond: a step of ical.js's
534
+ search costs three, a weekday compared one, five days walked one, a year scanned three (and one for
535
+ each eight days it lists), and two weekdays of a year listed one. Each occurrence passed costs one
536
+ more, each conversion of a time in an IANA zone half (one and a half in a zone only the feed
537
+ defines), and each instant Intl is asked for to read a zone's offsets five, the first time an
538
+ expansion reads that day of the zone, whatever the process has read before. A feed's own time zones
539
+ (VTIMEZONE) count against the same budget, each change listed when one is expanded costing four
540
+ tenths: one written the way Outlook writes them, from 1601, costs about 6,000. A feed may carry 100
541
+ time zones and name 100 it does not define, 1,000 observances and 2,000 RDATE values in the zones it
542
+ carries, and 20,000 RDATE and EXDATE values in its events, and an event 8 RRULEs, each further one
543
+ costing a step at every occurrence its expansion passes; a property's name and parameters may take
544
+ 8 KB and list 100. A MINUTELY to WEEKLY step, a `DURATION`, or an event's length may be a century at
426
545
  most. A calendar that needs more, such as a rule that never occurs
427
546
  (`FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30`), is refused: `invalid_event` on a write, `invalid_icalendar`
428
547
  from a feed.
429
548
 
430
549
  A feed's rules are expanded from their start, and each series may pass 4,000 occurrences on the way
431
- (a daily one, about 11 years) whatever the feed's other series take. A stored series keeps where
432
- its last expansion stopped, and the next one resumes there, so a daily series keeps rolling for as
433
- long as it runs: its budget goes on the window, not on the years before it. A write that changes a
434
- series searches from its start, and resumes from where it stopped only if that search is over the
435
- budget. A place serves only the rule, start and time zone it was kept for, read by the same version
436
- of ical.js, and never lies past an occurrence that could still reach the window (one within the
437
- event's length and a day of it, or one an exception moves into it). Otherwise the series is searched
438
- from its start, within the same budget. During maintenance, a stored series that outgrows the budget
439
- is counted as `refused` (its ids in `refusedEventIds`) and keeps its old coverage.
550
+ (a daily one, about 11 years) whatever the feed's other series take. A feed series with one DAILY or
551
+ WEEKLY rule and no COUNT, RDATE or `RANGE=THISANDFUTURE` override is searched from just before the
552
+ window instead, so a daily standup since 2014 reads like one that began this year. A stored series
553
+ keeps where its last expansion stopped, and the next one resumes there, so a daily series keeps
554
+ rolling for as long as it runs: its budget goes on the window, not on the years before it. A write
555
+ that changes a series searches from its start, and resumes from where it stopped only if that search
556
+ is over the budget. A place serves only the rule, start and time zone it was kept for, read by the
557
+ same version of ical.js, and never lies past an occurrence that could still reach the window (one
558
+ within the event's length and a day of it, or one an exception moves into it). Otherwise the series
559
+ is searched from its start, within the same budget. During maintenance, a stored series that
560
+ outgrows the budget, that an earlier release stored and this one refuses, or whose roll fails for
561
+ any other reason (a row no release could have written, such as a rule ical.js cannot read), is
562
+ counted as `refused` (its ids in `refusedEventIds`), keeps its old coverage and waits an hour behind
563
+ the others. A pass whose roll fails still prunes, then reports the failure.
564
+ `storedCalendarEventProblem(event)` says why this
565
+ release would refuse a stored event (a rule shape it no longer expands, `COUNT` with a yearly day
566
+ some years lack, or an exception that names no occurrence), or `null`; run it over your stored
567
+ events before upgrading.
568
+
569
+ A stored rule is daily or less frequent and follows RFC 5545's grammar: `COUNT=0`, an unknown or
570
+ repeated part, or `COUNT` with `UNTIL` is refused. Shapes ical.js 2.2.1 expands differently from
571
+ RFC 5545 are refused everywhere, a feed included, and `recurrenceRuleProblem(rule)` says why:
572
+ `BYWEEKNO`; a two-digit `BYDAY` ordinal; `BYSETPOS` other than with `FREQ=MONTHLY` and `BYDAY`
573
+ alone; `BYMONTH` with `FREQ=MONTHLY`; a negative `BYMONTHDAY` other than with `FREQ=MONTHLY` and
574
+ no `BYDAY`; `FREQ=YEARLY` with `BYMONTHDAY` but no `BYMONTH`, with a day none of its months has,
575
+ with `COUNT` or `BYDAY` and a day one of its months can lack, with `BYYEARDAY` and `BYMONTH` or
576
+ `BYMONTHDAY`, or with a numbered `BYDAY` and `BYMONTHDAY`; `BYHOUR` with `FREQ=HOURLY`,
577
+ `BYMINUTE` or `BYSECOND` with `FREQ=MINUTELY`, and any of them with `FREQ=MONTHLY` or `YEARLY`;
578
+ and `FREQ=SECONDLY`. A yearly day some years lack (29 February, or a 31st with April) occurs only
579
+ in the years that have it, as RFC 5545 says, so its series may not add an RDATE or a second RRULE.
580
+ A DATE `UNTIL` on a timed series includes its whole day on the series' clock. An event's time zone
581
+ is an IANA name, stored in its canonical spelling (`europe/london` becomes `Europe/London`); a UTC
582
+ offset such as `+05:00` is refused.
583
+
584
+ A recurrence exception names the occurrence it cancels or moves by that occurrence's instant, as
585
+ `expandCalendarEventOccurrences` reports it, and is exported as a UTC `RECURRENCE-ID`; one that
586
+ names no occurrence is refused, so a mistyped instant is an error rather than a cancellation that
587
+ frees nothing. A series' occurrences never overlap each other: one that reaches past the next one's
588
+ start (a day-long occurrence the night the clocks go forward) ends there, and the next ends at the
589
+ later of the two ends, so the series holds the same time as separate reservations.
440
590
 
441
591
  ### External calendars
442
592
 
@@ -445,6 +595,11 @@ Slotlock gives them neutral boundaries:
445
595
 
446
596
  - `normalizeExternalCalendarChange` for webhook changes; `parseICalendarChanges` and `emitICalendar`
447
597
  for bounded iCalendar in and out; `emitITipCalendar` for invitations.
598
+ - `parseICalendarFeed` reads a feed series by series: a series it cannot read (a refused rule
599
+ shape, an end the clocks move onto its start, DTEND with DURATION, more than 4,000 occurrences
600
+ from its start, or more work than the feed's budget has left) is named in `refused` by UID, with
601
+ `limit: true` for the last two, and the rest is read. Treat a refused series' time as busy, or the
602
+ feed's coverage as incomplete. `parseICalendarChanges` refuses the whole feed instead.
448
603
  - All-day events become whole local days in the calendar's time zone; a feed without one is refused.
449
604
  - A TZID Intl knows is read as that IANA zone, whether or not the feed defines it (a VTIMEZONE's
450
605
  own rules for such a name are not used); one only the feed defines, such as a Windows name, is
@@ -459,21 +614,40 @@ Slotlock gives them neutral boundaries:
459
614
  instant two of them, or an RRULE and an RDATE, name is one occurrence; every EXDATE excludes the
460
615
  occurrence it names (RFC 5545 §3.8.5). A DATE EXDATE of a timed series names the occurrence at the
461
616
  series' start time that day.
462
- - An occurrence ends on the wall clock, as calendars show it (00:30-02:30 lasts three hours the
463
- night the clocks go back). One that starts in an hour the clocks skip lasts its series' exact
464
- length instead, from the offset before the gap (RFC 5545 §3.3.5).
617
+ - An occurrence of a series occupies both readings of it when the clocks change inside it: its
618
+ exact length from its start (RFC 5545 §3.8.5.3) and its written end on the wall clock (the later
619
+ instant of a repeated hour). 00:00-06:00 holds six hours the night the clocks go forward and
620
+ seven the night they go back. A start in a skipped hour moves forward by the gap; one in a
621
+ repeated hour is its first instant. One-off events, overrides and PERIOD RDATEs occupy exactly
622
+ the times they give. A series' occurrences never overlap each other, as for stored series. SPEC.md,
623
+ "Daylight-saving time", lists the cases.
624
+ - An override without DTEND or DURATION lasts its series' length. One whose RECURRENCE-ID names no
625
+ occurrence, or whose occurrence the series also EXDATEs, occupies its own time, as the provider
626
+ shows it. The instances of a CalDAV-expanded series are each read, the first with or without a
627
+ RECURRENCE-ID. An event with both DTEND and DURATION is refused.
465
628
  - `parseTrustedICalendarEvents` reads times as `parseICalendarChanges` does, so a series stored from
466
- it occupies what the feed reports.
629
+ it occupies what the feed reports; an all-day series, stored from local midnight to local
630
+ midnight, may also hold the hour after a midnight the clocks go forward on, never less. It drops
631
+ an EXDATE that names no occurrence, and refuses what a stored series cannot hold: a zone only the
632
+ feed defines (a Windows name such as `GMT Standard Time`; map it to an IANA zone first), a DTSTART
633
+ or DTEND the clocks skip (an all-day series' first midnight included), a `DURATION` that counts
634
+ days or weeks outside UTC, an instance whose transparency differs from its series', or a moved
635
+ instance that names no occurrence.
636
+ - `emitICalendar` and `emitITipCalendar` write a VTIMEZONE for the zone their times name (RFC 5545
637
+ §3.6.5), from the IANA rules: every change over the years the event covers, and, where a zone
638
+ changes on the same numbered or last weekday of a month every year, yearly rules that carry a
639
+ series on past them. A zone that does not, such as Casablanca, Gaza, Jerusalem or Santiago, is
640
+ exact over those years only.
467
641
  - `recordCalendarCoverage` marks how much of a feed is read. Pass the sources a resource depends on as
468
642
  `requiredSources`, and any coverage short of `complete` means the time is not proven free.
469
643
 
470
644
  Read a feed someone else controls with `createICalendarWorkerPool`, which runs the same parsers on
471
645
  worker threads. However costly the feed, the thread that serves requests never runs ical.js on it: a
472
646
  worker past its deadline (3 seconds by default) or its heap (128 MB) is stopped, and the feed is
473
- refused with `CalendarExpansionLimitError`. When every worker is busy and the queue is full, the
474
- pool answers `ICalendarWorkerBusyError` at once. A worker starts in about 40 ms and is reused; idle
475
- workers do not keep the process alive. The pool runs from the built package (the worker is
476
- `ical-worker-thread.js`), not from Slotlock's TypeScript sources.
647
+ refused with `CalendarExpansionLimitError`. The pool offers `parseICalendarFeed` too. When every
648
+ worker is busy and the queue is full, the pool answers `ICalendarWorkerBusyError` at once. A worker
649
+ starts in about 40 ms and is reused; idle workers do not keep the process alive. The pool runs from
650
+ the built package (the worker is `ical-worker-thread.js`), not from Slotlock's TypeScript sources.
477
651
 
478
652
  <!-- example: examples/feeds.ts#feeds -->
479
653
  ```ts
@@ -609,7 +783,7 @@ export async function startCalendarServer(config: CalendarServerConfig) {
609
783
  | `slotlock_list_events` | Events the caller created in a window | read-only |
610
784
  | `slotlock_get_event` | One event the caller created | read-only |
611
785
  | `slotlock_create_event` | Create an event of up to 3,660 days (idempotent) | write |
612
- | `slotlock_update_event` | Patch at an expected revision | write |
786
+ | `slotlock_update_event` | Patch at an expected revision, optionally fenced to `expected_resource_id` | write |
613
787
  | `slotlock_delete_event` | Tombstone at an expected revision | write, destructive |
614
788
 
615
789
  A query window is at most 367 days. Each caller sees only the events it created, but everyone's
@@ -635,7 +809,7 @@ A refusal is a result the model can act on:
635
809
  | `confirmation_required` | The write needs a person, and this client cannot ask one |
636
810
  | `confirmation_declined`, `confirmation_cancelled` | The person said no, or dismissed the question |
637
811
  | `invalid_cursor`, `invalid_event`, `invalid_window`, … | The store rejected a bounded input |
638
- | `request_aborted`, `store_inconsistent` | Transient; retry |
812
+ | `request_aborted`, `server_busy`, `store_inconsistent` | Transient; retry |
639
813
 
640
814
  #### Errors from your own backend
641
815
 
@@ -691,6 +865,9 @@ handover".` Only an explicit yes runs the write.
691
865
  - The pending question is sealed (HMAC-SHA256) to the caller, the tool and the exact arguments, and
692
866
  expires after `ttlSeconds` (600).
693
867
  - Clients that cannot ask (MCP 2025 revisions, A2A) get `confirmation_required`; nothing is written.
868
+ - The yes comes from the client: Slotlock relies on the MCP client to ask its person, and a key
869
+ holder's own client can answer for itself. Confirmation keeps an agent from writing unasked; it is
870
+ not an authorization boundary. Scopes and `authorize` decide who may write.
694
871
  - `secrets` holds 1 to 8 keys of 32+ bytes: list the new one first to rotate. `onEvent` reports each
695
872
  outcome for audit.
696
873
 
@@ -703,7 +880,13 @@ returns busy time and coverage for the next `resourceWindowDays` (30). `subscrip
703
880
  - Changes are found by re-reading through your backend every `subscriptions.pollIntervalMs` (10 s),
704
881
  re-checking the caller each time.
705
882
  - A subscription ends after `maxDurationMs` (15 minutes), on `shutdown()`, or when access is lost.
706
- - At most 4 open per caller and 256 per server (`maxPerPrincipal`, `maxTotal`).
883
+ - At most 4 open per caller and 256 per server (`maxPerPrincipal`, `maxTotal`). `maxPerTenant`
884
+ caps one tenant's across all its callers (default `maxTotal`; `slotlock serve` sets 16), so a
885
+ tenant with many keys cannot hold every slot. When the server is full, a tenant holding fewer
886
+ than the tenant holding most, less one, ends that tenant's oldest stream instead of being
887
+ refused, so many tenants cannot hold every slot either. `slotlock serve` sets `maxTotal` to
888
+ `subscriptions` (256) and lets one key hold `key_subscriptions` (4), and always fewer than its
889
+ tenant may unless that is one, so one key cannot lock its tenant's other keys out.
707
890
  `subscriptions: false` turns them off.
708
891
 
709
892
  ### Caching and tracing
@@ -756,11 +939,15 @@ curl "http://localhost:8080/v1/resources" -H "Authorization: Bearer $SLOTLOCK_AP
756
939
  - Keys, scopes, `authorize` and the rate limit apply as over MCP. The body is the tool's output, or
757
940
  `{"error":{"code":…}}` with the matching HTTP status.
758
941
  - REST cannot ask a person, so a write listed in `confirmation` answers `428 confirmation_required`.
942
+ - Free/busy and the next-available search read each resource they name once, however often it is
943
+ named, and 10,000 busy intervals in all; the next-available search also expands 100,000 bookable
944
+ windows from its resources' hours in all. Past either they answer `400 calendar_work_limit`: ask
945
+ for fewer resources or a shorter window.
759
946
  - There are no CORS headers: call it from a server, not a browser.
760
947
  - From Python, Go, Rust, Java or .NET, use the [clients](clients/README.md): one method per
761
948
  operation, the application API included, each tested against the server in CI. For another
762
- language, generate one from [`openapi.json`](openapi.json) (or a server's `/openapi.json`) with any
763
- OpenAPI 3.1 generator.
949
+ language, generate one from [`openapi.json`](openapi.json) (or a server's `/openapi.json`, or
950
+ `slotlock/openapi.json` in the npm package) with any OpenAPI 3.1 generator.
764
951
 
765
952
  ### Application API
766
953
 
@@ -772,16 +959,27 @@ waits for a confirmation.
772
959
 
773
960
  | Request | Does |
774
961
  | --- | --- |
775
- | `POST /v1/resources` | Create the resource with this `external_ref`, or update its `timezone` |
776
- | `GET /v1/resources/{resource_id}` | One resource, with its own weekly hours |
962
+ | `POST /v1/resources` | Create the resource with this `external_ref`, or update its `timezone`; `visibility: "private"` hides it from agents |
963
+ | `GET /v1/resources/{resource_id}` | One resource, with its own weekly hours, visibility and `archived_at` |
964
+ | `POST /v1/resources/{resource_id}/archive` | Take it out of use; `…/restore` puts it back |
965
+ | `DELETE /v1/resources/{resource_id}` | Delete it with its history (`409 resource_in_use` while anything on it is live) |
966
+ | `GET /v1/resources/{resource_id}/busy?start=…&end=…` | Its occupied time, private resources included |
777
967
  | `PUT /v1/resources/{resource_id}/availability` | Its own weekly hours; `[]` never bookable, `null` the server's |
778
968
  | `PUT /v1/availability` | Every resource's weekly hours at once |
779
969
  | `POST /v1/holds` | Hold a window for `ttl_seconds` (up to 7 days) |
970
+ | `GET /v1/holds/{hold_id}` | One live hold; `GET /v1/resources/{resource_id}/holds` lists them |
780
971
  | `POST /v1/holds/{hold_id}/confirm` | Turn the hold into a reservation |
781
972
  | `DELETE /v1/holds/{hold_id}` | Release the hold |
973
+ | `GET /v1/reservations/{reservation_ref}` | The booking at its current revision |
974
+ | `GET /v1/resources/{resource_id}/reservations?start=…&end=…` | Its bookings in a window |
782
975
  | `PUT /v1/reservations/{reservation_ref}` | Book (`revision: 1`) or move (the current revision plus 1) |
783
976
  | `DELETE /v1/reservations/{reservation_ref}?revision=…` | Cancel, at the current revision plus 1 |
784
977
  | `PUT /v1/resources/{resource_id}/coverage/{source}` | Record that `source`'s events are complete for a window |
978
+ | `GET /v1/tenant/events?resource_id=…&start=…&end=…` | Every key's events on a resource, with the `id` the next two take |
979
+ | `GET /v1/tenant/events/{event_id}` | One of them |
980
+ | `DELETE /v1/tenant/events/{event_id}?expected_revision=…` | Cancel it, whichever key wrote it |
981
+ | `GET /v1/tenant` | The key's tenant, and whether it is suspended |
982
+ | `POST /v1/tenant/erase` | Erase the tenant, when the server allows it |
785
983
  | `POST /v1/maintenance` | Run [maintenance](#operations) for the key's tenant |
786
984
 
787
985
  ```sh
@@ -797,8 +995,23 @@ curl -X PUT "http://localhost:8080/v1/reservations/BK-42" \
797
995
  `replayed: true`, a confirmed hold confirms again, and a cancelled reference stays cancelled (a
798
996
  late booking for it answers `409`). A late copy of a booking or a move that has since been
799
997
  superseded answers `409 stale_revision`.
998
+ - A confirmed hold is the booking whose reference is the hold's id, at revision 1: cancel it with
999
+ `DELETE /v1/reservations/{hold_id}?revision=2`.
1000
+ - `POST /v1/holds` and `DELETE /v1/tenant/events/…` take a retry identity as the `Idempotency-Key`
1001
+ header or as `idempotency_key`. A retried hold answers the hold the first request made; the same
1002
+ key with another request is `409 idempotency_conflict`. Other routes are safe to retry as they
1003
+ are, and refuse the header (`400 idempotency_key_not_supported`).
1004
+ - Lists page by `limit` (up to 100) and `cursor`, the `next_cursor` of the page before, and read a
1005
+ window of at most 367 days.
800
1006
  - The time a calendar event occupies is the event's: reservation calls never find, move or cancel
801
- it, whatever reference they name. Change the event instead.
1007
+ it, whatever reference they name. Change the event instead, or cancel it as the tenant with
1008
+ `DELETE /v1/tenant/events/{event_id}`, which needs its revision and an idempotency key, and
1009
+ takes `expected_resource_id` to fence it to the resource you read it on.
1010
+ - Erasing a tenant over the API is off unless the backend is created with `tenantErasure: true`
1011
+ (`slotlock serve` leaves it off: its operator runs `slotlock tenant erase`). It needs
1012
+ `confirm_tenant_ref` to be the tenant's reference (`GET /v1/tenant`), deletes every key the tenant
1013
+ has, the caller's included, and is refused while the tenant is suspended. Suspending a tenant is
1014
+ never an API call: only the schema owner may, and a tenant could not lift its own.
802
1015
  - The tenant is the key's. Another tenant's resource or hold is `404`, whatever the database role.
803
1016
  - With the library, pass `application` beside `rest: true`:
804
1017
  `{ backend: createSlotlockStoreApplicationBackend(store, { maxTenantResources }), authorize }`.
@@ -1002,10 +1215,13 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1002
1215
  tenant list. `startSlotlockMaintenance({ store, sql, tenants, intervalMs })` is the scheduler
1003
1216
  `serve` uses, lock included; `withSlotlockMaintenanceLock` is the lock alone.
1004
1217
  - **Capped passes.** A capped pass that extended and pruned nothing met only series it cannot extend
1005
- (counted in `conflicts` and `refused`): another pass would too, so wait for the next interval.
1218
+ (counted in `conflicts` and `refused`). The roll puts each behind every series it has not put
1219
+ back, for an hour, so with `rollPending: true` (it stopped before those) run another pass at once:
1220
+ it meets new ones, and a healthy series is reached however many are stuck. Otherwise another pass
1221
+ would meet the same ones, so wait for the next interval.
1006
1222
  - **Paging the roll yourself.** `rollCalendarEventHorizon` returns `next`; pass it as `after` to the
1007
- next batch of the same pass. A series left in conflict or refused keeps its place at the front of
1008
- the order, so without `after` every later batch would examine it again.
1223
+ next batch of the same pass, so later batches go on from the last series examined. Series the
1224
+ roll has never put back come first, then those it has, longest waiting first.
1009
1225
  - **Quotas.** Each agent principal may retain 1,000 event identities (active plus cancelled) and
1010
1226
  10,000 idempotency commands, of which 9,000 can be creates or updates, so a cancel always has
1011
1227
  room. Adjust with `agentOwnerEventQuota` and `agentOwnerCommandQuota` on `createSlotlockStore`.
@@ -1033,13 +1249,18 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1033
1249
  5. Bound retries on serialization failures; never blindly retry an unknown write.
1034
1250
  6. Reconcile external calendars with durable cursors and coverage windows.
1035
1251
  7. Monitor conflicts, refused rules, sync lag, capped maintenance runs, quota errors and confirmation outcomes.
1036
- 8. Keep writes confirmation-gated wherever your policy needs a person.
1252
+ 8. Keep writes confirmation-gated wherever your policy needs a person (`slotlock serve`:
1253
+ `SLOTLOCK_CONFIRM_WRITES`, `none` by default).
1037
1254
  9. Size live updates: at most `maxTotal` backend reads per `pollIntervalMs`.
1038
1255
  10. Give each agent its own API key with the narrowest scope and an expiry.
1039
1256
  11. After each deployment, check it end to end with a short-lived admin key, then revoke the key:
1040
1257
  `SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs` checks health,
1041
1258
  discovery, MCP, A2A, holds and reservations (in a resource named `slotlock-smoke`, 400 days
1042
1259
  ahead, cancelled after) and the 401, 409, 413 and 415 refusals.
1260
+ 12. Rate-limit every public server. `slotlock serve` does it per client address, key, tenant and
1261
+ server (`SLOTLOCK_LIMITS`); behind a proxy, set `SLOTLOCK_TRUSTED_PROXY_HEADER` and firewall
1262
+ the server so nothing reaches it except through that proxy. Embedding the
1263
+ agent server, give it a `consumeRateLimit`. Watch serve's `refusals` log line for floods.
1043
1264
 
1044
1265
  ## Supported versions
1045
1266
 
@@ -1048,16 +1269,19 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
1048
1269
  | MCP | 2026-07-28 (stateless), 2025-11-25, 2025-06-18 |
1049
1270
  | A2A | 1.0, JSON-RPC binding |
1050
1271
  | MCP Apps | `io.modelcontextprotocol/ui` 2026-01-26 |
1051
- | Node.js | ≥ 22.12 (CI: 22 and 24) |
1052
- | PostgreSQL | 16 |
1272
+ | Node.js | ≥ 22.12 (CI: 22 and 24; the image runs 24) |
1273
+ | PostgreSQL | 16, 17 and 18 (CI runs the database suites on each; Compose ships 18) |
1053
1274
  | postgres (peer) | ^3.4.5 |
1054
1275
 
1055
1276
  ## Project status and boundaries
1056
1277
 
1057
- 0.1 is the core: availability maths, holds and reservations, events with recurrence, coverage-aware
1278
+ Slotlock is availability maths, holds and reservations, events with recurrence, coverage-aware
1058
1279
  free/busy, tenant isolation, iCalendar interchange, MCP and A2A with human confirmation and live
1059
- updates, and an MCP Apps view. Provider sync (Google, Microsoft, CalDAV), notifications to people,
1060
- and a human calendar app are yours to build around it.
1280
+ updates, an MCP Apps view, REST with the application API, scoped API keys, a GitHub sign-in
1281
+ dashboard, maintenance, and clients for five languages. [Hosted Slotlock](docs/hosted/README.md)
1282
+ runs it for you. Provider sync (Google, Microsoft, CalDAV), notifications to people, and a human
1283
+ calendar app are yours to build around it. What each version promises is in
1284
+ [VERSIONING.md](VERSIONING.md).
1061
1285
 
1062
1286
  Events need a finite duration (all-day events are whole local days; one event may last 3,660 days).
1063
1287
  Open-ended entries are refused rather than guessed.
package/SECURITY.md CHANGED
@@ -22,6 +22,20 @@ Do not include real customer, credential, calendar, or booking data. We will ack
22
22
  within three business days and coordinate disclosure after a fix is available. Fixed vulnerabilities
23
23
  are published as advisories at <https://github.com/PILOTAAI/slotlock/security/advisories>.
24
24
 
25
+ ## Testing hosted Slotlock
26
+
27
+ Hosted Slotlock (`slotlock-api.pylota.io` and `app.slotlock.pylota.io`, run by TREFT LTD) is in scope,
28
+ under these rules, which its [Terms of Service](TERMS.md) and
29
+ [Acceptable Use Policy](ACCEPTABLE-USE.md) refer to:
30
+
31
+ - Test only against an account of your own, signed in with your own GitHub account.
32
+ - Never read, change or keep another account's data. If a test reaches any, stop and report it at
33
+ once, and delete what you saw.
34
+ - Run no denial of service, load test or automated scanning that sends more than the published
35
+ limits ([hosted service page](docs/hosted/README.md#limits)); run Slotlock yourself for that.
36
+ - Report through private vulnerability reporting as above, and give us the time to fix it before
37
+ you disclose anything.
38
+
25
39
  ## Security model
26
40
 
27
41
  Slotlock treats all external calendar content as untrusted. The parser has explicit size, component,
@@ -108,7 +122,20 @@ protected-resource metadata, when configured, only tells clients where to obtain
108
122
  embedder's `authenticate` must verify every token, including its audience. Embedders must provide
109
123
  bearer-token verification, per-principal authorization, distributed rate limiting, TLS termination,
110
124
  and a readiness check. Do not place provider credentials or raw calendar content in authentication,
111
- authorization, rate-limit, or health callbacks.
125
+ authorization, rate-limit, or health callbacks. `slotlock serve`, its own embedder, limits requests
126
+ per client address before any credential is looked up, then per key, per tenant and for the whole
127
+ server, caps writes that expand a recurring series, maintenance runs and live-update streams per
128
+ tenant, answers a full key-lookup queue as busy rather than unauthenticated, and logs counts of
129
+ refusals with each client as a pseudonym (`SLOTLOCK_LIMITS`, `SLOTLOCK_TRUSTED_PROXY_HEADER`). A
130
+ key that authenticated in the last 15 minutes waits in a lookup queue of its own, served first; a
131
+ valid key that has not authenticated in the last 15 minutes (any key, after a restart) waits with
132
+ made-up keys and may be answered busy (503 with `Retry-After`) while a flood of them lasts, never
133
+ 401. Its counts are one process's: several instances each limit alone.
134
+
135
+ Write confirmation (MCP 2026-07-28 form elicitation) seals the pending write to the caller, the tool
136
+ and its exact arguments, but the answer comes from the client: Slotlock relies on the MCP client to
137
+ ask its person, and a key holder's own client can answer yes. It keeps an agent from writing unasked;
138
+ it is not an authorization boundary. Scopes and `authorize` decide who may write.
112
139
 
113
140
  ## API keys and the application API
114
141
 
@@ -118,7 +145,9 @@ bits) and a 6-character checksum, and the database keeps only its SHA-256. A slo
118
145
  238 random bits, so a fast hash
119
146
  loses nothing and lets every request look a key up by its digest in one indexed read. Code scanners
120
147
  that treat every secret as a password report this as weak hashing (CodeQL
121
- `js/insufficient-password-hash`); it is the design, as for GitHub's and Stripe's tokens.
148
+ `js/insufficient-password-hash`); it is the design, as for GitHub's and Stripe's tokens. The
149
+ repository's CodeQL configuration (`.github/codeql/codeql-config.yml`) drops that one query with
150
+ this reason beside it, rather than dismissing its alerts one by one.
122
151
 
123
152
  A key acts only in the tenant it was created for. `read` and `write` cover the agent tools; `admin`
124
153
  covers the application API (resources, hours, holds, reservations, coverage, maintenance), which is