slotlock 0.3.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/self-host.js CHANGED
@@ -3,18 +3,21 @@
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, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, slotlockKeySubscriptions, } 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
22
  import { createSlotlockRequestLimits } from './rate-limit.js';
20
23
  import { assertSlotlockSchemaCurrent, createSlotlockStore, } from './store.js';
@@ -27,16 +30,22 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
27
30
  'SLOTLOCK_AUTH_TOKEN',
28
31
  'SLOTLOCK_CONFIRMATION_SECRET',
29
32
  'SLOTLOCK_CONFIRM_WRITES',
33
+ 'SLOTLOCK_METRICS_TOKEN',
30
34
  'SLOTLOCK_PUBLIC_URL',
31
35
  'SLOTLOCK_TENANT',
32
36
  'SLOTLOCK_AVAILABILITY',
33
37
  'SLOTLOCK_REST_API',
38
+ 'SLOTLOCK_RETENTION_DAYS',
39
+ 'SLOTLOCK_WEBHOOKS',
40
+ 'SLOTLOCK_SHARED_LIMITS',
34
41
  'SLOTLOCK_LIMITS',
35
42
  'SLOTLOCK_TRUSTED_PROXY_HEADER',
36
43
  'SLOTLOCK_GITHUB_CLIENT_ID',
37
44
  'SLOTLOCK_GITHUB_CLIENT_SECRET',
38
45
  'SLOTLOCK_SESSION_SECRET',
39
46
  'SLOTLOCK_DASHBOARD_USERS',
47
+ 'SLOTLOCK_DASHBOARD_ORGS',
48
+ 'SLOTLOCK_DASHBOARD_NAME',
40
49
  'HOST',
41
50
  'PORT',
42
51
  ]);
@@ -59,6 +68,8 @@ const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
59
68
  const MAINTAIN_MAX_PASSES = 100;
60
69
  const WRITE_OPERATIONS = [
61
70
  'slotlock_create_event',
71
+ 'slotlock_book_together',
72
+ 'slotlock_book_pool',
62
73
  'slotlock_update_event',
63
74
  'slotlock_delete_event',
64
75
  ];
