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