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.
Files changed (56) hide show
  1. package/CHANGELOG.md +455 -0
  2. package/CONTRIBUTING.md +55 -19
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +403 -103
  7. package/SECURITY.md +70 -5
  8. package/SPEC.md +161 -21
  9. package/agent-server.d.ts +76 -10
  10. package/agent-server.js +381 -146
  11. package/agent-store-backend.d.ts +22 -0
  12. package/agent-store-backend.js +598 -24
  13. package/api-keys.d.ts +22 -7
  14. package/api-keys.js +38 -8
  15. package/application-api.d.ts +67 -0
  16. package/application-api.js +552 -0
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +10 -1
  20. package/dashboard.js +23 -5
  21. package/ddl.d.ts +47 -4
  22. package/ddl.js +679 -25
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/hooks.d.ts +7 -0
  27. package/hooks.js +13 -0
  28. package/ical-worker-thread.js +4 -2
  29. package/ical-worker.d.ts +8 -5
  30. package/ical-worker.js +9 -2
  31. package/index.d.ts +12 -8
  32. package/index.js +7 -5
  33. package/maintenance.d.ts +67 -0
  34. package/maintenance.js +213 -0
  35. package/node-server.d.ts +9 -1
  36. package/node-server.js +127 -6
  37. package/openapi.json +6024 -0
  38. package/package.json +3 -2
  39. package/rate-limit.d.ts +71 -0
  40. package/rate-limit.js +718 -0
  41. package/rest-api.d.ts +33 -18
  42. package/rest-api.js +141 -63
  43. package/rules.d.ts +19 -0
  44. package/rules.js +38 -7
  45. package/self-host.d.ts +26 -9
  46. package/self-host.js +427 -47
  47. package/slotlock-manifest.schema.json +6 -1
  48. package/slotlock.manifest.json +7 -2
  49. package/store.d.ts +359 -27
  50. package/store.js +2077 -296
  51. package/sync.d.ts +43 -2
  52. package/sync.js +1950 -277
  53. package/timezone.d.ts +77 -3
  54. package/timezone.js +265 -21
  55. package/types.d.ts +217 -10
  56. 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
- 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,152 @@ 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
+ // 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
- 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' },
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
- 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 } },
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
- '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 };
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. 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`.',
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: [{ url: args.publicBaseUrl }],
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 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
  }
@@ -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 every scope. Optional: API keys (`slotlock key create`)
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
- /** Writes that wait for a person's confirmation (MCP 2026-07-28) and are refused elsewhere. */
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
- /** Serve the REST API under /v1 and its OpenAPI document (SLOTLOCK_REST_API=on). */
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 every scope; a principal without scopes is refused everything here, though the
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 A2A over HTTP (--migrate runs migrate first)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n key create <name> [--scope read|write|read,write] [--expires-in-days <n>]\n Create an API key for the tenant (default scope read,write, no\n expiry) and print it once; only its SHA-256 is stored\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with every scope,\n 32+ random characters (serve); API keys work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve)\n SLOTLOCK_CONFIRM_WRITES all (default), none, or a comma-separated list of write tools\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: also serve the tools as a REST API under /v1, described at\n /openapi.json, with the same keys and confirmation (default off)\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";
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