slotlock 0.2.0 → 0.5.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 (66) hide show
  1. package/CHANGELOG.md +582 -0
  2. package/CONTRIBUTING.md +35 -8
  3. package/GOVERNANCE.md +4 -2
  4. package/MAINTAINERS.md +3 -2
  5. package/NOTICE +4 -4
  6. package/README.md +498 -65
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +62 -10
  10. package/agent-server.js +577 -105
  11. package/agent-store-backend.d.ts +12 -0
  12. package/agent-store-backend.js +1115 -55
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +35 -1
  16. package/application-api.js +683 -12
  17. package/booking-rules.d.ts +84 -0
  18. package/booking-rules.js +149 -0
  19. package/config.d.ts +112 -0
  20. package/config.js +225 -0
  21. package/dashboard.d.ts +67 -5
  22. package/dashboard.js +1012 -140
  23. package/ddl.d.ts +45 -3
  24. package/ddl.js +1124 -13
  25. package/engine.d.ts +5 -0
  26. package/engine.js +23 -6
  27. package/event-feed.d.ts +178 -0
  28. package/event-feed.js +435 -0
  29. package/event-schema.d.ts +59 -0
  30. package/event-schema.js +85 -0
  31. package/ical-worker-thread.js +4 -2
  32. package/ical-worker.d.ts +8 -5
  33. package/ical-worker.js +9 -2
  34. package/index.d.ts +12 -6
  35. package/index.js +9 -4
  36. package/interop.d.ts +117 -0
  37. package/interop.js +356 -0
  38. package/maintenance.d.ts +27 -5
  39. package/maintenance.js +92 -28
  40. package/mcp-app.d.ts +7 -1
  41. package/mcp-app.js +259 -40
  42. package/metrics.d.ts +24 -0
  43. package/metrics.js +167 -0
  44. package/node-server.d.ts +9 -1
  45. package/node-server.js +7 -2
  46. package/openapi.json +10970 -0
  47. package/package.json +3 -2
  48. package/rate-limit.d.ts +80 -0
  49. package/rate-limit.js +741 -0
  50. package/rest-api.d.ts +22 -0
  51. package/rest-api.js +166 -11
  52. package/rules.js +6 -3
  53. package/self-host.d.ts +45 -8
  54. package/self-host.js +616 -46
  55. package/slotlock-manifest.schema.json +7 -1
  56. package/slotlock.manifest.json +8 -2
  57. package/store.d.ts +647 -28
  58. package/store.js +2982 -327
  59. package/sync.d.ts +43 -2
  60. package/sync.js +1515 -323
  61. package/timezone.d.ts +58 -3
  62. package/timezone.js +249 -20
  63. package/types.d.ts +300 -9
  64. package/types.js +0 -2
  65. package/webhooks.d.ts +91 -0
  66. package/webhooks.js +300 -0
package/self-host.js CHANGED
@@ -3,20 +3,24 @@
3
3
  // supplies it the same way and nothing is read from a file or a request. A missing or weak secret
4
4
  // stops a command before it connects anywhere, and no secret is written to a log line or an error.
5
5
  // The one exception is the API key `key create` prints: that output is the key's only copy.
6
+ import { createSlotlockWebhookSender, deliverSlotlockWebhooks, startSlotlockWebhookDelivery, } from './webhooks.js';
6
7
  import { createHash, timingSafeEqual } from 'node:crypto';
7
8
  import { parseArgs } from 'node:util';
8
9
  import postgres from 'postgres';
9
10
  import { createSlotlockAgentServer, slotlockAgentOperationScope, } from './agent-server.js';
10
11
  import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, } from './agent-store-backend.js';
11
12
  import { SLOTLOCK_API_KEY_DEFAULT_SCOPES, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
12
- import { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, } from './dashboard.js';
13
- import { SECRET_HINT, SlotlockConfigError, optional, readAvailability, readDashboardConfig, readSecret, readServerUrl, } from './config.js';
13
+ import { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockAcceptanceRecords, createSlotlockDashboardState, } from './dashboard.js';
14
+ import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readDashboardOrganizations, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, slotlockKeySubscriptions, } from './config.js';
15
+ import { parseBookingRules } from './booking-rules.js';
14
16
  import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
15
17
  import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_TENANT_TABLES } from './ddl.js';
16
18
  import { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
17
19
  import { createSlotlockNodeServer, } from './node-server.js';
20
+ import { createSlotlockMetrics } from './metrics.js';
18
21
  import { slotlockPackageVersion } from './package-version.js';
19
- import { createSlotlockStore } from './store.js';
22
+ import { createSlotlockRequestLimits } from './rate-limit.js';
23
+ import { assertSlotlockSchemaCurrent, createSlotlockStore, } from './store.js';
20
24
  import { parseAvailabilityRules } from './weekly-hours.js';
21
25
  export { SlotlockConfigError };
22
26
  /** Every variable the commands read. The README, `.env.example` and `server.json` document these. */
@@ -26,21 +30,29 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
26
30
  'SLOTLOCK_AUTH_TOKEN',
27
31
  'SLOTLOCK_CONFIRMATION_SECRET',
28
32
  'SLOTLOCK_CONFIRM_WRITES',
33
+ 'SLOTLOCK_METRICS_TOKEN',
29
34
  'SLOTLOCK_PUBLIC_URL',
30
35
  'SLOTLOCK_TENANT',
31
36
  'SLOTLOCK_AVAILABILITY',
32
37
  'SLOTLOCK_REST_API',
38
+ 'SLOTLOCK_RETENTION_DAYS',
39
+ 'SLOTLOCK_WEBHOOKS',
40
+ 'SLOTLOCK_SHARED_LIMITS',
41
+ 'SLOTLOCK_LIMITS',
42
+ 'SLOTLOCK_TRUSTED_PROXY_HEADER',
33
43
  'SLOTLOCK_GITHUB_CLIENT_ID',
34
44
  'SLOTLOCK_GITHUB_CLIENT_SECRET',
35
45
  'SLOTLOCK_SESSION_SECRET',
36
46
  'SLOTLOCK_DASHBOARD_USERS',
47
+ 'SLOTLOCK_DASHBOARD_ORGS',
48
+ 'SLOTLOCK_DASHBOARD_NAME',
37
49
  'HOST',
38
50
  'PORT',
39
51
  ]);
40
52
  const DEFAULT_HOST = '127.0.0.1';
41
53
  const DEFAULT_PORT = 8080;
42
54
  const DEFAULT_TENANT = 'default';
43
- /** The principal the server token resolves to: it acts in SLOTLOCK_TENANT with every scope. */
55
+ /** The principal the server token resolves to: it acts in SLOTLOCK_TENANT with read and write. */
44
56
  const SELF_HOST_SUBJECT = 'self-host';
45
57
  /** Who `key create` records as a key's creator. */
46
58
  const CLI_KEY_CREATOR = 'cli';
@@ -56,6 +68,8 @@ const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
56
68
  const MAINTAIN_MAX_PASSES = 100;
57
69
  const WRITE_OPERATIONS = [
58
70
  'slotlock_create_event',
71
+ 'slotlock_book_together',
72
+ 'slotlock_book_pool',
59
73
  'slotlock_update_event',
60
74
  'slotlock_delete_event',
61
75
  ];
