slotlock 0.3.0 → 0.5.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slotlock",
3
- "version": "0.3.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
6
6
  "license": "Apache-2.0",
package/rate-limit.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type SlotlockAgentOperation, type SlotlockAgentPrincipal } from './agent-server.js';
2
2
  import { type SlotlockApplicationOperation } from './application-api.js';
3
- import type { SlotlockLimits } from './config.js';
3
+ import type { SlotlockLimits, SlotlockRate } from './config.js';
4
4
  import type { SlotlockNodeServerFetchTarget } from './node-server.js';
5
5
  export type SlotlockRefusalReason = 'auth_refused' | 'rate_limited' | 'lookup_overflow' | 'series_writes_busy' | 'maintenance_busy' | 'request_too_large';
6
6
  /** One `refusals` log line: counts since the last one, and the most refused clients as pseudonyms. */
@@ -31,6 +31,15 @@ export interface SlotlockRequestLimitsOptions {
31
31
  onIgnoredClientField?: (field: string) => void;
32
32
  /** Milliseconds, for tests; defaults to `performance.now`. */
33
33
  now?: () => number;
34
+ /**
35
+ * Budgets shared by every server (`SLOTLOCK_SHARED_LIMITS`): after this process's own buckets
36
+ * pass, each tenant budget (`tenant_*`, `app_*`) also takes a token from `take`, so N servers
37
+ * allow a tenant its rate once, not N times. `take` failing (the database away) lets the request
38
+ * through on this process's buckets alone, which still apply.
39
+ */
40
+ shared?: {
41
+ take(bucket: string, rate: SlotlockRate): Promise<boolean>;
42
+ };
34
43
  }
