slotlock 0.1.0 → 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 +138 -0
- package/CONTRIBUTING.md +35 -9
- package/README.md +153 -79
- 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,144 @@
|
|
|
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
|
+
|
|
131
|
+
## 0.1.1 - 2026-10-22
|
|
132
|
+
|
|
133
|
+
### Changed
|
|
134
|
+
|
|
135
|
+
- The README and the site install Slotlock from npm (`npm install slotlock`), now that 0.1.0 is
|
|
136
|
+
published there, instead of building it from a clone.
|
|
137
|
+
|
|
138
|
+
### Added
|
|
139
|
+
|
|
140
|
+
- Releases are cut from GitHub Actions: "Release a version" prepares the version on a branch, merges
|
|
141
|
+
it once CI passes, and tags it; the tag stages the package on npm for a maintainer's approval and
|
|
142
|
+
publishes the image and the MCP Registry entry.
|
|
143
|
+
|
|
6
144
|
## 0.1.0 - 2026-10-22
|
|
7
145
|
|
|
8
146
|
First public release. Nothing was published before it; the entries below describe the release as
|
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
|
|
@@ -66,11 +72,31 @@ one maintainer, GOVERNANCE.md's single-maintainer rule applies.
|
|
|
66
72
|
|
|
67
73
|
## Releases
|
|
68
74
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
Add each change's entry under `## Unreleased` in `CHANGELOG.md`, in the pull request that makes it:
|
|
76
|
+
that section becomes the release notes.
|
|
77
|
+
|
|
78
|
+
To release, run **Actions > Release a version** with the version (for example `0.1.1`). It sets the
|
|
79
|
+
version in `package.json`, `package-lock.json`, `server.json` and `slotlock.manifest.json` and dates
|
|
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.
|
|
98
|
+
|
|
99
|
+
When a run needs a step npm or GitHub keeps for a person (the first npm version, the npm trusted
|
|
100
|
+
publisher, the image's visibility, and the deploy key `tag-release.yml` pushes tags with), the
|
|
101
|
+
failing job names `scripts/bootstrap-release.sh`. One run of it does that step, re-runs the failed
|
|
102
|
+
jobs and waits for them.
|
package/README.md
CHANGED
|
@@ -5,9 +5,6 @@ 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
|
-
> **Status: pre-release.** 0.1.0 is not on npm yet. Build it from this repository:
|
|
9
|
-
> `npm ci && npm run build`, then `npm install <path-to>/dist` in your project.
|
|
10
|
-
|
|
11
8
|
## Why it is built for agents
|
|
12
9
|
|
|
13
10
|
- Resources own calendars, not users, and the tenant comes only from the authenticated caller.
|
|
@@ -40,6 +37,7 @@ docker compose exec slotlock slotlock resource add vehicle-42 --timezone Europe/
|
|
|
40
37
|
| --- | --- |
|
|
41
38
|
| `http://localhost:8080/mcp` | MCP, with `Authorization: Bearer <SLOTLOCK_AUTH_TOKEN>` |
|
|
42
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` |
|
|
43
41
|
| `http://localhost:8080/healthz` | Readiness, including the database |
|
|
44
42
|
|
|
45
43
|
- The server runs as a role that owns nothing and cannot bypass row-level security; a one-off
|
|
@@ -58,8 +56,9 @@ slotlock hours clear --all # back to SLOTLOCK_AVAILABILITY
|
|
|
58
56
|
|
|
59
57
|
### Without Docker
|
|
60
58
|
|
|
61
|
-
|
|
62
|
-
`node dist/cli.js` (a clone does not
|
|
59
|
+
With Node.js 22.12+ and PostgreSQL 16, run each command as `npx slotlock` after
|
|
60
|
+
`npm install slotlock postgres`, or from a clone, built once, as `node dist/cli.js` (a clone does not
|
|
61
|
+
put `slotlock` on your `PATH`). Use two roles, as Compose does:
|
|
63
62
|
the server connects as a role that owns nothing and cannot bypass row-level security, and `migrate`
|
|
64
63
|
connects as the database owner and grants that role what it needs. `serve` refuses to start as a
|
|
65
64
|
superuser or a `BYPASSRLS` role, which row-level security does not confine.
|
|
@@ -87,6 +86,7 @@ Give each agent its own key instead of sharing the server token:
|
|
|
87
86
|
```sh
|
|
88
87
|
slotlock key create "Booking agent"
|
|
89
88
|
slotlock key create "Availability bot" --scope read --expires-in-days 90
|
|
89
|
+
slotlock key create "Booking site" --scope read,write,admin
|
|
90
90
|
slotlock key list
|
|
91
91
|
slotlock key rotate <id>
|
|
92
92
|
slotlock key revoke <id>
|
|
@@ -94,8 +94,8 @@ slotlock key revoke <id>
|
|
|
94
94
|
|
|
95
95
|
- A key (`slk_…`) is shown once and stored only as a SHA-256. Send it as `Authorization: Bearer`.
|
|
96
96
|
- It acts in the tenant it was created for (`SLOTLOCK_TENANT`), so one server serves many tenants.
|
|
97
|
-
- `read` covers the five tools that only look; `write` covers create, update and delete
|
|
98
|
-
|
|
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.
|
|
99
99
|
- `rotate` keeps the key's id, scopes and bookings; `revoke` stops it on its next request.
|
|
100
100
|
- A tenant may hold 100 active keys. The last six characters are a checksum secret scanners can
|
|
101
101
|
verify offline.
|
|
@@ -267,7 +267,8 @@ export const slot = findNextAvailable({
|
|
|
267
267
|
// slot: 2026-09-14 10:00–12:00 UTC, the first two free hours after the busy one
|
|
268
268
|
```
|
|
269
269
|
|
|
270
|
-
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`.
|
|
271
272
|
|
|
272
273
|
## Set up PostgreSQL
|
|
273
274
|
|
|
@@ -413,26 +414,29 @@ export async function scheduleWeeklyInspection(
|
|
|
413
414
|
```
|
|
414
415
|
|
|
415
416
|
Occurrences are stored for a window of at most 367 days and 2,000 occurrences; maintenance rolls
|
|
416
|
-
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.
|
|
417
419
|
|
|
418
420
|
A rule is expanded within a budget: 50,000 steps and a second for a feed (the whole read, from its
|
|
419
421
|
text), or for a stored series as maintenance rolls it; half of that for one event as it is written.
|
|
420
|
-
A feed's own time zones (VTIMEZONE) count against the same budget. A feed may carry 100 time zones
|
|
421
|
-
1,000
|
|
422
|
-
its events; a property's name and parameters may take 8 KB
|
|
423
|
-
a `DURATION`, or an event's length may be a century at
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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.
|
|
436
440
|
|
|
437
441
|
### External calendars
|
|
438
442
|
|
|
@@ -442,6 +446,24 @@ Slotlock gives them neutral boundaries:
|
|
|
442
446
|
- `normalizeExternalCalendarChange` for webhook changes; `parseICalendarChanges` and `emitICalendar`
|
|
443
447
|
for bounded iCalendar in and out; `emitITipCalendar` for invitations.
|
|
444
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.
|
|
445
467
|
- `recordCalendarCoverage` marks how much of a feed is read. Pass the sources a resource depends on as
|
|
446
468
|
`requiredSources`, and any coverage short of `complete` means the time is not proven free.
|
|
447
469
|
|
|
@@ -627,10 +649,10 @@ on stderr.
|
|
|
627
649
|
|
|
628
650
|
| Where | Error |
|
|
629
651
|
| --- | --- |
|
|
630
|
-
| HTTP | `401` with `WWW-Authenticate`, `403` disallowed origin, `413`/`415` bad body, `429` rate limit |
|
|
631
|
-
| 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 |
|
|
632
654
|
| MCP 2026-07-28 | `-32020` header disagrees with body, `-32021` form elicitation not declared, `-32022` unsupported revision, `-31029` rate limit |
|
|
633
|
-
| 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) |
|
|
634
656
|
|
|
635
657
|
### Authentication and OAuth discovery
|
|
636
658
|
|
|
@@ -657,6 +679,8 @@ With `confirmation`, a listed write first asks the person a yes/no question that
|
|
|
657
679
|
will change, such as `Book vehicle-42 for 2027-03-29 10:00–11:00 (Europe/London): "Vehicle
|
|
658
680
|
handover".` Only an explicit yes runs the write.
|
|
659
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.
|
|
660
684
|
- Facts come first: the resource by its own reference, times in its own zone, and an event as it
|
|
661
685
|
stands now, read through your backend's `describeResource` and `getEvent` when the caller may
|
|
662
686
|
read them (within 2 seconds in all). Otherwise things are named by id, and each time carries its
|
|
@@ -687,10 +711,29 @@ returns busy time and coverage for the next `resourceWindowDays` (30). `subscrip
|
|
|
687
711
|
2026-07-28 results carry cache hints: discovery and tool lists are public for an hour, free/busy is
|
|
688
712
|
never cached. W3C `traceparent`, `tracestate` and `baggage` reach your backend as `context.trace`.
|
|
689
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
|
+
|
|
690
733
|
### REST and OpenAPI
|
|
691
734
|
|
|
692
|
-
`rest: true`
|
|
693
|
-
|
|
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.
|
|
694
737
|
|
|
695
738
|
| Request | Tool |
|
|
696
739
|
| --- | --- |
|
|
@@ -708,14 +751,66 @@ curl "http://localhost:8080/v1/resources" -H "Authorization: Bearer $SLOTLOCK_AP
|
|
|
708
751
|
```
|
|
709
752
|
|
|
710
753
|
- `GET` and `DELETE` take the tool's arguments as query parameters (a list repeats:
|
|
711
|
-
`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 `/`.
|
|
712
756
|
- Keys, scopes, `authorize` and the rate limit apply as over MCP. The body is the tool's output, or
|
|
713
757
|
`{"error":{"code":…}}` with the matching HTTP status.
|
|
714
758
|
- REST cannot ask a person, so a write listed in `confirmation` answers `428 confirmation_required`.
|
|
715
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.
|
|
716
806
|
|
|
717
807
|
## Connect an agent
|
|
718
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
|
+
|
|
719
814
|
### MCP (any client)
|
|
720
815
|
|
|
721
816
|
<!-- example: examples/mcp-client.ts#mcp -->
|
|
@@ -880,62 +975,37 @@ and never writes.
|
|
|
880
975
|
|
|
881
976
|
## Operations
|
|
882
977
|
|
|
883
|
-
|
|
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:
|
|
884
989
|
|
|
885
990
|
<!-- example: examples/maintenance.ts#maintenance -->
|
|
886
991
|
```ts
|
|
887
|
-
import {
|
|
888
|
-
type CalendarHorizonRollCursor,
|
|
889
|
-
type CalendarHorizonRollResult,
|
|
890
|
-
type SlotlockStore,
|
|
891
|
-
calendarEventRollingHorizon,
|
|
892
|
-
} from 'slotlock';
|
|
893
|
-
|
|
894
|
-
const MAX_BATCHES = 10;
|
|
992
|
+
import { type SlotlockStore, maintainSlotlockTenant } from 'slotlock';
|
|
895
993
|
|
|
896
994
|
export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
let refused = 0;
|
|
901
|
-
let horizonCapped = true;
|
|
902
|
-
let after: CalendarHorizonRollCursor | null = null;
|
|
903
|
-
for (let batch = 0; batch < MAX_BATCHES; batch++) {
|
|
904
|
-
const from = after;
|
|
905
|
-
const roll: CalendarHorizonRollResult = await store.withTenant(tenantRef, (tenant) =>
|
|
906
|
-
tenant.rollCalendarEventHorizon({ tenantRef, window, ...(from ? { after: from } : {}) }),
|
|
907
|
-
);
|
|
908
|
-
extended += roll.extended;
|
|
909
|
-
conflicts += roll.conflicts;
|
|
910
|
-
refused += roll.refused;
|
|
911
|
-
after = roll.next;
|
|
912
|
-
if (!after) {
|
|
913
|
-
horizonCapped = false;
|
|
914
|
-
break;
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
let pruned = 0;
|
|
919
|
-
let retentionCapped = true;
|
|
920
|
-
for (let batch = 0; batch < MAX_BATCHES; batch++) {
|
|
921
|
-
const prune = await store.withTenant(tenantRef, (tenant) =>
|
|
922
|
-
tenant.pruneCalendarEventRetention({ tenantRef }),
|
|
923
|
-
);
|
|
924
|
-
pruned += prune.commandsDeleted + prune.tombstonesDeleted;
|
|
925
|
-
if (!prune.hasMore) {
|
|
926
|
-
retentionCapped = false;
|
|
927
|
-
break;
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
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 });
|
|
931
998
|
}
|
|
932
999
|
```
|
|
933
1000
|
|
|
934
|
-
- **
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
`
|
|
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.
|
|
939
1009
|
- **Quotas.** Each agent principal may retain 1,000 event identities (active plus cancelled) and
|
|
940
1010
|
10,000 idempotency commands, of which 9,000 can be creates or updates, so a cancel always has
|
|
941
1011
|
room. Adjust with `agentOwnerEventQuota` and `agentOwnerCommandQuota` on `createSlotlockStore`.
|
|
@@ -966,6 +1036,10 @@ export async function maintainTenant(store: SlotlockStore, tenantRef: string) {
|
|
|
966
1036
|
8. Keep writes confirmation-gated wherever your policy needs a person.
|
|
967
1037
|
9. Size live updates: at most `maxTotal` backend reads per `pollIntervalMs`.
|
|
968
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.
|
|
969
1043
|
|
|
970
1044
|
## Supported versions
|
|
971
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.
|