slotlock 0.2.0 → 0.5.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 +582 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +498 -65
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +62 -10
- package/agent-server.js +577 -105
- package/agent-store-backend.d.ts +12 -0
- package/agent-store-backend.js +1115 -55
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +35 -1
- package/application-api.js +683 -12
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +112 -0
- package/config.js +225 -0
- package/dashboard.d.ts +67 -5
- package/dashboard.js +1012 -140
- package/ddl.d.ts +45 -3
- package/ddl.js +1124 -13
- package/engine.d.ts +5 -0
- package/engine.js +23 -6
- package/event-feed.d.ts +178 -0
- package/event-feed.js +435 -0
- 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 +12 -6
- package/index.js +9 -4
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +27 -5
- package/maintenance.js +92 -28
- package/mcp-app.d.ts +7 -1
- package/mcp-app.js +259 -40
- package/metrics.d.ts +24 -0
- package/metrics.js +167 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- package/openapi.json +10970 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +80 -0
- package/rate-limit.js +741 -0
- package/rest-api.d.ts +22 -0
- package/rest-api.js +166 -11
- package/rules.js +6 -3
- package/self-host.d.ts +45 -8
- package/self-host.js +616 -46
- package/slotlock-manifest.schema.json +7 -1
- package/slotlock.manifest.json +8 -2
- package/store.d.ts +647 -28
- package/store.js +2982 -327
- 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 +300 -9
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
package/rest-api.d.ts
CHANGED
|
@@ -5,10 +5,32 @@ export interface SlotlockRestRoute<Operation extends string = SlotlockAgentOpera
|
|
|
5
5
|
/** Relative to the server's base URL; `{name}` is one path segment. */
|
|
6
6
|
path: string;
|
|
7
7
|
operation: Operation;
|
|
8
|
+
/**
|
|
9
|
+
* The store refusals the route can answer besides the ones every route can: 404 for a record
|
|
10
|
+
* that is missing or another tenant's, 409 for a conflict with what is stored (an overlap, a
|
|
11
|
+
* stale revision). The OpenAPI document declares them, so a generated client types them.
|
|
12
|
+
*/
|
|
13
|
+
refusals?: readonly (404 | 409)[];
|
|
8
14
|
}
|
|
9
15
|
export declare const SLOTLOCK_REST_ROUTES: readonly SlotlockRestRoute[];
|
|
10
16
|
/** The application operations' routes, served when the server has an application backend. */
|
|
11
17
|
export declare const SLOTLOCK_APPLICATION_REST_ROUTES: readonly SlotlockRestRoute<SlotlockApplicationOperation>[];
|
|
18
|
+
/** Where every REST refusal's documentation is: the Link header's `describedby` target. */
|
|
19
|
+
export declare const SLOTLOCK_PROBLEM_DOCS = "https://slotlock.pylota.io/docs/api/#errors";
|
|
20
|
+
/** A problem type's identifier: a tag URI (RFC 4151), stable, one per code, not a locator. */
|
|
21
|
+
export declare function slotlockProblemType(code: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* A REST refusal as RFC 9457 problem details (`application/problem+json`): `type` and `title` name
|
|
24
|
+
* the problem, `code` is Slotlock's code, and `error.code` repeats it, the body every earlier
|
|
25
|
+
* client reads. `extensions` add what the refusal is about, such as the interval an overlap refused.
|
|
26
|
+
*/
|
|
27
|
+
export declare function slotlockProblem(status: number, code: string, extensions?: Record<string, unknown>): Record<string, unknown>;
|
|
28
|
+
/**
|
|
29
|
+
* The Idempotency-Key header's value: a Structured Field String (RFC 9651, as
|
|
30
|
+
* draft-ietf-httpapi-idempotency-key-header-08 defines it), or the bare token clients sent before.
|
|
31
|
+
* `null` when it is a quoted string that does not parse.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseIdempotencyKeyHeader(value: string): string | null;
|
|
12
34
|
/** Where an operation's arguments come from. */
|
|
13
35
|
export declare function restInputFromBody(route: {
|
|
14
36
|
method: string;
|
package/rest-api.js
CHANGED
|
@@ -1,13 +1,49 @@
|
|
|
1
1
|
import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, } from './application-api.js';
|
|
2
2
|
export const SLOTLOCK_REST_ROUTES = Object.freeze([
|
|
3
3
|
{ method: 'GET', path: '/v1/resources', operation: 'slotlock_list_resources' },
|
|
4
|
-
{ method: 'GET', path: '/v1/free-busy', operation: 'slotlock_get_free_busy' },
|
|
5
|
-
{
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
4
|
+
{ method: 'GET', path: '/v1/free-busy', operation: 'slotlock_get_free_busy', refusals: [404] },
|
|
5
|
+
{
|
|
6
|
+
method: 'GET',
|
|
7
|
+
path: '/v1/next-available',
|
|
8
|
+
operation: 'slotlock_find_next_available',
|
|
9
|
+
refusals: [404],
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
method: 'GET',
|
|
13
|
+
path: '/v1/available-slots',
|
|
14
|
+
operation: 'slotlock_find_available_slots',
|
|
15
|
+
refusals: [404],
|
|
16
|
+
},
|
|
17
|
+
{ method: 'POST', path: '/v1/slot-holds', operation: 'slotlock_hold_slots', refusals: [409] },
|
|
18
|
+
{ method: 'POST', path: '/v1/bundles', operation: 'slotlock_book_together', refusals: [404, 409] },
|
|
19
|
+
{ method: 'POST', path: '/v1/pool-bookings', operation: 'slotlock_book_pool', refusals: [404, 409] },
|
|
20
|
+
{
|
|
21
|
+
method: 'GET',
|
|
22
|
+
path: '/v1/pool-availability',
|
|
23
|
+
operation: 'slotlock_get_pool_availability',
|
|
24
|
+
refusals: [404, 409],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
method: 'DELETE',
|
|
28
|
+
path: '/v1/slot-holds/{hold_id}',
|
|
29
|
+
operation: 'slotlock_release_slots',
|
|
30
|
+
refusals: [409],
|
|
31
|
+
},
|
|
32
|
+
{ method: 'GET', path: '/v1/events', operation: 'slotlock_list_events', refusals: [404] },
|
|
33
|
+
{ method: 'POST', path: '/v1/events', operation: 'slotlock_create_event', refusals: [404, 409] },
|
|
34
|
+
{ method: 'GET', path: '/v1/events/{event_id}', operation: 'slotlock_get_event', refusals: [404] },
|
|
35
|
+
{
|
|
36
|
+
method: 'PATCH',
|
|
37
|
+
path: '/v1/events/{event_id}',
|
|
38
|
+
operation: 'slotlock_update_event',
|
|
39
|
+
refusals: [404, 409],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
method: 'DELETE',
|
|
43
|
+
path: '/v1/events/{event_id}',
|
|
44
|
+
operation: 'slotlock_delete_event',
|
|
45
|
+
refusals: [404, 409],
|
|
46
|
+
},
|
|
11
47
|
]);
|
|
12
48
|
/** The application operations' routes, served when the server has an application backend. */
|
|
13
49
|
export const SLOTLOCK_APPLICATION_REST_ROUTES = Object.freeze(SLOTLOCK_APPLICATION_OPERATIONS.map(({ method, path, name }) => ({
|
|
@@ -15,6 +51,86 @@ export const SLOTLOCK_APPLICATION_REST_ROUTES = Object.freeze(SLOTLOCK_APPLICATI
|
|
|
15
51
|
path,
|
|
16
52
|
operation: name,
|
|
17
53
|
})));
|
|
54
|
+
/** Where every REST refusal's documentation is: the Link header's `describedby` target. */
|
|
55
|
+
export const SLOTLOCK_PROBLEM_DOCS = 'https://slotlock.pylota.io/docs/api/#errors';
|
|
56
|
+
/** A problem type's identifier: a tag URI (RFC 4151), stable, one per code, not a locator. */
|
|
57
|
+
export function slotlockProblemType(code) {
|
|
58
|
+
return `tag:slotlock.pylota.io,2026:problem:${code}`;
|
|
59
|
+
}
|
|
60
|
+
const PROBLEM_TITLES = Object.freeze({
|
|
61
|
+
overlap: 'The time is taken',
|
|
62
|
+
idempotency_conflict: 'Idempotency-Key is already used',
|
|
63
|
+
idempotency_key_required: 'Idempotency-Key is missing',
|
|
64
|
+
idempotency_key_not_supported: 'Idempotency-Key is not taken here',
|
|
65
|
+
revision_conflict: 'The revision is stale',
|
|
66
|
+
confirmation_required: 'A person must confirm this write',
|
|
67
|
+
rate_limited: 'Too many requests',
|
|
68
|
+
authentication_required: 'Authentication required',
|
|
69
|
+
forbidden: 'Not allowed',
|
|
70
|
+
invalid_arguments: 'The arguments are not valid',
|
|
71
|
+
});
|
|
72
|
+
const STATUS_TITLES = Object.freeze({
|
|
73
|
+
400: 'Bad Request',
|
|
74
|
+
401: 'Unauthorized',
|
|
75
|
+
403: 'Forbidden',
|
|
76
|
+
404: 'Not Found',
|
|
77
|
+
405: 'Method Not Allowed',
|
|
78
|
+
409: 'Conflict',
|
|
79
|
+
410: 'Gone',
|
|
80
|
+
413: 'Content Too Large',
|
|
81
|
+
415: 'Unsupported Media Type',
|
|
82
|
+
422: 'Unprocessable Content',
|
|
83
|
+
428: 'Precondition Required',
|
|
84
|
+
429: 'Too Many Requests',
|
|
85
|
+
500: 'Internal Server Error',
|
|
86
|
+
503: 'Service Unavailable',
|
|
87
|
+
});
|
|
88
|
+
/**
|
|
89
|
+
* A REST refusal as RFC 9457 problem details (`application/problem+json`): `type` and `title` name
|
|
90
|
+
* the problem, `code` is Slotlock's code, and `error.code` repeats it, the body every earlier
|
|
91
|
+
* client reads. `extensions` add what the refusal is about, such as the interval an overlap refused.
|
|
92
|
+
*/
|
|
93
|
+
export function slotlockProblem(status, code, extensions = {}) {
|
|
94
|
+
return {
|
|
95
|
+
type: slotlockProblemType(code),
|
|
96
|
+
title: PROBLEM_TITLES[code] ?? STATUS_TITLES[status] ?? 'Error',
|
|
97
|
+
status,
|
|
98
|
+
code,
|
|
99
|
+
...extensions,
|
|
100
|
+
error: { code },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The Idempotency-Key header's value: a Structured Field String (RFC 9651, as
|
|
105
|
+
* draft-ietf-httpapi-idempotency-key-header-08 defines it), or the bare token clients sent before.
|
|
106
|
+
* `null` when it is a quoted string that does not parse.
|
|
107
|
+
*/
|
|
108
|
+
export function parseIdempotencyKeyHeader(value) {
|
|
109
|
+
const trimmed = value.trim();
|
|
110
|
+
if (!trimmed.startsWith('"'))
|
|
111
|
+
return trimmed;
|
|
112
|
+
let key = '';
|
|
113
|
+
for (let index = 1; index < trimmed.length; index++) {
|
|
114
|
+
const char = trimmed[index];
|
|
115
|
+
if (char === '\\') {
|
|
116
|
+
const next = trimmed[index + 1];
|
|
117
|
+
if (next !== '"' && next !== '\\')
|
|
118
|
+
return null;
|
|
119
|
+
key += next;
|
|
120
|
+
index++;
|
|
121
|
+
}
|
|
122
|
+
else if (char === '"') {
|
|
123
|
+
return index === trimmed.length - 1 ? key : null;
|
|
124
|
+
}
|
|
125
|
+
else if (char < ' ' || char > '~') {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
key += char;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
18
134
|
/** Where an operation's arguments come from. */
|
|
19
135
|
export function restInputFromBody(route) {
|
|
20
136
|
return route.method === 'POST' || route.method === 'PUT' || route.method === 'PATCH';
|
|
@@ -114,6 +230,12 @@ export function restQueryInput(search, inputSchema) {
|
|
|
114
230
|
return input;
|
|
115
231
|
}
|
|
116
232
|
const ERROR_RESPONSE = { $ref: '#/components/responses/Error' };
|
|
233
|
+
const IDEMPOTENCY_KEY_HEADER = 'Stands in for idempotency_key: a Structured Field String (`"8e03978e-40d5-43e8-bc93-6894a57f9324"`, ' +
|
|
234
|
+
'draft-ietf-httpapi-idempotency-key-header-08) or the bare key; when both are sent they must be equal. ' +
|
|
235
|
+
'A retry with the same key and request answers what the first did; the same key with another request is ' +
|
|
236
|
+
'422 idempotency_conflict. An event write keeps its key for 30 days (maintenance removes it after that), ' +
|
|
237
|
+
'a hold until the hold would have expired, is released or is confirmed. Where the key is required and ' +
|
|
238
|
+
'neither is sent: 400 idempotency_key_required.';
|
|
117
239
|
/** The OpenAPI operation object for one route. */
|
|
118
240
|
function openApiOperation(route, operation, extra) {
|
|
119
241
|
const pathNames = [...route.path.matchAll(/\{(\w+)\}/g)].map((match) => match[1]);
|
|
@@ -156,6 +278,17 @@ function openApiOperation(route, operation, extra) {
|
|
|
156
278
|
parameters.push({ name, in: 'query', required: required.has(name), schema });
|
|
157
279
|
}
|
|
158
280
|
}
|
|
281
|
+
// A write that keeps a retry identity also takes it as the Idempotency-Key header.
|
|
282
|
+
const keyed = fields.idempotency_key !== undefined;
|
|
283
|
+
if (keyed) {
|
|
284
|
+
parameters.push({
|
|
285
|
+
name: 'Idempotency-Key',
|
|
286
|
+
in: 'header',
|
|
287
|
+
required: false,
|
|
288
|
+
description: IDEMPOTENCY_KEY_HEADER,
|
|
289
|
+
schema: { type: 'string', minLength: 1, maxLength: 502 },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
159
292
|
if (parameters.length > 0)
|
|
160
293
|
result.parameters = parameters;
|
|
161
294
|
result.responses = {
|
|
@@ -164,6 +297,7 @@ function openApiOperation(route, operation, extra) {
|
|
|
164
297
|
content: { 'application/json': { schema: operation.outputSchema } },
|
|
165
298
|
},
|
|
166
299
|
...extra.responses,
|
|
300
|
+
...(keyed ? { '422': ERROR_RESPONSE } : {}),
|
|
167
301
|
};
|
|
168
302
|
return result;
|
|
169
303
|
}
|
|
@@ -184,6 +318,7 @@ export function slotlockOpenApiDocument(args) {
|
|
|
184
318
|
'400': ERROR_RESPONSE,
|
|
185
319
|
'401': ERROR_RESPONSE,
|
|
186
320
|
'403': ERROR_RESPONSE,
|
|
321
|
+
...Object.fromEntries((route.refusals ?? []).map((status) => [status, ERROR_RESPONSE])),
|
|
187
322
|
...(route.method === 'GET' ? {} : { '428': ERROR_RESPONSE }),
|
|
188
323
|
'429': ERROR_RESPONSE,
|
|
189
324
|
default: ERROR_RESPONSE,
|
|
@@ -203,6 +338,8 @@ export function slotlockOpenApiDocument(args) {
|
|
|
203
338
|
'403': ERROR_RESPONSE,
|
|
204
339
|
'404': ERROR_RESPONSE,
|
|
205
340
|
...(route.method === 'GET' ? {} : { '409': ERROR_RESPONSE }),
|
|
341
|
+
// A change cursor past the feed's retention.
|
|
342
|
+
...(route.operation === 'slotlock_list_changes' ? { '410': ERROR_RESPONSE } : {}),
|
|
206
343
|
'429': ERROR_RESPONSE,
|
|
207
344
|
default: ERROR_RESPONSE,
|
|
208
345
|
},
|
|
@@ -215,8 +352,10 @@ export function slotlockOpenApiDocument(args) {
|
|
|
215
352
|
version: args.version,
|
|
216
353
|
description: 'Calendars agents cannot double-book. The "Agent tools" operations are the Slotlock MCP tools; ' +
|
|
217
354
|
'the "Application" operations (resources, bookable hours, holds, reservations, coverage and ' +
|
|
218
|
-
'maintenance) need an API key with the admin scope. An error is
|
|
219
|
-
'
|
|
355
|
+
'maintenance) need an API key with the admin scope. An error is RFC 9457 problem details ' +
|
|
356
|
+
'(`application/problem+json`) whose `code` names it, also as `{"error":{"code":…}}`; an overlap ' +
|
|
357
|
+
'names the `resource_id`, `start` and `end` it refused. A write that waits for a person answers 428 ' +
|
|
358
|
+
'`confirmation_required`.',
|
|
220
359
|
},
|
|
221
360
|
servers: [
|
|
222
361
|
args.publicBaseUrl !== undefined
|
|
@@ -255,8 +394,16 @@ export function slotlockOpenApiDocument(args) {
|
|
|
255
394
|
schemas: {
|
|
256
395
|
Error: {
|
|
257
396
|
type: 'object',
|
|
258
|
-
|
|
397
|
+
description: 'RFC 9457 problem details.',
|
|
398
|
+
required: ['type', 'title', 'status', 'code', 'error'],
|
|
259
399
|
properties: {
|
|
400
|
+
type: { type: 'string', description: 'The problem type: a tag URI, one per code.' },
|
|
401
|
+
title: { type: 'string' },
|
|
402
|
+
status: { type: 'integer' },
|
|
403
|
+
code: { type: 'string', description: 'Why the operation did not run.' },
|
|
404
|
+
resource_id: { type: 'string', description: 'An overlap: the resource it refused.' },
|
|
405
|
+
start: { type: 'string', description: 'An overlap: the start it refused.' },
|
|
406
|
+
end: { type: 'string', description: 'An overlap: the end it refused.' },
|
|
260
407
|
error: {
|
|
261
408
|
type: 'object',
|
|
262
409
|
required: ['code'],
|
|
@@ -268,7 +415,15 @@ export function slotlockOpenApiDocument(args) {
|
|
|
268
415
|
responses: {
|
|
269
416
|
Error: {
|
|
270
417
|
description: 'The operation did not run; `code` says why.',
|
|
271
|
-
|
|
418
|
+
headers: {
|
|
419
|
+
Link: {
|
|
420
|
+
description: 'Where the error codes are documented (`rel="describedby"`).',
|
|
421
|
+
schema: { type: 'string' },
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
content: {
|
|
425
|
+
'application/problem+json': { schema: { $ref: '#/components/schemas/Error' } },
|
|
426
|
+
},
|
|
272
427
|
},
|
|
273
428
|
},
|
|
274
429
|
},
|
package/rules.js
CHANGED
|
@@ -159,10 +159,13 @@ export function expandRules(rules, searchWindow, timezone) {
|
|
|
159
159
|
let start;
|
|
160
160
|
let end;
|
|
161
161
|
if (converter) {
|
|
162
|
-
// The stamp's Y/M/D is the LOCAL calendar date; start and end are both wall-clock.
|
|
162
|
+
// The stamp's Y/M/D is the LOCAL calendar date; start and end are both wall-clock. An
|
|
163
|
+
// edge the clocks skip stands where the skipped hour ends: moved on by the gap, as RFC 5545
|
|
164
|
+
// reads a time, [02:30, 03:30) gave nothing although 03:00-03:30 exists, and [02:00,
|
|
165
|
+
// 02:30) gave 03:00-03:30, which it never names.
|
|
163
166
|
const wallDay = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate());
|
|
164
|
-
start = converter.wallFrameToUtc(wallDay + rule.startMinutes * MINUTE_MS);
|
|
165
|
-
end = converter.wallFrameToUtc(wallDay + (rule.startMinutes + rule.durationMinutes) * MINUTE_MS);
|
|
167
|
+
start = converter.wallFrameToUtc(wallDay + rule.startMinutes * MINUTE_MS, { gap: 'end' });
|
|
168
|
+
end = converter.wallFrameToUtc(wallDay + (rule.startMinutes + rule.durationMinutes) * MINUTE_MS, { gap: 'end' });
|
|
166
169
|
if (end.getTime() <= start.getTime())
|
|
167
170
|
continue;
|
|
168
171
|
}
|
package/self-host.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { type SlotlockAgentOperation, type SlotlockAgentPrincipal, type SlotlockAgentWriteOperation } from './agent-server.js';
|
|
2
|
-
import { SlotlockConfigError, type SlotlockEnv, type SlotlockServeDashboardConfig } from './config.js';
|
|
2
|
+
import { SlotlockConfigError, type SlotlockEnv, type SlotlockLimits, type SlotlockServeDashboardConfig } from './config.js';
|
|
3
3
|
import { type SlotlockNodeServerAddress, type SlotlockNodeServerCloseResult } from './node-server.js';
|
|
4
|
+
import { type SlotlockSql, type SlotlockStore } from './store.js';
|
|
4
5
|
import type { WeeklyAvailabilityRule } from './types.js';
|
|
5
6
|
export { SlotlockConfigError, type SlotlockEnv, type SlotlockServeDashboardConfig };
|
|
6
7
|
/** Every variable the commands read. The README, `.env.example` and `server.json` document these. */
|
|
7
|
-
export declare const SLOTLOCK_ENVIRONMENT_VARIABLES: readonly ["DATABASE_URL", "SLOTLOCK_MIGRATE_DATABASE_URL", "SLOTLOCK_AUTH_TOKEN", "SLOTLOCK_CONFIRMATION_SECRET", "SLOTLOCK_CONFIRM_WRITES", "SLOTLOCK_PUBLIC_URL", "SLOTLOCK_TENANT", "SLOTLOCK_AVAILABILITY", "SLOTLOCK_REST_API", "SLOTLOCK_GITHUB_CLIENT_ID", "SLOTLOCK_GITHUB_CLIENT_SECRET", "SLOTLOCK_SESSION_SECRET", "SLOTLOCK_DASHBOARD_USERS", "HOST", "PORT"];
|
|
8
|
+
export declare const SLOTLOCK_ENVIRONMENT_VARIABLES: readonly ["DATABASE_URL", "SLOTLOCK_MIGRATE_DATABASE_URL", "SLOTLOCK_AUTH_TOKEN", "SLOTLOCK_CONFIRMATION_SECRET", "SLOTLOCK_CONFIRM_WRITES", "SLOTLOCK_METRICS_TOKEN", "SLOTLOCK_PUBLIC_URL", "SLOTLOCK_TENANT", "SLOTLOCK_AVAILABILITY", "SLOTLOCK_REST_API", "SLOTLOCK_RETENTION_DAYS", "SLOTLOCK_WEBHOOKS", "SLOTLOCK_SHARED_LIMITS", "SLOTLOCK_LIMITS", "SLOTLOCK_TRUSTED_PROXY_HEADER", "SLOTLOCK_GITHUB_CLIENT_ID", "SLOTLOCK_GITHUB_CLIENT_SECRET", "SLOTLOCK_SESSION_SECRET", "SLOTLOCK_DASHBOARD_USERS", "SLOTLOCK_DASHBOARD_ORGS", "SLOTLOCK_DASHBOARD_NAME", "HOST", "PORT"];
|
|
8
9
|
export interface SlotlockDatabaseConfig {
|
|
9
10
|
/** The role `serve` and `resource` connect as. */
|
|
10
11
|
databaseUrl: string;
|
|
@@ -14,6 +15,11 @@ export interface SlotlockDatabaseConfig {
|
|
|
14
15
|
*/
|
|
15
16
|
migrateDatabaseUrl?: string;
|
|
16
17
|
tenantRef: string;
|
|
18
|
+
/**
|
|
19
|
+
* How long history is kept, in days (SLOTLOCK_RETENTION_DAYS); 30 without it. Every command that
|
|
20
|
+
* prunes or reads the feed takes it, so `maintain` from cron prunes as `serve` reads.
|
|
21
|
+
*/
|
|
22
|
+
retentionDays?: number;
|
|
17
23
|
}
|
|
18
24
|
export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
|
|
19
25
|
host: string;
|
|
@@ -21,11 +27,16 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
|
|
|
21
27
|
/** Where clients reach the server; Slotlock publishes its endpoints under this URL. */
|
|
22
28
|
publicUrl: string;
|
|
23
29
|
/**
|
|
24
|
-
* A bearer token for SLOTLOCK_TENANT with
|
|
25
|
-
* authenticate whether or not it is set, each as its own tenant and scopes.
|
|
30
|
+
* A bearer token for SLOTLOCK_TENANT with read and write, never admin. Optional: API keys
|
|
31
|
+
* (`slotlock key create`) authenticate whether or not it is set, each as its own tenant and scopes.
|
|
26
32
|
*/
|
|
27
33
|
authToken?: string;
|
|
28
|
-
/**
|
|
34
|
+
/** The bearer token `/metrics` answers (SLOTLOCK_METRICS_TOKEN); no metrics without it. */
|
|
35
|
+
metricsToken?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Writes that wait for a person's confirmation (MCP 2026-07-28) and are refused elsewhere. None
|
|
38
|
+
* unless SLOTLOCK_CONFIRM_WRITES opts in: every authenticated write takes effect at once.
|
|
39
|
+
*/
|
|
29
40
|
confirmWrites: readonly SlotlockAgentWriteOperation[];
|
|
30
41
|
/** Set exactly when `confirmWrites` is not empty. */
|
|
31
42
|
confirmationSecret?: string;
|
|
@@ -33,11 +44,24 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
|
|
|
33
44
|
availability: readonly WeeklyAvailabilityRule[];
|
|
34
45
|
/** Set exactly when SLOTLOCK_GITHUB_CLIENT_ID is. */
|
|
35
46
|
dashboard?: SlotlockServeDashboardConfig;
|
|
47
|
+
/**
|
|
48
|
+
* GitHub organization ids (SLOTLOCK_DASHBOARD_ORGS) whose `github-org:<id>` tenants this server's
|
|
49
|
+
* keys may act for, whether it serves the dashboard or another server (a Worker) does.
|
|
50
|
+
*/
|
|
51
|
+
organizations?: string[];
|
|
36
52
|
/**
|
|
37
53
|
* Serve the REST API under /v1, with the application API for admin keys, and its OpenAPI document
|
|
38
54
|
* (SLOTLOCK_REST_API: on unless off).
|
|
39
55
|
*/
|
|
40
56
|
restApi: boolean;
|
|
57
|
+
/** Webhook delivery (SLOTLOCK_WEBHOOKS: on unless off; private also reaches private networks). */
|
|
58
|
+
webhooks: 'on' | 'off' | 'private';
|
|
59
|
+
/** Tenant request budgets shared with every server through PostgreSQL (SLOTLOCK_SHARED_LIMITS=on). */
|
|
60
|
+
sharedLimits: boolean;
|
|
61
|
+
/** Request limits (SLOTLOCK_LIMITS over SLOTLOCK_DEFAULT_LIMITS): the server's only rate limit. */
|
|
62
|
+
limits: SlotlockLimits;
|
|
63
|
+
/** The lower-case request field a trusted proxy sets to the client address; else the socket's. */
|
|
64
|
+
trustedProxyHeader?: string;
|
|
41
65
|
}
|
|
42
66
|
export declare function readSlotlockDatabaseConfig(env: SlotlockEnv): SlotlockDatabaseConfig;
|
|
43
67
|
export declare function readSlotlockServeConfig(env: SlotlockEnv): SlotlockServeConfig;
|
|
@@ -54,8 +78,8 @@ export declare function slotlockHealthcheckUrl(env: SlotlockEnv): string;
|
|
|
54
78
|
export declare function createSlotlockTokenAuthenticator(token: string, principal: SlotlockAgentPrincipal): (request: Request) => Promise<SlotlockAgentPrincipal | null>;
|
|
55
79
|
/**
|
|
56
80
|
* What `serve` lets a principal call: a served operation its scopes cover. The server token's
|
|
57
|
-
* principal carries
|
|
58
|
-
* agent server alone would leave it to this function.
|
|
81
|
+
* principal carries read and write, never admin; a principal without scopes is refused everything
|
|
82
|
+
* here, though the agent server alone would leave it to this function.
|
|
59
83
|
*/
|
|
60
84
|
export declare function authorizeSlotlockServeOperation(args: {
|
|
61
85
|
principal: SlotlockAgentPrincipal;
|
|
@@ -73,6 +97,7 @@ export interface SlotlockCliIo {
|
|
|
73
97
|
}
|
|
74
98
|
export interface SlotlockLogger {
|
|
75
99
|
info(event: string, fields?: Record<string, unknown>): void;
|
|
100
|
+
warn(event: string, fields?: Record<string, unknown>): void;
|
|
76
101
|
error(event: string, fields?: Record<string, unknown>): void;
|
|
77
102
|
}
|
|
78
103
|
/**
|
|
@@ -85,9 +110,21 @@ export interface SlotlockRunningServer {
|
|
|
85
110
|
/** End open subscriptions, drain in-flight requests, then close the database pool. */
|
|
86
111
|
close(): Promise<SlotlockNodeServerCloseResult>;
|
|
87
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* The store `serve` answers requests from. Every surface writes through it, so its tenant limits
|
|
115
|
+
* bound what any key can add, and its transaction limits what one tenant holds of the shared pool.
|
|
116
|
+
*/
|
|
117
|
+
export declare function createSlotlockServeStore(sql: SlotlockSql, caps: SlotlockLimits['caps'], availability?: readonly WeeklyAvailabilityRule[], retentionDays?: number): SlotlockStore;
|
|
118
|
+
/**
|
|
119
|
+
* The store maintenance writes through, `serve`'s and `maintain`'s: the same tenant and per-holder
|
|
120
|
+
* limits as requests, so a waitlist offer is a hold like any other.
|
|
121
|
+
*/
|
|
122
|
+
export declare function createSlotlockMaintenanceStore(sql: SlotlockSql, caps: SlotlockLimits['caps'], options?: {
|
|
123
|
+
retentionDays?: number;
|
|
124
|
+
}): SlotlockStore;
|
|
88
125
|
/** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
|
|
89
126
|
export declare function startSlotlockServer(config: SlotlockServeConfig, log: SlotlockLogger): Promise<SlotlockRunningServer>;
|
|
90
|
-
export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains each tenant it serves every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with
|
|
127
|
+
export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains each tenant it serves every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n resource archive <ref> Take a resource out of use: no new bookings, holds or events, and\n no longer listed; what is on it stays\n resource restore <ref> Put an archived resource back in use\n resource delete <ref> Delete a resource and its history; refused while a booking, hold\n or event on it has not ended\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n rules set <json> --resource <ref>\n Set what a booking on the resource must satisfy, like\n {\"minNoticeMinutes\":120,\"slotStepMinutes\":30,\"turnaroundMinutes\":30,\"enforceHours\":true}\n rules clear --resource <ref>\n Remove the resource's booking rules\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n tenant status Print whether the tenant is suspended\n tenant suspend [--reason <text>]\n Suspend the tenant: it may read, and writes nothing; its keys get\n no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:\n only the schema owner suspends)\n tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)\n tenant erase --confirm <tenant>\n Delete everything the tenant has, its API keys included, for good;\n --confirm repeats SLOTLOCK_TENANT\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write\n access (never admin), 32+ random characters (serve); API keys\n work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;\n required when SLOTLOCK_CONFIRM_WRITES is not none)\n SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+\n random characters (serve; off without it)\n SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools\n that wait for a person (MCP 2026-07-28 form elicitation; refused\n with confirmation_required on REST, A2A and older MCP)\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,\n and retired answers (never under 30) (1-3650, default 30;\n serve and maintain)\n SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the\n database, so N servers allow a tenant its rate once; off (default):\n each server keeps its own\n SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,\n never to a private network address; private: those too, for\n testing; off: keep the change feed, send nothing\n SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:\n rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,\n every request per client, before any key lookup), key_read,\n tenant_read (20/s:40), key_write, tenant_write (5/s:20),\n global_read (500/s:1000), global_write (50/s:100), app_read\n (20/s:40) and app_write (5/s:20) per tenant for the\n application API, sign_in (20/m:20); counts for\n tenant_series_writes and series_writes\n (1: writes that may expand a recurring series at once),\n maintenance_runs (1: application API maintenance runs at once),\n subscriptions (256), tenant_subscriptions (16),\n key_subscriptions (4, and fewer than tenant_subscriptions),\n key_lookups (2), key_lookup_queue (256),\n tenant_transactions (2: one tenant's database transactions at\n once, of the pool's 10); what one tenant may store:\n tenant_holds (10000), tenant_reservations, tenant_events and\n tenant_coverage (100000), holder_holds (20) and\n holder_held_hours (2160), which maintain keeps too\n SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,\n such as CF-Connecting-IP (default: the connection's address);\n set it only when every request comes through that proxy\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)\n SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant\n SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
|
|
91
128
|
/**
|
|
92
129
|
* Run one `slotlock` command and resolve to its exit code: 0 success, 1 failure, 2 a usage or
|
|
93
130
|
* configuration error (nothing was started). `serve` resolves once `io.signal` aborts and it has
|