slotlock 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +455 -0
- package/CONTRIBUTING.md +55 -19
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +403 -103
- package/SECURITY.md +70 -5
- package/SPEC.md +161 -21
- package/agent-server.d.ts +76 -10
- package/agent-server.js +381 -146
- package/agent-store-backend.d.ts +22 -0
- package/agent-store-backend.js +598 -24
- package/api-keys.d.ts +22 -7
- package/api-keys.js +38 -8
- package/application-api.d.ts +67 -0
- package/application-api.js +552 -0
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +10 -1
- package/dashboard.js +23 -5
- package/ddl.d.ts +47 -4
- package/ddl.js +679 -25
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -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 -8
- package/index.js +7 -5
- package/maintenance.d.ts +67 -0
- package/maintenance.js +213 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +127 -6
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.d.ts +33 -18
- package/rest-api.js +141 -63
- package/rules.d.ts +19 -0
- package/rules.js +38 -7
- package/self-host.d.ts +26 -9
- package/self-host.js +427 -47
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +359 -27
- package/store.js +2077 -296
- package/sync.d.ts +43 -2
- package/sync.js +1950 -277
- package/timezone.d.ts +77 -3
- package/timezone.js +265 -21
- package/types.d.ts +217 -10
- package/weekly-hours.js +2 -2
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,152 @@ 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
|
+
// An application write that keeps a retry identity also takes it as the Idempotency-Key header.
|
|
160
|
+
if (extra.tag === 'Application' && fields.idempotency_key !== undefined) {
|
|
161
|
+
parameters.push({
|
|
162
|
+
name: 'Idempotency-Key',
|
|
163
|
+
in: 'header',
|
|
164
|
+
required: false,
|
|
165
|
+
description: 'Stands in for idempotency_key, where HTTP clients put a retry identity; when both are sent they must be equal.',
|
|
166
|
+
schema: { type: 'string', minLength: 1, maxLength: 500 },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (parameters.length > 0)
|
|
170
|
+
result.parameters = parameters;
|
|
171
|
+
result.responses = {
|
|
172
|
+
'200': {
|
|
173
|
+
description: 'The operation ran.',
|
|
174
|
+
content: { 'application/json': { schema: operation.outputSchema } },
|
|
175
|
+
},
|
|
176
|
+
...extra.responses,
|
|
177
|
+
};
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
/** The OpenAPI 3.1 document for the REST routes, from the operations' own schemas. */
|
|
107
181
|
export function slotlockOpenApiDocument(args) {
|
|
108
182
|
const paths = {};
|
|
183
|
+
const add = (route, operation) => {
|
|
184
|
+
paths[route.path] = { ...paths[route.path], [route.method.toLowerCase()]: operation };
|
|
185
|
+
};
|
|
109
186
|
for (const route of SLOTLOCK_REST_ROUTES) {
|
|
110
187
|
const tool = args.tools.find((candidate) => candidate.name === route.operation);
|
|
111
188
|
if (!tool)
|
|
112
189
|
throw new Error(`Slotlock REST route ${route.path} names no tool`);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
190
|
+
add(route, openApiOperation(route, tool, {
|
|
191
|
+
tag: 'Agent tools',
|
|
192
|
+
scope: tool.scope ?? (route.method === 'GET' ? 'read' : 'write'),
|
|
193
|
+
responses: {
|
|
194
|
+
'400': ERROR_RESPONSE,
|
|
195
|
+
'401': ERROR_RESPONSE,
|
|
196
|
+
'403': ERROR_RESPONSE,
|
|
197
|
+
...(route.method === 'GET' ? {} : { '428': ERROR_RESPONSE }),
|
|
198
|
+
'429': ERROR_RESPONSE,
|
|
199
|
+
default: ERROR_RESPONSE,
|
|
200
|
+
},
|
|
121
201
|
}));
|
|
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 } },
|
|
202
|
+
}
|
|
203
|
+
for (const route of args.application ? SLOTLOCK_APPLICATION_REST_ROUTES : []) {
|
|
204
|
+
const operation = args.application?.find((candidate) => candidate.name === route.operation);
|
|
205
|
+
if (!operation)
|
|
206
|
+
throw new Error(`Slotlock REST route ${route.path} names no operation`);
|
|
207
|
+
add(route, openApiOperation(route, operation, {
|
|
208
|
+
tag: 'Application',
|
|
209
|
+
scope: SLOTLOCK_APPLICATION_SCOPE,
|
|
210
|
+
responses: {
|
|
211
|
+
'400': ERROR_RESPONSE,
|
|
212
|
+
'401': ERROR_RESPONSE,
|
|
213
|
+
'403': ERROR_RESPONSE,
|
|
214
|
+
'404': ERROR_RESPONSE,
|
|
215
|
+
...(route.method === 'GET' ? {} : { '409': ERROR_RESPONSE }),
|
|
216
|
+
'429': ERROR_RESPONSE,
|
|
217
|
+
default: ERROR_RESPONSE,
|
|
157
218
|
},
|
|
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 };
|
|
219
|
+
}));
|
|
166
220
|
}
|
|
167
221
|
return {
|
|
168
222
|
openapi: '3.1.0',
|
|
169
223
|
info: {
|
|
170
224
|
title: 'Slotlock',
|
|
171
225
|
version: args.version,
|
|
172
|
-
description: 'Calendars agents cannot double-book.
|
|
173
|
-
'
|
|
174
|
-
'`
|
|
226
|
+
description: 'Calendars agents cannot double-book. The "Agent tools" operations are the Slotlock MCP tools; ' +
|
|
227
|
+
'the "Application" operations (resources, bookable hours, holds, reservations, coverage and ' +
|
|
228
|
+
'maintenance) need an API key with the admin scope. An error is `{"error":{"code":…}}`, and ' +
|
|
229
|
+
'a write that waits for a person answers 428 `confirmation_required`.',
|
|
175
230
|
},
|
|
176
|
-
servers: [
|
|
231
|
+
servers: [
|
|
232
|
+
args.publicBaseUrl !== undefined
|
|
233
|
+
? { url: args.publicBaseUrl }
|
|
234
|
+
: {
|
|
235
|
+
url: '{baseUrl}',
|
|
236
|
+
variables: {
|
|
237
|
+
baseUrl: {
|
|
238
|
+
default: 'http://localhost:8080',
|
|
239
|
+
description: "The server's public URL (SLOTLOCK_PUBLIC_URL), without a trailing slash.",
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
],
|
|
177
244
|
security: [{ bearer: [] }],
|
|
245
|
+
tags: [
|
|
246
|
+
{ name: 'Agent tools', description: 'What an agent does: the MCP tools and A2A skills.' },
|
|
247
|
+
...(args.application
|
|
248
|
+
? [
|
|
249
|
+
{
|
|
250
|
+
name: 'Application',
|
|
251
|
+
description: 'What the application around the agents does. Needs the admin scope; never offered to agents.',
|
|
252
|
+
},
|
|
253
|
+
]
|
|
254
|
+
: []),
|
|
255
|
+
],
|
|
178
256
|
paths,
|
|
179
257
|
components: {
|
|
180
258
|
securitySchemes: {
|
|
181
259
|
bearer: {
|
|
182
260
|
type: 'http',
|
|
183
261
|
scheme: 'bearer',
|
|
184
|
-
description: 'A Slotlock API key (`slk_…`) or the server token.',
|
|
262
|
+
description: 'A Slotlock API key (`slk_…`) or the server token. `x-slotlock-scope` on each operation names the scope it needs.',
|
|
185
263
|
},
|
|
186
264
|
},
|
|
187
265
|
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
|
}
|
|
@@ -131,10 +159,13 @@ export function expandRules(rules, searchWindow, timezone) {
|
|
|
131
159
|
let start;
|
|
132
160
|
let end;
|
|
133
161
|
if (converter) {
|
|
134
|
-
// 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.
|
|
135
166
|
const wallDay = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate());
|
|
136
|
-
start = converter.wallFrameToUtc(wallDay + rule.startMinutes * MINUTE_MS);
|
|
137
|
-
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' });
|
|
138
169
|
if (end.getTime() <= start.getTime())
|
|
139
170
|
continue;
|
|
140
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_PUBLIC_URL", "SLOTLOCK_TENANT", "SLOTLOCK_AVAILABILITY", "SLOTLOCK_REST_API", "SLOTLOCK_LIMITS", "SLOTLOCK_TRUSTED_PROXY_HEADER", "SLOTLOCK_GITHUB_CLIENT_ID", "SLOTLOCK_GITHUB_CLIENT_SECRET", "SLOTLOCK_SESSION_SECRET", "SLOTLOCK_DASHBOARD_USERS", "HOST", "PORT"];
|
|
8
9
|
export interface SlotlockDatabaseConfig {
|
|
9
10
|
/** The role `serve` and `resource` connect as. */
|
|
10
11
|
databaseUrl: string;
|
|
@@ -21,11 +22,14 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
|
|
|
21
22
|
/** Where clients reach the server; Slotlock publishes its endpoints under this URL. */
|
|
22
23
|
publicUrl: string;
|
|
23
24
|
/**
|
|
24
|
-
* A bearer token for SLOTLOCK_TENANT with
|
|
25
|
-
* authenticate whether or not it is set, each as its own tenant and scopes.
|
|
25
|
+
* A bearer token for SLOTLOCK_TENANT with read and write, never admin. Optional: API keys
|
|
26
|
+
* (`slotlock key create`) authenticate whether or not it is set, each as its own tenant and scopes.
|
|
26
27
|
*/
|
|
27
28
|
authToken?: string;
|
|
28
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Writes that wait for a person's confirmation (MCP 2026-07-28) and are refused elsewhere. None
|
|
31
|
+
* unless SLOTLOCK_CONFIRM_WRITES opts in: every authenticated write takes effect at once.
|
|
32
|
+
*/
|
|
29
33
|
confirmWrites: readonly SlotlockAgentWriteOperation[];
|
|
30
34
|
/** Set exactly when `confirmWrites` is not empty. */
|
|
31
35
|
confirmationSecret?: string;
|
|
@@ -33,8 +37,15 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
|
|
|
33
37
|
availability: readonly WeeklyAvailabilityRule[];
|
|
34
38
|
/** Set exactly when SLOTLOCK_GITHUB_CLIENT_ID is. */
|
|
35
39
|
dashboard?: SlotlockServeDashboardConfig;
|
|
36
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Serve the REST API under /v1, with the application API for admin keys, and its OpenAPI document
|
|
42
|
+
* (SLOTLOCK_REST_API: on unless off).
|
|
43
|
+
*/
|
|
37
44
|
restApi: boolean;
|
|
45
|
+
/** Request limits (SLOTLOCK_LIMITS over SLOTLOCK_DEFAULT_LIMITS): the server's only rate limit. */
|
|
46
|
+
limits: SlotlockLimits;
|
|
47
|
+
/** The lower-case request field a trusted proxy sets to the client address; else the socket's. */
|
|
48
|
+
trustedProxyHeader?: string;
|
|
38
49
|
}
|
|
39
50
|
export declare function readSlotlockDatabaseConfig(env: SlotlockEnv): SlotlockDatabaseConfig;
|
|
40
51
|
export declare function readSlotlockServeConfig(env: SlotlockEnv): SlotlockServeConfig;
|
|
@@ -51,8 +62,8 @@ export declare function slotlockHealthcheckUrl(env: SlotlockEnv): string;
|
|
|
51
62
|
export declare function createSlotlockTokenAuthenticator(token: string, principal: SlotlockAgentPrincipal): (request: Request) => Promise<SlotlockAgentPrincipal | null>;
|
|
52
63
|
/**
|
|
53
64
|
* What `serve` lets a principal call: a served operation its scopes cover. The server token's
|
|
54
|
-
* principal carries
|
|
55
|
-
* agent server alone would leave it to this function.
|
|
65
|
+
* principal carries read and write, never admin; a principal without scopes is refused everything
|
|
66
|
+
* here, though the agent server alone would leave it to this function.
|
|
56
67
|
*/
|
|
57
68
|
export declare function authorizeSlotlockServeOperation(args: {
|
|
58
69
|
principal: SlotlockAgentPrincipal;
|
|
@@ -70,6 +81,7 @@ export interface SlotlockCliIo {
|
|
|
70
81
|
}
|
|
71
82
|
export interface SlotlockLogger {
|
|
72
83
|
info(event: string, fields?: Record<string, unknown>): void;
|
|
84
|
+
warn(event: string, fields?: Record<string, unknown>): void;
|
|
73
85
|
error(event: string, fields?: Record<string, unknown>): void;
|
|
74
86
|
}
|
|
75
87
|
/**
|
|
@@ -82,9 +94,14 @@ export interface SlotlockRunningServer {
|
|
|
82
94
|
/** End open subscriptions, drain in-flight requests, then close the database pool. */
|
|
83
95
|
close(): Promise<SlotlockNodeServerCloseResult>;
|
|
84
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* The store `serve` answers requests from. Every surface writes through it, so its tenant limits
|
|
99
|
+
* bound what any key can add, and its transaction limits what one tenant holds of the shared pool.
|
|
100
|
+
*/
|
|
101
|
+
export declare function createSlotlockServeStore(sql: SlotlockSql, caps: SlotlockLimits['caps']): SlotlockStore;
|
|
85
102
|
/** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
|
|
86
103
|
export declare function startSlotlockServer(config: SlotlockServeConfig, log: SlotlockLogger): Promise<SlotlockRunningServer>;
|
|
87
|
-
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 and
|
|
104
|
+
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 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_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_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)\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 HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
|
|
88
105
|
/**
|
|
89
106
|
* Run one `slotlock` command and resolve to its exit code: 0 success, 1 failure, 2 a usage or
|
|
90
107
|
* configuration error (nothing was started). `serve` resolves once `io.signal` aborts and it has
|