slotlock 0.1.1 → 0.2.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.
- package/CHANGELOG.md +125 -0
- package/CONTRIBUTING.md +25 -16
- package/README.md +150 -74
- package/SECURITY.md +40 -4
- package/SPEC.md +19 -6
- package/agent-server.d.ts +64 -7
- package/agent-server.js +273 -68
- package/agent-store-backend.d.ts +16 -0
- package/agent-store-backend.js +257 -2
- package/api-keys.d.ts +11 -4
- package/api-keys.js +9 -2
- package/application-api.d.ts +54 -0
- package/application-api.js +290 -0
- package/dashboard.d.ts +4 -1
- package/dashboard.js +6 -4
- package/ddl.d.ts +2 -1
- package/ddl.js +143 -15
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/index.d.ts +8 -4
- package/index.js +4 -2
- package/maintenance.d.ts +57 -0
- package/maintenance.js +181 -0
- package/node-server.js +121 -5
- package/package.json +1 -1
- package/rest-api.d.ts +33 -18
- package/rest-api.js +131 -63
- package/rules.d.ts +19 -0
- package/rules.js +32 -4
- package/self-host.d.ts +5 -2
- package/self-host.js +127 -20
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +30 -8
- package/store.js +69 -27
- package/sync.js +530 -49
- package/timezone.d.ts +19 -0
- package/timezone.js +16 -1
- package/types.d.ts +4 -1
- package/weekly-hours.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,131 @@
|
|
|
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.2.0 - 2026-10-23
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- The application API: what an application does through the store, over REST from any language.
|
|
11
|
+
Create resources and set their weekly hours; hold, confirm and release time; book, move and cancel
|
|
12
|
+
reservations by reference and revision; record external calendar coverage; run maintenance. The
|
|
13
|
+
routes sit under `/v1` beside the tools, in the same OpenAPI document, and need an API key with the
|
|
14
|
+
new `admin` scope. They are never MCP tools or A2A skills. `createSlotlockStoreApplicationBackend`
|
|
15
|
+
serves them from the store; `createSlotlockAgentServer` takes them as `application`.
|
|
16
|
+
- The `admin` API key scope: `slotlock key create <name> --scope read,write,admin`, or "Full" in the
|
|
17
|
+
dashboard. New keys still default to read and write, and `SLOTLOCK_AUTH_TOKEN` is never admin: it
|
|
18
|
+
is the token agents are given, and the application API asks no person before it writes.
|
|
19
|
+
- Built-in maintenance. `slotlock serve` maintains every tenant it serves, hourly, one at a time,
|
|
20
|
+
under a per-tenant advisory lock, and logs the counts; `slotlock maintain` runs it once.
|
|
21
|
+
`maintainSlotlockTenant`, `startSlotlockMaintenance` and `withSlotlockMaintenanceLock` do the
|
|
22
|
+
same in a process of your own.
|
|
23
|
+
- `confirmHold` and `releaseHold` take `{ tenantRef }`, so another tenant's hold is not found
|
|
24
|
+
whatever the connection's row-level security.
|
|
25
|
+
- Clients for Python, Go, Rust, Java and .NET (`clients/`): one method per REST operation, the
|
|
26
|
+
application API included, generated from `openapi.json` with hand-written transports. Each runs
|
|
27
|
+
the same scenario against the server image in CI. They are not on their package registries yet.
|
|
28
|
+
- `openapi.json`, the REST API's OpenAPI 3.1 document, in the repository and at
|
|
29
|
+
https://slotlock.pylota.io/openapi.json; `slotlockOpenApi()` builds it.
|
|
30
|
+
- Property-based tests (fast-check) of the engine, time zones, iCalendar parsing and API keys.
|
|
31
|
+
- A `call` event for each MCP `tools/call` and A2A `SendMessage` that names an operation: its
|
|
32
|
+
outcome, duration and W3C trace id. `rest` events carry the duration and trace id too, and
|
|
33
|
+
`slotlock serve` logs both.
|
|
34
|
+
- Releases are opened and merged by a GitHub App once it is set up
|
|
35
|
+
(`node scripts/create-release-app.mjs`), so its checks run without a maintainer approving them.
|
|
36
|
+
Only the commit whose checks passed is merged.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- `slotlock serve` serves REST by default; `SLOTLOCK_REST_API=off` turns it off.
|
|
41
|
+
- `expandRules` and `findNextAvailableFor` refuse a search window longer than 367 days with
|
|
42
|
+
`invalid_window`, as free/busy already did, and skip a rule that lasts longer than a week, as the
|
|
43
|
+
hours validator refuses one. Fifty daily rules over a century were two million windows of
|
|
44
|
+
synchronous work.
|
|
45
|
+
- Upgrading rebuilds the unique index on booking references
|
|
46
|
+
(`slotlock_reservations_booking_external_ref_key`) to leave calendar event rows out, and drops
|
|
47
|
+
the one it replaces after the new one is built.
|
|
48
|
+
- A cursor 0.1 kept beside a stored series is not resumed if the series is zoned and ends (UNTIL):
|
|
49
|
+
0.1 read its occurrences as UTC beside the UNTIL, so the cursor may have stopped one occurrence
|
|
50
|
+
early (a booking could then take that time) or gone one past. The next roll searches such a series
|
|
51
|
+
from its start, within the same budget, and keeps a new cursor.
|
|
52
|
+
- Upgrading widens the API key scope constraint in place, and replaces the reservations overlap
|
|
53
|
+
constraint with one that counts every row's turnaround buffer (below). Expired holds in a buffer's
|
|
54
|
+
way are removed; while a live hold or untenanted reservation is in one, migrate stops with their
|
|
55
|
+
ids and changes nothing.
|
|
56
|
+
|
|
57
|
+
### Fixed
|
|
58
|
+
|
|
59
|
+
- A calendar event's occupation could be freed or moved through the reservation commands
|
|
60
|
+
(`cancelExternalReservation`, `rescheduleExternalReservation`, and the application API's
|
|
61
|
+
reservation routes) by naming the reference the store derives for it: the event stayed active but
|
|
62
|
+
stopped occupying its time. Those commands now leave event rows out. An event freed this way
|
|
63
|
+
before upgrading occupies its time again once it is written again.
|
|
64
|
+
- Maintenance replaced a series' stored window with the rolling horizon even when the stored window
|
|
65
|
+
ended later, deleting the occurrences past it.
|
|
66
|
+
- Free/busy called a window complete when a series started inside it but was stored only from
|
|
67
|
+
later: those occurrences were reported as free time.
|
|
68
|
+
- Upgrading as a non-superuser owner under forced row-level security failed with a raw error (or,
|
|
69
|
+
for some repairs, skipped them): the upgrade's data steps saw no rows. The upgrade now lifts FORCE
|
|
70
|
+
for those steps and records what it lifted, so an upgrade stopped part way is put right by the
|
|
71
|
+
next one, and `slotlock serve` refuses to start while a tenant table is left without FORCE.
|
|
72
|
+
- `expandRules` without a time zone offered windows after the rule's UNTIL.
|
|
73
|
+
- A delayed copy of a create, after the reservation had moved on, answered `idempotency_conflict`
|
|
74
|
+
instead of `stale_revision`.
|
|
75
|
+
- A time zone a calendar names without a VTIMEZONE was read as floating, and compared with UTC
|
|
76
|
+
values as if it were UTC. East of UTC a series lost the occurrence its UNTIL names (in a stored
|
|
77
|
+
series, a booking could then take that time); west of it, a series ran hours past its UNTIL; an
|
|
78
|
+
EXDATE or a moved occurrence's RECURRENCE-ID written in UTC was not found. All-day series had the
|
|
79
|
+
same fault with a UTC UNTIL.
|
|
80
|
+
- A feed with a PERIOD RDATE, or an event with no DTEND, was refused whole; a feed's event that
|
|
81
|
+
ended before it started was refused with `invalid_external_interval` instead of
|
|
82
|
+
`invalid_icalendar`; a document whose root is not a VCALENDAR read as an empty calendar.
|
|
83
|
+
- The 4,000 occurrences a series may pass from its start were shared by every series in a feed, so
|
|
84
|
+
three daily series begun in 2023 refused it for good.
|
|
85
|
+
- An EXDATE left its occurrence in when EXDATEs before it named no occurrence (ical.js passes them
|
|
86
|
+
one per occurrence), or when an RRULE and an RDATE both named it; such an instant was also
|
|
87
|
+
reported twice. A series of RDATEs with no RRULE left out its DTSTART, its first occurrence.
|
|
88
|
+
- An occurrence starting in an hour the clocks skip ended as it started and was left out; it now
|
|
89
|
+
lasts its series' exact length, from its DTEND or its DURATION. Others still end on the wall clock.
|
|
90
|
+
- ical.js matched exceptions and compared UNTIL and EXDATE values with a feed's own VTIMEZONE for an
|
|
91
|
+
IANA name, while Slotlock read the same times through Intl: under a stale definition, a moved
|
|
92
|
+
occurrence was found in the feed and lost from the series stored from it. Such a name is read
|
|
93
|
+
through Intl throughout.
|
|
94
|
+
- A series with two RRULEs lost occurrences once either rule ended: ical.js dropped the rule after
|
|
95
|
+
an ended first one, or the one before any other.
|
|
96
|
+
- A DATE EXDATE of a timed series excluded every occurrence that day from a feed and none from the
|
|
97
|
+
series stored from it; both now exclude the occurrence at the series' start time that day.
|
|
98
|
+
- A floating UNTIL was read as UTC beside a zoned DTSTART. A RECURRENCE-ID written in another zone
|
|
99
|
+
than its series' DTSTART did not find its occurrence, and a `RANGE=THISANDFUTURE` one written in
|
|
100
|
+
UTC moved later occurrences by the wrong amount.
|
|
101
|
+
- `parseTrustedICalendarEvents` did not read times as `parseICalendarChanges` does: a stored
|
|
102
|
+
all-day series kept an occurrence the feed's UTC EXDATE excludes. An event it cannot hold (no
|
|
103
|
+
length, say) is now `invalid_icalendar`, not `invalid_external_interval`.
|
|
104
|
+
- A feed naming thousands of time zones it does not define (50,000 spellings of one zone took
|
|
105
|
+
seconds) is refused past 100, as one defining more than 100 is; a feed listing too many EXDATE
|
|
106
|
+
values is refused before they are read, not after.
|
|
107
|
+
- A request body sent without `Content-Length` (chunked) was read whole before the size limit
|
|
108
|
+
applied, so any key holder could make the server hold gigabytes. It is refused as soon as it
|
|
109
|
+
passes `maxRequestBytes`. The Node adapter no longer aborts a request whose body the server stops
|
|
110
|
+
reading; it discards the rest once it has answered.
|
|
111
|
+
- `close()` while the Node adapter was still starting left `listen()` pending forever.
|
|
112
|
+
- On the Workers dashboard, `/dashboard/sign-in/` and `/dashboard/callback/` (with a trailing slash)
|
|
113
|
+
were outside the global sign-in rate limit.
|
|
114
|
+
- The confirmation question for a create did not say that a transparent booking leaves the time
|
|
115
|
+
free, or name the other fields it sets.
|
|
116
|
+
- `arguments` that were not an object (a string, an array) ran the operation with none; they are
|
|
117
|
+
now `-32602` on MCP and an invalid message on A2A. Left out or null is still none.
|
|
118
|
+
- A rate-limited A2A request was answered with an MCP error over HTTP 429; it is now A2A's 200
|
|
119
|
+
envelope with a `RATE_LIMITED` ErrorInfo. `Content-Type` is matched without regard to case.
|
|
120
|
+
- Calls refused before they ran (a client that cannot confirm, a malformed A2A message, a REST body
|
|
121
|
+
that is not JSON, too large or not an object) were not reported to `onEvent`. On MCP 2026-07-28
|
|
122
|
+
a confirmation was reported after the call it decided; it now comes first, as elsewhere.
|
|
123
|
+
- An async `onEvent`, `onError` or maintenance `onPass` that rejected was an unhandled rejection,
|
|
124
|
+
which ends a Node process by default; an `onPass` that threw made a pass that ran count as failed.
|
|
125
|
+
- A hold could take the time a reservation's `bufferAfterMs` keeps busy, and a buffered reservation
|
|
126
|
+
could be booked over a hold: the buffer bound only tenant-owned reservations to each other.
|
|
127
|
+
- `confirmHold` with the id of a reservation that has an external reference answered as if it had
|
|
128
|
+
confirmed a hold, describing it without its reference, revision or buffer. It is now
|
|
129
|
+
`hold_not_found`.
|
|
130
|
+
|
|
6
131
|
## 0.1.1 - 2026-10-22
|
|
7
132
|
|
|
8
133
|
### Changed
|
package/CONTRIBUTING.md
CHANGED
|
@@ -16,7 +16,8 @@ npm test # tests, build, then the packed-artifact verification
|
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Database suites (`*.integration.test.ts`) run when `DATABASE_URL` points at a PostgreSQL 16
|
|
19
|
-
database whose connecting role can create roles and extensions, and are skipped
|
|
19
|
+
database whose connecting role can create roles, databases and extensions, and are skipped
|
|
20
|
+
otherwise (the upgrade suite builds a database of its own and drops it):
|
|
20
21
|
|
|
21
22
|
```sh
|
|
22
23
|
DATABASE_URL=postgresql://slotlock_dev:dev-only-password@localhost:5432/slotlock_test npm test
|
|
@@ -29,6 +30,11 @@ role named `slotlock` is the package's schema, which `"$user"` puts first on the
|
|
|
29
30
|
command-line tool redacts the database password from everything it prints, so a short or
|
|
30
31
|
product-like password rewrites the output the tests read.
|
|
31
32
|
|
|
33
|
+
Property suites (`*.property.test.ts`) check the engine, time zones, iCalendar parsing and API
|
|
34
|
+
keys on input [fast-check](https://fast-check.dev/) generates, with a new seed each run. A failure
|
|
35
|
+
prints the seed and the shrunk counterexample; add `{ seed, path }` from it to that `fc.assert` to
|
|
36
|
+
replay it, then keep the counterexample as an ordinary test once it is fixed.
|
|
37
|
+
|
|
32
38
|
## Sign off your commits
|
|
33
39
|
|
|
34
40
|
Slotlock uses the [Developer Certificate of Origin](https://developercertificate.org/) instead of a
|
|
@@ -71,21 +77,24 @@ that section becomes the release notes.
|
|
|
71
77
|
|
|
72
78
|
To release, run **Actions > Release a version** with the version (for example `0.1.1`). It sets the
|
|
73
79
|
version in `package.json`, `package-lock.json`, `server.json` and `slotlock.manifest.json` and dates
|
|
74
|
-
the `## Unreleased` heading (`scripts/prepare-release.sh`)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
80
|
+
the `## Unreleased` heading (`scripts/prepare-release.sh`), opens the pull request, merges it once CI
|
|
81
|
+
and DCO pass on it, and `tag-release.yml` tags the release commit. The tag starts `release.yml` (the
|
|
82
|
+
suite, then npm and the GitHub release) and `image.yml` (the image, then the MCP Registry). npm holds
|
|
83
|
+
each staged version until a maintainer approves it with two-factor authentication (GOVERNANCE.md);
|
|
84
|
+
the Release run's summary gives the command. A version merged to `main` by hand, with its heading
|
|
85
|
+
dated, is released the same way. After a failed run, run it again with the same version: it keeps
|
|
86
|
+
the release branch the failed run left when that already holds this release, so checks already run
|
|
87
|
+
there still count, replaces it otherwise, and reuses its pull request (`scripts/open-release-pr.sh`).
|
|
88
|
+
|
|
89
|
+
The pull request is opened and merged by the release app, a GitHub App that can only write this
|
|
90
|
+
repository's contents and pull requests. An owner of the organization creates it once with
|
|
91
|
+
`node scripts/create-release-app.mjs` (click **Create GitHub App**, then install it on `slotlock`
|
|
92
|
+
only); the script checks the installation and stores the app's key in the `release-app` environment,
|
|
93
|
+
which only `main` can use. Without the app, the workflow opens the pull request as
|
|
94
|
+
github-actions[bot], whose checks GitHub holds until someone with write access approves them on the
|
|
95
|
+
pull request (**Approve workflows to run**). GitHub lets a workflow open pull requests at all only
|
|
96
|
+
once an admin allows it (Settings > Actions > General > Workflow permissions);
|
|
97
|
+
`scripts/configure-github.sh` sets it, and the run says so when it is off.
|
|
89
98
|
|
|
90
99
|
When a run needs a step npm or GitHub keeps for a person (the first npm version, the npm trusted
|
|
91
100
|
publisher, the image's visibility, and the deploy key `tag-release.yml` pushes tags with), the
|
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/
|
|
|
37
37
|
| --- | --- |
|
|
38
38
|
| `http://localhost:8080/mcp` | MCP, with `Authorization: Bearer <SLOTLOCK_AUTH_TOKEN>` |
|
|
39
39
|
| `http://localhost:8080/a2a` | A2A 1.0, same token; card at `/.well-known/agent-card.json` |
|
|
40
|
+
| `http://localhost:8080/v1/…` | [REST](#rest-and-openapi): the tools, and the [application API](#application-api) for admin keys; described at `/openapi.json` |
|
|
40
41
|
| `http://localhost:8080/healthz` | Readiness, including the database |
|
|
41
42
|
|
|
42
43
|
- The server runs as a role that owns nothing and cannot bypass row-level security; a one-off
|
|
@@ -85,6 +86,7 @@ Give each agent its own key instead of sharing the server token:
|
|
|
85
86
|
```sh
|
|
86
87
|
slotlock key create "Booking agent"
|
|
87
88
|
slotlock key create "Availability bot" --scope read --expires-in-days 90
|
|
89
|
+
slotlock key create "Booking site" --scope read,write,admin
|
|
88
90
|
slotlock key list
|
|
89
91
|
slotlock key rotate <id>
|
|
90
92
|
slotlock key revoke <id>
|
|
@@ -92,8 +94,8 @@ slotlock key revoke <id>
|
|
|
92
94
|
|
|
93
95
|
- A key (`slk_…`) is shown once and stored only as a SHA-256. Send it as `Authorization: Bearer`.
|
|
94
96
|
- It acts in the tenant it was created for (`SLOTLOCK_TENANT`), so one server serves many tenants.
|
|
95
|
-
- `read` covers the five tools that only look; `write` covers create, update and delete
|
|
96
|
-
|
|
97
|
+
- `read` covers the five tools that only look; `write` covers create, update and delete; `admin`
|
|
98
|
+
covers the [application API](#application-api), which no agent needs. Default: read and write.
|
|
97
99
|
- `rotate` keeps the key's id, scopes and bookings; `revoke` stops it on its next request.
|
|
98
100
|
- A tenant may hold 100 active keys. The last six characters are a checksum secret scanners can
|
|
99
101
|
verify offline.
|
|
@@ -265,7 +267,8 @@ export const slot = findNextAvailable({
|
|
|
265
267
|
// slot: 2026-09-14 10:00–12:00 UTC, the first two free hours after the busy one
|
|
266
268
|
```
|
|
267
269
|
|
|
268
|
-
A rule `expandRules` cannot evaluate adds no availability; it never invents a slot.
|
|
270
|
+
A rule `expandRules` cannot evaluate adds no availability; it never invents a slot. A search window
|
|
271
|
+
may be 367 days at most (`invalid_window`), here and in `findNextAvailableFor`.
|
|
269
272
|
|
|
270
273
|
## Set up PostgreSQL
|
|
271
274
|
|
|
@@ -411,26 +414,29 @@ export async function scheduleWeeklyInspection(
|
|
|
411
414
|
```
|
|
412
415
|
|
|
413
416
|
Occurrences are stored for a window of at most 367 days and 2,000 occurrences; maintenance rolls
|
|
414
|
-
the window forward.
|
|
417
|
+
the window forward, never moving its end back. Outside it, before or after, free/busy reports the
|
|
418
|
+
time as unproven.
|
|
415
419
|
|
|
416
420
|
A rule is expanded within a budget: 50,000 steps and a second for a feed (the whole read, from its
|
|
417
421
|
text), or for a stored series as maintenance rolls it; half of that for one event as it is written.
|
|
418
|
-
A feed's own time zones (VTIMEZONE) count against the same budget. A feed may carry 100 time zones
|
|
419
|
-
1,000
|
|
420
|
-
its events; a property's name and parameters may take 8 KB
|
|
421
|
-
a `DURATION`, or an event's length may be a century at
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
426
|
+
most. A calendar that needs more, such as a rule that never occurs
|
|
427
|
+
(`FREQ=DAILY;BYMONTH=2;BYMONTHDAY=30`), is refused: `invalid_event` on a write, `invalid_icalendar`
|
|
428
|
+
from a feed.
|
|
429
|
+
|
|
430
|
+
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.
|
|
434
440
|
|
|
435
441
|
### External calendars
|
|
436
442
|
|
|
@@ -440,6 +446,24 @@ Slotlock gives them neutral boundaries:
|
|
|
440
446
|
- `normalizeExternalCalendarChange` for webhook changes; `parseICalendarChanges` and `emitICalendar`
|
|
441
447
|
for bounded iCalendar in and out; `emitITipCalendar` for invitations.
|
|
442
448
|
- All-day events become whole local days in the calendar's time zone; a feed without one is refused.
|
|
449
|
+
- A TZID Intl knows is read as that IANA zone, whether or not the feed defines it (a VTIMEZONE's
|
|
450
|
+
own rules for such a name are not used); one only the feed defines, such as a Windows name, is
|
|
451
|
+
read from its VTIMEZONE. A floating time is read in the calendar's zone. So an UNTIL, EXDATE,
|
|
452
|
+
RDATE or RECURRENCE-ID written in UTC or another zone names the occurrence it means, a floating
|
|
453
|
+
UNTIL is read on the series' own clock, and a `RANGE=THISANDFUTURE` exception moves every later
|
|
454
|
+
occurrence by its own shift.
|
|
455
|
+
- An event with no length (a DATE-TIME DTSTART without DTEND or DURATION) occupies no time and is
|
|
456
|
+
left out. A PERIOD RDATE ends where its period does. A document whose root is not a VCALENDAR, or
|
|
457
|
+
an event that ends before it starts, is refused as `invalid_icalendar`.
|
|
458
|
+
- A series' DTSTART is its first occurrence, with or without an RRULE; every RRULE is expanded; an
|
|
459
|
+
instant two of them, or an RRULE and an RDATE, name is one occurrence; every EXDATE excludes the
|
|
460
|
+
occurrence it names (RFC 5545 §3.8.5). A DATE EXDATE of a timed series names the occurrence at the
|
|
461
|
+
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).
|
|
465
|
+
- `parseTrustedICalendarEvents` reads times as `parseICalendarChanges` does, so a series stored from
|
|
466
|
+
it occupies what the feed reports.
|
|
443
467
|
- `recordCalendarCoverage` marks how much of a feed is read. Pass the sources a resource depends on as
|
|
444
468
|
`requiredSources`, and any coverage short of `complete` means the time is not proven free.
|
|
445
469
|
|
|
@@ -625,10 +649,10 @@ on stderr.
|
|
|
625
649
|
|
|
626
650
|
| Where | Error |
|
|
627
651
|
| --- | --- |
|
|
628
|
-
| HTTP | `401` with `WWW-Authenticate`, `403` disallowed origin, `413`/`415` bad body, `429` rate limit |
|
|
629
|
-
| MCP | `-32602` unknown tool or altered confirmation
|
|
652
|
+
| HTTP | `401` with `WWW-Authenticate`, `403` disallowed origin, `413`/`415` bad body, `429` rate limit (MCP, REST) |
|
|
653
|
+
| MCP | `-32602` unknown tool, `arguments` that are not an object, or altered confirmation; `-32603` server fault; `-32601` unknown method |
|
|
630
654
|
| MCP 2026-07-28 | `-32020` header disagrees with body, `-32021` form elicitation not declared, `-32022` unsupported revision, `-31029` rate limit |
|
|
631
|
-
| A2A | `-32009` unsupported `A2A-Version`, `-32003` push notifications, `-32004` streaming, `-32001` task not found (the server keeps no tasks) |
|
|
655
|
+
| A2A | `-32602` malformed message or parameters, `-32029` rate limit (`RATE_LIMITED`, in the 200 envelope like every A2A error), `-32009` unsupported `A2A-Version`, `-32003` push notifications, `-32004` streaming, `-32001` task not found (the server keeps no tasks) |
|
|
632
656
|
|
|
633
657
|
### Authentication and OAuth discovery
|
|
634
658
|
|
|
@@ -655,6 +679,8 @@ With `confirmation`, a listed write first asks the person a yes/no question that
|
|
|
655
679
|
will change, such as `Book vehicle-42 for 2027-03-29 10:00–11:00 (Europe/London): "Vehicle
|
|
656
680
|
handover".` Only an explicit yes runs the write.
|
|
657
681
|
|
|
682
|
+
- A booking that leaves the time free (`transparency: 'transparent'`) says so, and a write names
|
|
683
|
+
the other fields it sets (`also description, attendees`) without quoting them.
|
|
658
684
|
- Facts come first: the resource by its own reference, times in its own zone, and an event as it
|
|
659
685
|
stands now, read through your backend's `describeResource` and `getEvent` when the caller may
|
|
660
686
|
read them (within 2 seconds in all). Otherwise things are named by id, and each time carries its
|
|
@@ -685,10 +711,29 @@ returns busy time and coverage for the next `resourceWindowDays` (30). `subscrip
|
|
|
685
711
|
2026-07-28 results carry cache hints: discovery and tool lists are public for an hour, free/busy is
|
|
686
712
|
never cached. W3C `traceparent`, `tracestate` and `baggage` reach your backend as `context.trace`.
|
|
687
713
|
|
|
714
|
+
`onEvent` hears about every call that names an operation, so you can count, time and correlate them:
|
|
715
|
+
|
|
716
|
+
- `{ type: 'call', protocol, operation, outcome, durationMs, traceId? }` for an MCP `tools/call` or
|
|
717
|
+
A2A `SendMessage`; `outcome` is `ok`, the refusal's code (`overlap`, `rate_limited`, …), or why
|
|
718
|
+
it was refused before it ran: `invalid_arguments`, `missing_capability` (a guarded write from a
|
|
719
|
+
client that cannot confirm), `invalid_message` or `invalid_message_parameters` (A2A).
|
|
720
|
+
- `{ type: 'rest', operation, status, durationMs, traceId? }` for a REST call, or one refused before
|
|
721
|
+
it ran: a body that is not JSON (415, 400), too large (413) or not an object (400).
|
|
722
|
+
- `{ type: 'confirmation', operation, outcome }` for each confirmation, before the call it decides.
|
|
723
|
+
- A request refused before its operation is read is not reported: a missing credential, the rate
|
|
724
|
+
limit, an `A2A-Version` other than 1.0, a JSON-RPC envelope that does not parse, a path no route
|
|
725
|
+
has.
|
|
726
|
+
- `traceId` is the trace id of a valid `traceparent` from `_meta` or the header. Arguments, callers,
|
|
727
|
+
the parent span, `tracestate` and `baggage` are never reported, and an `onEvent` or `onError` that
|
|
728
|
+
throws, or returns a promise that rejects, changes nothing a client sees. Resource reads and
|
|
729
|
+
subscription polls are not reported.
|
|
730
|
+
|
|
731
|
+
`slotlock serve` writes each as a JSON line (`"event":"call"`, `duration_ms`, `trace_id`).
|
|
732
|
+
|
|
688
733
|
### REST and OpenAPI
|
|
689
734
|
|
|
690
|
-
`rest: true`
|
|
691
|
-
|
|
735
|
+
`rest: true` also serves each tool as plain HTTP, described by an OpenAPI 3.1 document at
|
|
736
|
+
`/openapi.json`. `slotlock serve` has it on; `SLOTLOCK_REST_API=off` turns it off.
|
|
692
737
|
|
|
693
738
|
| Request | Tool |
|
|
694
739
|
| --- | --- |
|
|
@@ -706,14 +751,66 @@ curl "http://localhost:8080/v1/resources" -H "Authorization: Bearer $SLOTLOCK_AP
|
|
|
706
751
|
```
|
|
707
752
|
|
|
708
753
|
- `GET` and `DELETE` take the tool's arguments as query parameters (a list repeats:
|
|
709
|
-
`resource_ids=a&resource_ids=b`); `POST` and `PATCH` take a JSON body.
|
|
754
|
+
`resource_ids=a&resource_ids=b`); `POST`, `PUT` and `PATCH` take a JSON body. Ids are in the path;
|
|
755
|
+
percent-encode one that holds `/`.
|
|
710
756
|
- Keys, scopes, `authorize` and the rate limit apply as over MCP. The body is the tool's output, or
|
|
711
757
|
`{"error":{"code":…}}` with the matching HTTP status.
|
|
712
758
|
- REST cannot ask a person, so a write listed in `confirmation` answers `428 confirmation_required`.
|
|
713
759
|
- There are no CORS headers: call it from a server, not a browser.
|
|
760
|
+
- From Python, Go, Rust, Java or .NET, use the [clients](clients/README.md): one method per
|
|
761
|
+
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.
|
|
764
|
+
|
|
765
|
+
### Application API
|
|
766
|
+
|
|
767
|
+
What the application around the agents does, from any language: the store's resource, hours, hold,
|
|
768
|
+
reservation, coverage and maintenance calls, as REST beside the tools. It needs a key with the
|
|
769
|
+
`admin` scope ("Full" in the dashboard); the server's own `SLOTLOCK_AUTH_TOKEN`, the token agents
|
|
770
|
+
are given, reads and writes but is never admin. It is never an MCP tool or an A2A skill, and never
|
|
771
|
+
waits for a confirmation.
|
|
772
|
+
|
|
773
|
+
| Request | Does |
|
|
774
|
+
| --- | --- |
|
|
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 |
|
|
777
|
+
| `PUT /v1/resources/{resource_id}/availability` | Its own weekly hours; `[]` never bookable, `null` the server's |
|
|
778
|
+
| `PUT /v1/availability` | Every resource's weekly hours at once |
|
|
779
|
+
| `POST /v1/holds` | Hold a window for `ttl_seconds` (up to 7 days) |
|
|
780
|
+
| `POST /v1/holds/{hold_id}/confirm` | Turn the hold into a reservation |
|
|
781
|
+
| `DELETE /v1/holds/{hold_id}` | Release the hold |
|
|
782
|
+
| `PUT /v1/reservations/{reservation_ref}` | Book (`revision: 1`) or move (the current revision plus 1) |
|
|
783
|
+
| `DELETE /v1/reservations/{reservation_ref}?revision=…` | Cancel, at the current revision plus 1 |
|
|
784
|
+
| `PUT /v1/resources/{resource_id}/coverage/{source}` | Record that `source`'s events are complete for a window |
|
|
785
|
+
| `POST /v1/maintenance` | Run [maintenance](#operations) for the key's tenant |
|
|
786
|
+
|
|
787
|
+
```sh
|
|
788
|
+
curl -X PUT "http://localhost:8080/v1/reservations/BK-42" \
|
|
789
|
+
-H "Authorization: Bearer $SLOTLOCK_ADMIN_KEY" -H "Content-Type: application/json" \
|
|
790
|
+
-d '{"resource_id":"<id>","start":"2027-03-29T09:00:00Z","end":"2027-03-31T09:00:00Z",
|
|
791
|
+
"buffer_after_minutes":60,"revision":1}'
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
- A taken window answers `409 overlap`. `buffer_after_minutes` keeps the time after a reservation
|
|
795
|
+
busy for holds and other reservations alike.
|
|
796
|
+
- Every write is safe to retry: the same reservation at its current revision answers
|
|
797
|
+
`replayed: true`, a confirmed hold confirms again, and a cancelled reference stays cancelled (a
|
|
798
|
+
late booking for it answers `409`). A late copy of a booking or a move that has since been
|
|
799
|
+
superseded answers `409 stale_revision`.
|
|
800
|
+
- 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.
|
|
802
|
+
- The tenant is the key's. Another tenant's resource or hold is `404`, whatever the database role.
|
|
803
|
+
- With the library, pass `application` beside `rest: true`:
|
|
804
|
+
`{ backend: createSlotlockStoreApplicationBackend(store, { maxTenantResources }), authorize }`.
|
|
805
|
+
A principal with `scopes` needs `admin`; then `authorize` decides.
|
|
714
806
|
|
|
715
807
|
## Connect an agent
|
|
716
808
|
|
|
809
|
+
MCP and A2A are wire protocols, so an agent in any language connects with its own MCP or A2A client
|
|
810
|
+
to `/mcp` or `/a2a`, the API key as a bearer token. The examples below use TypeScript. Code that
|
|
811
|
+
calls Slotlock directly, without an agent, can use the [REST clients](clients/README.md) for
|
|
812
|
+
Python, Go, Rust, Java and .NET.
|
|
813
|
+
|
|
717
814
|
### MCP (any client)
|
|
718
815
|
|
|
719
816
|
<!-- example: examples/mcp-client.ts#mcp -->
|
|
@@ -878,62 +975,37 @@ and never writes.
|
|
|
878
975
|
|
|
879
976
|
## Operations
|
|
880
977
|
|
|
881
|
-
|
|
978
|
+
Every tenant needs maintenance at least daily: it extends recurring events to the rolling horizon
|
|
979
|
+
and prunes what the retention window no longer needs, which is what returns agent quota.
|
|
980
|
+
|
|
981
|
+
- `slotlock serve` maintains `SLOTLOCK_TENANT` when it starts and every tenant whose key it serves,
|
|
982
|
+
then each hourly, one tenant at a time. It logs a `maintenance` line with counts.
|
|
983
|
+
- `slotlock maintain` runs one tenant's maintenance now, for cron or after a bulk import. An admin
|
|
984
|
+
key can call `POST /v1/maintenance`.
|
|
985
|
+
- Each pass holds a per-tenant PostgreSQL advisory lock, so servers and cron jobs never repeat each
|
|
986
|
+
other's work; a pass that finds the lock taken does nothing.
|
|
987
|
+
|
|
988
|
+
In a process of your own:
|
|
882
989
|
|
|
883
990
|
<!-- example: examples/maintenance.ts#maintenance -->
|
|
884
991
|
```ts
|
|
885
|
-
import {
|
|
886
|
-
type CalendarHorizonRollCursor,
|
|
887
|
-
type CalendarHorizonRollResult,
|
|
888
|
-
type SlotlockStore,
|
|
889
|
-
calendarEventRollingHorizon,
|
|
890
|
-
} from 'slotlock';
|
|
891
|
-
|
|
892
|
-
const MAX_BATCHES = 10;
|
|
992
|
+
import { type SlotlockStore, maintainSlotlockTenant } from 'slotlock';
|
|
893
993
|
|
|
894
994
|
export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
let refused = 0;
|
|
899
|
-
let horizonCapped = true;
|
|
900
|
-
let after: CalendarHorizonRollCursor | null = null;
|
|
901
|
-
for (let batch = 0; batch < MAX_BATCHES; batch++) {
|
|
902
|
-
const from = after;
|
|
903
|
-
const roll: CalendarHorizonRollResult = await store.withTenant(tenantRef, (tenant) =>
|
|
904
|
-
tenant.rollCalendarEventHorizon({ tenantRef, window, ...(from ? { after: from } : {}) }),
|
|
905
|
-
);
|
|
906
|
-
extended += roll.extended;
|
|
907
|
-
conflicts += roll.conflicts;
|
|
908
|
-
refused += roll.refused;
|
|
909
|
-
after = roll.next;
|
|
910
|
-
if (!after) {
|
|
911
|
-
horizonCapped = false;
|
|
912
|
-
break;
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
let pruned = 0;
|
|
917
|
-
let retentionCapped = true;
|
|
918
|
-
for (let batch = 0; batch < MAX_BATCHES; batch++) {
|
|
919
|
-
const prune = await store.withTenant(tenantRef, (tenant) =>
|
|
920
|
-
tenant.pruneCalendarEventRetention({ tenantRef }),
|
|
921
|
-
);
|
|
922
|
-
pruned += prune.commandsDeleted + prune.tombstonesDeleted;
|
|
923
|
-
if (!prune.hasMore) {
|
|
924
|
-
retentionCapped = false;
|
|
925
|
-
break;
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
return { extended, conflicts, refused, pruned, capped: horizonCapped || retentionCapped };
|
|
995
|
+
// Extends recurring events to the rolling horizon, then prunes past the retention window, each in
|
|
996
|
+
// at most ten batches. `capped: true` means work was left: run another pass.
|
|
997
|
+
return maintainSlotlockTenant(store, tenantRef, { batches: 10 });
|
|
929
998
|
}
|
|
930
999
|
```
|
|
931
1000
|
|
|
932
|
-
- **
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
`
|
|
1001
|
+
- **Every tenant.** Forced RLS hides other tenants from the application role, so iterate your own
|
|
1002
|
+
tenant list. `startSlotlockMaintenance({ store, sql, tenants, intervalMs })` is the scheduler
|
|
1003
|
+
`serve` uses, lock included; `withSlotlockMaintenanceLock` is the lock alone.
|
|
1004
|
+
- **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.
|
|
1006
|
+
- **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.
|
|
937
1009
|
- **Quotas.** Each agent principal may retain 1,000 event identities (active plus cancelled) and
|
|
938
1010
|
10,000 idempotency commands, of which 9,000 can be creates or updates, so a cancel always has
|
|
939
1011
|
room. Adjust with `agentOwnerEventQuota` and `agentOwnerCommandQuota` on `createSlotlockStore`.
|
|
@@ -964,6 +1036,10 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
|
|
|
964
1036
|
8. Keep writes confirmation-gated wherever your policy needs a person.
|
|
965
1037
|
9. Size live updates: at most `maxTotal` backend reads per `pollIntervalMs`.
|
|
966
1038
|
10. Give each agent its own API key with the narrowest scope and an expiry.
|
|
1039
|
+
11. After each deployment, check it end to end with a short-lived admin key, then revoke the key:
|
|
1040
|
+
`SLOTLOCK_URL=https://… SLOTLOCK_ADMIN_KEY=… node scripts/smoke-deployment.mjs` checks health,
|
|
1041
|
+
discovery, MCP, A2A, holds and reservations (in a resource named `slotlock-smoke`, 400 days
|
|
1042
|
+
ahead, cancelled after) and the 401, 409, 413 and 415 refusals.
|
|
967
1043
|
|
|
968
1044
|
## Supported versions
|
|
969
1045
|
|
package/SECURITY.md
CHANGED
|
@@ -7,9 +7,10 @@ documented breaking changes; security releases will identify the lowest safe ver
|
|
|
7
7
|
|
|
8
8
|
## Report a vulnerability
|
|
9
9
|
|
|
10
|
-
Do not open a public issue. Report privately through GitHub's private vulnerability reporting
|
|
11
|
-
|
|
12
|
-
vulnerability
|
|
10
|
+
Do not open a public issue. Report privately through GitHub's private vulnerability reporting:
|
|
11
|
+
<https://github.com/PILOTAAI/slotlock/security/advisories/new> (the repository's **Security** tab,
|
|
12
|
+
**Report a vulnerability**). The maintainers listed in
|
|
13
|
+
[MAINTAINERS.md](https://github.com/PILOTAAI/slotlock/blob/main/MAINTAINERS.md) receive the report.
|
|
13
14
|
Include:
|
|
14
15
|
|
|
15
16
|
- the affected version and surface;
|
|
@@ -18,7 +19,8 @@ Include:
|
|
|
18
19
|
- any known mitigations.
|
|
19
20
|
|
|
20
21
|
Do not include real customer, credential, calendar, or booking data. We will acknowledge a report
|
|
21
|
-
within three business days and coordinate disclosure after a fix is available.
|
|
22
|
+
within three business days and coordinate disclosure after a fix is available. Fixed vulnerabilities
|
|
23
|
+
are published as advisories at <https://github.com/PILOTAAI/slotlock/security/advisories>.
|
|
22
24
|
|
|
23
25
|
## Security model
|
|
24
26
|
|
|
@@ -28,6 +30,16 @@ PostgreSQL exclusion constraint. Tenant policies are forced and fail closed when
|
|
|
28
30
|
context is missing. Embedding applications remain responsible for authentication, authorization,
|
|
29
31
|
provider-secret storage, webhook verification, network egress, and erasure.
|
|
30
32
|
|
|
33
|
+
Upgrading repairs data across tenants (expired holds in a turnaround buffer, duplicate resources,
|
|
34
|
+
archives and event details from earlier versions), which forced policies would hide from the owning
|
|
35
|
+
role. `applySchema` therefore lifts FORCE from the tables that have it for its data steps and puts
|
|
36
|
+
it back on exactly those, inside its one locked transaction: a failure rolls the lift back with the
|
|
37
|
+
rest. Run statement by statement instead (psql without `--single-transaction`), a refused step
|
|
38
|
+
stops the run with FORCE lifted. The tables lifted are recorded in
|
|
39
|
+
`slotlock.deployment_lifted_row_security`, so the next deployment, from any session, puts FORCE back
|
|
40
|
+
on each of them, and until it does `slotlock serve` refuses to start (`row_security_lifted`). Run
|
|
41
|
+
the deployment DDL through `applySchema` or `slotlock migrate`.
|
|
42
|
+
|
|
31
43
|
Application traffic must use a role that cannot leave forced RLS. `grantApplicationRole` grants
|
|
32
44
|
such a role schema usage and DML on the tenant tables, and first refuses the known ways out, whether
|
|
33
45
|
the role holds them itself, through a role it belongs to, or through `PUBLIC`, and names the ones
|
|
@@ -97,3 +109,27 @@ embedder's `authenticate` must verify every token, including its audience. Embed
|
|
|
97
109
|
bearer-token verification, per-principal authorization, distributed rate limiting, TLS termination,
|
|
98
110
|
and a readiness check. Do not place provider credentials or raw calendar content in authentication,
|
|
99
111
|
authorization, rate-limit, or health callbacks.
|
|
112
|
+
|
|
113
|
+
## API keys and the application API
|
|
114
|
+
|
|
115
|
+
An API key is `slk_`, 40 base62 characters from the operating system's random source (about 238
|
|
116
|
+
bits) and a 6-character checksum, and the database keeps only its SHA-256. A slow password hash
|
|
117
|
+
(bcrypt, scrypt, Argon2) defends a secret a person chose against offline guessing; nobody can guess
|
|
118
|
+
238 random bits, so a fast hash
|
|
119
|
+
loses nothing and lets every request look a key up by its digest in one indexed read. Code scanners
|
|
120
|
+
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.
|
|
122
|
+
|
|
123
|
+
A key acts only in the tenant it was created for. `read` and `write` cover the agent tools; `admin`
|
|
124
|
+
covers the application API (resources, hours, holds, reservations, coverage, maintenance), which is
|
|
125
|
+
never offered to agents over MCP or A2A. Every application operation checks the tenant itself, not
|
|
126
|
+
only through row-level security: another tenant's resource or hold is not found even on a connection
|
|
127
|
+
that bypasses RLS.
|
|
128
|
+
|
|
129
|
+
## Dependencies
|
|
130
|
+
|
|
131
|
+
The published package depends on `ical.js`, `rrule` and `zod`, and on `postgres` as a peer; the
|
|
132
|
+
image adds nothing else. The documentation site (`site/`) is built with Astro and Starlight, and
|
|
133
|
+
those development dependencies never reach the package or the image. `braces`, which the site's
|
|
134
|
+
`starlight-llms-txt` reaches through `micromatch`, has an open denial-of-service advisory with no fixed
|
|
135
|
+
release; it only expands the site's own glob patterns at build time, so no outside input reaches it.
|