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/self-host.js CHANGED
@@ -7,14 +7,17 @@ import { createHash, timingSafeEqual } from 'node:crypto';
7
7
  import { parseArgs } from 'node:util';
8
8
  import postgres from 'postgres';
9
9
  import { createSlotlockAgentServer, slotlockAgentOperationScope, } from './agent-server.js';
10
- import { createSlotlockStoreAgentBackend } from './agent-store-backend.js';
11
- import { SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
10
+ import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, } from './agent-store-backend.js';
11
+ import { SLOTLOCK_API_KEY_DEFAULT_SCOPES, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
12
12
  import { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, } from './dashboard.js';
13
- import { SECRET_HINT, SlotlockConfigError, optional, readAvailability, readDashboardConfig, readSecret, readServerUrl, } from './config.js';
14
- import { SLOTLOCK_DASHBOARD_FUNCTIONS } from './ddl.js';
13
+ import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, slotlockKeySubscriptions, } from './config.js';
14
+ import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
15
+ import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_TENANT_TABLES } from './ddl.js';
16
+ import { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
15
17
  import { createSlotlockNodeServer, } from './node-server.js';
16
18
  import { slotlockPackageVersion } from './package-version.js';
17
- import { createSlotlockStore } from './store.js';
19
+ import { createSlotlockRequestLimits } from './rate-limit.js';
20
+ import { assertSlotlockSchemaCurrent, createSlotlockStore, } from './store.js';
18
21
  import { parseAvailabilityRules } from './weekly-hours.js';
19
22
  export { SlotlockConfigError };
20
23
  /** Every variable the commands read. The README, `.env.example` and `server.json` document these. */
@@ -28,6 +31,8 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
28
31
  'SLOTLOCK_TENANT',
29
32
  'SLOTLOCK_AVAILABILITY',
30
33
  'SLOTLOCK_REST_API',
34
+ 'SLOTLOCK_LIMITS',
35
+ 'SLOTLOCK_TRUSTED_PROXY_HEADER',
31
36
  'SLOTLOCK_GITHUB_CLIENT_ID',
32
37
  'SLOTLOCK_GITHUB_CLIENT_SECRET',
33
38
  'SLOTLOCK_SESSION_SECRET',
@@ -38,11 +43,20 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
38
43
  const DEFAULT_HOST = '127.0.0.1';
39
44
  const DEFAULT_PORT = 8080;
40
45
  const DEFAULT_TENANT = 'default';
41
- /** The principal the server token resolves to: it acts in SLOTLOCK_TENANT with every scope. */
46
+ /** The principal the server token resolves to: it acts in SLOTLOCK_TENANT with read and write. */
42
47
  const SELF_HOST_SUBJECT = 'self-host';
43
48
  /** Who `key create` records as a key's creator. */
44
49
  const CLI_KEY_CREATOR = 'cli';
45
50
  const DAY_MS = 86_400_000;
51
+ /**
52
+ * The most resources `serve` lets one tenant create through the application API. A dashboard open to
53
+ * every GitHub user gives each a tenant, so the cap keeps one from filling the database.
54
+ */
55
+ const SERVE_MAX_TENANT_RESOURCES = 10_000;
56
+ /** How often `serve` maintains each tenant it serves (README, Operations: at least daily). */
57
+ const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
58
+ /** Passes `slotlock maintain` runs at most, each as big as `serve`'s, until nothing is left. */
59
+ const MAINTAIN_MAX_PASSES = 100;
46
60
  const WRITE_OPERATIONS = [
47
61
  'slotlock_create_event',
48
62
  'slotlock_update_event',
@@ -63,8 +77,13 @@ const SERVED_OPERATIONS = new Set([
63
77
  'slotlock_delete_event',
64
78
  ]);
65
79
  const HEALTH_QUERY_TIMEOUT_MS = 2_000;
80
+ /** How long one database check answers /healthz, so a flood of health checks is not one of queries. */
81
+ const HEALTH_RESULT_MS = 1_000;
66
82
  const HEALTHCHECK_TIMEOUT_MS = 5_000;
83
+ /** One pool for every tenant; SLOTLOCK_LIMITS `tenant_transactions` (config.ts) goes up to it. */
67
84
  const POOL_SIZE = 10;
85
+ /** How long a request's transaction waits for any one lock before it answers 503 `server_busy`. */
86
+ const SERVE_LOCK_TIMEOUT_MS = 5_000;
68
87
  const RESOURCE_PAGE_SIZE = 1_000;
69
88
  function readDatabaseUrl(env, name) {
70
89
  const value = optional(env, name);
@@ -110,7 +129,7 @@ function readTenant(env) {
110
129
  return value;
111
130
  }
112
131
  function readConfirmWrites(env) {
113
- const value = optional(env, 'SLOTLOCK_CONFIRM_WRITES') ?? 'all';
132
+ const value = optional(env, 'SLOTLOCK_CONFIRM_WRITES') ?? 'none';
114
133
  if (value === 'all')
115
134
  return [...WRITE_OPERATIONS];
116
135
  if (value === 'none')
@@ -138,7 +157,7 @@ export function readSlotlockDatabaseConfig(env) {
138
157
  return config;
139
158
  }
140
159
  function readRestApi(env) {
141
- const value = optional(env, 'SLOTLOCK_REST_API') ?? 'off';
160
+ const value = optional(env, 'SLOTLOCK_REST_API') ?? 'on';
142
161
  if (value !== 'on' && value !== 'off') {
143
162
  throw new SlotlockConfigError('SLOTLOCK_REST_API must be on or off');
144
163
  }
@@ -150,7 +169,7 @@ export function readSlotlockServeConfig(env) {
150
169
  const confirmWrites = readConfirmWrites(env);
151
170
  const confirmationSecret = readSecret(env, 'SLOTLOCK_CONFIRMATION_SECRET');
152
171
  if (confirmWrites.length > 0 && confirmationSecret === undefined) {
153
- 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`);
172
+ 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`);
154
173
  }
155
174
  if (confirmationSecret !== undefined &&
156
175
  authToken !== undefined &&
@@ -166,7 +185,11 @@ export function readSlotlockServeConfig(env) {
166
185
  confirmWrites,
167
186
  availability: readAvailability(env),
168
187
  restApi: readRestApi(env),
188
+ limits: readLimits(env),
169
189
  };
190
+ const trustedProxyHeader = readTrustedProxyHeader(env);
191
+ if (trustedProxyHeader !== undefined)
192
+ config.trustedProxyHeader = trustedProxyHeader;
170
193
  if (authToken !== undefined)
171
194
  config.authToken = authToken;
172
195
  const dashboard = readDashboardConfig(env);
@@ -208,8 +231,8 @@ export function createSlotlockTokenAuthenticator(token, principal) {
208
231
  }
209
232
  /**
210
233
  * What `serve` lets a principal call: a served operation its scopes cover. The server token's
211
- * principal carries every scope; a principal without scopes is refused everything here, though the
212
- * agent server alone would leave it to this function.
234
+ * principal carries read and write, never admin; a principal without scopes is refused everything
235
+ * here, though the agent server alone would leave it to this function.
213
236
  */
214
237
  export function authorizeSlotlockServeOperation(args) {
215
238
  return (SERVED_OPERATIONS.has(args.operation) &&
@@ -245,7 +268,10 @@ function secretsIn(env) {
245
268
  // Longest first, so a URL is replaced whole rather than around its password.
246
269
  return [...new Set(secrets)].sort((a, b) => b.length - a.length);
247
270
  }
248
- /** One JSON object per line: info on stdout, errors on stderr, secrets replaced by `[redacted]`. */
271
+ /**
272
+ * One JSON object per line: info on stdout, warnings and errors on stderr, secrets replaced by
273
+ * `[redacted]`.
274
+ */
249
275
  function createLogger(io) {
250
276
  const secrets = secretsIn(io.env);
251
277
  const write = (stream, level, event, fields = {}) => {
@@ -256,6 +282,7 @@ function createLogger(io) {
256
282
  };
257
283
  return {
258
284
  info: (event, fields) => write(io.stdout, 'info', event, fields),
285
+ warn: (event, fields) => write(io.stderr, 'warn', event, fields),
259
286
  error: (event, fields) => write(io.stderr, 'error', event, fields),
260
287
  };
261
288
  }
@@ -272,6 +299,13 @@ function describeError(error) {
272
299
  }
273
300
  return detail;
274
301
  }
302
+ /** An agent server event's fields under the log's snake_case keys (`durationMs` → `duration_ms`). */
303
+ function snakeCaseKeys(fields) {
304
+ return Object.fromEntries(Object.entries(fields).map(([key, value]) => [
305
+ key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`),
306
+ value,
307
+ ]));
308
+ }
275
309
  function connect(url, max) {
276
310
  return postgres(url, { max, onnotice: () => { }, connect_timeout: 10 });
277
311
  }
@@ -340,6 +374,26 @@ function dashboardAllows(config, tenantRef) {
340
374
  const githubId = /^github:(\d+)$/.exec(tenantRef)?.[1];
341
375
  return githubId === undefined || users.includes(githubId);
342
376
  }
377
+ function serveTenantLimits(caps) {
378
+ return {
379
+ resources: SERVE_MAX_TENANT_RESOURCES,
380
+ holds: caps.tenant_holds,
381
+ reservations: caps.tenant_reservations,
382
+ events: caps.tenant_events,
383
+ coverage: caps.tenant_coverage,
384
+ };
385
+ }
386
+ /**
387
+ * The store `serve` answers requests from. Every surface writes through it, so its tenant limits
388
+ * bound what any key can add, and its transaction limits what one tenant holds of the shared pool.
389
+ */
390
+ export function createSlotlockServeStore(sql, caps) {
391
+ return createSlotlockStore(sql, {
392
+ tenantLimits: serveTenantLimits(caps),
393
+ maxTenantTransactions: caps.tenant_transactions,
394
+ tenantLockTimeoutMs: SERVE_LOCK_TIMEOUT_MS,
395
+ });
396
+ }
343
397
  /** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
344
398
  export async function startSlotlockServer(config, log) {
345
399
  const sql = connect(config.databaseUrl, POOL_SIZE);
@@ -358,6 +412,23 @@ export async function startSlotlockServer(config, log) {
358
412
  if (!schema?.present) {
359
413
  throw Object.assign(new Error('The slotlock schema is missing: run `slotlock migrate` or `slotlock serve --migrate`'), { code: 'schema_missing' });
360
414
  }
415
+ // Migrated by another release: an older schema needs this release's migrate first, and a newer
416
+ // one has tables and rules this release's code does not know.
417
+ await assertSlotlockSchemaCurrent(sql);
418
+ // A deployment run statement by statement lifts forced row-level security for its data steps
419
+ // and, stopped part way, leaves it lifted until the next one puts it back. The owning role then
420
+ // reads every tenant's rows: refuse to serve until then.
421
+ const lifted = await sql `
422
+ SELECT rel.relname AS name
423
+ FROM pg_catalog.pg_class rel
424
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
425
+ WHERE ns.nspname = 'slotlock'
426
+ AND rel.relname IN ${sql(SLOTLOCK_TENANT_TABLES)}
427
+ AND rel.relrowsecurity AND NOT rel.relforcerowsecurity
428
+ ORDER BY rel.relname`;
429
+ if (lifted.length > 0) {
430
+ throw Object.assign(new Error(`Row-level security is enabled but not forced on ${lifted.map(({ name }) => `slotlock.${name}`).join(', ')}: a deployment stopped part way. Run \`slotlock migrate\` (or \`slotlock serve --migrate\`) to put it back`), { code: 'row_security_lifted' });
431
+ }
361
432
  if (config.dashboard) {
362
433
  for (const signature of SLOTLOCK_DASHBOARD_FUNCTIONS) {
363
434
  const [granted] = await sql `
@@ -368,16 +439,47 @@ export async function startSlotlockServer(config, log) {
368
439
  }
369
440
  }
370
441
  }
371
- const store = createSlotlockStore(sql);
442
+ const { caps } = config.limits;
443
+ const store = createSlotlockServeStore(sql, caps);
372
444
  const publicUrl = new URL(config.publicUrl);
373
- const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql));
445
+ const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql), {
446
+ maxConcurrentLookups: caps.key_lookups,
447
+ maxWaitingLookups: caps.key_lookup_queue,
448
+ });
374
449
  const authenticateToken = config.authToken === undefined
375
450
  ? undefined
376
- : createSlotlockTokenAuthenticator(config.authToken, {
377
- subject: SELF_HOST_SUBJECT,
378
- tenantRef: config.tenantRef,
379
- scopes: SLOTLOCK_API_KEY_SCOPES,
380
- });
451
+ : // Read and write, never admin: this is the token the docs put in agents' configuration,
452
+ // and the application API's writes ask no person. An admin key is made for the purpose.
453
+ createSlotlockTokenAuthenticator(config.authToken, {
454
+ subject: SELF_HOST_SUBJECT,
455
+ tenantRef: config.tenantRef,
456
+ scopes: SLOTLOCK_API_KEY_DEFAULT_SCOPES,
457
+ });
458
+ // Set once the listener is up; the authenticator tells it which tenants this server serves.
459
+ let maintenance;
460
+ const basePath = publicUrl.pathname.replace(/\/$/, '');
461
+ const limits = createSlotlockRequestLimits({
462
+ limits: config.limits,
463
+ ...(config.trustedProxyHeader !== undefined
464
+ ? { trustedProxyHeader: config.trustedProxyHeader }
465
+ : {}),
466
+ basePath,
467
+ signInPaths: config.dashboard ? ['/dashboard/sign-in', '/dashboard/callback'] : [],
468
+ // The token is compared in constant time first; only then is a key looked up. A person the
469
+ // dashboard no longer allows loses their keys with their session.
470
+ authenticate: async (request) => {
471
+ const principal = (await authenticateToken?.(request)) ?? (await authenticateKey(request));
472
+ if (!principal || !dashboardAllows(config, principal.tenantRef))
473
+ return null;
474
+ maintenance?.touch(principal.tenantRef);
475
+ return principal;
476
+ },
477
+ // Counts and pseudonyms only: never an address, a credential or a tenant.
478
+ onRefusals: (counts) => log.info('refusals', { ...counts }),
479
+ // Behind a proxy without the setting, every client shares the proxy's address budget.
480
+ onIgnoredClientField: (field) => log.warn('client_address_field_ignored', { field, setting: 'SLOTLOCK_TRUSTED_PROXY_HEADER' }),
481
+ });
482
+ let health;
381
483
  const agentServer = createSlotlockAgentServer({
382
484
  publicBaseUrl: config.publicUrl,
383
485
  // readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
@@ -385,22 +487,51 @@ export async function startSlotlockServer(config, log) {
385
487
  backend: createSlotlockStoreAgentBackend(store, {
386
488
  availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
387
489
  }),
388
- // The token is compared in constant time first; only then is a key looked up. A person the
389
- // dashboard no longer allows loses their keys with their session.
390
- authenticate: async (request) => {
391
- const principal = (await authenticateToken?.(request)) ?? (await authenticateKey(request));
392
- return principal && dashboardAllows(config, principal.tenantRef) ? principal : null;
490
+ authenticate: limits.authenticate,
491
+ consumeRateLimit: limits.consumeRateLimit,
492
+ // A write that may expand a recurring series also waits for one of a few slots, so one
493
+ // tenant's expansions cannot hold the event loop from every other tenant's requests.
494
+ authorize: async ({ principal, operation, input }) => {
495
+ if (!authorizeSlotlockServeOperation({ principal, operation }))
496
+ return false;
497
+ await limits.admit({ principal, operation, input });
498
+ return true;
499
+ },
500
+ subscriptions: {
501
+ maxTotal: caps.subscriptions,
502
+ maxPerTenant: caps.tenant_subscriptions,
503
+ maxPerPrincipal: slotlockKeySubscriptions(caps),
504
+ },
505
+ health: async () => {
506
+ if (!health || performance.now() - health.at > HEALTH_RESULT_MS) {
507
+ health = { at: performance.now(), ready: databaseReachable(sql) };
508
+ }
509
+ return (await health.ready)
510
+ ? { ready: true, checks: ['database'] }
511
+ : { ready: false, checks: ['database_unreachable'] };
393
512
  },
394
- authorize: async ({ principal, operation }) => authorizeSlotlockServeOperation({ principal, operation }),
395
- health: async () => (await databaseReachable(sql))
396
- ? { ready: true, checks: ['database'] }
397
- : { ready: false, checks: ['database_unreachable'] },
398
513
  // Outcomes and counts only: the server never reports arguments or identities.
399
- onEvent: ({ type, ...fields }) => log.info(type, fields),
514
+ onEvent: ({ type, ...fields }) => log.info(type, snakeCaseKeys(fields)),
400
515
  // What an agent sees only as internal_error: the operation and the error's message and code,
401
516
  // never the arguments or the request, with every configured secret redacted.
402
517
  onError: (error, { operation }) => log.error('agent_error', { operation, ...describeError(error) }),
403
- ...(config.restApi ? { rest: true } : {}),
518
+ ...(config.restApi
519
+ ? {
520
+ rest: true,
521
+ // Resources, hours, holds, reservations, coverage and maintenance: admin keys only.
522
+ application: {
523
+ backend: createSlotlockStoreApplicationBackend(store, {
524
+ maxTenantResources: SERVE_MAX_TENANT_RESOURCES,
525
+ }),
526
+ authorize: async ({ principal, operation, input }) => {
527
+ if (principal.scopes?.includes(SLOTLOCK_APPLICATION_SCOPE) !== true)
528
+ return false;
529
+ await limits.admit({ principal, operation, input });
530
+ return true;
531
+ },
532
+ },
533
+ }
534
+ : {}),
404
535
  ...(config.confirmationSecret !== undefined
405
536
  ? {
406
537
  confirmation: {
@@ -424,19 +555,30 @@ export async function startSlotlockServer(config, log) {
424
555
  resources: createSlotlockDashboardResources(store),
425
556
  state: createSlotlockDashboardState(sql),
426
557
  availability: config.availability,
558
+ clientAddress: (request) => limits.clientAddress(request),
427
559
  onError: (error) => log.error('dashboard_error', describeError(error)),
428
560
  });
429
- const target = dashboard
561
+ const target = limits.serve(dashboard
430
562
  ? {
431
563
  fetch: (request) => dashboard.handles(request) ? dashboard.fetch(request) : agentServer.fetch(request),
432
564
  shutdown: () => agentServer.shutdown(),
433
565
  }
434
- : agentServer;
566
+ : agentServer);
435
567
  const listener = createSlotlockNodeServer(target, {
436
568
  requestOrigin: publicUrl.origin,
437
569
  onError: (error, phase) => log.error('http_error', { phase, ...describeError(error) }),
438
570
  });
439
571
  const address = await listener.listen({ host: config.host, port: config.port });
572
+ maintenance = startSlotlockMaintenance({
573
+ // One tenant at a time, and its long passes may wait on a request's locks for longer.
574
+ store: createSlotlockStore(sql, { tenantLimits: serveTenantLimits(caps) }),
575
+ sql,
576
+ tenants: [config.tenantRef],
577
+ intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
578
+ // Counts only, never the tenant.
579
+ onPass: (result) => log.info('maintenance', { ...result }),
580
+ onError: (error) => log.error('maintenance_failed', describeError(error)),
581
+ });
440
582
  log.info('listening', {
441
583
  address: address.origin,
442
584
  mcp: `${config.publicUrl}/mcp`,
@@ -446,11 +588,22 @@ export async function startSlotlockServer(config, log) {
446
588
  dashboard: dashboard ? `${config.publicUrl}/dashboard` : null,
447
589
  confirm_writes: config.confirmWrites,
448
590
  availability_rules: config.availability.length,
591
+ rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
592
+ maintenance_interval_minutes: SERVE_MAINTENANCE_INTERVAL_MS / 60_000,
593
+ limits: {
594
+ ...Object.fromEntries(Object.entries(config.limits.rates).map(([name, rate]) => [name, formatSlotlockRate(rate)])),
595
+ ...caps,
596
+ // What a key may hold, which is below key_subscriptions when the tenant's cap is lower.
597
+ key_subscriptions: slotlockKeySubscriptions(caps),
598
+ },
599
+ client_address: config.trustedProxyHeader ?? 'socket',
449
600
  });
450
601
  return {
451
602
  address,
452
603
  async close() {
453
604
  const result = await listener.close();
605
+ limits.close();
606
+ await maintenance?.close();
454
607
  await sql.end({ timeout: 5 });
455
608
  return result;
456
609
  },
@@ -509,8 +662,8 @@ function hoursLine(resource) {
509
662
  hours: resource.availabilityRules ?? null,
510
663
  })}\n`;
511
664
  }
512
- /** Every resource of the tenant, a page at a time. */
513
- async function tenantResources(store, tenantRef) {
665
+ /** Every resource of the tenant in use (with `includeArchived`, every one), a page at a time. */
666
+ async function tenantResources(store, tenantRef, includeArchived = false) {
514
667
  const all = [];
515
668
  let after;
516
669
  for (;;) {
@@ -518,6 +671,7 @@ async function tenantResources(store, tenantRef) {
518
671
  tenantRef,
519
672
  limit: RESOURCE_PAGE_SIZE,
520
673
  ...(after !== undefined ? { after } : {}),
674
+ ...(includeArchived ? { includeArchived } : {}),
521
675
  }));
522
676
  all.push(...page);
523
677
  after = page.at(-1)?.id;
@@ -564,6 +718,90 @@ async function runHours(config, out, target, rules) {
564
718
  await sql.end({ timeout: 5 });
565
719
  }
566
720
  }
721
+ /** A resource as `resource archive` and `resource restore` print it. */
722
+ function resourceStateLine(resource) {
723
+ return `${JSON.stringify({
724
+ id: resource.id,
725
+ external_ref: resource.externalRef,
726
+ timezone: resource.timezone,
727
+ visibility: resource.visibility ?? 'shared',
728
+ archived_at: resource.archivedAt?.toISOString() ?? null,
729
+ })}\n`;
730
+ }
731
+ /**
732
+ * `resource archive|restore|delete`: the tenant's resource named by id, else by reference, archived
733
+ * ones included. Returns false when no resource has that name, and `in_use` when a delete is
734
+ * refused because something on the resource has not ended.
735
+ */
736
+ async function changeResource(config, out, name, action) {
737
+ const sql = connect(config.databaseUrl, 1);
738
+ try {
739
+ const store = createSlotlockStore(sql);
740
+ const tenantRef = config.tenantRef;
741
+ const resources = await tenantResources(store, tenantRef, true);
742
+ // An id first: another resource's reference may spell it.
743
+ const named = resources.find((resource) => resource.id === name) ??
744
+ resources.find((resource) => resource.externalRef === name);
745
+ if (!named)
746
+ return false;
747
+ const id = named.id;
748
+ if (action === 'delete') {
749
+ const result = await store.withTenant(tenantRef, (tenant) => tenant.deleteResource({ tenantRef, id }));
750
+ if (!result.ok)
751
+ return 'in_use';
752
+ if (!result.deleted)
753
+ return false;
754
+ out.write(`${JSON.stringify({ id, external_ref: named.externalRef, deleted: true })}\n`);
755
+ return true;
756
+ }
757
+ const changed = await store.withTenant(tenantRef, (tenant) => action === 'archive'
758
+ ? tenant.archiveResource({ tenantRef, id })
759
+ : tenant.restoreResource({ tenantRef, id }));
760
+ if (!changed)
761
+ return false;
762
+ out.write(resourceStateLine(changed));
763
+ return true;
764
+ }
765
+ finally {
766
+ await sql.end({ timeout: 5 });
767
+ }
768
+ }
769
+ /**
770
+ * `tenant status|suspend|unsuspend|erase` for SLOTLOCK_TENANT. Suspending and lifting a suspension
771
+ * are the deployment role's alone, so they connect as SLOTLOCK_MIGRATE_DATABASE_URL when it is set;
772
+ * the serving role reads a suspension and may erase.
773
+ */
774
+ async function runTenantCommand(config, out, action, reason) {
775
+ const owner = action === 'suspend' || action === 'unsuspend';
776
+ const sql = connect(owner ? (config.migrateDatabaseUrl ?? config.databaseUrl) : config.databaseUrl, 1);
777
+ try {
778
+ const store = createSlotlockStore(sql);
779
+ const tenantRef = config.tenantRef;
780
+ let line;
781
+ if (action === 'status') {
782
+ line = { tenant: tenantRef, suspended: await store.isTenantSuspended({ tenantRef }) };
783
+ }
784
+ else if (action === 'suspend') {
785
+ const { suspended } = await store.suspendTenant({
786
+ tenantRef,
787
+ ...(reason !== undefined ? { reason } : {}),
788
+ });
789
+ line = { tenant: tenantRef, suspended: true, changed: suspended };
790
+ }
791
+ else if (action === 'unsuspend') {
792
+ const { unsuspended } = await store.unsuspendTenant({ tenantRef });
793
+ line = { tenant: tenantRef, suspended: false, changed: unsuspended };
794
+ }
795
+ else {
796
+ const erased = await store.eraseTenant({ tenantRef });
797
+ line = { tenant: tenantRef, total: erased.total, rows: erased.rows };
798
+ }
799
+ out.write(`${JSON.stringify(line)}\n`);
800
+ }
801
+ finally {
802
+ await sql.end({ timeout: 5 });
803
+ }
804
+ }
567
805
  /** One key as `key create`, `key list` and `key revoke` print it: never the digest. */
568
806
  function apiKeyLine(apiKey, key) {
569
807
  return `${JSON.stringify({
@@ -579,17 +817,49 @@ function apiKeyLine(apiKey, key) {
579
817
  ...(key !== undefined ? { key } : {}),
580
818
  })}\n`;
581
819
  }
582
- /** `--scope`: read, write, or both comma-separated. */
820
+ /** `--scope`: read, write and admin, comma-separated; read,write unless given. */
583
821
  function parseKeyScopes(value) {
584
822
  if (value === undefined)
585
- return [...SLOTLOCK_API_KEY_SCOPES];
823
+ return [...SLOTLOCK_API_KEY_DEFAULT_SCOPES];
586
824
  const names = value.split(',').map((name) => name.trim());
587
825
  const scopes = SLOTLOCK_API_KEY_SCOPES.filter((scope) => names.includes(scope));
588
826
  if (names.length === 0 || names.some((name) => !scopes.includes(name))) {
589
- throw new Error('--scope must be read, write or read,write');
827
+ throw new Error('--scope must be a comma-separated list of read, write and admin');
590
828
  }
591
829
  return scopes;
592
830
  }
831
+ /**
832
+ * `maintain`: maintenance passes for the tenant until nothing is left, under the tenant's lock, so
833
+ * it never repeats what a server is doing. Null when another server holds the lock.
834
+ */
835
+ async function maintainTenant(config) {
836
+ const sql = connect(config.databaseUrl, 2);
837
+ try {
838
+ const store = createSlotlockStore(sql);
839
+ const total = await withSlotlockMaintenanceLock(sql, config.tenantRef, async () => {
840
+ const sum = { extended: 0, conflicts: 0, refused: 0, pruned: 0, capped: true, passes: 0 };
841
+ while (sum.capped && sum.passes < MAINTAIN_MAX_PASSES) {
842
+ const pass = await maintainSlotlockTenant(store, config.tenantRef);
843
+ sum.passes += 1;
844
+ sum.extended += pass.extended;
845
+ sum.conflicts += pass.conflicts;
846
+ sum.refused += pass.refused;
847
+ sum.pruned += pass.pruned;
848
+ // A capped pass that got nowhere met only series it cannot extend. While its roll stopped
849
+ // before the series it had put back, the next pass meets new ones (and perhaps a healthy
850
+ // one); once it reached them, another pass would meet them again.
851
+ sum.capped = pass.capped;
852
+ if (pass.capped && pass.extended + pass.pruned === 0 && !pass.rollPending)
853
+ break;
854
+ }
855
+ return sum;
856
+ });
857
+ return total ?? null;
858
+ }
859
+ finally {
860
+ await sql.end({ timeout: 5 });
861
+ }
862
+ }
593
863
  /** `--expires-in-days`: a whole number of days up to the longest a key may live. */
594
864
  function parseKeyLifetime(value) {
595
865
  if (value === undefined)
@@ -632,23 +902,41 @@ export const SLOTLOCK_CLI_HELP = `Usage: slotlock <command> [options]
632
902
  Commands:
633
903
  migrate Apply the schema and row-level security, then grant the serving
634
904
  role when SLOTLOCK_MIGRATE_DATABASE_URL names another role
635
- serve [--migrate] Serve MCP and A2A over HTTP (--migrate runs migrate first)
905
+ serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);
906
+ maintains each tenant it serves every hour
907
+ maintain Run maintenance for the tenant now: extend recurring events to the
908
+ rolling horizon and prune past the retention window (for cron)
636
909
  resource add <ref> [--timezone <IANA zone>]
637
910
  Create a calendar resource (a car, room, person or machine)
638
911
  resource list Print the tenant's resources, one JSON object per line
912
+ resource archive <ref> Take a resource out of use: no new bookings, holds or events, and
913
+ no longer listed; what is on it stays
914
+ resource restore <ref> Put an archived resource back in use
915
+ resource delete <ref> Delete a resource and its history; refused while a booking, hold
916
+ or event on it has not ended
639
917
  hours show [--resource <ref>]
640
918
  Print each resource's bookable hours (null: the server's)
641
919
  hours set <json> (--resource <ref> | --all)
642
920
  Give one resource, or every one, weekly hours of its own; [] closes
643
921
  hours clear (--resource <ref> | --all)
644
922
  Hand one resource, or every one, back to the server's hours
645
- key create <name> [--scope read|write|read,write] [--expires-in-days <n>]
923
+ key create <name> [--scope read,write,admin] [--expires-in-days <n>]
646
924
  Create an API key for the tenant (default scope read,write, no
647
- expiry) and print it once; only its SHA-256 is stored
925
+ expiry; admin adds the application API) and print it once; only its
926
+ SHA-256 is stored
648
927
  key list Print the tenant's API keys, one JSON object per line
649
928
  key rotate <id> Give an active key a new secret and print it once; the old one stops
650
929
  working at once, and the key keeps its id and what it booked
651
930
  key revoke <id> Revoke one of the tenant's API keys at once
931
+ tenant status Print whether the tenant is suspended
932
+ tenant suspend [--reason <text>]
933
+ Suspend the tenant: it may read, and writes nothing; its keys get
934
+ no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:
935
+ only the schema owner suspends)
936
+ tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)
937
+ tenant erase --confirm <tenant>
938
+ Delete everything the tenant has, its API keys included, for good;
939
+ --confirm repeats SLOTLOCK_TENANT
652
940
  healthcheck Exit 0 when the local server reports ready
653
941
 
654
942
  Options:
@@ -659,15 +947,40 @@ Environment:
659
947
  DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve
660
948
  refuses a superuser or a role with BYPASSRLS
661
949
  SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)
662
- SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with every scope,
663
- 32+ random characters (serve); API keys work with or without it
664
- SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve)
665
- SLOTLOCK_CONFIRM_WRITES all (default), none, or a comma-separated list of write tools
950
+ SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write
951
+ access (never admin), 32+ random characters (serve); API keys
952
+ work with or without it
953
+ SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;
954
+ required when SLOTLOCK_CONFIRM_WRITES is not none)
955
+ SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools
956
+ that wait for a person (MCP 2026-07-28 form elicitation; refused
957
+ with confirmation_required on REST, A2A and older MCP)
666
958
  SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)
667
959
  SLOTLOCK_TENANT Tenant of the token, resource and key commands (default "default")
668
960
  SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)
669
- SLOTLOCK_REST_API on: also serve the tools as a REST API under /v1, described at
670
- /openapi.json, with the same keys and confirmation (default off)
961
+ SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and
962
+ the application API for admin keys, described at /openapi.json;
963
+ off: MCP and A2A only
964
+ SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:
965
+ rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,
966
+ every request per client, before any key lookup), key_read,
967
+ tenant_read (20/s:40), key_write, tenant_write (5/s:20),
968
+ global_read (500/s:1000), global_write (50/s:100), app_read
969
+ (20/s:40) and app_write (5/s:20) per tenant for the
970
+ application API, sign_in (20/m:20); counts for
971
+ tenant_series_writes and series_writes
972
+ (1: writes that may expand a recurring series at once),
973
+ maintenance_runs (1: application API maintenance runs at once),
974
+ subscriptions (256), tenant_subscriptions (16),
975
+ key_subscriptions (4, and fewer than tenant_subscriptions),
976
+ key_lookups (2), key_lookup_queue (256),
977
+ tenant_transactions (2: one tenant's database transactions at
978
+ once, of the pool's 10); what one tenant may store:
979
+ tenant_holds (10000), tenant_reservations, tenant_events and
980
+ tenant_coverage (100000)
981
+ SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,
982
+ such as CF-Connecting-IP (default: the connection's address);
983
+ set it only when every request comes through that proxy
671
984
  SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
672
985
  of a GitHub OAuth app with callback .../dashboard/callback
673
986
  SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
@@ -709,15 +1022,23 @@ export async function runSlotlockCli(argv, io) {
709
1022
  io.stderr.write(SLOTLOCK_CLI_HELP);
710
1023
  return 2;
711
1024
  }
712
- const subcommand = command === 'resource' || command === 'key' || command === 'hours'
1025
+ const subcommand = command === 'resource' || command === 'key' || command === 'hours' || command === 'tenant'
713
1026
  ? `${command} ${operands[0] ?? ''}`.trim()
714
1027
  : command;
715
1028
  const expectedOperands = {
716
1029
  migrate: 0,
717
1030
  serve: 0,
1031
+ maintain: 0,
718
1032
  healthcheck: 0,
719
1033
  'resource add': 2,
720
1034
  'resource list': 1,
1035
+ 'resource archive': 2,
1036
+ 'resource restore': 2,
1037
+ 'resource delete': 2,
1038
+ 'tenant status': 1,
1039
+ 'tenant suspend': 1,
1040
+ 'tenant unsuspend': 1,
1041
+ 'tenant erase': 1,
721
1042
  'key create': 2,
722
1043
  'key list': 1,
723
1044
  'key rotate': 2,
@@ -741,6 +1062,15 @@ export async function runSlotlockCli(argv, io) {
741
1062
  return usage(`--${option} applies only to key create`);
742
1063
  }
743
1064
  }
1065
+ if (values.reason !== undefined && subcommand !== 'tenant suspend') {
1066
+ return usage('--reason applies only to tenant suspend');
1067
+ }
1068
+ if (values.confirm !== undefined && subcommand !== 'tenant erase') {
1069
+ return usage('--confirm applies only to tenant erase');
1070
+ }
1071
+ if (subcommand === 'tenant erase' && values.confirm === undefined) {
1072
+ return usage('tenant erase needs --confirm <tenant>, the tenant it erases (SLOTLOCK_TENANT)');
1073
+ }
744
1074
  if (values.all && subcommand !== 'hours set' && subcommand !== 'hours clear') {
745
1075
  return usage('--all applies only to hours set and hours clear');
746
1076
  }
@@ -797,6 +1127,15 @@ export async function runSlotlockCli(argv, io) {
797
1127
  log.info('stopped', { forced });
798
1128
  return 0;
799
1129
  }
1130
+ case 'maintain': {
1131
+ const result = await maintainTenant(readSlotlockDatabaseConfig(io.env));
1132
+ if (!result) {
1133
+ io.stderr.write('slotlock: another server is maintaining this tenant now; nothing to do\n');
1134
+ return 0;
1135
+ }
1136
+ io.stdout.write(`${JSON.stringify(result)}\n`);
1137
+ return 0;
1138
+ }
800
1139
  case 'resource add': {
801
1140
  const config = readSlotlockDatabaseConfig(io.env);
802
1141
  const resource = await addResource(config, operands[1], values.timezone ?? 'UTC');
@@ -806,6 +1145,45 @@ export async function runSlotlockCli(argv, io) {
806
1145
  case 'resource list':
807
1146
  await listResources(readSlotlockDatabaseConfig(io.env), io.stdout);
808
1147
  return 0;
1148
+ case 'resource archive':
1149
+ case 'resource restore':
1150
+ case 'resource delete': {
1151
+ const changed = await changeResource(readSlotlockDatabaseConfig(io.env), io.stdout, operands[1], subcommand.slice('resource '.length));
1152
+ if (changed === 'in_use') {
1153
+ 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');
1154
+ return 1;
1155
+ }
1156
+ if (!changed) {
1157
+ io.stderr.write('slotlock: no resource with that reference or id in this tenant\n');
1158
+ return 1;
1159
+ }
1160
+ return 0;
1161
+ }
1162
+ case 'tenant status':
1163
+ case 'tenant suspend':
1164
+ case 'tenant unsuspend':
1165
+ case 'tenant erase': {
1166
+ const config = readSlotlockDatabaseConfig(io.env);
1167
+ // The tenant named twice, so erasing the one SLOTLOCK_TENANT happens to name is deliberate.
1168
+ if (subcommand === 'tenant erase' && values.confirm !== config.tenantRef) {
1169
+ return usage('--confirm must repeat SLOTLOCK_TENANT, the tenant tenant erase deletes');
1170
+ }
1171
+ try {
1172
+ await runTenantCommand(config, io.stdout, subcommand.slice('tenant '.length), values.reason);
1173
+ }
1174
+ catch (error) {
1175
+ const code = error?.code;
1176
+ if (code === 'tenant_suspension_not_permitted') {
1177
+ io.stderr.write('slotlock: only the schema owner suspends a tenant or lifts a suspension; set SLOTLOCK_MIGRATE_DATABASE_URL to its URL\n');
1178
+ return 1;
1179
+ }
1180
+ if (code === 'invalid_suspension_reason') {
1181
+ return usage('--reason must be 1-500 characters without control characters');
1182
+ }
1183
+ throw error;
1184
+ }
1185
+ return 0;
1186
+ }
809
1187
  case 'hours show':
810
1188
  case 'hours set':
811
1189
  case 'hours clear': {
@@ -889,6 +1267,8 @@ function parseCliArguments(argv) {
889
1267
  'expires-in-days': { type: 'string' },
890
1268
  resource: { type: 'string' },
891
1269
  all: { type: 'boolean' },
1270
+ reason: { type: 'string' },
1271
+ confirm: { type: 'string' },
892
1272
  help: { type: 'boolean', short: 'h' },
893
1273
  version: { type: 'boolean', short: 'v' },
894
1274
  },