@@ -70,6 +81,12 @@ const SERVED_OPERATIONS = new Set([
70
81
  'slotlock_list_resources',
71
82
  'slotlock_get_free_busy',
72
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',
73
90
  'slotlock_create_event',
74
91
  'slotlock_get_event',
75
92
  'slotlock_list_events',
@@ -151,11 +168,38 @@ export function readSlotlockDatabaseConfig(env) {
151
168
  throw new SlotlockConfigError('DATABASE_URL is required: the postgres:// URL of the role Slotlock serves as');
152
169
  }
153
170
  const config = { databaseUrl, tenantRef: readTenant(env) };
171
+ const retentionDays = readRetentionDays(env);
172
+ if (retentionDays !== undefined)
173
+ config.retentionDays = retentionDays;
154
174
  const migrateDatabaseUrl = readDatabaseUrl(env, 'SLOTLOCK_MIGRATE_DATABASE_URL');
155
175
  if (migrateDatabaseUrl !== undefined)
156
176
  config.migrateDatabaseUrl = migrateDatabaseUrl;
157
177
  return config;
158
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
+ }
159
203
  function readRestApi(env) {
160
204
  const value = optional(env, 'SLOTLOCK_REST_API') ?? 'on';
161
205
  if (value !== 'on' && value !== 'off') {
@@ -185,6 +229,8 @@ export function readSlotlockServeConfig(env) {
185
229
  confirmWrites,
186
230
  availability: readAvailability(env),
187
231
  restApi: readRestApi(env),
232
+ webhooks: readWebhooks(env),
233
+ sharedLimits: readSharedLimits(env),
188
234
  limits: readLimits(env),
189
235
  };
190
236
  const trustedProxyHeader = readTrustedProxyHeader(env);
@@ -192,9 +238,25 @@ export function readSlotlockServeConfig(env) {
192
238
  config.trustedProxyHeader = trustedProxyHeader;
193
239
  if (authToken !== undefined)
194
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
+ }
195
254
  const dashboard = readDashboardConfig(env);
196
255
  if (dashboard !== undefined)
197
256
  config.dashboard = dashboard;
257
+ const organizations = readDashboardOrganizations(env);
258
+ if (organizations !== undefined)
259
+ config.organizations = organizations;
198
260
  if (confirmWrites.length > 0 && confirmationSecret !== undefined) {
199
261
  config.confirmationSecret = confirmationSecret;
200
262
  }
@@ -246,6 +308,7 @@ function secretsIn(env) {
246
308
  'SLOTLOCK_CONFIRMATION_SECRET',
247
309
  'SLOTLOCK_GITHUB_CLIENT_SECRET',
248
310
  'SLOTLOCK_SESSION_SECRET',
311
+ 'SLOTLOCK_METRICS_TOKEN',
249
312
  ]) {
250
313
  const value = env[name]?.trim();
251
314
  if (value)
@@ -364,10 +427,48 @@ async function databaseReachable(sql) {
364
427
  }
365
428
  }
366
429
  /**
367
- * Whether the dashboard's allowlist lets a tenant act: every tenant when there is no dashboard or it
368
- * 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.
369
467
  */
370
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;
371
472
  const users = config.dashboard?.allowedUsers;
372
473
  if (users === undefined || users === '*')
373
474
  return true;
@@ -387,13 +488,29 @@ function serveTenantLimits(caps) {
387
488
  * The store `serve` answers requests from. Every surface writes through it, so its tenant limits
388
489
  * bound what any key can add, and its transaction limits what one tenant holds of the shared pool.
389
490
  */
390
- export function createSlotlockServeStore(sql, caps) {
491
+ export function createSlotlockServeStore(sql, caps, availability = [], retentionDays) {
391
492
  return createSlotlockStore(sql, {
493
+ ...(retentionDays !== undefined ? { retentionDays } : {}),
494
+ defaultAvailabilityRules: availability,
392
495
  tenantLimits: serveTenantLimits(caps),
393
496
  maxTenantTransactions: caps.tenant_transactions,
497
+ maxHoldsPerHolder: caps.holder_holds,
498
+ maxHeldHoursPerHolder: caps.holder_held_hours,
394
499
  tenantLockTimeoutMs: SERVE_LOCK_TIMEOUT_MS,
395
500
  });
396
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
+ }
397
514
  /** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
398
515
  export async function startSlotlockServer(config, log) {
399
516
  const sql = connect(config.databaseUrl, POOL_SIZE);
@@ -439,8 +556,22 @@ export async function startSlotlockServer(config, log) {
439
556
  }
440
557
  }
441
558
  }
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
+ }
442
572
  const { caps } = config.limits;
443
- const store = createSlotlockServeStore(sql, caps);
573
+ const store = createSlotlockServeStore(sql, caps, config.availability, config.retentionDays);
574
+ const retention = config.retentionDays !== undefined ? { retentionDays: config.retentionDays } : {};
444
575
  const publicUrl = new URL(config.publicUrl);
445
576
  const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql), {
446
577
  maxConcurrentLookups: caps.key_lookups,
@@ -457,9 +588,29 @@ export async function startSlotlockServer(config, log) {
457
588
  });
458
589
  // Set once the listener is up; the authenticator tells it which tenants this server serves.
459
590
  let maintenance;
591
+ let webhooks;
460
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
+ });
461
599
  const limits = createSlotlockRequestLimits({
462
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
+ : {}),
463
614
  ...(config.trustedProxyHeader !== undefined
464
615
  ? { trustedProxyHeader: config.trustedProxyHeader }
465
616
  : {}),
@@ -475,7 +626,10 @@ export async function startSlotlockServer(config, log) {
475
626
  return principal;
476
627
  },
477
628
  // Counts and pseudonyms only: never an address, a credential or a tenant.
478
- onRefusals: (counts) => log.info('refusals', { ...counts }),
629
+ onRefusals: (counts) => {
630
+ metrics?.refusals(counts);
631
+ log.info('refusals', { ...counts });
632
+ },
479
633
  // Behind a proxy without the setting, every client shares the proxy's address budget.
480
634
  onIgnoredClientField: (field) => log.warn('client_address_field_ignored', { field, setting: 'SLOTLOCK_TRUSTED_PROXY_HEADER' }),
481
635
  });
@@ -511,7 +665,11 @@ export async function startSlotlockServer(config, log) {
511
665
  : { ready: false, checks: ['database_unreachable'] };
512
666
  },
513
667
  // Outcomes and counts only: the server never reports arguments or identities.
514
- 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
+ },
515
673
  // What an agent sees only as internal_error: the operation and the error's message and code,
516
674
  // never the arguments or the request, with every configured secret redacted.
517
675
  onError: (error, { operation }) => log.error('agent_error', { operation, ...describeError(error) }),
@@ -537,6 +695,7 @@ export async function startSlotlockServer(config, log) {
537
695
  confirmation: {
538
696
  operations: config.confirmWrites,
539
697
  secrets: [config.confirmationSecret],
698
+ consumeAcceptance: createSlotlockAcceptanceRecords(sql),
540
699
  },
541
700
  }
542
701
  : {}),
@@ -551,6 +710,10 @@ export async function startSlotlockServer(config, log) {
551
710
  },
552
711
  sessionSecret: config.dashboard.sessionSecret,
553
712
  allowedUsers: config.dashboard.allowedUsers,
713
+ ...(config.dashboard.name ? { name: config.dashboard.name } : {}),
714
+ ...(config.dashboard.organizations
715
+ ? { organizations: config.dashboard.organizations }
716
+ : {}),
554
717
  keys: createSlotlockApiKeyStore(sql),
555
718
  resources: createSlotlockDashboardResources(store),
556
719
  state: createSlotlockDashboardState(sql),
@@ -564,21 +727,58 @@ export async function startSlotlockServer(config, log) {
564
727
  shutdown: () => agentServer.shutdown(),
565
728
  }
566
729
  : agentServer);
567
- const listener = createSlotlockNodeServer(target, {
730
+ const served = metrics && config.metricsToken !== undefined
731
+ ? serveMetrics(target, metrics, `${basePath}/metrics`, config.metricsToken)
732
+ : target;
733
+ const listener = createSlotlockNodeServer(served, {
568
734
  requestOrigin: publicUrl.origin,
569
735
  onError: (error, phase) => log.error('http_error', { phase, ...describeError(error) }),
570
736
  });
571
737
  const address = await listener.listen({ host: config.host, port: config.port });
572
738
  maintenance = startSlotlockMaintenance({
573
739
  // 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) }),
740
+ store: createSlotlockMaintenanceStore(sql, caps, retention),
575
741
  sql,
576
742
  tenants: [config.tenantRef],
577
743
  intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
578
744
  // Counts only, never the tenant.
579
- onPass: (result) => log.info('maintenance', { ...result }),
745
+ onPass: (result) => {
746
+ metrics?.maintenance(result);
747
+ log.info('maintenance', { ...result });
748
+ },
580
749
  onError: (error) => log.error('maintenance_failed', describeError(error)),
581
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
+ }
582
782
  log.info('listening', {
583
783
  address: address.origin,
584
784
  mcp: `${config.publicUrl}/mcp`,
@@ -589,6 +789,9 @@ export async function startSlotlockServer(config, log) {
589
789
  confirm_writes: config.confirmWrites,
590
790
  availability_rules: config.availability.length,
591
791
  rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
792
+ webhooks: config.webhooks,
793
+ shared_limits: config.sharedLimits,
794
+ tzdata: process.versions.tz ?? null,
592
795
  maintenance_interval_minutes: SERVE_MAINTENANCE_INTERVAL_MS / 60_000,
593
796
  limits: {
594
797
  ...Object.fromEntries(Object.entries(config.limits.rates).map(([name, rate]) => [name, formatSlotlockRate(rate)])),
@@ -604,6 +807,7 @@ export async function startSlotlockServer(config, log) {
604
807
  const result = await listener.close();
605
808
  limits.close();
606
809
  await maintenance?.close();
810
+ await webhooks?.close();
607
811
  await sql.end({ timeout: 5 });
608
812
  return result;
609
813
  },
@@ -718,6 +922,34 @@ async function runHours(config, out, target, rules) {
718
922
  await sql.end({ timeout: 5 });
719
923
  }
720
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
+ }
721
953
  /** A resource as `resource archive` and `resource restore` print it. */
722
954
  function resourceStateLine(resource) {
723
955
  return `${JSON.stringify({
@@ -832,12 +1064,23 @@ function parseKeyScopes(value) {
832
1064
  * `maintain`: maintenance passes for the tenant until nothing is left, under the tenant's lock, so
833
1065
  * it never repeats what a server is doing. Null when another server holds the lock.
834
1066
  */
835
- async function maintainTenant(config) {
1067
+ async function maintainTenant(config, caps) {
836
1068
  const sql = connect(config.databaseUrl, 2);
837
1069
  try {
838
- const store = createSlotlockStore(sql);
1070
+ const store = createSlotlockMaintenanceStore(sql, caps, config.retentionDays !== undefined ? { retentionDays: config.retentionDays } : {});
839
1071
  const total = await withSlotlockMaintenanceLock(sql, config.tenantRef, async () => {
840
- 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
+ };
841
1084
  while (sum.capped && sum.passes < MAINTAIN_MAX_PASSES) {
842
1085
  const pass = await maintainSlotlockTenant(store, config.tenantRef);
843
1086
  sum.passes += 1;
@@ -845,6 +1088,10 @@ async function maintainTenant(config) {
845
1088
  sum.conflicts += pass.conflicts;
846
1089
  sum.refused += pass.refused;
847
1090
  sum.pruned += pass.pruned;
1091
+ sum.zoneShifts += pass.zoneShifts;
1092
+ sum.staleSources += pass.staleSources;
1093
+ sum.noShows += pass.noShows;
1094
+ sum.waitlistOffers += pass.waitlistOffers;
848
1095
  // A capped pass that got nowhere met only series it cannot extend. While its roll stopped
849
1096
  // before the series it had put back, the next pass meets new ones (and perhaps a healthy
850
1097
  // one); once it reached them, another pass would meet them again.
@@ -920,6 +1167,11 @@ Commands:
920
1167
  Give one resource, or every one, weekly hours of its own; [] closes
921
1168
  hours clear (--resource <ref> | --all)
922
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
923
1175
  key create <name> [--scope read,write,admin] [--expires-in-days <n>]
924
1176
  Create an API key for the tenant (default scope read,write, no
925
1177
  expiry; admin adds the application API) and print it once; only its
@@ -952,6 +1204,8 @@ Environment:
952
1204
  work with or without it
953
1205
  SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;
954
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)
955
1209
  SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools
956
1210
  that wait for a person (MCP 2026-07-28 form elicitation; refused
957
1211
  with confirmation_required on REST, A2A and older MCP)
@@ -961,6 +1215,15 @@ Environment:
961
1215
  SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and
962
1216
  the application API for admin keys, described at /openapi.json;
963
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
964
1227
  SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:
965
1228
  rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,
966
1229
  every request per client, before any key lookup), key_read,
@@ -977,7 +1240,8 @@ Environment:
977
1240
  tenant_transactions (2: one tenant's database transactions at
978
1241
  once, of the pool's 10); what one tenant may store:
979
1242
  tenant_holds (10000), tenant_reservations, tenant_events and
980
- tenant_coverage (100000)
1243
+ tenant_coverage (100000), holder_holds (20) and
1244
+ holder_held_hours (2160), which maintain keeps too
981
1245
  SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,
982
1246
  such as CF-Connecting-IP (default: the connection's address);
983
1247
  set it only when every request comes through that proxy
@@ -986,6 +1250,8 @@ Environment:
986
1250
  SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
987
1251
  SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)
988
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)
989
1255
  HOST Listen address (default 127.0.0.1)
990
1256
  PORT Listen port (default 8080)
991
1257
 
@@ -1022,7 +1288,11 @@ export async function runSlotlockCli(argv, io) {
1022
1288
  io.stderr.write(SLOTLOCK_CLI_HELP);
1023
1289
  return 2;
1024
1290
  }
1025
- const subcommand = command === 'resource' || command === 'key' || command === 'hours' || command === 'tenant'
1291
+ const subcommand = command === 'resource' ||
1292
+ command === 'key' ||
1293
+ command === 'hours' ||
1294
+ command === 'rules' ||
1295
+ command === 'tenant'
1026
1296
  ? `${command} ${operands[0] ?? ''}`.trim()
1027
1297
  : command;
1028
1298
  const expectedOperands = {
@@ -1046,6 +1316,8 @@ export async function runSlotlockCli(argv, io) {
1046
1316
  'hours show': 1,
1047
1317
  'hours set': 2,
1048
1318
  'hours clear': 1,
1319
+ 'rules set': 2,
1320
+ 'rules clear': 1,
1049
1321
  };
1050
1322
  const expected = expectedOperands[subcommand];
1051
1323
  if (expected === undefined)
@@ -1074,8 +1346,24 @@ export async function runSlotlockCli(argv, io) {
1074
1346
  if (values.all && subcommand !== 'hours set' && subcommand !== 'hours clear') {
1075
1347
  return usage('--all applies only to hours set and hours clear');
1076
1348
  }
1077
- if (values.resource !== undefined && !subcommand.startsWith('hours ')) {
1078
- 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
+ }
1079
1367
  }
1080
1368
  if ((subcommand === 'hours set' || subcommand === 'hours clear') &&
1081
1369
  (values.resource === undefined) === (values.all !== true)) {
@@ -1128,7 +1416,7 @@ export async function runSlotlockCli(argv, io) {
1128
1416
  return 0;
1129
1417
  }
1130
1418
  case 'maintain': {
1131
- const result = await maintainTenant(readSlotlockDatabaseConfig(io.env));
1419
+ const result = await maintainTenant(readSlotlockDatabaseConfig(io.env), readLimits(io.env).caps);
1132
1420
  if (!result) {
1133
1421
  io.stderr.write('slotlock: another server is maintaining this tenant now; nothing to do\n');
1134
1422
  return 0;
@@ -1199,6 +1487,15 @@ export async function runSlotlockCli(argv, io) {
1199
1487
  }
1200
1488
  return 0;
1201
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
+ }
1202
1499
  case 'key create': {
1203
1500
  const config = readSlotlockDatabaseConfig(io.env);
1204
1501
  const created = await withApiKeys(config, (keys) => keys.create({
@@ -43,6 +43,7 @@
43
43
  "enum": [
44
44
  "availability-rules",
45
45
  "find-next-available",
46
+ "find-available-slots",
46
47
  "atomic-holds",
47
48
  "conflict-safe-reservations",
48
49
  "revisioned-external-commands",
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": "1.0",
4
4
  "name": "Slotlock",
5
5
  "package": "slotlock",
6
- "version": "0.3.0",
6
+ "version": "0.5.1",
7
7
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
8
8
  "intervalSemantics": {
9
9
  "boundary": "[)",
@@ -13,6 +13,7 @@
13
13
  "capabilities": [
14
14
  "availability-rules",
15
15
  "find-next-available",
16
+ "find-available-slots",
16
17
  "atomic-holds",
17
18
  "conflict-safe-reservations",
18
19
  "revisioned-external-commands",