slotlock 0.2.0 → 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 (51) hide show
  1. package/CHANGELOG.md +330 -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 +277 -53
  7. package/SECURITY.md +31 -2
  8. package/SPEC.md +142 -15
  9. package/agent-server.d.ts +12 -3
  10. package/agent-server.js +108 -78
  11. package/agent-store-backend.d.ts +6 -0
  12. package/agent-store-backend.js +355 -36
  13. package/api-keys.d.ts +11 -3
  14. package/api-keys.js +29 -6
  15. package/application-api.d.ts +14 -1
  16. package/application-api.js +274 -12
  17. package/config.d.ts +93 -0
  18. package/config.js +192 -0
  19. package/dashboard.d.ts +6 -0
  20. package/dashboard.js +17 -1
  21. package/ddl.d.ts +45 -3
  22. package/ddl.js +538 -12
  23. package/engine.js +20 -6
  24. package/event-schema.d.ts +59 -0
  25. package/event-schema.js +85 -0
  26. package/ical-worker-thread.js +4 -2
  27. package/ical-worker.d.ts +8 -5
  28. package/ical-worker.js +9 -2
  29. package/index.d.ts +5 -5
  30. package/index.js +3 -3
  31. package/maintenance.d.ts +15 -5
  32. package/maintenance.js +59 -27
  33. package/node-server.d.ts +9 -1
  34. package/node-server.js +7 -2
  35. package/openapi.json +6024 -0
  36. package/package.json +3 -2
  37. package/rate-limit.d.ts +71 -0
  38. package/rate-limit.js +718 -0
  39. package/rest-api.js +10 -0
  40. package/rules.js +6 -3
  41. package/self-host.d.ts +22 -8
  42. package/self-host.js +307 -34
  43. package/slotlock-manifest.schema.json +6 -1
  44. package/slotlock.manifest.json +7 -2
  45. package/store.d.ts +334 -24
  46. package/store.js +2029 -290
  47. package/sync.d.ts +43 -2
  48. package/sync.js +1515 -323
  49. package/timezone.d.ts +58 -3
  50. package/timezone.js +249 -20
  51. package/types.d.ts +213 -9
package/self-host.js CHANGED
@@ -10,13 +10,14 @@ import { createSlotlockAgentServer, slotlockAgentOperationScope, } from './agent
10
10
  import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, } from './agent-store-backend.js';
11
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';
13
+ import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, slotlockKeySubscriptions, } from './config.js';
14
14
  import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
15
15
  import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_TENANT_TABLES } from './ddl.js';