35
44
  export interface SlotlockRequestLimits {
36
45
  /** The agent server's `authenticate`: the request's lookup from `fetch`, else a lookup now. */
package/rate-limit.js CHANGED
@@ -508,7 +508,7 @@ export function createSlotlockRequestLimits(options) {
508
508
  * charged its own buckets, so its refused application calls cannot spend the application's.
509
509
  * False, with the refusal recorded, when one is short.
510
510
  */
511
- function chargeRequest(state, principal, classes) {
511
+ async function chargeRequest(state, principal, classes) {
512
512
  const due = classes.filter((kind) => !state.charged[kind]);
513
513
  if (due.length === 0)
514
514
  return true;
@@ -532,6 +532,29 @@ export function createSlotlockRequestLimits(options) {
532
532
  };
533
533
  return false;
534
534
  }
535
+ if (options.shared) {
536
+ for (const check of checks) {
537
+ if (!check.name.startsWith('tenant_') && !check.name.startsWith('app_'))
538
+ continue;
539
+ const policy = policies.get(check.name)?.policy;
540
+ if (!policy)
541
+ continue;
542
+ let allowed = true;
543
+ try {
544
+ allowed = await options.shared.take(`${check.name}\0${check.key}`, policy.rate);
545
+ }
546
+ catch {
547
+ // The shared budget is out of reach: this process's own buckets decided.
548
+ }
549
+ if (!allowed) {
550
+ state.refusal = {
551
+ reason: 'rate_limited',
552
+ fields: fieldsOf({ policy, remaining: 0, seconds: Math.max(1, Math.ceil(1 / policy.rate.perSecond)) }, true),
553
+ };
554
+ return false;
555
+ }
556
+ }
557
+ }
535
558
  for (const kind of due)
536
559
  state.charged[kind] = true;
537
560
  if (outcome.verdict)
@@ -596,7 +619,7 @@ export function createSlotlockRequestLimits(options) {
596
619
  // where consumeRateLimit would take it (on /mcp and /a2a a request is charged a read token
597
620
  // only): a write over its budget is refused at once, not after holding a place in the queue
598
621
  // for a slot it could not use.
599
- if (!chargeRequest(state, principal, ['write'])) {
622
+ if (!(await chargeRequest(state, principal, ['write']))) {
600
623
  throw new SlotlockAgentOperationError('rate_limited', 429);
601
624
  }
602
625
  const slots = gates[kind];
package/rest-api.d.ts CHANGED
@@ -5,10 +5,32 @@ export interface SlotlockRestRoute<Operation extends string = SlotlockAgentOpera
5
5
  /** Relative to the server's base URL; `{name}` is one path segment. */
6
6
  path: string;
7
7
  operation: Operation;
8
+ /**
9
+ * The store refusals the route can answer besides the ones every route can: 404 for a record
10
+ * that is missing or another tenant's, 409 for a conflict with what is stored (an overlap, a
11
+ * stale revision). The OpenAPI document declares them, so a generated client types them.
12
+ */
13
+ refusals?: readonly (404 | 409)[];
8
14
  }
9
15
  export declare const SLOTLOCK_REST_ROUTES: readonly SlotlockRestRoute[];
10
16
  /** The application operations' routes, served when the server has an application backend. */
11
17
  export declare const SLOTLOCK_APPLICATION_REST_ROUTES: readonly SlotlockRestRoute<SlotlockApplicationOperation>[];
18
+ /** Where every REST refusal's documentation is: the Link header's `describedby` target. */
19
+ export declare const SLOTLOCK_PROBLEM_DOCS = "https://slotlock.pylota.io/docs/api/#errors";
20
+ /** A problem type's identifier: a tag URI (RFC 4151), stable, one per code, not a locator. */
21
+ export declare function slotlockProblemType(code: string): string;
22
+ /**
23
+ * A REST refusal as RFC 9457 problem details (`application/problem+json`): `type` and `title` name
24
+ * the problem, `code` is Slotlock's code, and `error.code` repeats it, the body every earlier
25
+ * client reads. `extensions` add what the refusal is about, such as the interval an overlap refused.
26
+ */
27
+ export declare function slotlockProblem(status: number, code: string, extensions?: Record<string, unknown>): Record<string, unknown>;
28
+ /**
29
+ * The Idempotency-Key header's value: a Structured Field String (RFC 9651, as
30
+ * draft-ietf-httpapi-idempotency-key-header-08 defines it), or the bare token clients sent before.
31
+ * `null` when it is a quoted string that does not parse.
32
+ */
33
+ export declare function parseIdempotencyKeyHeader(value: string): string | null;
12
34
  /** Where an operation's arguments come from. */
13
35
  export declare function restInputFromBody(route: {
14
36
  method: string;
package/rest-api.js CHANGED
@@ -1,13 +1,49 @@
1
1
  import { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, } from './application-api.js';
2
2
  export const SLOTLOCK_REST_ROUTES = Object.freeze([
3
3
  { method: 'GET', path: '/v1/resources', operation: 'slotlock_list_resources' },
4
- { method: 'GET', path: '/v1/free-busy', operation: 'slotlock_get_free_busy' },
5
- { method: 'GET', path: '/v1/next-available', operation: 'slotlock_find_next_available' },
6
- { method: 'GET', path: '/v1/events', operation: 'slotlock_list_events' },
7
- { method: 'POST', path: '/v1/events', operation: 'slotlock_create_event' },
8
- { method: 'GET', path: '/v1/events/{event_id}', operation: 'slotlock_get_event' },
9
- { method: 'PATCH', path: '/v1/events/{event_id}', operation: 'slotlock_update_event' },
10
- { method: 'DELETE', path: '/v1/events/{event_id}', operation: 'slotlock_delete_event' },
4
+ { method: 'GET', path: '/v1/free-busy', operation: 'slotlock_get_free_busy', refusals: [404] },
5
+ {
6
+ method: 'GET',
7
+ path: '/v1/next-available',
8
+ operation: 'slotlock_find_next_available',
9
+ refusals: [404],
10
+ },
11
+ {
12
+ method: 'GET',
13
+ path: '/v1/available-slots',
14
+ operation: 'slotlock_find_available_slots',
15
+ refusals: [404],
16
+ },
17
+ { method: 'POST', path: '/v1/slot-holds', operation: 'slotlock_hold_slots', refusals: [409] },
18
+ { method: 'POST', path: '/v1/bundles', operation: 'slotlock_book_together', refusals: [404, 409] },
19
+ { method: 'POST', path: '/v1/pool-bookings', operation: 'slotlock_book_pool', refusals: [404, 409] },
20
+ {
21
+ method: 'GET',
22
+ path: '/v1/pool-availability',
23
+ operation: 'slotlock_get_pool_availability',
24
+ refusals: [404, 409],
25
+ },
26
+ {
27
+ method: 'DELETE',
28
+ path: '/v1/slot-holds/{hold_id}',
29
+ operation: 'slotlock_release_slots',
30
+ refusals: [409],
31
+ },
32
+ { method: 'GET', path: '/v1/events', operation: 'slotlock_list_events', refusals: [404] },
33
+ { method: 'POST', path: '/v1/events', operation: 'slotlock_create_event', refusals: [404, 409] },
34
+ { method: 'GET', path: '/v1/events/{event_id}', operation: 'slotlock_get_event', refusals: [404] },
35
+ {
36
+ method: 'PATCH',
37
+ path: '/v1/events/{event_id}',
38
+ operation: 'slotlock_update_event',
39
+ refusals: [404, 409],
40
+ },
41
+ {
42
+ method: 'DELETE',
43
+ path: '/v1/events/{event_id}',
44
+ operation: 'slotlock_delete_event',
45
+ refusals: [404, 409],
46
+ },
11
47
  ]);
12
48
  /** The application operations' routes, served when the server has an application backend. */
13
49
  export const SLOTLOCK_APPLICATION_REST_ROUTES = Object.freeze(SLOTLOCK_APPLICATION_OPERATIONS.map(({ method, path, name }) => ({
@@ -15,6 +51,86 @@ export const SLOTLOCK_APPLICATION_REST_ROUTES = Object.freeze(SLOTLOCK_APPLICATI
15
51
  path,
16
52
  operation: name,
17
53
  })));
54
+ /** Where every REST refusal's documentation is: the Link header's `describedby` target. */
55
+ export const SLOTLOCK_PROBLEM_DOCS = 'https://slotlock.pylota.io/docs/api/#errors';
56
+ /** A problem type's identifier: a tag URI (RFC 4151), stable, one per code, not a locator. */
57
+ export function slotlockProblemType(code) {
58
+ return `tag:slotlock.pylota.io,2026:problem:${code}`;
59
+ }
60
+ const PROBLEM_TITLES = Object.freeze({
61
+ overlap: 'The time is taken',
62
+ idempotency_conflict: 'Idempotency-Key is already used',
63
+ idempotency_key_required: 'Idempotency-Key is missing',
64
+ idempotency_key_not_supported: 'Idempotency-Key is not taken here',
65
+ revision_conflict: 'The revision is stale',
66
+ confirmation_required: 'A person must confirm this write',
67
+ rate_limited: 'Too many requests',
68
+ authentication_required: 'Authentication required',
69
+ forbidden: 'Not allowed',
70
+ invalid_arguments: 'The arguments are not valid',
71
+ });
72
+ const STATUS_TITLES = Object.freeze({
73
+ 400: 'Bad Request',
74
+ 401: 'Unauthorized',
75
+ 403: 'Forbidden',
76
+ 404: 'Not Found',
77
+ 405: 'Method Not Allowed',
78
+ 409: 'Conflict',
79
+ 410: 'Gone',
80
+ 413: 'Content Too Large',
81
+ 415: 'Unsupported Media Type',
82
+ 422: 'Unprocessable Content',
83
+ 428: 'Precondition Required',
84
+ 429: 'Too Many Requests',
85
+ 500: 'Internal Server Error',
86
+ 503: 'Service Unavailable',
87
+ });
88
+ /**
89
+ * A REST refusal as RFC 9457 problem details (`application/problem+json`): `type` and `title` name
90
+ * the problem, `code` is Slotlock's code, and `error.code` repeats it, the body every earlier
91
+ * client reads. `extensions` add what the refusal is about, such as the interval an overlap refused.
92
+ */
93
+ export function slotlockProblem(status, code, extensions = {}) {
94
+ return {
95
+ type: slotlockProblemType(code),
96
+ title: PROBLEM_TITLES[code] ?? STATUS_TITLES[status] ?? 'Error',
97
+ status,
98
+ code,
99
+ ...extensions,
100
+ error: { code },
101
+ };
102
+ }
103
+ /**
104
+ * The Idempotency-Key header's value: a Structured Field String (RFC 9651, as
105
+ * draft-ietf-httpapi-idempotency-key-header-08 defines it), or the bare token clients sent before.
106
+ * `null` when it is a quoted string that does not parse.
107
+ */
108
+ export function parseIdempotencyKeyHeader(value) {
109
+ const trimmed = value.trim();
110
+ if (!trimmed.startsWith('"'))
111
+ return trimmed;
112
+ let key = '';
113
+ for (let index = 1; index < trimmed.length; index++) {
114
+ const char = trimmed[index];
115
+ if (char === '\\') {
116
+ const next = trimmed[index + 1];
117
+ if (next !== '"' && next !== '\\')
118
+ return null;
119
+ key += next;
120
+ index++;
121
+ }
122
+ else if (char === '"') {
123
+ return index === trimmed.length - 1 ? key : null;
124
+ }
125
+ else if (char < ' ' || char > '~') {
126
+ return null;
127
+ }
128
+ else {
129
+ key += char;
130
+ }
131
+ }
132
+ return null;
133
+ }
18
134
  /** Where an operation's arguments come from. */
19
135
  export function restInputFromBody(route) {
20
136
  return route.method === 'POST' || route.method === 'PUT' || route.method === 'PATCH';
@@ -114,6 +230,12 @@ export function restQueryInput(search, inputSchema) {
114
230
  return input;
115
231
  }
116
232
  const ERROR_RESPONSE = { $ref: '#/components/responses/Error' };
233
+ const IDEMPOTENCY_KEY_HEADER = 'Stands in for idempotency_key: a Structured Field String (`"8e03978e-40d5-43e8-bc93-6894a57f9324"`, ' +
234
+ 'draft-ietf-httpapi-idempotency-key-header-08) or the bare key; when both are sent they must be equal. ' +
235
+ 'A retry with the same key and request answers what the first did; the same key with another request is ' +
236
+ '422 idempotency_conflict. An event write keeps its key for 30 days (maintenance removes it after that), ' +
237
+ 'a hold until the hold would have expired, is released or is confirmed. Where the key is required and ' +
238
+ 'neither is sent: 400 idempotency_key_required.';
117
239
  /** The OpenAPI operation object for one route. */
118
240
  function openApiOperation(route, operation, extra) {
119
241
  const pathNames = [...route.path.matchAll(/\{(\w+)\}/g)].map((match) => match[1]);
@@ -156,14 +278,15 @@ function openApiOperation(route, operation, extra) {
156
278
  parameters.push({ name, in: 'query', required: required.has(name), schema });
157
279
  }
158
280
  }
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) {
281
+ // A write that keeps a retry identity also takes it as the Idempotency-Key header.
282
+ const keyed = fields.idempotency_key !== undefined;
283
+ if (keyed) {
161
284
  parameters.push({
162
285
  name: 'Idempotency-Key',
163
286
  in: 'header',
164
287
  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 },
288
+ description: IDEMPOTENCY_KEY_HEADER,
289
+ schema: { type: 'string', minLength: 1, maxLength: 502 },
167
290
  });
168
291
  }
169
292
  if (parameters.length > 0)
@@ -174,6 +297,7 @@ function openApiOperation(route, operation, extra) {
174
297
  content: { 'application/json': { schema: operation.outputSchema } },
175
298
  },
176
299
  ...extra.responses,
300
+ ...(keyed ? { '422': ERROR_RESPONSE } : {}),
177
301
  };
178
302
  return result;
179
303
  }
@@ -194,6 +318,7 @@ export function slotlockOpenApiDocument(args) {
194
318
  '400': ERROR_RESPONSE,
195
319
  '401': ERROR_RESPONSE,
196
320
  '403': ERROR_RESPONSE,
321
+ ...Object.fromEntries((route.refusals ?? []).map((status) => [status, ERROR_RESPONSE])),
197
322
  ...(route.method === 'GET' ? {} : { '428': ERROR_RESPONSE }),
198
323
  '429': ERROR_RESPONSE,
199
324
  default: ERROR_RESPONSE,
@@ -213,6 +338,8 @@ export function slotlockOpenApiDocument(args) {
213
338
  '403': ERROR_RESPONSE,
214
339
  '404': ERROR_RESPONSE,
215
340
  ...(route.method === 'GET' ? {} : { '409': ERROR_RESPONSE }),
341
+ // A change cursor past the feed's retention.
342
+ ...(route.operation === 'slotlock_list_changes' ? { '410': ERROR_RESPONSE } : {}),
216
343
  '429': ERROR_RESPONSE,
217
344
  default: ERROR_RESPONSE,
218
345
  },
@@ -225,8 +352,10 @@ export function slotlockOpenApiDocument(args) {
225
352
  version: args.version,
226
353
  description: 'Calendars agents cannot double-book. The "Agent tools" operations are the Slotlock MCP tools; ' +
227
354
  '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`.',
355
+ 'maintenance) need an API key with the admin scope. An error is RFC 9457 problem details ' +
356
+ '(`application/problem+json`) whose `code` names it, also as `{"error":{"code":…}}`; an overlap ' +
357
+ 'names the `resource_id`, `start` and `end` it refused. A write that waits for a person answers 428 ' +
358
+ '`confirmation_required`.',
230
359
  },
231
360
  servers: [
232
361
  args.publicBaseUrl !== undefined
@@ -265,8 +394,16 @@ export function slotlockOpenApiDocument(args) {
265
394
  schemas: {
266
395
  Error: {
267
396
  type: 'object',
268
- required: ['error'],
397
+ description: 'RFC 9457 problem details.',
398
+ required: ['type', 'title', 'status', 'code', 'error'],
269
399
  properties: {
400
+ type: { type: 'string', description: 'The problem type: a tag URI, one per code.' },
401
+ title: { type: 'string' },
402
+ status: { type: 'integer' },
403
+ code: { type: 'string', description: 'Why the operation did not run.' },
404
+ resource_id: { type: 'string', description: 'An overlap: the resource it refused.' },
405
+ start: { type: 'string', description: 'An overlap: the start it refused.' },
406
+ end: { type: 'string', description: 'An overlap: the end it refused.' },
270
407
  error: {
271
408
  type: 'object',
272
409
  required: ['code'],
@@ -278,7 +415,15 @@ export function slotlockOpenApiDocument(args) {
278
415
  responses: {
279
416
  Error: {
280
417
  description: 'The operation did not run; `code` says why.',
281
- content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
418
+ headers: {
419
+ Link: {
420
+ description: 'Where the error codes are documented (`rel="describedby"`).',
421
+ schema: { type: 'string' },
422
+ },
423
+ },
424
+ content: {
425
+ 'application/problem+json': { schema: { $ref: '#/components/schemas/Error' } },
426
+ },
282
427
  },
283
428
  },
284
429
  },
package/self-host.d.ts CHANGED
@@ -5,7 +5,7 @@ import { type SlotlockSql, type SlotlockStore } from './store.js';
5
5
  import type { WeeklyAvailabilityRule } from './types.js';
6
6
  export { SlotlockConfigError, type SlotlockEnv, type SlotlockServeDashboardConfig };
7
7
  /** Every variable the commands read. The README, `.env.example` and `server.json` document these. */
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
+ export declare const SLOTLOCK_ENVIRONMENT_VARIABLES: readonly ["DATABASE_URL", "SLOTLOCK_MIGRATE_DATABASE_URL", "SLOTLOCK_AUTH_TOKEN", "SLOTLOCK_CONFIRMATION_SECRET", "SLOTLOCK_CONFIRM_WRITES", "SLOTLOCK_METRICS_TOKEN", "SLOTLOCK_PUBLIC_URL", "SLOTLOCK_TENANT", "SLOTLOCK_AVAILABILITY", "SLOTLOCK_REST_API", "SLOTLOCK_RETENTION_DAYS", "SLOTLOCK_WEBHOOKS", "SLOTLOCK_SHARED_LIMITS", "SLOTLOCK_LIMITS", "SLOTLOCK_TRUSTED_PROXY_HEADER", "SLOTLOCK_GITHUB_CLIENT_ID", "SLOTLOCK_GITHUB_CLIENT_SECRET", "SLOTLOCK_SESSION_SECRET", "SLOTLOCK_DASHBOARD_USERS", "SLOTLOCK_DASHBOARD_ORGS", "SLOTLOCK_DASHBOARD_NAME", "HOST", "PORT"];
9
9
  export interface SlotlockDatabaseConfig {
10
10
  /** The role `serve` and `resource` connect as. */
11
11
  databaseUrl: string;
@@ -15,6 +15,11 @@ export interface SlotlockDatabaseConfig {
15
15
  */
16
16
  migrateDatabaseUrl?: string;
17
17
  tenantRef: string;
18
+ /**
19
+ * How long history is kept, in days (SLOTLOCK_RETENTION_DAYS); 30 without it. Every command that
20
+ * prunes or reads the feed takes it, so `maintain` from cron prunes as `serve` reads.
21
+ */
22
+ retentionDays?: number;
18
23
  }
19
24
  export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
20
25
  host: string;
@@ -26,6 +31,8 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
26
31
  * (`slotlock key create`) authenticate whether or not it is set, each as its own tenant and scopes.
27
32
  */
28
33
  authToken?: string;
34
+ /** The bearer token `/metrics` answers (SLOTLOCK_METRICS_TOKEN); no metrics without it. */
35
+ metricsToken?: string;
29
36
  /**
30
37
  * Writes that wait for a person's confirmation (MCP 2026-07-28) and are refused elsewhere. None
31
38
  * unless SLOTLOCK_CONFIRM_WRITES opts in: every authenticated write takes effect at once.
@@ -37,11 +44,20 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
37
44
  availability: readonly WeeklyAvailabilityRule[];
38
45
  /** Set exactly when SLOTLOCK_GITHUB_CLIENT_ID is. */
39
46
  dashboard?: SlotlockServeDashboardConfig;
47
+ /**
48
+ * GitHub organization ids (SLOTLOCK_DASHBOARD_ORGS) whose `github-org:<id>` tenants this server's
49
+ * keys may act for, whether it serves the dashboard or another server (a Worker) does.
50
+ */
51
+ organizations?: string[];
40
52
  /**
41
53
  * Serve the REST API under /v1, with the application API for admin keys, and its OpenAPI document
42
54
  * (SLOTLOCK_REST_API: on unless off).
43
55
  */
44
56
  restApi: boolean;
57
+ /** Webhook delivery (SLOTLOCK_WEBHOOKS: on unless off; private also reaches private networks). */
58
+ webhooks: 'on' | 'off' | 'private';
59
+ /** Tenant request budgets shared with every server through PostgreSQL (SLOTLOCK_SHARED_LIMITS=on). */
60
+ sharedLimits: boolean;
45
61
  /** Request limits (SLOTLOCK_LIMITS over SLOTLOCK_DEFAULT_LIMITS): the server's only rate limit. */
46
62
  limits: SlotlockLimits;
47
63
  /** The lower-case request field a trusted proxy sets to the client address; else the socket's. */
@@ -98,10 +114,17 @@ export interface SlotlockRunningServer {
98
114
  * The store `serve` answers requests from. Every surface writes through it, so its tenant limits
99
115
  * bound what any key can add, and its transaction limits what one tenant holds of the shared pool.
100
116
  */
101
- export declare function createSlotlockServeStore(sql: SlotlockSql, caps: SlotlockLimits['caps']): SlotlockStore;
117
+ export declare function createSlotlockServeStore(sql: SlotlockSql, caps: SlotlockLimits['caps'], availability?: readonly WeeklyAvailabilityRule[], retentionDays?: number): SlotlockStore;
118
+ /**
119
+ * The store maintenance writes through, `serve`'s and `maintain`'s: the same tenant and per-holder
120
+ * limits as requests, so a waitlist offer is a hold like any other.
121
+ */
122
+ export declare function createSlotlockMaintenanceStore(sql: SlotlockSql, caps: SlotlockLimits['caps'], options?: {
123
+ retentionDays?: number;
124
+ }): SlotlockStore;
102
125
  /** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
103
126
  export declare function startSlotlockServer(config: SlotlockServeConfig, log: SlotlockLogger): Promise<SlotlockRunningServer>;
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";
127
+ export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains each tenant it serves every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n resource archive <ref> Take a resource out of use: no new bookings, holds or events, and\n no longer listed; what is on it stays\n resource restore <ref> Put an archived resource back in use\n resource delete <ref> Delete a resource and its history; refused while a booking, hold\n or event on it has not ended\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n rules set <json> --resource <ref>\n Set what a booking on the resource must satisfy, like\n {\"minNoticeMinutes\":120,\"slotStepMinutes\":30,\"turnaroundMinutes\":30,\"enforceHours\":true}\n rules clear --resource <ref>\n Remove the resource's booking rules\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n tenant status Print whether the tenant is suspended\n tenant suspend [--reason <text>]\n Suspend the tenant: it may read, and writes nothing; its keys get\n no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:\n only the schema owner suspends)\n tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)\n tenant erase --confirm <tenant>\n Delete everything the tenant has, its API keys included, for good;\n --confirm repeats SLOTLOCK_TENANT\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write\n access (never admin), 32+ random characters (serve); API keys\n work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;\n required when SLOTLOCK_CONFIRM_WRITES is not none)\n SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+\n random characters (serve; off without it)\n SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools\n that wait for a person (MCP 2026-07-28 form elicitation; refused\n with confirmation_required on REST, A2A and older MCP)\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,\n and retired answers (never under 30) (1-3650, default 30;\n serve and maintain)\n SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the\n database, so N servers allow a tenant its rate once; off (default):\n each server keeps its own\n SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,\n never to a private network address; private: those too, for\n testing; off: keep the change feed, send nothing\n SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:\n rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,\n every request per client, before any key lookup), key_read,\n tenant_read (20/s:40), key_write, tenant_write (5/s:20),\n global_read (500/s:1000), global_write (50/s:100), app_read\n (20/s:40) and app_write (5/s:20) per tenant for the\n application API, sign_in (20/m:20); counts for\n tenant_series_writes and series_writes\n (1: writes that may expand a recurring series at once),\n maintenance_runs (1: application API maintenance runs at once),\n subscriptions (256), tenant_subscriptions (16),\n key_subscriptions (4, and fewer than tenant_subscriptions),\n key_lookups (2), key_lookup_queue (256),\n tenant_transactions (2: one tenant's database transactions at\n once, of the pool's 10); what one tenant may store:\n tenant_holds (10000), tenant_reservations, tenant_events and\n tenant_coverage (100000), holder_holds (20) and\n holder_held_hours (2160), which maintain keeps too\n SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,\n such as CF-Connecting-IP (default: the connection's address);\n set it only when every request comes through that proxy\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)\n SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant\n SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
105
128
  /**
106
129
  * Run one `slotlock` command and resolve to its exit code: 0 success, 1 failure, 2 a usage or
107
130
  * configuration error (nothing was started). `serve` resolves once `io.signal` aborts and it has