slotlock 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/CONTRIBUTING.md +25 -16
- package/README.md +150 -74
- package/SECURITY.md +40 -4
- package/SPEC.md +19 -6
- package/agent-server.d.ts +64 -7
- package/agent-server.js +273 -68
- package/agent-store-backend.d.ts +16 -0
- package/agent-store-backend.js +257 -2
- package/api-keys.d.ts +11 -4
- package/api-keys.js +9 -2
- package/application-api.d.ts +54 -0
- package/application-api.js +290 -0
- package/dashboard.d.ts +4 -1
- package/dashboard.js +6 -4
- package/ddl.d.ts +2 -1
- package/ddl.js +143 -15
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -0
- package/index.d.ts +8 -4
- package/index.js +4 -2
- package/maintenance.d.ts +57 -0
- package/maintenance.js +181 -0
- package/node-server.js +121 -5
- package/package.json +1 -1
- package/rest-api.d.ts +33 -18
- package/rest-api.js +131 -63
- package/rules.d.ts +19 -0
- package/rules.js +32 -4
- package/self-host.d.ts +5 -2
- package/self-host.js +127 -20
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +30 -8
- package/store.js +69 -27
- package/sync.js +530 -49
- package/timezone.d.ts +19 -0
- package/timezone.js +16 -1
- package/types.d.ts +4 -1
- package/weekly-hours.js +2 -2
package/node-server.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// Kept in the emitted node-server.d.ts: this entry point's types import `node:http`.
|
|
2
2
|
/// <reference types="node" preserve="true" />
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
|
-
import { Readable } from 'node:stream';
|
|
4
|
+
import { Readable, finished } from 'node:stream';
|
|
5
5
|
import { pipeline } from 'node:stream/promises';
|
|
6
6
|
const DEFAULT_HANDLER_TIMEOUT_MS = 30_000;
|
|
7
7
|
const DEFAULT_HEADERS_TIMEOUT_MS = 10_000;
|
|
8
8
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
9
9
|
const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 5_000;
|
|
10
|
+
/** How often Node checks its connections against headersTimeout and requestTimeout, at most. */
|
|
11
|
+
const DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS = 1_000;
|
|
10
12
|
const DEFAULT_SHUTDOWN_GRACE_MS = 10_000;
|
|
11
13
|
const DEFAULT_MAX_HEADER_BYTES = 16_384;
|
|
12
14
|
const DEFAULT_MAX_REQUESTS_PER_SOCKET = 100;
|
|
@@ -72,11 +74,73 @@ function appendIncomingHeaders(request) {
|
|
|
72
74
|
}
|
|
73
75
|
return headers;
|
|
74
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* The request body as a web stream that a target may cancel, as it does a body over its size limit.
|
|
79
|
+
* Cancelling a `Readable.toWeb` stream destroys the IncomingMessage: Node emits 'aborted', read
|
|
80
|
+
* here as the client leaving, and the unread rest stays on the connection, which then holds
|
|
81
|
+
* `close()` until it is forced. Cancelling this one only stops delivery; once the response is
|
|
82
|
+
* written the rest is discarded as it arrives, as Node discards a body no handler reads, within
|
|
83
|
+
* `requestTimeout`.
|
|
84
|
+
*/
|
|
85
|
+
function requestBody(request) {
|
|
86
|
+
let controller;
|
|
87
|
+
// Once the stream is closed, errored or cancelled, what arrives is dropped.
|
|
88
|
+
let settled = false;
|
|
89
|
+
const stream = new ReadableStream({
|
|
90
|
+
start(started) {
|
|
91
|
+
controller = started;
|
|
92
|
+
request.pause();
|
|
93
|
+
request.on('data', (chunk) => {
|
|
94
|
+
if (settled)
|
|
95
|
+
return;
|
|
96
|
+
started.enqueue(new Uint8Array(chunk));
|
|
97
|
+
if ((started.desiredSize ?? 0) <= 0)
|
|
98
|
+
request.pause();
|
|
99
|
+
});
|
|
100
|
+
finished(request, (error) => {
|
|
101
|
+
if (settled)
|
|
102
|
+
return;
|
|
103
|
+
settled = true;
|
|
104
|
+
if (error)
|
|
105
|
+
started.error(error);
|
|
106
|
+
else
|
|
107
|
+
started.close();
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
pull() {
|
|
111
|
+
if (!settled)
|
|
112
|
+
request.resume();
|
|
113
|
+
},
|
|
114
|
+
cancel() {
|
|
115
|
+
// Held until the response is written, so the client is not read from while it waits.
|
|
116
|
+
settled = true;
|
|
117
|
+
request.pause();
|
|
118
|
+
},
|
|
119
|
+
}, { highWaterMark: request.readableHighWaterMark, size: (chunk) => chunk.byteLength });
|
|
120
|
+
return {
|
|
121
|
+
stream,
|
|
122
|
+
discard() {
|
|
123
|
+
// A target still reading a body it has already answered gets an error, not a read that
|
|
124
|
+
// never settles.
|
|
125
|
+
if (!settled) {
|
|
126
|
+
settled = true;
|
|
127
|
+
controller?.error(abortReason('request_body_discarded'));
|
|
128
|
+
}
|
|
129
|
+
request.resume();
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
75
133
|
function toWebRequest(request, requestOrigin, signal) {
|
|
76
134
|
const target = request.url ?? '';
|
|
77
135
|
if (!target.startsWith('/') || target.startsWith('//')) {
|
|
78
136
|
throw new Error('invalid_request_target');
|
|
79
137
|
}
|
|
138
|
+
// URL parsing resolves dot segments, `%2E` spellings included: `/v1/resources/%2E%2E/availability`
|
|
139
|
+
// would reach the target as `/v1/availability`, a path parameter of `..` naming another route.
|
|
140
|
+
const path = target.split(/[?#]/, 1)[0] ?? '';
|
|
141
|
+
if (path.split('/').some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment))) {
|
|
142
|
+
throw new Error('invalid_request_target');
|
|
143
|
+
}
|
|
80
144
|
const url = new URL(target, requestOrigin);
|
|
81
145
|
if (url.origin !== requestOrigin)
|
|
82
146
|
throw new Error('invalid_request_target');
|
|
@@ -88,11 +152,20 @@ function toWebRequest(request, requestOrigin, signal) {
|
|
|
88
152
|
headers: appendIncomingHeaders(request),
|
|
89
153
|
signal,
|
|
90
154
|
};
|
|
155
|
+
let body;
|
|
91
156
|
if (init.method !== 'GET' && init.method !== 'HEAD') {
|
|
92
|
-
|
|
157
|
+
body = requestBody(request);
|
|
158
|
+
init.body = body.stream;
|
|
93
159
|
init.duplex = 'half';
|
|
94
160
|
}
|
|
95
|
-
|
|
161
|
+
try {
|
|
162
|
+
return { request: new Request(url, init), ...(body ? { body } : {}) };
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
// A method the Fetch API forbids, say: nothing will read the body, so drop it as it arrives.
|
|
166
|
+
body?.discard();
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
96
169
|
}
|
|
97
170
|
const HOP_BY_HOP_RESPONSE_HEADERS = new Set([
|
|
98
171
|
'connection',
|
|
@@ -161,10 +234,15 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
161
234
|
// opening check, but `close()` can change the state while the handler awaits.
|
|
162
235
|
const closing = () => state === 'closing';
|
|
163
236
|
let removeLifecycleAbort;
|
|
237
|
+
/** Rejects a listen() still starting with `listen_aborted`; set while one is. */
|
|
238
|
+
let cancelStartup;
|
|
164
239
|
let closePromise;
|
|
165
240
|
const server = createServer({
|
|
166
241
|
headersTimeout: headersTimeoutMs,
|
|
167
242
|
requestTimeout: requestTimeoutMs,
|
|
243
|
+
// Node enforces both only when it checks its connections (every 30 s by default), so a body
|
|
244
|
+
// trickled after its answer could outlast requestTimeout by that much.
|
|
245
|
+
connectionsCheckingInterval: Math.min(DEFAULT_CONNECTIONS_CHECKING_INTERVAL_MS, headersTimeoutMs, requestTimeoutMs),
|
|
168
246
|
keepAliveTimeout: boundedInteger('keepAliveTimeoutMs', options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS, 1, MAX_TIMEOUT_MS),
|
|
169
247
|
maxHeaderSize: boundedInteger('maxHeaderBytes', options.maxHeaderBytes ?? DEFAULT_MAX_HEADER_BYTES, 1_024, MAX_HEADER_BYTES),
|
|
170
248
|
insecureHTTPParser: false,
|
|
@@ -187,8 +265,9 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
187
265
|
abortForClient();
|
|
188
266
|
});
|
|
189
267
|
let webRequest;
|
|
268
|
+
let body;
|
|
190
269
|
try {
|
|
191
|
-
webRequest = toWebRequest(request, config.requestOrigin, controller.signal);
|
|
270
|
+
({ request: webRequest, body } = toWebRequest(request, config.requestOrigin, controller.signal));
|
|
192
271
|
}
|
|
193
272
|
catch {
|
|
194
273
|
activeRequests.delete(controller);
|
|
@@ -223,6 +302,18 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
223
302
|
if (timeout)
|
|
224
303
|
clearTimeout(timeout);
|
|
225
304
|
activeRequests.delete(controller);
|
|
305
|
+
// The answer is written: what is left of a body the target did not read (one it refused as
|
|
306
|
+
// too large, say) is discarded as it arrives rather than held.
|
|
307
|
+
if (!request.complete) {
|
|
308
|
+
body?.discard();
|
|
309
|
+
// The connection is idle only once that rest has arrived: close it then if shutting down.
|
|
310
|
+
request.once('end', () => {
|
|
311
|
+
setImmediate(() => {
|
|
312
|
+
if (closing())
|
|
313
|
+
server.closeIdleConnections();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
}
|
|
226
317
|
// A keep-alive connection whose response finished during shutdown would otherwise hold
|
|
227
318
|
// `server.close()` open until its idle timeout; Node closed only the idle ones at the start.
|
|
228
319
|
if (closing())
|
|
@@ -244,6 +335,16 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
244
335
|
closePromise = (async () => {
|
|
245
336
|
removeLifecycleAbort?.();
|
|
246
337
|
removeLifecycleAbort = undefined;
|
|
338
|
+
if (state === 'starting') {
|
|
339
|
+
// Closed before it was listening. Node abandons a listen whose server closes first, with
|
|
340
|
+
// neither 'listening' nor 'error', so the pending listen() is rejected here, as an aborted
|
|
341
|
+
// signal rejects it.
|
|
342
|
+
state = 'closing';
|
|
343
|
+
cancelStartup?.();
|
|
344
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
345
|
+
state = 'closed';
|
|
346
|
+
return { forced: false };
|
|
347
|
+
}
|
|
247
348
|
if (state === 'new' || state === 'closed') {
|
|
248
349
|
state = 'closed';
|
|
249
350
|
return { forced: false };
|
|
@@ -305,24 +406,33 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
305
406
|
state = 'starting';
|
|
306
407
|
return new Promise((resolve, reject) => {
|
|
307
408
|
let abortRequested = false;
|
|
409
|
+
let cancelled = false;
|
|
308
410
|
const onAbort = () => {
|
|
309
411
|
abortRequested = true;
|
|
310
412
|
if (state === 'listening')
|
|
311
413
|
void close();
|
|
312
414
|
};
|
|
313
415
|
const cleanupStartup = () => {
|
|
416
|
+
cancelStartup = undefined;
|
|
314
417
|
server.off('error', onListenError);
|
|
315
418
|
server.off('listening', onListening);
|
|
316
419
|
};
|
|
317
420
|
const onListenError = (error) => {
|
|
318
421
|
cleanupStartup();
|
|
319
422
|
listenOptions.signal?.removeEventListener('abort', onAbort);
|
|
320
|
-
state = 'closed';
|
|
321
423
|
safeReport(config.onError, error, 'listen');
|
|
424
|
+
if (cancelled)
|
|
425
|
+
return;
|
|
426
|
+
state = 'closed';
|
|
322
427
|
reject(error);
|
|
323
428
|
};
|
|
324
429
|
const onListening = () => {
|
|
325
430
|
cleanupStartup();
|
|
431
|
+
if (cancelled) {
|
|
432
|
+
// close() came first; a listener that bound all the same goes with it.
|
|
433
|
+
server.close();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
326
436
|
const address = server.address();
|
|
327
437
|
if (!address || typeof address === 'string') {
|
|
328
438
|
state = 'closing';
|
|
@@ -344,6 +454,12 @@ export function createSlotlockNodeServer(target, options) {
|
|
|
344
454
|
origin: formatListenerOrigin(address.address, address.port),
|
|
345
455
|
});
|
|
346
456
|
};
|
|
457
|
+
cancelStartup = () => {
|
|
458
|
+
cancelled = true;
|
|
459
|
+
cancelStartup = undefined;
|
|
460
|
+
listenOptions.signal?.removeEventListener('abort', onAbort);
|
|
461
|
+
reject(abortReason('listen_aborted'));
|
|
462
|
+
};
|
|
347
463
|
listenOptions.signal?.addEventListener('abort', onAbort, { once: true });
|
|
348
464
|
server.once('error', onListenError);
|
|
349
465
|
server.once('listening', onListening);
|
package/package.json
CHANGED
package/rest-api.d.ts
CHANGED
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
import type { SlotlockAgentOperation } from './agent-server.js';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { type SlotlockApplicationOperation } from './application-api.js';
|
|
3
|
+
export interface SlotlockRestRoute<Operation extends string = SlotlockAgentOperation> {
|
|
4
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
5
|
+
/** Relative to the server's base URL; `{name}` is one path segment. */
|
|
5
6
|
path: string;
|
|
6
|
-
operation:
|
|
7
|
+
operation: Operation;
|
|
7
8
|
}
|
|
8
9
|
export declare const SLOTLOCK_REST_ROUTES: readonly SlotlockRestRoute[];
|
|
10
|
+
/** The application operations' routes, served when the server has an application backend. */
|
|
11
|
+
export declare const SLOTLOCK_APPLICATION_REST_ROUTES: readonly SlotlockRestRoute<SlotlockApplicationOperation>[];
|
|
9
12
|
/** Where an operation's arguments come from. */
|
|
10
|
-
export declare function restInputFromBody(route:
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
export declare function restInputFromBody(route: {
|
|
14
|
+
method: string;
|
|
15
|
+
}): boolean;
|
|
16
|
+
export type SlotlockRestMatch<Operation extends string = SlotlockAgentOperation> = {
|
|
17
|
+
route: SlotlockRestRoute<Operation>;
|
|
13
18
|
params: Record<string, string>;
|
|
14
19
|
} | {
|
|
15
20
|
allow: readonly string[];
|
|
16
21
|
} | {
|
|
17
22
|
invalid: true;
|
|
18
23
|
} | null;
|
|
19
|
-
/**
|
|
20
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The route for a method and path, the methods the path does have, or nothing. The first route in
|
|
26
|
+
* `routes` that fits wins; no two of Slotlock's routes fit the same path and method (a test checks).
|
|
27
|
+
*/
|
|
28
|
+
export declare function matchSlotlockRestRoute<Operation extends string = SlotlockAgentOperation>(method: string, path: string, routes?: readonly SlotlockRestRoute<Operation>[]): SlotlockRestMatch<Operation>;
|
|
21
29
|
type JsonSchema = Record<string, unknown>;
|
|
22
30
|
/**
|
|
23
31
|
* An operation's input from a query string: an array parameter repeats (`resource_ids=a&resource_ids=b`,
|
|
@@ -25,16 +33,23 @@ type JsonSchema = Record<string, unknown>;
|
|
|
25
33
|
* not have, or a repeated one, is passed on as it is, so the operation's strict schema refuses it.
|
|
26
34
|
*/
|
|
27
35
|
export declare function restQueryInput(search: URLSearchParams, inputSchema: JsonSchema): Record<string, unknown>;
|
|
28
|
-
/**
|
|
36
|
+
/** One operation, as the OpenAPI document needs it. */
|
|
37
|
+
export interface SlotlockOpenApiOperation {
|
|
38
|
+
name: string;
|
|
39
|
+
title: string;
|
|
40
|
+
description: string;
|
|
41
|
+
inputSchema: JsonSchema;
|
|
42
|
+
outputSchema: JsonSchema;
|
|
43
|
+
}
|
|
44
|
+
/** The OpenAPI 3.1 document for the REST routes, from the operations' own schemas. */
|
|
29
45
|
export declare function slotlockOpenApiDocument(args: {
|
|
30
|
-
|
|
46
|
+
/** Where the server is; without it, the `{baseUrl}` server variable. */
|
|
47
|
+
publicBaseUrl?: string;
|
|
31
48
|
version: string;
|
|
32
|
-
tools: readonly {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
outputSchema: JsonSchema;
|
|
38
|
-
}[];
|
|
49
|
+
tools: readonly (SlotlockOpenApiOperation & {
|
|
50
|
+
scope?: string;
|
|
51
|
+
})[];
|
|
52
|
+
/** The application operations, when the server serves them. */
|
|
53
|
+
application?: readonly SlotlockOpenApiOperation[];
|
|
39
54
|
}): Record<string, unknown>;
|
|
40
55
|
export {};
|
package/rest-api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, } from './application-api.js';
|
|
1
2
|
export const SLOTLOCK_REST_ROUTES = Object.freeze([
|
|
2
3
|
{ method: 'GET', path: '/v1/resources', operation: 'slotlock_list_resources' },
|
|
3
4
|
{ method: 'GET', path: '/v1/free-busy', operation: 'slotlock_get_free_busy' },
|
|
@@ -8,9 +9,15 @@ export const SLOTLOCK_REST_ROUTES = Object.freeze([
|
|
|
8
9
|
{ method: 'PATCH', path: '/v1/events/{event_id}', operation: 'slotlock_update_event' },
|
|
9
10
|
{ method: 'DELETE', path: '/v1/events/{event_id}', operation: 'slotlock_delete_event' },
|
|
10
11
|
]);
|
|
12
|
+
/** The application operations' routes, served when the server has an application backend. */
|
|
13
|
+
export const SLOTLOCK_APPLICATION_REST_ROUTES = Object.freeze(SLOTLOCK_APPLICATION_OPERATIONS.map(({ method, path, name }) => ({
|
|
14
|
+
method,
|
|
15
|
+
path,
|
|
16
|
+
operation: name,
|
|
17
|
+
})));
|
|
11
18
|
/** Where an operation's arguments come from. */
|
|
12
19
|
export function restInputFromBody(route) {
|
|
13
|
-
return route.method === 'POST' || route.method === 'PATCH';
|
|
20
|
+
return route.method === 'POST' || route.method === 'PUT' || route.method === 'PATCH';
|
|
14
21
|
}
|
|
15
22
|
/** The path's parameters when it fits `pattern`, else `null`. */
|
|
16
23
|
function matchPath(pattern, segments) {
|
|
@@ -34,17 +41,21 @@ function matchPath(pattern, segments) {
|
|
|
34
41
|
}
|
|
35
42
|
return params;
|
|
36
43
|
}
|
|
37
|
-
/**
|
|
38
|
-
|
|
44
|
+
/**
|
|
45
|
+
* The route for a method and path, the methods the path does have, or nothing. The first route in
|
|
46
|
+
* `routes` that fits wins; no two of Slotlock's routes fit the same path and method (a test checks).
|
|
47
|
+
*/
|
|
48
|
+
export function matchSlotlockRestRoute(method, path, routes = SLOTLOCK_REST_ROUTES) {
|
|
39
49
|
const segments = path.split('/');
|
|
40
50
|
const allow = [];
|
|
41
51
|
let hit = null;
|
|
42
|
-
for (const route of
|
|
52
|
+
for (const route of routes) {
|
|
43
53
|
const params = matchPath(route.path, segments);
|
|
44
54
|
if (!params)
|
|
45
55
|
continue;
|
|
46
|
-
allow.
|
|
47
|
-
|
|
56
|
+
if (!allow.includes(route.method))
|
|
57
|
+
allow.push(route.method);
|
|
58
|
+
if (route.method === method && !hit)
|
|
48
59
|
hit = { route, params };
|
|
49
60
|
}
|
|
50
61
|
if (!hit)
|
|
@@ -103,85 +114,142 @@ export function restQueryInput(search, inputSchema) {
|
|
|
103
114
|
return input;
|
|
104
115
|
}
|
|
105
116
|
const ERROR_RESPONSE = { $ref: '#/components/responses/Error' };
|
|
106
|
-
/** The OpenAPI
|
|
117
|
+
/** The OpenAPI operation object for one route. */
|
|
118
|
+
function openApiOperation(route, operation, extra) {
|
|
119
|
+
const pathNames = [...route.path.matchAll(/\{(\w+)\}/g)].map((match) => match[1]);
|
|
120
|
+
const fields = properties(operation.inputSchema);
|
|
121
|
+
const required = new Set((operation.inputSchema.required ?? []));
|
|
122
|
+
const parameters = pathNames.map((name) => ({
|
|
123
|
+
name,
|
|
124
|
+
in: 'path',
|
|
125
|
+
required: true,
|
|
126
|
+
schema: fields[name] ?? { type: 'string' },
|
|
127
|
+
}));
|
|
128
|
+
const result = {
|
|
129
|
+
operationId: operation.name,
|
|
130
|
+
summary: operation.title,
|
|
131
|
+
description: operation.description,
|
|
132
|
+
tags: [extra.tag],
|
|
133
|
+
'x-slotlock-scope': extra.scope,
|
|
134
|
+
};
|
|
135
|
+
if (restInputFromBody(route)) {
|
|
136
|
+
const bodyFields = Object.fromEntries(Object.entries(fields).filter(([name]) => !pathNames.includes(name)));
|
|
137
|
+
result.requestBody = {
|
|
138
|
+
required: true,
|
|
139
|
+
content: {
|
|
140
|
+
'application/json': {
|
|
141
|
+
schema: pathNames.length === 0
|
|
142
|
+
? operation.inputSchema
|
|
143
|
+
: {
|
|
144
|
+
...operation.inputSchema,
|
|
145
|
+
properties: bodyFields,
|
|
146
|
+
required: [...required].filter((name) => !pathNames.includes(name)),
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
for (const [name, schema] of Object.entries(fields)) {
|
|
154
|
+
if (pathNames.includes(name))
|
|
155
|
+
continue;
|
|
156
|
+
parameters.push({ name, in: 'query', required: required.has(name), schema });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (parameters.length > 0)
|
|
160
|
+
result.parameters = parameters;
|
|
161
|
+
result.responses = {
|
|
162
|
+
'200': {
|
|
163
|
+
description: 'The operation ran.',
|
|
164
|
+
content: { 'application/json': { schema: operation.outputSchema } },
|
|
165
|
+
},
|
|
166
|
+
...extra.responses,
|
|
167
|
+
};
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
/** The OpenAPI 3.1 document for the REST routes, from the operations' own schemas. */
|
|
107
171
|
export function slotlockOpenApiDocument(args) {
|
|
108
172
|
const paths = {};
|
|
173
|
+
const add = (route, operation) => {
|
|
174
|
+
paths[route.path] = { ...paths[route.path], [route.method.toLowerCase()]: operation };
|
|
175
|
+
};
|
|
109
176
|
for (const route of SLOTLOCK_REST_ROUTES) {
|
|
110
177
|
const tool = args.tools.find((candidate) => candidate.name === route.operation);
|
|
111
178
|
if (!tool)
|
|
112
179
|
throw new Error(`Slotlock REST route ${route.path} names no tool`);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
180
|
+
add(route, openApiOperation(route, tool, {
|
|
181
|
+
tag: 'Agent tools',
|
|
182
|
+
scope: tool.scope ?? (route.method === 'GET' ? 'read' : 'write'),
|
|
183
|
+
responses: {
|
|
184
|
+
'400': ERROR_RESPONSE,
|
|
185
|
+
'401': ERROR_RESPONSE,
|
|
186
|
+
'403': ERROR_RESPONSE,
|
|
187
|
+
...(route.method === 'GET' ? {} : { '428': ERROR_RESPONSE }),
|
|
188
|
+
'429': ERROR_RESPONSE,
|
|
189
|
+
default: ERROR_RESPONSE,
|
|
190
|
+
},
|
|
121
191
|
}));
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
required: [...required].filter((name) => !pathNames.includes(name)),
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
},
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
else {
|
|
145
|
-
for (const [name, schema] of Object.entries(fields)) {
|
|
146
|
-
if (pathNames.includes(name))
|
|
147
|
-
continue;
|
|
148
|
-
parameters.push({ name, in: 'query', required: required.has(name), schema });
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
if (parameters.length > 0)
|
|
152
|
-
operation.parameters = parameters;
|
|
153
|
-
operation.responses = {
|
|
154
|
-
'200': {
|
|
155
|
-
description: 'The operation ran.',
|
|
156
|
-
content: { 'application/json': { schema: tool.outputSchema } },
|
|
192
|
+
}
|
|
193
|
+
for (const route of args.application ? SLOTLOCK_APPLICATION_REST_ROUTES : []) {
|
|
194
|
+
const operation = args.application?.find((candidate) => candidate.name === route.operation);
|
|
195
|
+
if (!operation)
|
|
196
|
+
throw new Error(`Slotlock REST route ${route.path} names no operation`);
|
|
197
|
+
add(route, openApiOperation(route, operation, {
|
|
198
|
+
tag: 'Application',
|
|
199
|
+
scope: SLOTLOCK_APPLICATION_SCOPE,
|
|
200
|
+
responses: {
|
|
201
|
+
'400': ERROR_RESPONSE,
|
|
202
|
+
'401': ERROR_RESPONSE,
|
|
203
|
+
'403': ERROR_RESPONSE,
|
|
204
|
+
'404': ERROR_RESPONSE,
|
|
205
|
+
...(route.method === 'GET' ? {} : { '409': ERROR_RESPONSE }),
|
|
206
|
+
'429': ERROR_RESPONSE,
|
|
207
|
+
default: ERROR_RESPONSE,
|
|
157
208
|
},
|
|
158
|
-
|
|
159
|
-
'401': ERROR_RESPONSE,
|
|
160
|
-
'403': ERROR_RESPONSE,
|
|
161
|
-
...(route.method === 'GET' ? {} : { '428': ERROR_RESPONSE }),
|
|
162
|
-
'429': ERROR_RESPONSE,
|
|
163
|
-
default: ERROR_RESPONSE,
|
|
164
|
-
};
|
|
165
|
-
paths[route.path] = { ...paths[route.path], [route.method.toLowerCase()]: operation };
|
|
209
|
+
}));
|
|
166
210
|
}
|
|
167
211
|
return {
|
|
168
212
|
openapi: '3.1.0',
|
|
169
213
|
info: {
|
|
170
214
|
title: 'Slotlock',
|
|
171
215
|
version: args.version,
|
|
172
|
-
description: 'Calendars agents cannot double-book.
|
|
173
|
-
'
|
|
174
|
-
'`
|
|
216
|
+
description: 'Calendars agents cannot double-book. The "Agent tools" operations are the Slotlock MCP tools; ' +
|
|
217
|
+
'the "Application" operations (resources, bookable hours, holds, reservations, coverage and ' +
|
|
218
|
+
'maintenance) need an API key with the admin scope. An error is `{"error":{"code":…}}`, and ' +
|
|
219
|
+
'a write that waits for a person answers 428 `confirmation_required`.',
|
|
175
220
|
},
|
|
176
|
-
servers: [
|
|
221
|
+
servers: [
|
|
222
|
+
args.publicBaseUrl !== undefined
|
|
223
|
+
? { url: args.publicBaseUrl }
|
|
224
|
+
: {
|
|
225
|
+
url: '{baseUrl}',
|
|
226
|
+
variables: {
|
|
227
|
+
baseUrl: {
|
|
228
|
+
default: 'http://localhost:8080',
|
|
229
|
+
description: "The server's public URL (SLOTLOCK_PUBLIC_URL), without a trailing slash.",
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
],
|
|
177
234
|
security: [{ bearer: [] }],
|
|
235
|
+
tags: [
|
|
236
|
+
{ name: 'Agent tools', description: 'What an agent does: the MCP tools and A2A skills.' },
|
|
237
|
+
...(args.application
|
|
238
|
+
? [
|
|
239
|
+
{
|
|
240
|
+
name: 'Application',
|
|
241
|
+
description: 'What the application around the agents does. Needs the admin scope; never offered to agents.',
|
|
242
|
+
},
|
|
243
|
+
]
|
|
244
|
+
: []),
|
|
245
|
+
],
|
|
178
246
|
paths,
|
|
179
247
|
components: {
|
|
180
248
|
securitySchemes: {
|
|
181
249
|
bearer: {
|
|
182
250
|
type: 'http',
|
|
183
251
|
scheme: 'bearer',
|
|
184
|
-
description: 'A Slotlock API key (`slk_…`) or the server token.',
|
|
252
|
+
description: 'A Slotlock API key (`slk_…`) or the server token. `x-slotlock-scope` on each operation names the scope it needs.',
|
|
185
253
|
},
|
|
186
254
|
},
|
|
187
255
|
schemas: {
|
package/rules.d.ts
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
import type { Interval, WeeklyAvailabilityRule } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The longest window Slotlock reads or searches at once, in days: free/busy, the rolling event
|
|
4
|
+
* horizon, a recurring event's materialization and a search for the next slot all stop here.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SLOTLOCK_CALENDAR_HORIZON_DAYS = 367;
|
|
7
|
+
/**
|
|
8
|
+
* The longest window one availability rule may open, in minutes: a week. Expansion scans back as
|
|
9
|
+
* many days as a rule lasts, so a longer one is skipped, as the hours validator refuses it.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SLOTLOCK_MAX_RULE_DURATION_MINUTES = 10080;
|
|
12
|
+
export declare function invalidWindowError(message?: string): Error & {
|
|
13
|
+
code: 'invalid_window';
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Refuse (`invalid_window`) a search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS. Expansion
|
|
17
|
+
* is synchronous: fifty daily rules over a century are close to two million windows.
|
|
18
|
+
*/
|
|
19
|
+
export declare function assertBoundedSearchWindow(window: Interval): void;
|
|
2
20
|
/**
|
|
3
21
|
* Expand availability rules to concrete, merged, sorted UTC windows clipped to `searchWindow`.
|
|
4
22
|
* Unsupported or unparseable rules contribute nothing rather than throwing: one bad rule must not
|
|
5
23
|
* blank the whole calendar — and must never manufacture availability the rule doesn't mean.
|
|
6
24
|
* Callers should validate rules at write time; this read-path skip remains the fail-closed backstop.
|
|
25
|
+
* A search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS is refused (`invalid_window`).
|
|
7
26
|
*/
|
|
8
27
|
export declare function expandRules(rules: WeeklyAvailabilityRule[], searchWindow: Interval, timezone?: string): Interval[];
|
package/rules.js
CHANGED
|
@@ -6,6 +6,29 @@ import { mergeIntervals } from './engine.js';
|
|
|
6
6
|
import { createTimeZoneConverter } from './timezone.js';
|
|
7
7
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
8
8
|
const MINUTE_MS = 60 * 1000;
|
|
9
|
+
/**
|
|
10
|
+
* The longest window Slotlock reads or searches at once, in days: free/busy, the rolling event
|
|
11
|
+
* horizon, a recurring event's materialization and a search for the next slot all stop here.
|
|
12
|
+
*/
|
|
13
|
+
export const SLOTLOCK_CALENDAR_HORIZON_DAYS = 367;
|
|
14
|
+
const MAX_SEARCH_WINDOW_MS = SLOTLOCK_CALENDAR_HORIZON_DAYS * DAY_MS;
|
|
15
|
+
/**
|
|
16
|
+
* The longest window one availability rule may open, in minutes: a week. Expansion scans back as
|
|
17
|
+
* many days as a rule lasts, so a longer one is skipped, as the hours validator refuses it.
|
|
18
|
+
*/
|
|
19
|
+
export const SLOTLOCK_MAX_RULE_DURATION_MINUTES = 10_080;
|
|
20
|
+
export function invalidWindowError(message = 'Slotlock window must contain two finite dates with end > start') {
|
|
21
|
+
return Object.assign(new Error(message), { code: 'invalid_window' });
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Refuse (`invalid_window`) a search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS. Expansion
|
|
25
|
+
* is synchronous: fifty daily rules over a century are close to two million windows.
|
|
26
|
+
*/
|
|
27
|
+
export function assertBoundedSearchWindow(window) {
|
|
28
|
+
if (window.end.getTime() - window.start.getTime() > MAX_SEARCH_WINDOW_MS) {
|
|
29
|
+
throw invalidWindowError(`Slotlock searches a window of at most ${SLOTLOCK_CALENDAR_HORIZON_DAYS} days at once`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
9
32
|
/** UTC midnight of the day containing `d`. */
|
|
10
33
|
function utcDayFloor(d) {
|
|
11
34
|
return new Date(Math.floor(d.getTime() / DAY_MS) * DAY_MS);
|
|
@@ -83,8 +106,10 @@ function isSupportedRule(parsed) {
|
|
|
83
106
|
* Unsupported or unparseable rules contribute nothing rather than throwing: one bad rule must not
|
|
84
107
|
* blank the whole calendar — and must never manufacture availability the rule doesn't mean.
|
|
85
108
|
* Callers should validate rules at write time; this read-path skip remains the fail-closed backstop.
|
|
109
|
+
* A search window longer than SLOTLOCK_CALENDAR_HORIZON_DAYS is refused (`invalid_window`).
|
|
86
110
|
*/
|
|
87
111
|
export function expandRules(rules, searchWindow, timezone) {
|
|
112
|
+
assertBoundedSearchWindow(searchWindow);
|
|
88
113
|
// Validate ONCE, loudly: a typo'd zone must never silently fall back to UTC math — that
|
|
89
114
|
// would manufacture availability shifted by the zone offset. 'UTC'/omitted = spike path.
|
|
90
115
|
const converter = timezone !== undefined ? createTimeZoneConverter(timezone) : null;
|
|
@@ -94,7 +119,8 @@ export function expandRules(rules, searchWindow, timezone) {
|
|
|
94
119
|
rule.startMinutes < 0 ||
|
|
95
120
|
rule.startMinutes > 1439 ||
|
|
96
121
|
!Number.isInteger(rule.durationMinutes) ||
|
|
97
|
-
rule.durationMinutes <= 0
|
|
122
|
+
rule.durationMinutes <= 0 ||
|
|
123
|
+
rule.durationMinutes > SLOTLOCK_MAX_RULE_DURATION_MINUTES) {
|
|
98
124
|
continue;
|
|
99
125
|
}
|
|
100
126
|
let rrule;
|
|
@@ -110,9 +136,11 @@ export function expandRules(rules, searchWindow, timezone) {
|
|
|
110
136
|
const parsed = RRule.parseString(rule.rrule);
|
|
111
137
|
if (!isSupportedRule(parsed))
|
|
112
138
|
continue;
|
|
113
|
-
// rrule
|
|
114
|
-
//
|
|
115
|
-
|
|
139
|
+
// rrule's stamps are days, not window starts: a UTC midnight, or under a zone a LOCAL date
|
|
140
|
+
// read as UTC. Left to rrule, UNTIL would keep a window that starts after it on its own day,
|
|
141
|
+
// and under a zone east of UTC drop one that starts before it. Each window's real start is
|
|
142
|
+
// compared with UNTIL instead (below), with or without a zone, so the two paths agree.
|
|
143
|
+
if (parsed.until) {
|
|
116
144
|
absoluteUntil = parsed.until;
|
|
117
145
|
parsed.until = null;
|
|
118
146
|
}
|