@@ -67,6 +81,12 @@ const SERVED_OPERATIONS = new Set([
67
81
  'slotlock_list_resources',
68
82
  'slotlock_get_free_busy',
69
83
  'slotlock_find_next_available',
84
+ 'slotlock_find_available_slots',
85
+ 'slotlock_hold_slots',
86
+ 'slotlock_release_slots',
87
+ 'slotlock_book_together',
88
+ 'slotlock_book_pool',
89
+ 'slotlock_get_pool_availability',
70
90
  'slotlock_create_event',
71
91
  'slotlock_get_event',
72
92
  'slotlock_list_events',
@@ -74,8 +94,13 @@ const SERVED_OPERATIONS = new Set([
74
94
  'slotlock_delete_event',
75
95
  ]);
76
96
  const HEALTH_QUERY_TIMEOUT_MS = 2_000;
97
+ /** How long one database check answers /healthz, so a flood of health checks is not one of queries. */
98
+ const HEALTH_RESULT_MS = 1_000;
77
99
  const HEALTHCHECK_TIMEOUT_MS = 5_000;
100
+ /** One pool for every tenant; SLOTLOCK_LIMITS `tenant_transactions` (config.ts) goes up to it. */
78
101
  const POOL_SIZE = 10;
102
+ /** How long a request's transaction waits for any one lock before it answers 503 `server_busy`. */
103
+ const SERVE_LOCK_TIMEOUT_MS = 5_000;
79
104
  const RESOURCE_PAGE_SIZE = 1_000;
80
105
  function readDatabaseUrl(env, name) {
81
106
  const value = optional(env, name);
@@ -121,7 +146,7 @@ function readTenant(env) {
121
146
  return value;
122
147
  }
123
148
  function readConfirmWrites(env) {
124
- const value = optional(env, 'SLOTLOCK_CONFIRM_WRITES') ?? 'all';
149
+ const value = optional(env, 'SLOTLOCK_CONFIRM_WRITES') ?? 'none';
125
150
  if (value === 'all')
126
151
  return [...WRITE_OPERATIONS];
127
152
  if (value === 'none')
@@ -143,11 +168,38 @@ export function readSlotlockDatabaseConfig(env) {
143
168
  throw new SlotlockConfigError('DATABASE_URL is required: the postgres:// URL of the role Slotlock serves as');
144
169
  }
145
170
  const config = { databaseUrl, tenantRef: readTenant(env) };
171
+ const retentionDays = readRetentionDays(env);
172
+ if (retentionDays !== undefined)
173
+ config.retentionDays = retentionDays;
146
174
  const migrateDatabaseUrl = readDatabaseUrl(env, 'SLOTLOCK_MIGRATE_DATABASE_URL');
147
175
  if (migrateDatabaseUrl !== undefined)
148
176
  config.migrateDatabaseUrl = migrateDatabaseUrl;
149
177
  return config;
150
178
  }
179
+ function readWebhooks(env) {
180
+ const value = optional(env, 'SLOTLOCK_WEBHOOKS') ?? 'on';
181
+ if (value !== 'on' && value !== 'off' && value !== 'private') {
182
+ throw new SlotlockConfigError('SLOTLOCK_WEBHOOKS must be on, off or private');
183
+ }
184
+ return value;
185
+ }
186
+ /** SLOTLOCK_RETENTION_DAYS: how long history is kept, in days (createSlotlockStore's `retentionDays`). */
187
+ function readRetentionDays(env) {
188
+ const value = optional(env, 'SLOTLOCK_RETENTION_DAYS');
189
+ if (value === undefined)
190
+ return undefined;
191
+ if (!/^[1-9]\d{0,3}$/.test(value) || Number(value) > 3_650) {
192
+ throw new SlotlockConfigError('SLOTLOCK_RETENTION_DAYS must be a whole number of days from 1 to 3650');
193
+ }
194
+ return Number(value);
195
+ }
196
+ function readSharedLimits(env) {
197
+ const value = optional(env, 'SLOTLOCK_SHARED_LIMITS') ?? 'off';
198
+ if (value !== 'on' && value !== 'off') {
199
+ throw new SlotlockConfigError('SLOTLOCK_SHARED_LIMITS must be on or off');
200
+ }
201
+ return value === 'on';
202
+ }
151
203
  function readRestApi(env) {
152
204
  const value = optional(env, 'SLOTLOCK_REST_API') ?? 'on';
153
205
  if (value !== 'on' && value !== 'off') {
@@ -161,7 +213,7 @@ export function readSlotlockServeConfig(env) {
161
213
  const confirmWrites = readConfirmWrites(env);
162
214
  const confirmationSecret = readSecret(env, 'SLOTLOCK_CONFIRMATION_SECRET');
163
215
  if (confirmWrites.length > 0 && confirmationSecret === undefined) {
164
- throw new SlotlockConfigError(`SLOTLOCK_CONFIRMATION_SECRET is required while SLOTLOCK_CONFIRM_WRITES guards writes (the default): it signs each pending confirmation; ${SECRET_HINT}, or set SLOTLOCK_CONFIRM_WRITES=none to let agents write without asking a person`);
216
+ throw new SlotlockConfigError(`SLOTLOCK_CONFIRMATION_SECRET is required while SLOTLOCK_CONFIRM_WRITES guards writes: it signs each pending confirmation; ${SECRET_HINT}, or set SLOTLOCK_CONFIRM_WRITES=none (the default) to let agents write without asking a person`);
165
217
  }
166
218
  if (confirmationSecret !== undefined &&
167
219
  authToken !== undefined &&
@@ -177,12 +229,34 @@ export function readSlotlockServeConfig(env) {
177
229
  confirmWrites,
178
230
  availability: readAvailability(env),
179
231
  restApi: readRestApi(env),
232
+ webhooks: readWebhooks(env),
233
+ sharedLimits: readSharedLimits(env),
234
+ limits: readLimits(env),
180
235
  };
236
+ const trustedProxyHeader = readTrustedProxyHeader(env);
237
+ if (trustedProxyHeader !== undefined)
238
+ config.trustedProxyHeader = trustedProxyHeader;
181
239
  if (authToken !== undefined)
182
240
  config.authToken = authToken;
241
+ const metricsToken = readSecret(env, 'SLOTLOCK_METRICS_TOKEN');
242
+ if (metricsToken !== undefined) {
243
+ const others = [
244
+ authToken,
245
+ confirmationSecret,
246
+ env.SLOTLOCK_SESSION_SECRET?.trim(),
247
+ env.SLOTLOCK_GITHUB_CLIENT_SECRET?.trim(),
248
+ ];
249
+ if (others.includes(metricsToken)) {
250
+ throw new SlotlockConfigError('SLOTLOCK_METRICS_TOKEN must differ from every other secret');
251
+ }
252
+ config.metricsToken = metricsToken;
253
+ }
183
254
  const dashboard = readDashboardConfig(env);
184
255
  if (dashboard !== undefined)
185
256
  config.dashboard = dashboard;
257
+ const organizations = readDashboardOrganizations(env);
258
+ if (organizations !== undefined)
259
+ config.organizations = organizations;
186
260
  if (confirmWrites.length > 0 && confirmationSecret !== undefined) {
187
261
  config.confirmationSecret = confirmationSecret;
188
262
  }
@@ -219,8 +293,8 @@ export function createSlotlockTokenAuthenticator(token, principal) {
219
293
  }
220
294
  /**
221
295
  * What `serve` lets a principal call: a served operation its scopes cover. The server token's
222
- * principal carries every scope; a principal without scopes is refused everything here, though the
223
- * agent server alone would leave it to this function.
296
+ * principal carries read and write, never admin; a principal without scopes is refused everything
297
+ * here, though the agent server alone would leave it to this function.
224
298
  */
225
299
  export function authorizeSlotlockServeOperation(args) {
226
300
  return (SERVED_OPERATIONS.has(args.operation) &&
@@ -234,6 +308,7 @@ function secretsIn(env) {
234
308
  'SLOTLOCK_CONFIRMATION_SECRET',
235
309
  'SLOTLOCK_GITHUB_CLIENT_SECRET',
236
310
  'SLOTLOCK_SESSION_SECRET',
311
+ 'SLOTLOCK_METRICS_TOKEN',
237
312
  ]) {
238
313
  const value = env[name]?.trim();
239
314
  if (value)
@@ -256,7 +331,10 @@ function secretsIn(env) {
256
331
  // Longest first, so a URL is replaced whole rather than around its password.
257
332
  return [...new Set(secrets)].sort((a, b) => b.length - a.length);
258
333
  }
259
- /** One JSON object per line: info on stdout, errors on stderr, secrets replaced by `[redacted]`. */
334
+ /**
335
+ * One JSON object per line: info on stdout, warnings and errors on stderr, secrets replaced by
336
+ * `[redacted]`.
337
+ */
260
338
  function createLogger(io) {
261
339
  const secrets = secretsIn(io.env);
262
340
  const write = (stream, level, event, fields = {}) => {
@@ -267,6 +345,7 @@ function createLogger(io) {
267
345
  };
268
346
  return {
269
347
  info: (event, fields) => write(io.stdout, 'info', event, fields),
348
+ warn: (event, fields) => write(io.stderr, 'warn', event, fields),
270
349
  error: (event, fields) => write(io.stderr, 'error', event, fields),
271
350
  };
272
351
  }
@@ -348,16 +427,90 @@ async function databaseReachable(sql) {
348
427
  }
349
428
  }
350
429
  /**
351
- * Whether the dashboard's allowlist lets a tenant act: every tenant when there is no dashboard or it
352
- * allows everyone, and a `github:<id>` tenant only while that id is listed.
430
+ * `GET <path>` answers the metrics to `Authorization: Bearer <token>`, before the request limits:
431
+ * a scrape is in memory and names no tenant. Anything else goes on to `target`.
432
+ */
433
+ function serveMetrics(target, metrics, path, token) {
434
+ const expected = createHash('sha256').update(token).digest();
435
+ return {
436
+ fetch(request, connection) {
437
+ if (new URL(request.url).pathname !== path)
438
+ return target.fetch(request, connection);
439
+ // The scheme is case-insensitive (RFC 9110); the token is compared as digests, in constant time.
440
+ const presented = /^bearer +(\S+) *$/i.exec(request.headers.get('authorization') ?? '')?.[1] ?? '';
441
+ const given = createHash('sha256').update(presented).digest();
442
+ if (!timingSafeEqual(given, expected)) {
443
+ metrics.refusedScrape();
444
+ return Promise.resolve(new Response('Unauthorized\n', {
445
+ status: 401,
446
+ headers: { 'Content-Type': 'text/plain; charset=utf-8', 'WWW-Authenticate': 'Bearer' },
447
+ }));
448
+ }
449
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
450
+ return Promise.resolve(new Response(null, { status: 405, headers: { Allow: 'GET, HEAD' } }));
451
+ }
452
+ return Promise.resolve(new Response(request.method === 'HEAD' ? null : metrics.render(), {
453
+ headers: {
454
+ 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
455
+ 'Cache-Control': 'no-store',
456
+ },
457
+ }));
458
+ },
459
+ ...(target.shutdown ? { shutdown: () => target.shutdown?.() } : {}),
460
+ };
461
+ }
462
+ /**
463
+ * Whether the dashboard's allowlists let a tenant act: a `github-org:<id>` tenant only while
464
+ * SLOTLOCK_DASHBOARD_ORGS lists that organization, wherever the dashboard runs; otherwise every
465
+ * tenant when there is no dashboard or it allows everyone, and a `github:<id>` tenant only while
466
+ * that id is listed.
353
467
  */
354
468
  function dashboardAllows(config, tenantRef) {
469
+ const orgId = /^github-org:(\d+)$/.exec(tenantRef)?.[1];
470
+ if (orgId !== undefined)
471
+ return config.organizations?.includes(orgId) === true;
355
472
  const users = config.dashboard?.allowedUsers;
356
473
  if (users === undefined || users === '*')
357
474
  return true;
358
475
  const githubId = /^github:(\d+)$/.exec(tenantRef)?.[1];
359
476
  return githubId === undefined || users.includes(githubId);
360
477
  }
478
+ function serveTenantLimits(caps) {
479
+ return {
480
+ resources: SERVE_MAX_TENANT_RESOURCES,
481
+ holds: caps.tenant_holds,
482
+ reservations: caps.tenant_reservations,
483
+ events: caps.tenant_events,
484
+ coverage: caps.tenant_coverage,
485
+ };
486
+ }
487
+ /**
488
+ * The store `serve` answers requests from. Every surface writes through it, so its tenant limits
489
+ * bound what any key can add, and its transaction limits what one tenant holds of the shared pool.
490
+ */
491
+ export function createSlotlockServeStore(sql, caps, availability = [], retentionDays) {
492
+ return createSlotlockStore(sql, {
493
+ ...(retentionDays !== undefined ? { retentionDays } : {}),
494
+ defaultAvailabilityRules: availability,
495
+ tenantLimits: serveTenantLimits(caps),
496
+ maxTenantTransactions: caps.tenant_transactions,
497
+ maxHoldsPerHolder: caps.holder_holds,
498
+ maxHeldHoursPerHolder: caps.holder_held_hours,
499
+ tenantLockTimeoutMs: SERVE_LOCK_TIMEOUT_MS,
500
+ });
501
+ }
502
+ /**
503
+ * The store maintenance writes through, `serve`'s and `maintain`'s: the same tenant and per-holder
504
+ * limits as requests, so a waitlist offer is a hold like any other.
505
+ */
506
+ export function createSlotlockMaintenanceStore(sql, caps, options = {}) {
507
+ return createSlotlockStore(sql, {
508
+ ...options,
509
+ tenantLimits: serveTenantLimits(caps),
510
+ maxHoldsPerHolder: caps.holder_holds,
511
+ maxHeldHoursPerHolder: caps.holder_held_hours,
512
+ });
513
+ }
361
514
  /** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
362
515
  export async function startSlotlockServer(config, log) {
363
516
  const sql = connect(config.databaseUrl, POOL_SIZE);
@@ -376,6 +529,9 @@ export async function startSlotlockServer(config, log) {
376
529
  if (!schema?.present) {
377
530
  throw Object.assign(new Error('The slotlock schema is missing: run `slotlock migrate` or `slotlock serve --migrate`'), { code: 'schema_missing' });
378
531
  }
532
+ // Migrated by another release: an older schema needs this release's migrate first, and a newer
533
+ // one has tables and rules this release's code does not know.
534
+ await assertSlotlockSchemaCurrent(sql);
379
535
  // A deployment run statement by statement lifts forced row-level security for its data steps
380
536
  // and, stopped part way, leaves it lifted until the next one puts it back. The owning role then
381
537
  // reads every tenant's rows: refuse to serve until then.
@@ -400,9 +556,27 @@ export async function startSlotlockServer(config, log) {
400
556
  }
401
557
  }
402
558
  }
403
- const store = createSlotlockStore(sql);
559
+ if (config.confirmationSecret !== undefined) {
560
+ // Accepted confirmations are recorded once each (0.3.1); an older schema cannot hold them.
561
+ const [recordable] = await sql `
562
+ SELECT EXISTS (
563
+ SELECT 1 FROM pg_catalog.pg_constraint
564
+ WHERE conname = 'slotlock_dashboard_tokens_kind_valid'
565
+ AND conrelid = to_regclass('slotlock.dashboard_tokens')
566
+ AND pg_catalog.pg_get_constraintdef(oid) LIKE '%confirmation%'
567
+ ) AND has_function_privilege(to_regprocedure(${SLOTLOCK_DASHBOARD_FUNCTIONS[0]}), 'EXECUTE') AS ready`;
568
+ if (!recordable?.ready) {
569
+ throw Object.assign(new Error('SLOTLOCK_CONFIRM_WRITES records each accepted confirmation, which this schema cannot: run `slotlock migrate` (or `slotlock serve --migrate`) with this release'), { code: 'schema_outdated' });
570
+ }
571
+ }
572
+ const { caps } = config.limits;
573
+ const store = createSlotlockServeStore(sql, caps, config.availability, config.retentionDays);
574
+ const retention = config.retentionDays !== undefined ? { retentionDays: config.retentionDays } : {};
404
575
  const publicUrl = new URL(config.publicUrl);
405
- const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql));
576
+ const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql), {
577
+ maxConcurrentLookups: caps.key_lookups,
578
+ maxWaitingLookups: caps.key_lookup_queue,
579
+ });
406
580
  const authenticateToken = config.authToken === undefined
407
581
  ? undefined
408
582
  : // Read and write, never admin: this is the token the docs put in agents' configuration,
@@ -414,13 +588,34 @@ export async function startSlotlockServer(config, log) {
414
588
  });
415
589
  // Set once the listener is up; the authenticator tells it which tenants this server serves.
416
590
  let maintenance;
417
- const agentServer = createSlotlockAgentServer({
418
- publicBaseUrl: config.publicUrl,
419
- // readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
420
- allowInsecureLocalhost: publicUrl.protocol === 'http:',
421
- backend: createSlotlockStoreAgentBackend(store, {
422
- availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
423
- }),
591
+ let webhooks;
592
+ const basePath = publicUrl.pathname.replace(/\/$/, '');
593
+ const metrics = config.metricsToken === undefined
594
+ ? undefined
595
+ : createSlotlockMetrics({
596
+ version: slotlockPackageVersion(),
597
+ ...(process.versions.tz ? { tzdata: process.versions.tz } : {}),
598
+ });
599
+ const limits = createSlotlockRequestLimits({
600
+ limits: config.limits,
601
+ ...(config.sharedLimits
602
+ ? {
603
+ shared: {
604
+ // Keyed by a digest: the table keeps no tenant name.
605
+ take: async (bucket, rate) => {
606
+ const digest = createHash('sha256').update(bucket).digest('hex');
607
+ const [row] = await sql `
608
+ SELECT slotlock.take_rate_token(${digest}, ${rate.perSecond}, ${rate.burst}) AS taken`;
609
+ return row?.taken === true;
610
+ },
611
+ },
612
+ }
613
+ : {}),
614
+ ...(config.trustedProxyHeader !== undefined
615
+ ? { trustedProxyHeader: config.trustedProxyHeader }
616
+ : {}),
617
+ basePath,
618
+ signInPaths: config.dashboard ? ['/dashboard/sign-in', '/dashboard/callback'] : [],
424
619
  // The token is compared in constant time first; only then is a key looked up. A person the
425
620
  // dashboard no longer allows loses their keys with their session.
426
621
  authenticate: async (request) => {
@@ -430,12 +625,51 @@ export async function startSlotlockServer(config, log) {
430
625
  maintenance?.touch(principal.tenantRef);
431
626
  return principal;
432
627
  },
433
- authorize: async ({ principal, operation }) => authorizeSlotlockServeOperation({ principal, operation }),
434
- health: async () => (await databaseReachable(sql))
435
- ? { ready: true, checks: ['database'] }
436
- : { ready: false, checks: ['database_unreachable'] },
628
+ // Counts and pseudonyms only: never an address, a credential or a tenant.
629
+ onRefusals: (counts) => {
630
+ metrics?.refusals(counts);
631
+ log.info('refusals', { ...counts });
632
+ },
633
+ // Behind a proxy without the setting, every client shares the proxy's address budget.
634
+ onIgnoredClientField: (field) => log.warn('client_address_field_ignored', { field, setting: 'SLOTLOCK_TRUSTED_PROXY_HEADER' }),
635
+ });
636
+ let health;
637
+ const agentServer = createSlotlockAgentServer({
638
+ publicBaseUrl: config.publicUrl,
639
+ // readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
640
+ allowInsecureLocalhost: publicUrl.protocol === 'http:',
641
+ backend: createSlotlockStoreAgentBackend(store, {
642
+ availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
643
+ }),
644
+ authenticate: limits.authenticate,
645
+ consumeRateLimit: limits.consumeRateLimit,
646
+ // A write that may expand a recurring series also waits for one of a few slots, so one
647
+ // tenant's expansions cannot hold the event loop from every other tenant's requests.
648
+ authorize: async ({ principal, operation, input }) => {
649
+ if (!authorizeSlotlockServeOperation({ principal, operation }))
650
+ return false;
651
+ await limits.admit({ principal, operation, input });
652
+ return true;
653
+ },
654
+ subscriptions: {
655
+ maxTotal: caps.subscriptions,
656
+ maxPerTenant: caps.tenant_subscriptions,
657
+ maxPerPrincipal: slotlockKeySubscriptions(caps),
658
+ },
659
+ health: async () => {
660
+ if (!health || performance.now() - health.at > HEALTH_RESULT_MS) {
661
+ health = { at: performance.now(), ready: databaseReachable(sql) };
662
+ }
663
+ return (await health.ready)
664
+ ? { ready: true, checks: ['database'] }
665
+ : { ready: false, checks: ['database_unreachable'] };
666
+ },
437
667
  // Outcomes and counts only: the server never reports arguments or identities.
438
- onEvent: ({ type, ...fields }) => log.info(type, snakeCaseKeys(fields)),
668
+ onEvent: (event) => {
669
+ metrics?.event(event);
670
+ const { type, ...fields } = event;
671
+ log.info(type, snakeCaseKeys(fields));
672
+ },
439
673
  // What an agent sees only as internal_error: the operation and the error's message and code,
440
674
  // never the arguments or the request, with every configured secret redacted.
441
675
  onError: (error, { operation }) => log.error('agent_error', { operation, ...describeError(error) }),
@@ -447,7 +681,12 @@ export async function startSlotlockServer(config, log) {
447
681
  backend: createSlotlockStoreApplicationBackend(store, {
448
682
  maxTenantResources: SERVE_MAX_TENANT_RESOURCES,
449
683
  }),
450
- authorize: async ({ principal }) => principal.scopes?.includes(SLOTLOCK_APPLICATION_SCOPE) === true,
684
+ authorize: async ({ principal, operation, input }) => {
685
+ if (principal.scopes?.includes(SLOTLOCK_APPLICATION_SCOPE) !== true)
686
+ return false;
687
+ await limits.admit({ principal, operation, input });
688
+ return true;
689
+ },
451
690
  },
452
691
  }
453
692
  : {}),
@@ -456,6 +695,7 @@ export async function startSlotlockServer(config, log) {
456
695
  confirmation: {
457
696
  operations: config.confirmWrites,
458
697
  secrets: [config.confirmationSecret],
698
+ consumeAcceptance: createSlotlockAcceptanceRecords(sql),
459
699
  },
460
700
  }
461
701
  : {}),
@@ -470,32 +710,75 @@ export async function startSlotlockServer(config, log) {
470
710
  },
471
711
  sessionSecret: config.dashboard.sessionSecret,
472
712
  allowedUsers: config.dashboard.allowedUsers,
713
+ ...(config.dashboard.name ? { name: config.dashboard.name } : {}),
714
+ ...(config.dashboard.organizations
715
+ ? { organizations: config.dashboard.organizations }
716
+ : {}),
473
717
  keys: createSlotlockApiKeyStore(sql),
474
718
  resources: createSlotlockDashboardResources(store),
475
719
  state: createSlotlockDashboardState(sql),
476
720
  availability: config.availability,
721
+ clientAddress: (request) => limits.clientAddress(request),
477
722
  onError: (error) => log.error('dashboard_error', describeError(error)),
478
723
  });
479
- const target = dashboard
724
+ const target = limits.serve(dashboard
480
725
  ? {
481
726
  fetch: (request) => dashboard.handles(request) ? dashboard.fetch(request) : agentServer.fetch(request),
482
727
  shutdown: () => agentServer.shutdown(),
483
728
  }
484
- : agentServer;
485
- const listener = createSlotlockNodeServer(target, {
729
+ : agentServer);
730
+ const served = metrics && config.metricsToken !== undefined
731
+ ? serveMetrics(target, metrics, `${basePath}/metrics`, config.metricsToken)
732
+ : target;
733
+ const listener = createSlotlockNodeServer(served, {
486
734
  requestOrigin: publicUrl.origin,
487
735
  onError: (error, phase) => log.error('http_error', { phase, ...describeError(error) }),
488
736
  });
489
737
  const address = await listener.listen({ host: config.host, port: config.port });
490
738
  maintenance = startSlotlockMaintenance({
491
- store,
739
+ // One tenant at a time, and its long passes may wait on a request's locks for longer.
740
+ store: createSlotlockMaintenanceStore(sql, caps, retention),
492
741
  sql,
493
742
  tenants: [config.tenantRef],
494
743
  intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
495
744
  // Counts only, never the tenant.
496
- onPass: (result) => log.info('maintenance', { ...result }),
745
+ onPass: (result) => {
746
+ metrics?.maintenance(result);
747
+ log.info('maintenance', { ...result });
748
+ },
497
749
  onError: (error) => log.error('maintenance_failed', describeError(error)),
498
750
  });
751
+ if (config.webhooks !== 'off') {
752
+ const deliveryStore = createSlotlockStore(sql, retention);
753
+ const send = createSlotlockWebhookSender({ allowPrivateNetworks: config.webhooks === 'private' });
754
+ webhooks = startSlotlockWebhookDelivery({
755
+ // The tenants with a webhook, from the database: a restarted server finds them all.
756
+ tenants: async () => {
757
+ const tenants = [];
758
+ for (;;) {
759
+ const page = await sql `
760
+ SELECT slotlock.webhook_tenants(${tenants.at(-1) ?? null}::text) AS tenant`;
761
+ tenants.push(...page.map(({ tenant }) => tenant));
762
+ if (page.length < 1_000)
763
+ return tenants;
764
+ }
765
+ },
766
+ deliver: (tenantRef) => deliverSlotlockWebhooks({
767
+ tenantRef,
768
+ send,
769
+ feed: {
770
+ claimWebhookDeliveries: (params) => deliveryStore.withTenant(tenantRef, (tenant) => tenant.claimWebhookDeliveries(params)),
771
+ settleWebhookDelivery: (params) => deliveryStore.withTenant(tenantRef, (tenant) => tenant.settleWebhookDelivery(params)),
772
+ },
773
+ }),
774
+ // Counts only, never the tenant or a URL.
775
+ onPass: (_tenant, result) => {
776
+ metrics?.webhooks(result);
777
+ log.info('webhooks', { ...result });
778
+ },
779
+ onError: (error) => log.error('webhooks_failed', describeError(error)),
780
+ });
781
+ }
499
782
  log.info('listening', {
500
783
  address: address.origin,
501
784
  mcp: `${config.publicUrl}/mcp`,
@@ -506,13 +789,25 @@ export async function startSlotlockServer(config, log) {
506
789
  confirm_writes: config.confirmWrites,
507
790
  availability_rules: config.availability.length,
508
791
  rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
792
+ webhooks: config.webhooks,
793
+ shared_limits: config.sharedLimits,
794
+ tzdata: process.versions.tz ?? null,
509
795
  maintenance_interval_minutes: SERVE_MAINTENANCE_INTERVAL_MS / 60_000,
796
+ limits: {
797
+ ...Object.fromEntries(Object.entries(config.limits.rates).map(([name, rate]) => [name, formatSlotlockRate(rate)])),
798
+ ...caps,
799
+ // What a key may hold, which is below key_subscriptions when the tenant's cap is lower.
800
+ key_subscriptions: slotlockKeySubscriptions(caps),
801
+ },
802
+ client_address: config.trustedProxyHeader ?? 'socket',
510
803
  });
511
804
  return {
512
805
  address,
513
806
  async close() {
514
807
  const result = await listener.close();
808
+ limits.close();
515
809
  await maintenance?.close();
810
+ await webhooks?.close();
516
811
  await sql.end({ timeout: 5 });
517
812
  return result;
518
813
  },
@@ -571,8 +866,8 @@ function hoursLine(resource) {
571
866
  hours: resource.availabilityRules ?? null,
572
867
  })}\n`;
573
868
  }
574
- /** Every resource of the tenant, a page at a time. */
575
- async function tenantResources(store, tenantRef) {
869
+ /** Every resource of the tenant in use (with `includeArchived`, every one), a page at a time. */
870
+ async function tenantResources(store, tenantRef, includeArchived = false) {
576
871
  const all = [];
577
872
  let after;
578
873
  for (;;) {
@@ -580,6 +875,7 @@ async function tenantResources(store, tenantRef) {
580
875
  tenantRef,
581
876
  limit: RESOURCE_PAGE_SIZE,
582
877
  ...(after !== undefined ? { after } : {}),
878
+ ...(includeArchived ? { includeArchived } : {}),
583
879
  }));
584
880
  all.push(...page);
585
881
  after = page.at(-1)?.id;
@@ -626,6 +922,118 @@ async function runHours(config, out, target, rules) {
626
922
  await sql.end({ timeout: 5 });
627
923
  }
628
924
  }
925
+ /**
926
+ * `rules set|clear`: set or clear (`null`) the booking rules of the tenant's resource named by id,
927
+ * else by reference, and print it. Returns false when no resource has that name.
928
+ */
929
+ async function runRules(config, out, name, rules) {
930
+ const sql = connect(config.databaseUrl, 1);
931
+ try {
932
+ const store = createSlotlockStore(sql);
933
+ const tenantRef = config.tenantRef;
934
+ const resources = await tenantResources(store, tenantRef);
935
+ const named = resources.find((resource) => resource.id === name) ??
936
+ resources.find((resource) => resource.externalRef === name);
937
+ if (!named)
938
+ return false;
939
+ const updated = await store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id: named.id, rules }));
940
+ if (!updated)
941
+ return false;
942
+ out.write(`${JSON.stringify({
943
+ id: updated.id,
944
+ external_ref: updated.externalRef,
945
+ booking_rules: updated.bookingRules ?? null,
946
+ })}\n`);
947
+ return true;
948
+ }
949
+ finally {
950
+ await sql.end({ timeout: 5 });
951
+ }
952
+ }
953
+ /** A resource as `resource archive` and `resource restore` print it. */
954
+ function resourceStateLine(resource) {
955
+ return `${JSON.stringify({
956
+ id: resource.id,
957
+ external_ref: resource.externalRef,
958
+ timezone: resource.timezone,
959
+ visibility: resource.visibility ?? 'shared',
960
+ archived_at: resource.archivedAt?.toISOString() ?? null,
961
+ })}\n`;
962
+ }
963
+ /**
964
+ * `resource archive|restore|delete`: the tenant's resource named by id, else by reference, archived
965
+ * ones included. Returns false when no resource has that name, and `in_use` when a delete is
966
+ * refused because something on the resource has not ended.
967
+ */
968
+ async function changeResource(config, out, name, action) {
969
+ const sql = connect(config.databaseUrl, 1);
970
+ try {
971
+ const store = createSlotlockStore(sql);
972
+ const tenantRef = config.tenantRef;
973
+ const resources = await tenantResources(store, tenantRef, true);
974
+ // An id first: another resource's reference may spell it.
975
+ const named = resources.find((resource) => resource.id === name) ??
976
+ resources.find((resource) => resource.externalRef === name);
977
+ if (!named)
978
+ return false;
979
+ const id = named.id;
980
+ if (action === 'delete') {
981
+ const result = await store.withTenant(tenantRef, (tenant) => tenant.deleteResource({ tenantRef, id }));
982
+ if (!result.ok)
983
+ return 'in_use';
984
+ if (!result.deleted)
985
+ return false;
986
+ out.write(`${JSON.stringify({ id, external_ref: named.externalRef, deleted: true })}\n`);
987
+ return true;
988
+ }
989
+ const changed = await store.withTenant(tenantRef, (tenant) => action === 'archive'
990
+ ? tenant.archiveResource({ tenantRef, id })
991
+ : tenant.restoreResource({ tenantRef, id }));
992
+ if (!changed)
993
+ return false;
994
+ out.write(resourceStateLine(changed));
995
+ return true;
996
+ }
997
+ finally {
998
+ await sql.end({ timeout: 5 });
999
+ }
1000
+ }
1001
+ /**
1002
+ * `tenant status|suspend|unsuspend|erase` for SLOTLOCK_TENANT. Suspending and lifting a suspension
1003
+ * are the deployment role's alone, so they connect as SLOTLOCK_MIGRATE_DATABASE_URL when it is set;
1004
+ * the serving role reads a suspension and may erase.
1005
+ */
1006
+ async function runTenantCommand(config, out, action, reason) {
1007
+ const owner = action === 'suspend' || action === 'unsuspend';
1008
+ const sql = connect(owner ? (config.migrateDatabaseUrl ?? config.databaseUrl) : config.databaseUrl, 1);
1009
+ try {
1010
+ const store = createSlotlockStore(sql);
1011
+ const tenantRef = config.tenantRef;
1012
+ let line;
1013
+ if (action === 'status') {
1014
+ line = { tenant: tenantRef, suspended: await store.isTenantSuspended({ tenantRef }) };
1015
+ }
1016
+ else if (action === 'suspend') {
1017
+ const { suspended } = await store.suspendTenant({
1018
+ tenantRef,
1019
+ ...(reason !== undefined ? { reason } : {}),
1020
+ });
1021
+ line = { tenant: tenantRef, suspended: true, changed: suspended };
1022
+ }
1023
+ else if (action === 'unsuspend') {
1024
+ const { unsuspended } = await store.unsuspendTenant({ tenantRef });
1025
+ line = { tenant: tenantRef, suspended: false, changed: unsuspended };
1026
+ }
1027
+ else {
1028
+ const erased = await store.eraseTenant({ tenantRef });
1029
+ line = { tenant: tenantRef, total: erased.total, rows: erased.rows };
1030
+ }
1031
+ out.write(`${JSON.stringify(line)}\n`);
1032
+ }
1033
+ finally {
1034
+ await sql.end({ timeout: 5 });
1035
+ }
1036
+ }
629
1037
  /** One key as `key create`, `key list` and `key revoke` print it: never the digest. */
630
1038
  function apiKeyLine(apiKey, key) {
631
1039
  return `${JSON.stringify({
@@ -656,12 +1064,23 @@ function parseKeyScopes(value) {
656
1064
  * `maintain`: maintenance passes for the tenant until nothing is left, under the tenant's lock, so
657
1065
  * it never repeats what a server is doing. Null when another server holds the lock.
658
1066
  */
659
- async function maintainTenant(config) {
1067
+ async function maintainTenant(config, caps) {
660
1068
  const sql = connect(config.databaseUrl, 2);
661
1069
  try {
662
- const store = createSlotlockStore(sql);
1070
+ const store = createSlotlockMaintenanceStore(sql, caps, config.retentionDays !== undefined ? { retentionDays: config.retentionDays } : {});
663
1071
  const total = await withSlotlockMaintenanceLock(sql, config.tenantRef, async () => {
664
- const sum = { extended: 0, conflicts: 0, refused: 0, pruned: 0, capped: true, passes: 0 };
1072
+ const sum = {
1073
+ extended: 0,
1074
+ conflicts: 0,
1075
+ refused: 0,
1076
+ pruned: 0,
1077
+ zoneShifts: 0,
1078
+ staleSources: 0,
1079
+ noShows: 0,
1080
+ waitlistOffers: 0,
1081
+ capped: true,
1082
+ passes: 0,
1083
+ };
665
1084
  while (sum.capped && sum.passes < MAINTAIN_MAX_PASSES) {
666
1085
  const pass = await maintainSlotlockTenant(store, config.tenantRef);
667
1086
  sum.passes += 1;
@@ -669,9 +1088,15 @@ async function maintainTenant(config) {
669
1088
  sum.conflicts += pass.conflicts;
670
1089
  sum.refused += pass.refused;
671
1090
  sum.pruned += pass.pruned;
672
- // A capped pass that got nowhere met only series it cannot extend: another would too.
1091
+ sum.zoneShifts += pass.zoneShifts;
1092
+ sum.staleSources += pass.staleSources;
1093
+ sum.noShows += pass.noShows;
1094
+ sum.waitlistOffers += pass.waitlistOffers;
1095
+ // A capped pass that got nowhere met only series it cannot extend. While its roll stopped
1096
+ // before the series it had put back, the next pass meets new ones (and perhaps a healthy
1097
+ // one); once it reached them, another pass would meet them again.
673
1098
  sum.capped = pass.capped;
674
- if (pass.capped && pass.extended + pass.pruned === 0)
1099
+ if (pass.capped && pass.extended + pass.pruned === 0 && !pass.rollPending)
675
1100
  break;
676
1101
  }
677
1102
  return sum;
@@ -731,12 +1156,22 @@ Commands:
731
1156
  resource add <ref> [--timezone <IANA zone>]
732
1157
  Create a calendar resource (a car, room, person or machine)
733
1158
  resource list Print the tenant's resources, one JSON object per line
1159
+ resource archive <ref> Take a resource out of use: no new bookings, holds or events, and
1160
+ no longer listed; what is on it stays
1161
+ resource restore <ref> Put an archived resource back in use
1162
+ resource delete <ref> Delete a resource and its history; refused while a booking, hold
1163
+ or event on it has not ended
734
1164
  hours show [--resource <ref>]
735
1165
  Print each resource's bookable hours (null: the server's)
736
1166
  hours set <json> (--resource <ref> | --all)
737
1167
  Give one resource, or every one, weekly hours of its own; [] closes
738
1168
  hours clear (--resource <ref> | --all)
739
1169
  Hand one resource, or every one, back to the server's hours
1170
+ rules set <json> --resource <ref>
1171
+ Set what a booking on the resource must satisfy, like
1172
+ {"minNoticeMinutes":120,"slotStepMinutes":30,"turnaroundMinutes":30,"enforceHours":true}
1173
+ rules clear --resource <ref>
1174
+ Remove the resource's booking rules
740
1175
  key create <name> [--scope read,write,admin] [--expires-in-days <n>]
741
1176
  Create an API key for the tenant (default scope read,write, no
742
1177
  expiry; admin adds the application API) and print it once; only its
@@ -745,6 +1180,15 @@ Commands:
745
1180
  key rotate <id> Give an active key a new secret and print it once; the old one stops
746
1181
  working at once, and the key keeps its id and what it booked
747
1182
  key revoke <id> Revoke one of the tenant's API keys at once
1183
+ tenant status Print whether the tenant is suspended
1184
+ tenant suspend [--reason <text>]
1185
+ Suspend the tenant: it may read, and writes nothing; its keys get
1186
+ no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:
1187
+ only the schema owner suspends)
1188
+ tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)
1189
+ tenant erase --confirm <tenant>
1190
+ Delete everything the tenant has, its API keys included, for good;
1191
+ --confirm repeats SLOTLOCK_TENANT
748
1192
  healthcheck Exit 0 when the local server reports ready
749
1193
 
750
1194
  Options:
@@ -755,21 +1199,59 @@ Environment:
755
1199
  DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve
756
1200
  refuses a superuser or a role with BYPASSRLS
757
1201
  SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)
758
- SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with every scope,
759
- 32+ random characters (serve); API keys work with or without it
760
- SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve)
761
- SLOTLOCK_CONFIRM_WRITES all (default), none, or a comma-separated list of write tools
1202
+ SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write
1203
+ access (never admin), 32+ random characters (serve); API keys
1204
+ work with or without it
1205
+ SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;
1206
+ required when SLOTLOCK_CONFIRM_WRITES is not none)
1207
+ SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+
1208
+ random characters (serve; off without it)
1209
+ SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools
1210
+ that wait for a person (MCP 2026-07-28 form elicitation; refused
1211
+ with confirmation_required on REST, A2A and older MCP)
762
1212
  SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)
763
1213
  SLOTLOCK_TENANT Tenant of the token, resource and key commands (default "default")
764
1214
  SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)
765
1215
  SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and
766
1216
  the application API for admin keys, described at /openapi.json;
767
1217
  off: MCP and A2A only
1218
+ SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,
1219
+ and retired answers (never under 30) (1-3650, default 30;
1220
+ serve and maintain)
1221
+ SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the
1222
+ database, so N servers allow a tenant its rate once; off (default):
1223
+ each server keeps its own
1224
+ SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,
1225
+ never to a private network address; private: those too, for
1226
+ testing; off: keep the change feed, send nothing
1227
+ SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:
1228
+ rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,
1229
+ every request per client, before any key lookup), key_read,
1230
+ tenant_read (20/s:40), key_write, tenant_write (5/s:20),
1231
+ global_read (500/s:1000), global_write (50/s:100), app_read
1232
+ (20/s:40) and app_write (5/s:20) per tenant for the
1233
+ application API, sign_in (20/m:20); counts for
1234
+ tenant_series_writes and series_writes
1235
+ (1: writes that may expand a recurring series at once),
1236
+ maintenance_runs (1: application API maintenance runs at once),
1237
+ subscriptions (256), tenant_subscriptions (16),
1238
+ key_subscriptions (4, and fewer than tenant_subscriptions),
1239
+ key_lookups (2), key_lookup_queue (256),
1240
+ tenant_transactions (2: one tenant's database transactions at
1241
+ once, of the pool's 10); what one tenant may store:
1242
+ tenant_holds (10000), tenant_reservations, tenant_events and
1243
+ tenant_coverage (100000), holder_holds (20) and
1244
+ holder_held_hours (2160), which maintain keeps too
1245
+ SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,
1246
+ such as CF-Connecting-IP (default: the connection's address);
1247
+ set it only when every request comes through that proxy
768
1248
  SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
769
1249
  of a GitHub OAuth app with callback .../dashboard/callback
770
1250
  SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
771
1251
  SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)
772
1252
  SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)
1253
+ SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant
1254
+ SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)
773
1255
  HOST Listen address (default 127.0.0.1)
774
1256
  PORT Listen port (default 8080)
775
1257
 
@@ -806,7 +1288,11 @@ export async function runSlotlockCli(argv, io) {
806
1288
  io.stderr.write(SLOTLOCK_CLI_HELP);
807
1289
  return 2;
808
1290
  }
809
- const subcommand = command === 'resource' || command === 'key' || command === 'hours'
1291
+ const subcommand = command === 'resource' ||
1292
+ command === 'key' ||
1293
+ command === 'hours' ||
1294
+ command === 'rules' ||
1295
+ command === 'tenant'
810
1296
  ? `${command} ${operands[0] ?? ''}`.trim()
811
1297
  : command;
812
1298
  const expectedOperands = {
@@ -816,6 +1302,13 @@ export async function runSlotlockCli(argv, io) {
816
1302
  healthcheck: 0,
817
1303
  'resource add': 2,
818
1304
  'resource list': 1,
1305
+ 'resource archive': 2,
1306
+ 'resource restore': 2,
1307
+ 'resource delete': 2,
1308
+ 'tenant status': 1,
1309
+ 'tenant suspend': 1,
1310
+ 'tenant unsuspend': 1,
1311
+ 'tenant erase': 1,
819
1312
  'key create': 2,
820
1313
  'key list': 1,
821
1314
  'key rotate': 2,
@@ -823,6 +1316,8 @@ export async function runSlotlockCli(argv, io) {
823
1316
  'hours show': 1,
824
1317
  'hours set': 2,
825
1318
  'hours clear': 1,
1319
+ 'rules set': 2,
1320
+ 'rules clear': 1,
826
1321
  };
827
1322
  const expected = expectedOperands[subcommand];
828
1323
  if (expected === undefined)
@@ -839,11 +1334,36 @@ export async function runSlotlockCli(argv, io) {
839
1334
  return usage(`--${option} applies only to key create`);
840
1335
  }
841
1336
  }
1337
+ if (values.reason !== undefined && subcommand !== 'tenant suspend') {
1338
+ return usage('--reason applies only to tenant suspend');
1339
+ }
1340
+ if (values.confirm !== undefined && subcommand !== 'tenant erase') {
1341
+ return usage('--confirm applies only to tenant erase');
1342
+ }
1343
+ if (subcommand === 'tenant erase' && values.confirm === undefined) {
1344
+ return usage('tenant erase needs --confirm <tenant>, the tenant it erases (SLOTLOCK_TENANT)');
1345
+ }
842
1346
  if (values.all && subcommand !== 'hours set' && subcommand !== 'hours clear') {
843
1347
  return usage('--all applies only to hours set and hours clear');
844
1348
  }
845
- if (values.resource !== undefined && !subcommand.startsWith('hours ')) {
846
- return usage('--resource applies only to hours commands');
1349
+ if (values.resource !== undefined &&
1350
+ !subcommand.startsWith('hours ') &&
1351
+ !subcommand.startsWith('rules ')) {
1352
+ return usage('--resource applies only to hours and rules commands');
1353
+ }
1354
+ if (subcommand.startsWith('rules ') && values.resource === undefined) {
1355
+ return usage(`${subcommand} needs --resource <ref>`);
1356
+ }
1357
+ let bookingRules = null;
1358
+ if (subcommand === 'rules set') {
1359
+ try {
1360
+ bookingRules = parseBookingRules(JSON.parse(operands[1]));
1361
+ }
1362
+ catch (error) {
1363
+ return usage(error instanceof SyntaxError
1364
+ ? 'rules must be a JSON object like {"minNoticeMinutes":120,"slotStepMinutes":30}'
1365
+ : error.message);
1366
+ }
847
1367
  }
848
1368
  if ((subcommand === 'hours set' || subcommand === 'hours clear') &&
849
1369
  (values.resource === undefined) === (values.all !== true)) {
@@ -896,7 +1416,7 @@ export async function runSlotlockCli(argv, io) {
896
1416
  return 0;
897
1417
  }
898
1418
  case 'maintain': {
899
- const result = await maintainTenant(readSlotlockDatabaseConfig(io.env));
1419
+ const result = await maintainTenant(readSlotlockDatabaseConfig(io.env), readLimits(io.env).caps);
900
1420
  if (!result) {
901
1421
  io.stderr.write('slotlock: another server is maintaining this tenant now; nothing to do\n');
902
1422
  return 0;
@@ -913,6 +1433,45 @@ export async function runSlotlockCli(argv, io) {
913
1433
  case 'resource list':
914
1434
  await listResources(readSlotlockDatabaseConfig(io.env), io.stdout);
915
1435
  return 0;
1436
+ case 'resource archive':
1437
+ case 'resource restore':
1438
+ case 'resource delete': {
1439
+ const changed = await changeResource(readSlotlockDatabaseConfig(io.env), io.stdout, operands[1], subcommand.slice('resource '.length));
1440
+ if (changed === 'in_use') {
1441
+ io.stderr.write('slotlock: a booking, hold or calendar event on that resource has not ended; cancel or move it first, or archive the resource\n');
1442
+ return 1;
1443
+ }
1444
+ if (!changed) {
1445
+ io.stderr.write('slotlock: no resource with that reference or id in this tenant\n');
1446
+ return 1;
1447
+ }
1448
+ return 0;
1449
+ }
1450
+ case 'tenant status':
1451
+ case 'tenant suspend':
1452
+ case 'tenant unsuspend':
1453
+ case 'tenant erase': {
1454
+ const config = readSlotlockDatabaseConfig(io.env);
1455
+ // The tenant named twice, so erasing the one SLOTLOCK_TENANT happens to name is deliberate.
1456
+ if (subcommand === 'tenant erase' && values.confirm !== config.tenantRef) {
1457
+ return usage('--confirm must repeat SLOTLOCK_TENANT, the tenant tenant erase deletes');
1458
+ }
1459
+ try {
1460
+ await runTenantCommand(config, io.stdout, subcommand.slice('tenant '.length), values.reason);
1461
+ }
1462
+ catch (error) {
1463
+ const code = error?.code;
1464
+ if (code === 'tenant_suspension_not_permitted') {
1465
+ io.stderr.write('slotlock: only the schema owner suspends a tenant or lifts a suspension; set SLOTLOCK_MIGRATE_DATABASE_URL to its URL\n');
1466
+ return 1;
1467
+ }
1468
+ if (code === 'invalid_suspension_reason') {
1469
+ return usage('--reason must be 1-500 characters without control characters');
1470
+ }
1471
+ throw error;
1472
+ }
1473
+ return 0;
1474
+ }
916
1475
  case 'hours show':
917
1476
  case 'hours set':
918
1477
  case 'hours clear': {
@@ -928,6 +1487,15 @@ export async function runSlotlockCli(argv, io) {
928
1487
  }
929
1488
  return 0;
930
1489
  }
1490
+ case 'rules set':
1491
+ case 'rules clear': {
1492
+ const found = await runRules(readSlotlockDatabaseConfig(io.env), io.stdout, values.resource, bookingRules);
1493
+ if (!found) {
1494
+ io.stderr.write('slotlock: no resource with that reference or id in this tenant\n');
1495
+ return 1;
1496
+ }
1497
+ return 0;
1498
+ }
931
1499
  case 'key create': {
932
1500
  const config = readSlotlockDatabaseConfig(io.env);
933
1501
  const created = await withApiKeys(config, (keys) => keys.create({
@@ -996,6 +1564,8 @@ function parseCliArguments(argv) {
996
1564
  'expires-in-days': { type: 'string' },
997
1565
  resource: { type: 'string' },
998
1566
  all: { type: 'boolean' },
1567
+ reason: { type: 'string' },
1568
+ confirm: { type: 'string' },
999
1569
  help: { type: 'boolean', short: 'h' },
1000
1570
  version: { type: 'boolean', short: 'v' },
1001
1571
  },