16
16
  import { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
17
17
  import { createSlotlockNodeServer, } from './node-server.js';
18
18
  import { slotlockPackageVersion } from './package-version.js';
19
- import { createSlotlockStore } from './store.js';
19
+ import { createSlotlockRequestLimits } from './rate-limit.js';
20
+ import { assertSlotlockSchemaCurrent, createSlotlockStore, } from './store.js';
20
21
  import { parseAvailabilityRules } from './weekly-hours.js';
21
22
  export { SlotlockConfigError };
22
23
  /** Every variable the commands read. The README, `.env.example` and `server.json` document these. */
@@ -30,6 +31,8 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
30
31
  'SLOTLOCK_TENANT',
31
32
  'SLOTLOCK_AVAILABILITY',
32
33
  'SLOTLOCK_REST_API',
34
+ 'SLOTLOCK_LIMITS',
35
+ 'SLOTLOCK_TRUSTED_PROXY_HEADER',
33
36
  'SLOTLOCK_GITHUB_CLIENT_ID',
34
37
  'SLOTLOCK_GITHUB_CLIENT_SECRET',
35
38
  'SLOTLOCK_SESSION_SECRET',
@@ -40,7 +43,7 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
40
43
  const DEFAULT_HOST = '127.0.0.1';
41
44
  const DEFAULT_PORT = 8080;
42
45
  const DEFAULT_TENANT = 'default';
43
- /** 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. */
44
47
  const SELF_HOST_SUBJECT = 'self-host';
45
48
  /** Who `key create` records as a key's creator. */
46
49
  const CLI_KEY_CREATOR = 'cli';
@@ -74,8 +77,13 @@ const SERVED_OPERATIONS = new Set([
74
77
  'slotlock_delete_event',
75
78
  ]);
76
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;
77
82
  const HEALTHCHECK_TIMEOUT_MS = 5_000;
83
+ /** One pool for every tenant; SLOTLOCK_LIMITS `tenant_transactions` (config.ts) goes up to it. */
78
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;
79
87
  const RESOURCE_PAGE_SIZE = 1_000;
80
88
  function readDatabaseUrl(env, name) {
81
89
  const value = optional(env, name);
@@ -121,7 +129,7 @@ function readTenant(env) {
121
129
  return value;
122
130
  }
123
131
  function readConfirmWrites(env) {
124
- const value = optional(env, 'SLOTLOCK_CONFIRM_WRITES') ?? 'all';
132
+ const value = optional(env, 'SLOTLOCK_CONFIRM_WRITES') ?? 'none';
125
133
  if (value === 'all')
126
134
  return [...WRITE_OPERATIONS];
127
135
  if (value === 'none')
@@ -161,7 +169,7 @@ export function readSlotlockServeConfig(env) {
161
169
  const confirmWrites = readConfirmWrites(env);
162
170
  const confirmationSecret = readSecret(env, 'SLOTLOCK_CONFIRMATION_SECRET');
163
171
  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`);
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`);
165
173
  }
166
174
  if (confirmationSecret !== undefined &&
167
175
  authToken !== undefined &&
@@ -177,7 +185,11 @@ export function readSlotlockServeConfig(env) {
177
185
  confirmWrites,
178
186
  availability: readAvailability(env),
179
187
  restApi: readRestApi(env),
188
+ limits: readLimits(env),
180
189
  };
190
+ const trustedProxyHeader = readTrustedProxyHeader(env);
191
+ if (trustedProxyHeader !== undefined)
192
+ config.trustedProxyHeader = trustedProxyHeader;
181
193
  if (authToken !== undefined)
182
194
  config.authToken = authToken;
183
195
  const dashboard = readDashboardConfig(env);
@@ -219,8 +231,8 @@ export function createSlotlockTokenAuthenticator(token, principal) {
219
231
  }
220
232
  /**
221
233
  * 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.
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.
224
236
  */
225
237
  export function authorizeSlotlockServeOperation(args) {
226
238
  return (SERVED_OPERATIONS.has(args.operation) &&
@@ -256,7 +268,10 @@ function secretsIn(env) {
256
268
  // Longest first, so a URL is replaced whole rather than around its password.
257
269
  return [...new Set(secrets)].sort((a, b) => b.length - a.length);
258
270
  }
259
- /** 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
+ */
260
275
  function createLogger(io) {
261
276
  const secrets = secretsIn(io.env);
262
277
  const write = (stream, level, event, fields = {}) => {
@@ -267,6 +282,7 @@ function createLogger(io) {
267
282
  };
268
283
  return {
269
284
  info: (event, fields) => write(io.stdout, 'info', event, fields),
285
+ warn: (event, fields) => write(io.stderr, 'warn', event, fields),
270
286
  error: (event, fields) => write(io.stderr, 'error', event, fields),
271
287
  };
272
288
  }
@@ -358,6 +374,26 @@ function dashboardAllows(config, tenantRef) {
358
374
  const githubId = /^github:(\d+)$/.exec(tenantRef)?.[1];
359
375
  return githubId === undefined || users.includes(githubId);
360
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
+ }
361
397
  /** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
362
398
  export async function startSlotlockServer(config, log) {
363
399
  const sql = connect(config.databaseUrl, POOL_SIZE);
@@ -376,6 +412,9 @@ export async function startSlotlockServer(config, log) {
376
412
  if (!schema?.present) {
377
413
  throw Object.assign(new Error('The slotlock schema is missing: run `slotlock migrate` or `slotlock serve --migrate`'), { code: 'schema_missing' });
378
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);
379
418
  // A deployment run statement by statement lifts forced row-level security for its data steps
380
419
  // and, stopped part way, leaves it lifted until the next one puts it back. The owning role then
381
420
  // reads every tenant's rows: refuse to serve until then.
@@ -400,9 +439,13 @@ export async function startSlotlockServer(config, log) {
400
439
  }
401
440
  }
402
441
  }
403
- const store = createSlotlockStore(sql);
442
+ const { caps } = config.limits;
443
+ const store = createSlotlockServeStore(sql, caps);
404
444
  const publicUrl = new URL(config.publicUrl);
405
- const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql));
445
+ const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql), {
446
+ maxConcurrentLookups: caps.key_lookups,
447
+ maxWaitingLookups: caps.key_lookup_queue,
448
+ });
406
449
  const authenticateToken = config.authToken === undefined
407
450
  ? undefined
408
451
  : // Read and write, never admin: this is the token the docs put in agents' configuration,
@@ -414,13 +457,14 @@ export async function startSlotlockServer(config, log) {
414
457
  });
415
458
  // Set once the listener is up; the authenticator tells it which tenants this server serves.
416
459
  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
- }),
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'] : [],
424
468
  // The token is compared in constant time first; only then is a key looked up. A person the
425
469
  // dashboard no longer allows loses their keys with their session.
426
470
  authenticate: async (request) => {
@@ -430,10 +474,42 @@ export async function startSlotlockServer(config, log) {
430
474
  maintenance?.touch(principal.tenantRef);
431
475
  return principal;
432
476
  },
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'] },
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;
483
+ const agentServer = createSlotlockAgentServer({
484
+ publicBaseUrl: config.publicUrl,
485
+ // readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
486
+ allowInsecureLocalhost: publicUrl.protocol === 'http:',
487
+ backend: createSlotlockStoreAgentBackend(store, {
488
+ availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
489
+ }),
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'] };
512
+ },
437
513
  // Outcomes and counts only: the server never reports arguments or identities.
438
514
  onEvent: ({ type, ...fields }) => log.info(type, snakeCaseKeys(fields)),
439
515
  // What an agent sees only as internal_error: the operation and the error's message and code,
@@ -447,7 +523,12 @@ export async function startSlotlockServer(config, log) {
447
523
  backend: createSlotlockStoreApplicationBackend(store, {
448
524
  maxTenantResources: SERVE_MAX_TENANT_RESOURCES,
449
525
  }),
450
- authorize: async ({ principal }) => principal.scopes?.includes(SLOTLOCK_APPLICATION_SCOPE) === true,
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
+ },
451
532
  },
452
533
  }
453
534
  : {}),
@@ -474,21 +555,23 @@ export async function startSlotlockServer(config, log) {
474
555
  resources: createSlotlockDashboardResources(store),
475
556
  state: createSlotlockDashboardState(sql),
476
557
  availability: config.availability,
558
+ clientAddress: (request) => limits.clientAddress(request),
477
559
  onError: (error) => log.error('dashboard_error', describeError(error)),
478
560
  });
479
- const target = dashboard
561
+ const target = limits.serve(dashboard
480
562
  ? {
481
563
  fetch: (request) => dashboard.handles(request) ? dashboard.fetch(request) : agentServer.fetch(request),
482
564
  shutdown: () => agentServer.shutdown(),
483
565
  }
484
- : agentServer;
566
+ : agentServer);
485
567
  const listener = createSlotlockNodeServer(target, {
486
568
  requestOrigin: publicUrl.origin,
487
569
  onError: (error, phase) => log.error('http_error', { phase, ...describeError(error) }),
488
570
  });
489
571
  const address = await listener.listen({ host: config.host, port: config.port });
490
572
  maintenance = startSlotlockMaintenance({
491
- store,
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) }),
492
575
  sql,
493
576
  tenants: [config.tenantRef],
494
577
  intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
@@ -507,11 +590,19 @@ export async function startSlotlockServer(config, log) {
507
590
  availability_rules: config.availability.length,
508
591
  rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
509
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',
510
600
  });
511
601
  return {
512
602
  address,
513
603
  async close() {
514
604
  const result = await listener.close();
605
+ limits.close();
515
606
  await maintenance?.close();
516
607
  await sql.end({ timeout: 5 });
517
608
  return result;
@@ -571,8 +662,8 @@ function hoursLine(resource) {
571
662
  hours: resource.availabilityRules ?? null,
572
663
  })}\n`;
573
664
  }
574
- /** Every resource of the tenant, a page at a time. */
575
- 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) {
576
667
  const all = [];
577
668
  let after;
578
669
  for (;;) {
@@ -580,6 +671,7 @@ async function tenantResources(store, tenantRef) {
580
671
  tenantRef,
581
672
  limit: RESOURCE_PAGE_SIZE,
582
673
  ...(after !== undefined ? { after } : {}),
674
+ ...(includeArchived ? { includeArchived } : {}),
583
675
  }));
584
676
  all.push(...page);
585
677
  after = page.at(-1)?.id;
@@ -626,6 +718,90 @@ async function runHours(config, out, target, rules) {
626
718
  await sql.end({ timeout: 5 });
627
719
  }
628
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
+ }
629
805
  /** One key as `key create`, `key list` and `key revoke` print it: never the digest. */
630
806
  function apiKeyLine(apiKey, key) {
631
807
  return `${JSON.stringify({
@@ -669,9 +845,11 @@ async function maintainTenant(config) {
669
845
  sum.conflicts += pass.conflicts;
670
846
  sum.refused += pass.refused;
671
847
  sum.pruned += pass.pruned;
672
- // A capped pass that got nowhere met only series it cannot extend: another would too.
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.
673
851
  sum.capped = pass.capped;
674
- if (pass.capped && pass.extended + pass.pruned === 0)
852
+ if (pass.capped && pass.extended + pass.pruned === 0 && !pass.rollPending)
675
853
  break;
676
854
  }
677
855
  return sum;
@@ -731,6 +909,11 @@ Commands:
731
909
  resource add <ref> [--timezone <IANA zone>]
732
910
  Create a calendar resource (a car, room, person or machine)
733
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
734
917
  hours show [--resource <ref>]
735
918
  Print each resource's bookable hours (null: the server's)
736
919
  hours set <json> (--resource <ref> | --all)
@@ -745,6 +928,15 @@ Commands:
745
928
  key rotate <id> Give an active key a new secret and print it once; the old one stops
746
929
  working at once, and the key keeps its id and what it booked
747
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
748
940
  healthcheck Exit 0 when the local server reports ready
749
941
 
750
942
  Options:
@@ -755,16 +947,40 @@ Environment:
755
947
  DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve
756
948
  refuses a superuser or a role with BYPASSRLS
757
949
  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
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)
762
958
  SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)
763
959
  SLOTLOCK_TENANT Tenant of the token, resource and key commands (default "default")
764
960
  SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)
765
961
  SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and
766
962
  the application API for admin keys, described at /openapi.json;
767
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
768
984
  SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
769
985
  of a GitHub OAuth app with callback .../dashboard/callback
770
986
  SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
@@ -806,7 +1022,7 @@ export async function runSlotlockCli(argv, io) {
806
1022
  io.stderr.write(SLOTLOCK_CLI_HELP);
807
1023
  return 2;
808
1024
  }
809
- const subcommand = command === 'resource' || command === 'key' || command === 'hours'
1025
+ const subcommand = command === 'resource' || command === 'key' || command === 'hours' || command === 'tenant'
810
1026
  ? `${command} ${operands[0] ?? ''}`.trim()
811
1027
  : command;
812
1028
  const expectedOperands = {
@@ -816,6 +1032,13 @@ export async function runSlotlockCli(argv, io) {
816
1032
  healthcheck: 0,
817
1033
  'resource add': 2,
818
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,
819
1042
  'key create': 2,
820
1043
  'key list': 1,
821
1044
  'key rotate': 2,
@@ -839,6 +1062,15 @@ export async function runSlotlockCli(argv, io) {
839
1062
  return usage(`--${option} applies only to key create`);
840
1063
  }
841
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
+ }
842
1074
  if (values.all && subcommand !== 'hours set' && subcommand !== 'hours clear') {
843
1075
  return usage('--all applies only to hours set and hours clear');
844
1076
  }
@@ -913,6 +1145,45 @@ export async function runSlotlockCli(argv, io) {
913
1145
  case 'resource list':
914
1146
  await listResources(readSlotlockDatabaseConfig(io.env), io.stdout);
915
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
+ }
916
1187
  case 'hours show':
917
1188
  case 'hours set':
918
1189
  case 'hours clear': {
@@ -996,6 +1267,8 @@ function parseCliArguments(argv) {
996
1267
  'expires-in-days': { type: 'string' },
997
1268
  resource: { type: 'string' },
998
1269
  all: { type: 'boolean' },
1270
+ reason: { type: 'string' },
1271
+ confirm: { type: 'string' },
999
1272
  help: { type: 'boolean', short: 'h' },
1000
1273
  version: { type: 'boolean', short: 'v' },
1001
1274
  },
@@ -66,7 +66,12 @@
66
66
  "mcp-human-confirmation",
67
67
  "mcp-calendar-resources",
68
68
  "mcp-live-updates",
69
- "trace-context-propagation"
69
+ "trace-context-propagation",
70
+ "rest-api",
71
+ "application-api",
72
+ "api-keys",
73
+ "dashboard",
74
+ "maintenance"
70
75
  ]
71
76
  }
72
77
  },
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": "1.0",
4
4
  "name": "Slotlock",
5
5
  "package": "slotlock",
6
- "version": "0.2.0",
6
+ "version": "0.3.0",
7
7
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
8
8
  "intervalSemantics": {
9
9
  "boundary": "[)",
@@ -36,7 +36,12 @@
36
36
  "mcp-human-confirmation",
37
37
  "mcp-calendar-resources",
38
38
  "mcp-live-updates",
39
- "trace-context-propagation"
39
+ "trace-context-propagation",
40
+ "rest-api",
41
+ "application-api",
42
+ "api-keys",
43
+ "dashboard",
44
+ "maintenance"
40
45
  ],
41
46
  "transportPolicy": "adapter-defined",
42
47
  "externalCalendarsAreAuthority": false