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.
- package/CHANGELOG.md +330 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +277 -53
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +12 -3
- package/agent-server.js +108 -78
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +355 -36
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +14 -1
- package/application-api.js +274 -12
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +6 -0
- package/dashboard.js +17 -1
- package/ddl.d.ts +45 -3
- package/ddl.js +538 -12
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +5 -5
- package/index.js +3 -3
- package/maintenance.d.ts +15 -5
- package/maintenance.js +59 -27
- package/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.js +10 -0
- package/rules.js +6 -3
- package/self-host.d.ts +22 -8
- package/self-host.js +307 -34
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +334 -24
- package/store.js +2029 -290
- package/sync.d.ts +43 -2
- package/sync.js +1515 -323
- package/timezone.d.ts +58 -3
- package/timezone.js +249 -20
- 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
|
|
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
|
-
|
|
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` (
|
|
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
|
-
`
|
|
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
|
|
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
|
|
189
|
-
- `SLOTLOCK_CLIENT_RATE_LIMIT` counts every dashboard request per client address
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
|
463
|
-
|
|
464
|
-
|
|
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`.
|
|
474
|
-
pool answers `ICalendarWorkerBusyError` at once. A worker
|
|
475
|
-
workers do not keep the process alive. The pool runs from
|
|
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
|
|
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`)
|
|
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
|
|
1008
|
-
|
|
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
|
-
|
|
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,
|
|
1060
|
-
|
|
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
|