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/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
- init.body = Readable.toWeb(request);
157
+ body = requestBody(request);
158
+ init.body = body.stream;
93
159
  init.duplex = 'half';
94
160
  }
95
- return new Request(url, init);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slotlock",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
6
6
  "license": "Apache-2.0",
package/rest-api.d.ts CHANGED
@@ -1,23 +1,31 @@
1
1
  import type { SlotlockAgentOperation } from './agent-server.js';
2
- export interface SlotlockRestRoute {
3
- method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
4
- /** Relative to the server's base URL; `{event_id}` is one path segment. */
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: SlotlockAgentOperation;
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: SlotlockRestRoute): boolean;
11
- export type SlotlockRestMatch = {
12
- route: SlotlockRestRoute;
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
- /** The route for a method and path, the methods the path does have, or nothing. */
20
- export declare function matchSlotlockRestRoute(method: string, path: string): SlotlockRestMatch;
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
- /** The OpenAPI 3.1 document for the REST routes, from the tools' own schemas. */
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
- publicBaseUrl: string;
46
+ /** Where the server is; without it, the `{baseUrl}` server variable. */
47
+ publicBaseUrl?: string;
31
48
  version: string;
32
- tools: readonly {
33
- name: string;
34
- title: string;
35
- description: string;
36
- inputSchema: JsonSchema;
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
- /** The route for a method and path, the methods the path does have, or nothing. */
38
- export function matchSlotlockRestRoute(method, path) {
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 SLOTLOCK_REST_ROUTES) {
52
+ for (const route of routes) {
43
53
  const params = matchPath(route.path, segments);
44
54
  if (!params)
45
55
  continue;
46
- allow.push(route.method);
47
- if (route.method === method)
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 3.1 document for the REST routes, from the tools' own schemas. */
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
- const pathNames = [...route.path.matchAll(/\{(\w+)\}/g)].map((match) => match[1]);
114
- const fields = properties(tool.inputSchema);
115
- const required = new Set((tool.inputSchema.required ?? []));
116
- const parameters = pathNames.map((name) => ({
117
- name,
118
- in: 'path',
119
- required: true,
120
- schema: fields[name] ?? { type: 'string' },
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
- const operation = {
123
- operationId: tool.name,
124
- summary: tool.title,
125
- description: tool.description,
126
- };
127
- if (restInputFromBody(route)) {
128
- const bodyFields = Object.fromEntries(Object.entries(fields).filter(([name]) => !pathNames.includes(name)));
129
- operation.requestBody = {
130
- required: true,
131
- content: {
132
- 'application/json': {
133
- schema: pathNames.length === 0
134
- ? tool.inputSchema
135
- : {
136
- ...tool.inputSchema,
137
- properties: bodyFields,
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
- '400': ERROR_RESPONSE,
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. Each operation is one of the Slotlock MCP tools; ' +
173
- 'an error is `{"error":{"code":…}}`, and a write that waits for a person answers 428 ' +
174
- '`confirmation_required`.',
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: [{ url: args.publicBaseUrl }],
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 operates on pseudo-UTC local-date stamps. In zoned mode its UNTIL comparison must
114
- // happen after wall time becomes a real instant or east-of-UTC occurrences disappear early.
115
- if (converter && parsed.until) {
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
  }