slotlock 0.6.2 → 0.7.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 (100) hide show
  1. package/CHANGELOG.md +492 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1544
  4. package/SECURITY.md +5 -2
  5. package/SPEC.md +1 -1
  6. package/agent-server.d.ts +160 -8
  7. package/agent-server.js +560 -49
  8. package/agent-store-backend.d.ts +39 -7
  9. package/agent-store-backend.js +1439 -104
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +218 -29
  12. package/application-api.d.ts +59 -6
  13. package/application-api.js +842 -39
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +58 -27
  17. package/assistant.js +151 -53
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/booking-rules.d.ts +30 -41
  21. package/booking-rules.js +17 -25
  22. package/change-watch.d.ts +1 -0
  23. package/change-watch.js +1 -1
  24. package/cli.js +3 -4
  25. package/config.d.ts +42 -6
  26. package/config.js +114 -7
  27. package/dashboard-icon.d.ts +1 -0
  28. package/dashboard-icon.js +5 -0
  29. package/dashboard.d.ts +203 -7
  30. package/dashboard.js +1558 -64
  31. package/ddl.d.ts +67 -35
  32. package/ddl.js +2732 -162
  33. package/engine.d.ts +6 -9
  34. package/engine.js +5 -9
  35. package/event-feed.d.ts +53 -35
  36. package/event-feed.js +201 -64
  37. package/event-schema.js +6 -9
  38. package/hooks.d.ts +2 -4
  39. package/hooks.js +2 -4
  40. package/ical-worker-thread.js +3 -4
  41. package/ical-worker.d.ts +8 -10
  42. package/ical-worker.js +7 -9
  43. package/index.d.ts +15 -4
  44. package/index.js +8 -2
  45. package/interop.js +1 -4
  46. package/maintenance.d.ts +24 -10
  47. package/maintenance.js +59 -28
  48. package/mcp-app.d.ts +7 -9
  49. package/mcp-app.js +7 -9
  50. package/mcp-modern.d.ts +14 -16
  51. package/mcp-modern.js +23 -27
  52. package/metrics.d.ts +41 -0
  53. package/metrics.js +22 -1
  54. package/node-server.d.ts +9 -9
  55. package/node-server.js +25 -34
  56. package/openapi.json +14675 -5957
  57. package/otel.d.ts +86 -0
  58. package/otel.js +273 -0
  59. package/overage.d.ts +33 -0
  60. package/overage.js +115 -0
  61. package/package-version.js +2 -4
  62. package/package.json +1 -1
  63. package/partner-billing.d.ts +81 -0
  64. package/partner-billing.js +333 -0
  65. package/partner-names.d.ts +8 -0
  66. package/partner-names.js +10 -0
  67. package/partners.d.ts +147 -0
  68. package/partners.js +375 -0
  69. package/plans.d.ts +92 -0
  70. package/plans.js +115 -0
  71. package/quotas.d.ts +108 -0
  72. package/quotas.js +258 -0
  73. package/rate-limit.d.ts +27 -2
  74. package/rate-limit.js +106 -23
  75. package/resource-profile.d.ts +135 -0
  76. package/resource-profile.js +263 -0
  77. package/rest-api.d.ts +15 -14
  78. package/rest-api.js +79 -15
  79. package/rules.d.ts +6 -7
  80. package/rules.js +30 -42
  81. package/sandbox.d.ts +70 -0
  82. package/sandbox.js +295 -0
  83. package/self-host.d.ts +46 -4
  84. package/self-host.js +990 -41
  85. package/slotlock.manifest.json +1 -1
  86. package/store.d.ts +482 -165
  87. package/store.js +1460 -484
  88. package/sync.d.ts +44 -59
  89. package/sync.js +325 -469
  90. package/teams.d.ts +25 -0
  91. package/teams.js +63 -0
  92. package/timezone.d.ts +25 -33
  93. package/timezone.js +50 -53
  94. package/types.d.ts +130 -57
  95. package/usage.d.ts +106 -0
  96. package/usage.js +238 -0
  97. package/webhooks.d.ts +22 -25
  98. package/webhooks.js +35 -40
  99. package/weekly-hours.d.ts +3 -0
  100. package/weekly-hours.js +1 -1
package/self-host.js CHANGED
@@ -4,26 +4,41 @@
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
6
  import { createSlotlockWebhookSender, deliverSlotlockWebhooks, startSlotlockWebhookDelivery, } from './webhooks.js';
7
- import { createHash, timingSafeEqual } from 'node:crypto';
7
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
8
8
  import { parseArgs } from 'node:util';
9
9
  import postgres from 'postgres';
10
10
  import { createSlotlockAgentServer, slotlockAgentOperationScope, } from './agent-server.js';
11
11
  import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, createSlotlockStoreChangeSource, } from './agent-store-backend.js';
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 { SLOTLOCK_API_KEY_DEFAULT_SCOPES, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, SLOTLOCK_TEST_TENANT_SUFFIX, slotlockBearerCredential, slotlockLiveTenant, } from './api-keys.js';
13
+ import { SLOTLOCK_SANDBOX_MAX_RACERS, SLOTLOCK_SANDBOX_SCENARIOS, fastForwardSlotlockSandbox, raceSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
13
14
  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 { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readDashboardOrganizations, readDashboardUsers, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, readTrustedProxySecret, slotlockKeySubscriptions, } from './config.js';
15
16
  import { parseBookingRules } from './booking-rules.js';
16
- import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
17
- import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_TENANT_TABLES } from './ddl.js';
17
+ import { slotlockApplicationOperation, slotlockApplicationScope, } from './application-api.js';
18
+ import { SLOTLOCK_ACCOUNT_FIELD, createSlotlockPartnerKey, createSlotlockPartnerKeyAuthenticator, createSlotlockPartnerStore, listSlotlockPartners, putSlotlockPartner, revokeSlotlockPartnerKey, setSlotlockPartnerBilling, setSlotlockPartnerStatus, slotlockPartnerContractJson, } from './partners.js';
19
+ import { closeSlotlockPartnerMonth, slotlockClientLabel, slotlockPreviousMonth } from './partner-billing.js';
20
+ import { createSlotlockDashboardTeams, createSlotlockOrganizationCheck } from './teams.js';
21
+ import { SLOTLOCK_DEFAULT_OVERAGE_STOP_PERCENT, reportSlotlockOverage, slotlockMonthOf } from './overage.js';
22
+ import { createSlotlockOtlpExporter, createSlotlockOtlpMetricsExporter, readSlotlockOtlpConfig, } from './otel.js';
23
+ import { readFile } from 'node:fs/promises';
24
+ import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_QUOTA_FUNCTIONS, SLOTLOCK_TENANT_TABLES, SLOTLOCK_USAGE_FUNCTIONS, } from './ddl.js';
18
25
  import { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
19
26
  import { createSlotlockNodeServer, } from './node-server.js';
20
27
  import { createSlotlockMetrics } from './metrics.js';
21
28
  import { slotlockPackageVersion } from './package-version.js';
29
+ import { readSlotlockPlanDefault, readSlotlockTenantPlan, readSlotlockTenantQuotas, setSlotlockPlanDefault, } from './plans.js';
30
+ import { isSlotlockResourceScope } from './resource-profile.js';
31
+ import { createSlotlockQuotas, noteSlotlockQuotaThreshold, readSlotlockQuotaUse } from './quotas.js';
22
32
  import { createSlotlockRequestLimits } from './rate-limit.js';
33
+ import { createSlotlockUsageMeter, recordSlotlockUsage } from './usage.js';
23
34
  import { assertSlotlockSchemaCurrent, createSlotlockStore, } from './store.js';
24
35
  import { parseAvailabilityRules } from './weekly-hours.js';
25
36
  export { SlotlockConfigError };
26
- /** Every variable the commands read. The README, `.env.example` and `server.json` document these. */
37
+ /**
38
+ * Every variable the commands read. `slotlock --help`, `.env.example`, `server.json` and the docs
39
+ * (site/docs-src/self-host.md) document these. Not STRIPE_SECRET_KEY, which only the owner's
40
+ * `partner close` reads, to bill partners.
41
+ */
27
42
  export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
28
43
  'DATABASE_URL',
29
44
  'SLOTLOCK_MIGRATE_DATABASE_URL',
@@ -38,16 +53,31 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
38
53
  'SLOTLOCK_RETENTION_DAYS',
39
54
  'SLOTLOCK_WEBHOOKS',
40
55
  'SLOTLOCK_SHARED_LIMITS',
56
+ 'SLOTLOCK_USAGE',
57
+ 'SLOTLOCK_QUOTAS',
58
+ 'SLOTLOCK_OVERAGE',
41
59
  'SLOTLOCK_LIMITS',
42
60
  'SLOTLOCK_TRUSTED_PROXY_HEADER',
61
+ 'SLOTLOCK_TRUSTED_PROXY_SECRET',
62
+ 'SLOTLOCK_KNOWN_CLIENTS',
63
+ 'OTEL_EXPORTER_OTLP_ENDPOINT',
64
+ 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT',
65
+ 'OTEL_EXPORTER_OTLP_METRICS_ENDPOINT',
66
+ 'OTEL_EXPORTER_OTLP_HEADERS',
67
+ 'OTEL_SERVICE_NAME',
43
68
  'SLOTLOCK_GITHUB_CLIENT_ID',
44
69
  'SLOTLOCK_GITHUB_CLIENT_SECRET',
70
+ 'SLOTLOCK_GOOGLE_CLIENT_ID',
71
+ 'SLOTLOCK_GOOGLE_CLIENT_SECRET',
45
72
  'SLOTLOCK_SESSION_SECRET',
46
73
  'SLOTLOCK_ASSISTANT_SECRET',
74
+ 'SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS',
47
75
  'SLOTLOCK_ASSISTANT_PRIVATE_URLS',
48
76
  'SLOTLOCK_DASHBOARD_USERS',
49
77
  'SLOTLOCK_DASHBOARD_ORGS',
50
78
  'SLOTLOCK_DASHBOARD_NAME',
79
+ 'SLOTLOCK_DASHBOARD_TERMS_URL',
80
+ 'SLOTLOCK_DASHBOARD_PRIVACY_URL',
51
81
  'HOST',
52
82
  'PORT',
53
83
  ]);
@@ -64,7 +94,7 @@ const DAY_MS = 86_400_000;
64
94
  * every GitHub user gives each a tenant, so the cap keeps one from filling the database.
65
95
  */
66
96
  const SERVE_MAX_TENANT_RESOURCES = 10_000;
67
- /** How often `serve` maintains each tenant it serves (README, Operations: at least daily). */
97
+ /** How often `serve` maintains each tenant with data (docs, Operations: at least daily). */
68
98
  const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
69
99
  /** Passes `slotlock maintain` runs at most, each as big as `serve`'s, until nothing is left. */
70
100
  const MAINTAIN_MAX_PASSES = 100;
@@ -94,6 +124,9 @@ const SERVED_OPERATIONS = new Set([
94
124
  'slotlock_list_events',
95
125
  'slotlock_update_event',
96
126
  'slotlock_delete_event',
127
+ 'slotlock_confirm_slot',
128
+ 'slotlock_reschedule',
129
+ 'slotlock_cancel',
97
130
  ]);
98
131
  const HEALTH_QUERY_TIMEOUT_MS = 2_000;
99
132
  /** How long one database check answers /healthz, so a flood of health checks is not one of queries. */
@@ -202,6 +235,39 @@ function readSharedLimits(env) {
202
235
  }
203
236
  return value === 'on';
204
237
  }
238
+ /** SLOTLOCK_USAGE: on to meter each tenant's use (usage.ts); off, the default, records nothing. */
239
+ function readUsageMetering(env) {
240
+ const value = optional(env, 'SLOTLOCK_USAGE') ?? 'off';
241
+ if (value !== 'on' && value !== 'off') {
242
+ throw new SlotlockConfigError('SLOTLOCK_USAGE must be on or off');
243
+ }
244
+ return value === 'on';
245
+ }
246
+ /** SLOTLOCK_QUOTAS: on to hold each tenant on a plan to its monthly quotas; it counts with usage. */
247
+ function readQuotas(env, usage) {
248
+ const value = optional(env, 'SLOTLOCK_QUOTAS') ?? 'off';
249
+ if (value !== 'on' && value !== 'off') {
250
+ throw new SlotlockConfigError('SLOTLOCK_QUOTAS must be on or off');
251
+ }
252
+ if (value === 'on' && !usage) {
253
+ throw new SlotlockConfigError('SLOTLOCK_QUOTAS=on needs SLOTLOCK_USAGE=on: a quota is held against the usage it counts');
254
+ }
255
+ return value === 'on';
256
+ }
257
+ /**
258
+ * SLOTLOCK_KNOWN_CLIENTS: the integrators metrics name (comma-separated, lower-case names like
259
+ * stackapi or n8n), at most 50, so a scrape stays bounded and names only those the deployment chose.
260
+ */
261
+ function readKnownClients(env) {
262
+ const value = optional(env, 'SLOTLOCK_KNOWN_CLIENTS');
263
+ if (value === undefined)
264
+ return [];
265
+ const names = value.split(',').map((name) => name.trim());
266
+ if (names.length > 50 || names.some((name) => !/^[a-z][a-z0-9_.-]{0,39}$/.test(name) || name === 'other' || name === 'none')) {
267
+ throw new SlotlockConfigError('SLOTLOCK_KNOWN_CLIENTS must be up to 50 comma-separated lower-case names (letters, digits, _ . -), not other or none');
268
+ }
269
+ return [...new Set(names)];
270
+ }
205
271
  function readRestApi(env) {
206
272
  const value = optional(env, 'SLOTLOCK_REST_API') ?? 'on';
207
273
  if (value !== 'on' && value !== 'off') {
@@ -233,11 +299,39 @@ export function readSlotlockServeConfig(env) {
233
299
  restApi: readRestApi(env),
234
300
  webhooks: readWebhooks(env),
235
301
  sharedLimits: readSharedLimits(env),
302
+ usage: readUsageMetering(env),
303
+ quotas: false,
236
304
  limits: readLimits(env),
237
305
  };
306
+ config.quotas = readQuotas(env, config.usage);
307
+ const overage = optional(env, 'SLOTLOCK_OVERAGE') ?? 'off';
308
+ if (overage !== 'off') {
309
+ const percent = overage === 'on' ? SLOTLOCK_DEFAULT_OVERAGE_STOP_PERCENT : Number(overage);
310
+ if (!Number.isSafeInteger(percent) || percent < 111 || percent > 1_000) {
311
+ throw new SlotlockConfigError('SLOTLOCK_OVERAGE must be off, on, or the percent of its quota a paying Developer tenant is refused at (111-1000)');
312
+ }
313
+ if (!config.quotas)
314
+ throw new SlotlockConfigError('SLOTLOCK_OVERAGE needs SLOTLOCK_QUOTAS=on: overage is what is used past a quota');
315
+ config.overageStopPercent = percent;
316
+ }
238
317
  const trustedProxyHeader = readTrustedProxyHeader(env);
239
318
  if (trustedProxyHeader !== undefined)
240
319
  config.trustedProxyHeader = trustedProxyHeader;
320
+ const trustedProxySecret = readTrustedProxySecret(env, trustedProxyHeader);
321
+ if (trustedProxySecret !== undefined)
322
+ config.trustedProxySecret = trustedProxySecret;
323
+ const knownClients = readKnownClients(env);
324
+ if (knownClients.length > 0)
325
+ config.knownClients = knownClients;
326
+ let otlp;
327
+ try {
328
+ otlp = readSlotlockOtlpConfig(env);
329
+ }
330
+ catch (error) {
331
+ throw new SlotlockConfigError(error.message);
332
+ }
333
+ if (otlp !== undefined)
334
+ config.otlp = otlp;
241
335
  if (authToken !== undefined)
242
336
  config.authToken = authToken;
243
337
  const metricsToken = readSecret(env, 'SLOTLOCK_METRICS_TOKEN');
@@ -248,6 +342,7 @@ export function readSlotlockServeConfig(env) {
248
342
  env.SLOTLOCK_SESSION_SECRET?.trim(),
249
343
  env.SLOTLOCK_GITHUB_CLIENT_SECRET?.trim(),
250
344
  env.SLOTLOCK_ASSISTANT_SECRET?.trim(),
345
+ ...(env.SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS?.split(',').map((part) => part.trim()) ?? []),
251
346
  ];
252
347
  if (others.includes(metricsToken)) {
253
348
  throw new SlotlockConfigError('SLOTLOCK_METRICS_TOKEN must differ from every other secret');
@@ -260,6 +355,9 @@ export function readSlotlockServeConfig(env) {
260
355
  const organizations = readDashboardOrganizations(env);
261
356
  if (organizations !== undefined)
262
357
  config.organizations = organizations;
358
+ const allowedUsers = dashboard?.allowedUsers ?? readDashboardUsers(env);
359
+ if (allowedUsers !== undefined)
360
+ config.allowedUsers = allowedUsers;
263
361
  if (confirmWrites.length > 0 && confirmationSecret !== undefined) {
264
362
  config.confirmationSecret = confirmationSecret;
265
363
  }
@@ -313,11 +411,20 @@ function secretsIn(env) {
313
411
  'SLOTLOCK_SESSION_SECRET',
314
412
  'SLOTLOCK_ASSISTANT_SECRET',
315
413
  'SLOTLOCK_METRICS_TOKEN',
414
+ 'SLOTLOCK_TRUSTED_PROXY_SECRET',
316
415
  ]) {
317
416
  const value = env[name]?.trim();
318
417
  if (value)
319
418
  secrets.push(value);
320
419
  }
420
+ // A list: each secret in it, as well as the whole.
421
+ const previous = env.SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS?.trim();
422
+ if (previous) {
423
+ secrets.push(previous);
424
+ for (const part of previous.split(','))
425
+ if (part.trim())
426
+ secrets.push(part.trim());
427
+ }
321
428
  for (const name of ['DATABASE_URL', 'SLOTLOCK_MIGRATE_DATABASE_URL']) {
322
429
  const value = env[name]?.trim();
323
430
  if (!value)
@@ -499,19 +606,25 @@ function serveMetrics(target, metrics, path, token) {
499
606
  };
500
607
  }
501
608
  /**
502
- * Whether the dashboard's allowlists let a tenant act: a `github-org:<id>` tenant only while
503
- * SLOTLOCK_DASHBOARD_ORGS lists that organization, wherever the dashboard runs; otherwise every
504
- * tenant when there is no dashboard or it allows everyone, and a `github:<id>` tenant only while
505
- * that id is listed.
609
+ * Whether the dashboard's allowlists let a tenant act, wherever the dashboard runs: a
610
+ * `github-org:<id>` tenant only while SLOTLOCK_DASHBOARD_ORGS lists that organization; a
611
+ * `github:<id>` tenant only while SLOTLOCK_DASHBOARD_USERS is `*` or lists that id; every tenant when
612
+ * SLOTLOCK_DASHBOARD_USERS is not set, and every other tenant.
506
613
  */
507
- function dashboardAllows(config, tenantRef) {
508
- const orgId = /^github-org:(\d+)$/.exec(tenantRef)?.[1];
614
+ export function slotlockServeAllowsTenant(config, tenantRef) {
615
+ // A test environment (RFC 0003 G9) is let through exactly as its live tenant.
616
+ const live = slotlockLiveTenant(tenantRef);
617
+ const orgId = /^github-org:(\d+)$/.exec(live)?.[1];
618
+ // With `enabled`, the database decides, as serve's authenticate asks (createSlotlockOrganizationCheck).
509
619
  if (orgId !== undefined)
510
- return config.organizations?.includes(orgId) === true;
511
- const users = config.dashboard?.allowedUsers;
620
+ return config.organizations === 'enabled' || config.organizations?.includes(orgId) === true;
621
+ const users = config.allowedUsers;
512
622
  if (users === undefined || users === '*')
513
623
  return true;
514
- const githubId = /^github:(\d+)$/.exec(tenantRef)?.[1];
624
+ // A Google person (google:<subject>) only while the list names them so.
625
+ if (live.startsWith('google:'))
626
+ return users.includes(live);
627
+ const githubId = /^github:(\d+)$/.exec(live)?.[1];
515
628
  return githubId === undefined || users.includes(githubId);
516
629
  }
517
630
  function serveTenantLimits(caps) {
@@ -550,6 +663,16 @@ export function createSlotlockMaintenanceStore(sql, caps, options = {}) {
550
663
  maxHeldHoursPerHolder: caps.holder_held_hours,
551
664
  });
552
665
  }
666
+ /** Every tenant a paged SECURITY DEFINER list (1,000 names a page, after the last) returns. */
667
+ async function pageTenants(page) {
668
+ const tenants = [];
669
+ for (;;) {
670
+ const rows = await page(tenants.at(-1) ?? null);
671
+ tenants.push(...rows.map(({ tenant }) => tenant));
672
+ if (rows.length < 1_000)
673
+ return tenants;
674
+ }
675
+ }
553
676
  /** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
554
677
  export async function startSlotlockServer(config, log) {
555
678
  const sql = connect(config.databaseUrl, POOL_SIZE);
@@ -621,10 +744,41 @@ export async function startSlotlockServer(config, log) {
621
744
  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' });
622
745
  }
623
746
  }
747
+ if (config.usage) {
748
+ for (const signature of [...SLOTLOCK_USAGE_FUNCTIONS, ...(config.quotas ? SLOTLOCK_QUOTA_FUNCTIONS : [])]) {
749
+ const [granted] = await sql `
750
+ SELECT CASE WHEN to_regprocedure(${signature}) IS NULL THEN false
751
+ ELSE has_function_privilege(to_regprocedure(${signature}), 'EXECUTE') END AS ready`;
752
+ if (!granted?.ready) {
753
+ throw Object.assign(new Error(`${SLOTLOCK_USAGE_FUNCTIONS.includes(signature) ? 'SLOTLOCK_USAGE' : 'SLOTLOCK_QUOTAS'} needs ${signature}, which this database role cannot run: run \`slotlock migrate\` (or \`slotlock serve --migrate\`) with this release`), { code: 'schema_outdated' });
754
+ }
755
+ }
756
+ }
757
+ // Each tenant's operations, recorded every 15 seconds and when the server stops (usage.ts).
758
+ const usage = config.usage
759
+ ? createSlotlockUsageMeter({
760
+ record: (flushId, rows) => recordSlotlockUsage(sql, flushId, rows),
761
+ onError: (error) => log.error('usage_record_failed', {
762
+ error: error instanceof Error ? error.message : 'unknown',
763
+ }),
764
+ })
765
+ : undefined;
766
+ // Each tenant on a plan held to its monthly quotas, counted with the meter (quotas.ts).
767
+ const quotas = config.quotas && usage
768
+ ? createSlotlockQuotas({
769
+ plan: (tenantRef) => readSlotlockTenantQuotas(sql, tenantRef, config.overageStopPercent !== undefined ? { overageStopPercent: config.overageStopPercent } : {}),
770
+ used: (tenantRef, fromDay) => readSlotlockQuotaUse(sql, tenantRef, fromDay),
771
+ local: (tenantRef, metric, fromDay) => usage.local(tenantRef, metric, fromDay),
772
+ notice: (notice) => noteSlotlockQuotaThreshold(sql, notice),
773
+ onError: (error) => log.error('quota_failed', { error: error instanceof Error ? error.message : 'unknown' }),
774
+ })
775
+ : undefined;
624
776
  const { caps } = config.limits;
625
777
  const store = createSlotlockServeStore(sql, caps, config.availability, config.retentionDays);
626
778
  const retention = config.retentionDays !== undefined ? { retentionDays: config.retentionDays } : {};
627
779
  const publicUrl = new URL(config.publicUrl);
780
+ // Partner keys (`slk_partner_…`, RFC 0003 P1): the partner API, for the partner's accounts only.
781
+ const authenticatePartner = createSlotlockPartnerKeyAuthenticator(sql);
628
782
  const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql), {
629
783
  maxConcurrentLookups: caps.key_lookups,
630
784
  maxWaitingLookups: caps.key_lookup_queue,
@@ -642,12 +796,28 @@ export async function startSlotlockServer(config, log) {
642
796
  let maintenance;
643
797
  let webhooks;
644
798
  const basePath = publicUrl.pathname.replace(/\/$/, '');
645
- const metrics = config.metricsToken === undefined
799
+ // Counted for the scrape (SLOTLOCK_METRICS_TOKEN), an OpenTelemetry collector, or both.
800
+ const metrics = config.metricsToken === undefined && config.otlp?.metricsUrl === undefined
646
801
  ? undefined
647
802
  : createSlotlockMetrics({
648
803
  version: slotlockPackageVersion(),
649
804
  ...(process.versions.tz ? { tzdata: process.versions.tz } : {}),
650
805
  });
806
+ const knownClients = new Set(config.knownClients ?? []);
807
+ // The same counters to an OpenTelemetry collector each minute, when one is configured.
808
+ const metricsExport = metrics && config.otlp?.metricsUrl
809
+ ? createSlotlockOtlpMetricsExporter({ ...config.otlp, metricsUrl: config.otlp.metricsUrl }, metrics, {
810
+ onError: (reason) => log.warn('metrics_export_failed', { reason }),
811
+ })
812
+ : undefined;
813
+ // A span per call to an OpenTelemetry collector, when one is configured.
814
+ const tracer = config.otlp?.tracesUrl
815
+ ? createSlotlockOtlpExporter(config.otlp, {
816
+ onError: (reason) => log.warn('trace_export_failed', { reason }),
817
+ })
818
+ : undefined;
819
+ // Teams on Cloud: an organization's keys work while an owner has it turned on.
820
+ const organizationEnabled = config.organizations === 'enabled' ? createSlotlockOrganizationCheck(sql) : null;
651
821
  const limits = createSlotlockRequestLimits({
652
822
  limits: config.limits,
653
823
  ...(config.sharedLimits
@@ -666,15 +836,31 @@ export async function startSlotlockServer(config, log) {
666
836
  ...(config.trustedProxyHeader !== undefined
667
837
  ? { trustedProxyHeader: config.trustedProxyHeader }
668
838
  : {}),
839
+ ...(config.trustedProxySecret !== undefined
840
+ ? { trustedProxySecret: config.trustedProxySecret }
841
+ : {}),
669
842
  basePath,
843
+ ...(quotas ? { quotas } : {}),
670
844
  signInPaths: config.dashboard ? ['/dashboard/sign-in', '/dashboard/callback'] : [],
671
845
  // The token is compared in constant time first; only then is a key looked up. A person the
672
846
  // dashboard no longer allows loses their keys with their session.
673
847
  authenticate: async (request) => {
674
- const principal = (await authenticateToken?.(request)) ?? (await authenticateKey(request));
675
- if (!principal || !dashboardAllows(config, principal.tenantRef))
848
+ const principal = (await authenticateToken?.(request)) ??
849
+ (await authenticatePartner(request)) ??
850
+ (await authenticateKey(request));
851
+ if (!principal || !slotlockServeAllowsTenant(config, principal.tenantRef))
852
+ return null;
853
+ const orgId = organizationEnabled ? /^github-org:(\d+)$/.exec(slotlockLiveTenant(principal.tenantRef))?.[1] : undefined;
854
+ if (orgId !== undefined && !(await organizationEnabled?.(orgId)))
855
+ return null;
856
+ // Slotlock-Account is a partner key's alone (RFC 0003 P3): with any other credential, or one
857
+ // that could not act for that account, the request is refused rather than run elsewhere.
858
+ if (request.headers.has(SLOTLOCK_ACCOUNT_FIELD) && principal.onBehalfOf === undefined)
676
859
  return null;
677
- maintenance?.touch(principal.tenantRef);
860
+ // A partner key's own tenant holds nothing to maintain; its accounts' keys are as any key.
861
+ if (principal.partnerId === undefined)
862
+ maintenance?.touch(principal.tenantRef);
863
+ metrics?.client(slotlockClientLabel(request.headers.get('slotlock-client'), knownClients));
678
864
  return principal;
679
865
  },
680
866
  // Counts and pseudonyms only: never an address, a credential or a tenant.
@@ -690,6 +876,7 @@ export async function startSlotlockServer(config, log) {
690
876
  availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
691
877
  });
692
878
  const agentServer = createSlotlockAgentServer({
879
+ ...(usage ? { onUsage: usage.count } : {}),
693
880
  publicBaseUrl: config.publicUrl,
694
881
  // readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
695
882
  allowInsecureLocalhost: publicUrl.protocol === 'http:',
@@ -701,6 +888,7 @@ export async function startSlotlockServer(config, log) {
701
888
  authorize: async ({ principal, operation, input }) => {
702
889
  if (!authorizeSlotlockServeOperation({ principal, operation }))
703
890
  return false;
891
+ await limits.quota({ principal, operation });
704
892
  await limits.admit({ principal, operation, input });
705
893
  return true;
706
894
  },
@@ -719,9 +907,13 @@ export async function startSlotlockServer(config, log) {
719
907
  ? { ready: true, checks: ['database'] }
720
908
  : { ready: false, checks: ['database_unreachable'] };
721
909
  },
910
+ ...(tracer ? { onCallTrace: (event, trace) => tracer.record(event, trace.parentSpanId) } : {}),
722
911
  // Outcomes and counts only: the server never reports arguments or identities.
723
912
  onEvent: (event) => {
724
913
  metrics?.event(event);
914
+ // A traced call's span is made from onCallTrace, as a child of the caller's.
915
+ if (!('traceId' in event && event.traceId !== undefined))
916
+ tracer?.record(event);
725
917
  const { type, ...fields } = event;
726
918
  log.info(type, snakeCaseKeys(fields));
727
919
  },
@@ -735,10 +927,19 @@ export async function startSlotlockServer(config, log) {
735
927
  application: {
736
928
  backend: createSlotlockStoreApplicationBackend(store, {
737
929
  maxTenantResources: SERVE_MAX_TENANT_RESOURCES,
930
+ // A granted approval's booking is made as an agent's would be (RFC 0004 A4).
931
+ agentBackend,
932
+ partners: createSlotlockPartnerStore(sql, {
933
+ keys: createSlotlockApiKeyStore(sql),
934
+ eraseTenant: (tenantRef) => store.eraseTenant({ tenantRef }),
935
+ }),
738
936
  }),
739
937
  authorize: async ({ principal, operation, input }) => {
740
- if (principal.scopes?.includes(SLOTLOCK_APPLICATION_SCOPE) !== true)
938
+ // admin, or a partner key's partner scope for the partner API: never either for the other.
939
+ const needed = slotlockApplicationScope(slotlockApplicationOperation(operation));
940
+ if (principal.scopes?.includes(needed) !== true)
741
941
  return false;
942
+ await limits.quota({ principal, operation });
742
943
  await limits.admit({ principal, operation, input });
743
944
  return true;
744
945
  },
@@ -765,27 +966,46 @@ export async function startSlotlockServer(config, log) {
765
966
  },
766
967
  sessionSecret: config.dashboard.sessionSecret,
767
968
  allowedUsers: config.dashboard.allowedUsers,
969
+ ...(config.dashboard.google ? { google: config.dashboard.google } : {}),
768
970
  ...(config.dashboard.name ? { name: config.dashboard.name } : {}),
971
+ ...(config.dashboard.termsUrl ? { termsUrl: config.dashboard.termsUrl } : {}),
972
+ ...(config.dashboard.privacyUrl ? { privacyUrl: config.dashboard.privacyUrl } : {}),
769
973
  ...(config.dashboard.organizations
770
974
  ? { organizations: config.dashboard.organizations }
771
975
  : {}),
976
+ ...(config.dashboard.organizations === 'enabled' ? { teams: createSlotlockDashboardTeams(sql) } : {}),
772
977
  keys: createSlotlockApiKeyStore(sql),
773
- resources: createSlotlockDashboardResources(store),
978
+ resources: createSlotlockDashboardResources(store, { agentBackend }),
774
979
  state: createSlotlockDashboardState(sql),
775
980
  availability: config.availability,
981
+ ...(config.usage ? { usage: true } : {}),
982
+ ...(config.quotas ? { quotas: true } : {}),
776
983
  ...(config.dashboard.assistantSecret !== undefined
777
984
  ? {
778
985
  // The assistant's reads and approved writes are agent calls: the same backend,
779
986
  // operations, rate limits and series-write slots as an API key's.
780
987
  assistant: {
781
988
  secret: config.dashboard.assistantSecret,
989
+ ...(usage
990
+ ? {
991
+ onUsage: usage.count,
992
+ onMessage: (tenantRef) => usage.add({ tenantRef, metric: 'assistant_messages', origin: 'dashboard-assistant' }),
993
+ }
994
+ : {}),
995
+ ...(config.dashboard.assistantPreviousSecrets
996
+ ? { previousSecrets: config.dashboard.assistantPreviousSecrets }
997
+ : {}),
782
998
  ...(config.dashboard.assistantPrivateUrls ? { privateUrls: true } : {}),
783
999
  backend: agentBackend,
784
1000
  consumeRateLimit: limits.consumeRateLimit,
785
1001
  // Checks only: a proposal holds nothing. The series-write slot and the write
786
1002
  // token are taken when an approved change runs (admit, consumeRateLimit).
787
1003
  authorize: async ({ principal, operation }) => authorizeSlotlockServeOperation({ principal, operation }),
788
- admit: (args) => limits.admit(args),
1004
+ // A read, or an approved write, over the tenant's monthly quota is refused.
1005
+ admit: async (args) => {
1006
+ await limits.quota(args);
1007
+ await limits.admit(args);
1008
+ },
789
1009
  },
790
1010
  }
791
1011
  : {}),
@@ -811,7 +1031,12 @@ export async function startSlotlockServer(config, log) {
811
1031
  store: createSlotlockMaintenanceStore(sql, caps, retention),
812
1032
  sql,
813
1033
  tenants: [config.tenantRef],
1034
+ // Every tenant with data, from the database: one whose keys have not called since this server
1035
+ // started is maintained too. Names only; each pass runs in its tenant's own context.
1036
+ discover: () => pageTenants((after) => sql `
1037
+ SELECT slotlock.maintenance_tenants(${after}::text) AS tenant`),
814
1038
  intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
1039
+ ...(config.usage ? { usage: true } : {}),
815
1040
  // Counts only, never the tenant.
816
1041
  onPass: (result) => {
817
1042
  metrics?.maintenance(result);
@@ -824,16 +1049,8 @@ export async function startSlotlockServer(config, log) {
824
1049
  const send = createSlotlockWebhookSender({ allowPrivateNetworks: config.webhooks === 'private' });
825
1050
  webhooks = startSlotlockWebhookDelivery({
826
1051
  // The tenants with a webhook, from the database: a restarted server finds them all.
827
- tenants: async () => {
828
- const tenants = [];
829
- for (;;) {
830
- const page = await sql `
831
- SELECT slotlock.webhook_tenants(${tenants.at(-1) ?? null}::text) AS tenant`;
832
- tenants.push(...page.map(({ tenant }) => tenant));
833
- if (page.length < 1_000)
834
- return tenants;
835
- }
836
- },
1052
+ tenants: () => pageTenants((after) => sql `
1053
+ SELECT slotlock.webhook_tenants(${after}::text) AS tenant`),
837
1054
  deliver: (tenantRef) => deliverSlotlockWebhooks({
838
1055
  tenantRef,
839
1056
  send,
@@ -844,10 +1061,11 @@ export async function startSlotlockServer(config, log) {
844
1061
  }),
845
1062
  },
846
1063
  }),
847
- // Counts only, never the tenant or a URL.
848
- onPass: (_tenant, result) => {
1064
+ // Counts only, never the tenant or a URL; the tenant's own count goes to its usage.
1065
+ onPass: (tenantRef, result) => {
849
1066
  metrics?.webhooks(result);
850
1067
  log.info('webhooks', { ...result });
1068
+ usage?.add({ tenantRef, metric: 'webhook_deliveries', origin: 'webhooks', count: result.delivered });
851
1069
  },
852
1070
  onError: (error) => log.error('webhooks_failed', describeError(error)),
853
1071
  });
@@ -864,6 +1082,8 @@ export async function startSlotlockServer(config, log) {
864
1082
  rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
865
1083
  webhooks: config.webhooks,
866
1084
  shared_limits: config.sharedLimits,
1085
+ usage: config.usage,
1086
+ quotas: config.quotas,
867
1087
  tzdata: process.versions.tz ?? null,
868
1088
  maintenance_interval_minutes: SERVE_MAINTENANCE_INTERVAL_MS / 60_000,
869
1089
  limits: {
@@ -873,12 +1093,17 @@ export async function startSlotlockServer(config, log) {
873
1093
  key_subscriptions: slotlockKeySubscriptions(caps),
874
1094
  },
875
1095
  client_address: config.trustedProxyHeader ?? 'socket',
1096
+ client_address_proof: config.trustedProxySecret !== undefined,
876
1097
  });
877
1098
  return {
878
1099
  address,
879
1100
  async close() {
880
1101
  const result = await listener.close();
881
1102
  limits.close();
1103
+ // What the last requests counted is recorded before the pool closes.
1104
+ await usage?.close();
1105
+ await tracer?.shutdown();
1106
+ await metricsExport?.shutdown();
882
1107
  await maintenance?.close();
883
1108
  await webhooks?.close();
884
1109
  await sql.end({ timeout: 5 });
@@ -899,6 +1124,94 @@ function resourceLine(resource) {
899
1124
  })}\n`;
900
1125
  }
901
1126
  /** `resource add`: create (or, for an existing reference, re-zone) one resource in the tenant. */
1127
+ /**
1128
+ * `slotlock sandbox …` (RFC 0004 A6), in the tenant SLOTLOCK_TENANT names, which must be a test
1129
+ * environment (`<tenant>~test`): seed a scenario, reset, fast-forward, or race agents for one slot.
1130
+ */
1131
+ async function runSandboxCommand(subcommand, operands, values, io) {
1132
+ const config = readSlotlockDatabaseConfig(io.env);
1133
+ if (!config.tenantRef.endsWith(SLOTLOCK_TEST_TENANT_SUFFIX)) {
1134
+ io.stderr.write(`slotlock: the sandbox works only in a test environment: SLOTLOCK_TENANT=${config.tenantRef}${SLOTLOCK_TEST_TENANT_SUFFIX}\n`);
1135
+ return 2;
1136
+ }
1137
+ const line = (value) => io.stdout.write(`${JSON.stringify(value)}\n`);
1138
+ const sql = connect(config.databaseUrl, subcommand === 'sandbox race' ? 20 : 1);
1139
+ const store = createSlotlockStore(sql);
1140
+ try {
1141
+ if (subcommand === 'sandbox seed') {
1142
+ const scenario = operands[1];
1143
+ if (!SLOTLOCK_SANDBOX_SCENARIOS.includes(scenario)) {
1144
+ io.stderr.write(`slotlock: the scenarios are ${SLOTLOCK_SANDBOX_SCENARIOS.join(', ')}\n`);
1145
+ return 2;
1146
+ }
1147
+ line(await seedSlotlockSandbox(store, config.tenantRef, scenario));
1148
+ return 0;
1149
+ }
1150
+ if (subcommand === 'sandbox reset') {
1151
+ line({ removed: await resetSlotlockSandbox(store, config.tenantRef) });
1152
+ return 0;
1153
+ }
1154
+ if (subcommand === 'sandbox fast-forward') {
1155
+ const minutes = /^[1-9][0-9]{0,5}$/.test(operands[1] ?? '') ? Number(operands[1]) : Number.NaN;
1156
+ if (!(minutes >= 1 && minutes <= 525_600)) {
1157
+ io.stderr.write('slotlock: fast-forward takes whole minutes, 1 to 525,600\n');
1158
+ return 2;
1159
+ }
1160
+ const forward = await fastForwardSlotlockSandbox(store, config.tenantRef, minutes);
1161
+ line({ minutes, holds: forward.holds, approvals: forward.approvals, pruned: forward.maintenance.pruned, approvals_expired: forward.maintenance.approvalsExpired });
1162
+ return 0;
1163
+ }
1164
+ // A race: on the resource named, from --start (an hour from tomorrow's 10:00 UTC by default).
1165
+ const agents = values.agents === undefined ? 100 : /^[0-9]{1,3}$/.test(values.agents) ? Number(values.agents) : Number.NaN;
1166
+ if (!(agents >= 2 && agents <= SLOTLOCK_SANDBOX_MAX_RACERS)) {
1167
+ io.stderr.write(`slotlock: --agents must be 2 to ${SLOTLOCK_SANDBOX_MAX_RACERS}\n`);
1168
+ return 2;
1169
+ }
1170
+ const tomorrow = new Date(Date.now() + 86_400_000);
1171
+ const start = values.start !== undefined
1172
+ ? new Date(values.start)
1173
+ : new Date(Date.UTC(tomorrow.getUTCFullYear(), tomorrow.getUTCMonth(), tomorrow.getUTCDate(), 10));
1174
+ if (!Number.isFinite(start.getTime())) {
1175
+ io.stderr.write('slotlock: --start must be a date and time, like 2027-03-02T10:00:00Z\n');
1176
+ return 2;
1177
+ }
1178
+ const resource = (await store.withTenant(config.tenantRef, (tenant) => tenant.listResources({ tenantRef: config.tenantRef, limit: 1000 }))).find((candidate) => candidate.externalRef === operands[1] || candidate.id === operands[1]);
1179
+ if (!resource) {
1180
+ io.stderr.write('slotlock: no resource with that reference in the test environment\n');
1181
+ return 1;
1182
+ }
1183
+ const raced = await raceSlotlockSandbox({
1184
+ backend: createSlotlockStoreAgentBackend(store, { availabilityRules: async () => [...readAvailability(io.env)] }),
1185
+ tenantRef: config.tenantRef,
1186
+ resourceId: resource.id,
1187
+ start,
1188
+ end: new Date(start.getTime() + 3_600_000),
1189
+ agents,
1190
+ });
1191
+ line({
1192
+ agents: raced.agents,
1193
+ booked: raced.booked,
1194
+ refused: raced.refused,
1195
+ with_alternatives: raced.withAlternatives,
1196
+ server_errors: raced.serverErrors,
1197
+ p50_ms: raced.p50Ms,
1198
+ p99_ms: raced.p99Ms,
1199
+ max_ms: raced.maxMs,
1200
+ });
1201
+ return raced.booked === 1 && raced.serverErrors === 0 ? 0 : 1;
1202
+ }
1203
+ catch (error) {
1204
+ const code = error?.code;
1205
+ if (code === 'sandbox_not_empty') {
1206
+ io.stderr.write('slotlock: the test environment has resources already; run slotlock sandbox reset first\n');
1207
+ return 1;
1208
+ }
1209
+ throw error;
1210
+ }
1211
+ finally {
1212
+ await sql.end({ timeout: 5 });
1213
+ }
1214
+ }
902
1215
  async function addResource(config, externalRef, timezone) {
903
1216
  const sql = connect(config.databaseUrl, 1);
904
1217
  try {
@@ -1071,6 +1384,229 @@ async function changeResource(config, out, name, action) {
1071
1384
  await sql.end({ timeout: 5 });
1072
1385
  }
1073
1386
  }
1387
+ /**
1388
+ * `billing daily`: everything billing needs once a day, idempotent, for a cron job. Last month is
1389
+ * closed for each active partner only once it has been over for BILLING_GRACE_MS, as late counts
1390
+ * (bookings roll up hourly) settle first; overage goes for last month and this one; and each
1391
+ * partner's reconciliation mismatches of the last week are listed. Exit 1 when anything waits or
1392
+ * differs, so the cron job's failure is the incident.
1393
+ */
1394
+ async function runBillingDaily(owner, io, options) {
1395
+ const line = (value) => io.stdout.write(`${JSON.stringify(value)}\n`);
1396
+ let attention = false;
1397
+ const month = slotlockPreviousMonth(options.now);
1398
+ const today = new Date(options.now);
1399
+ const monthBegan = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1);
1400
+ if (options.now - monthBegan < BILLING_GRACE_MS) {
1401
+ line({ job: 'partner_close', month, waiting_until: new Date(monthBegan + BILLING_GRACE_MS).toISOString() });
1402
+ }
1403
+ else {
1404
+ for (const partner of await listSlotlockPartners(owner)) {
1405
+ if (partner.status !== 'active')
1406
+ continue;
1407
+ const closed = await closeSlotlockPartnerMonth(owner, {
1408
+ partnerId: partner.id,
1409
+ month,
1410
+ now: options.now,
1411
+ ...(options.stripeSecretKey ? { stripe: { secretKey: options.stripeSecretKey } } : {}),
1412
+ });
1413
+ line({ job: 'partner_close', partner: partner.id, month, webhook: closed.webhook, invoiced: closed.invoiced, pending: closed.pending });
1414
+ if (closed.webhook === 'failed' || closed.pending > 0)
1415
+ attention = true;
1416
+ }
1417
+ }
1418
+ if (options.overage) {
1419
+ if (!options.stripeSecretKey) {
1420
+ line({ job: 'overage', skipped: 'no STRIPE_SECRET_KEY' });
1421
+ attention = true;
1422
+ }
1423
+ else {
1424
+ for (const reported of [month, slotlockMonthOf(options.now)]) {
1425
+ const report = await reportSlotlockOverage(owner, {
1426
+ month: reported,
1427
+ now: options.now,
1428
+ stripe: { secretKey: options.stripeSecretKey, eventName: 'slotlock_operations' },
1429
+ });
1430
+ line({ job: 'overage', month: reported, sent: report.sent, failed: report.failed });
1431
+ if (report.failed > 0)
1432
+ attention = true;
1433
+ }
1434
+ }
1435
+ }
1436
+ const mismatches = await owner `
1437
+ SELECT partner_id, day::text, mismatched, worst_percent::float AS worst
1438
+ FROM slotlock.partner_reconciliations
1439
+ WHERE mismatched > 0 AND day >= current_date - 7
1440
+ ORDER BY day, partner_id`;
1441
+ for (const mismatch of mismatches) {
1442
+ line({ job: 'reconciliation', partner: mismatch.partner_id, day: mismatch.day, mismatched: mismatch.mismatched, worst_percent: mismatch.worst });
1443
+ attention = true;
1444
+ }
1445
+ line({ job: 'done', month, attention });
1446
+ if (attention)
1447
+ io.stderr.write('slotlock: billing needs attention: see the lines above; run billing daily again once it is resolved\n');
1448
+ return attention ? 1 : 0;
1449
+ }
1450
+ /** How long after a month ends billing daily waits to close it: late counts settle first. */
1451
+ const BILLING_GRACE_MS = 6 * 3_600_000;
1452
+ /**
1453
+ * `resource parent <ref> <parent-ref | none>` (RFC 0003 G8): make a resource a part of a composite,
1454
+ * or no part. The refusal's code when the database refuses it, false for no such resource.
1455
+ */
1456
+ async function setResourceParent(config, out, name, parentName) {
1457
+ const sql = connect(config.databaseUrl, 1);
1458
+ try {
1459
+ const store = createSlotlockStore(sql);
1460
+ const tenantRef = config.tenantRef;
1461
+ const resources = await tenantResources(store, tenantRef, true);
1462
+ const find = (wanted) => resources.find((resource) => resource.id === wanted) ?? resources.find((resource) => resource.externalRef === wanted);
1463
+ const named = find(name);
1464
+ if (!named)
1465
+ return false;
1466
+ const parent = parentName === 'none' ? null : find(parentName);
1467
+ if (parent === undefined)
1468
+ return 'parent_not_found';
1469
+ try {
1470
+ const changed = await store.withTenant(tenantRef, (tenant) => tenant.setResourceParent({ tenantRef, id: named.id, parentId: parent?.id ?? null }));
1471
+ if (!changed)
1472
+ return false;
1473
+ out.write(`${JSON.stringify({ id: changed.id, external_ref: changed.externalRef, parent_id: changed.parentId ?? null, part_index: changed.partIndex ?? null })}\n`);
1474
+ return true;
1475
+ }
1476
+ catch (error) {
1477
+ const code = error?.code;
1478
+ if (typeof code === 'string' && (code.startsWith('composite_') || code === 'parent_not_found'))
1479
+ return code;
1480
+ throw error;
1481
+ }
1482
+ }
1483
+ finally {
1484
+ await sql.end({ timeout: 5 });
1485
+ }
1486
+ }
1487
+ /**
1488
+ * `partner …` (RFC 0003 part 4), as the schema owner (SLOTLOCK_MIGRATE_DATABASE_URL when set): make
1489
+ * a partner with its contract, list them, suspend one (every account with it) or make it active,
1490
+ * make or revoke its keys (shown once), and set its Stripe customer and its webhook (whose secret
1491
+ * is made here and shown once).
1492
+ */
1493
+ async function runPartnerCommand(config, io, action, operands, contractGiven, close = { month: undefined, stripeSecretKey: undefined }) {
1494
+ const owner = connect(config.migrateDatabaseUrl ?? config.databaseUrl, 1);
1495
+ const [id = '', second = ''] = operands;
1496
+ const line = (value) => io.stdout.write(`${JSON.stringify(value)}\n`);
1497
+ try {
1498
+ switch (action) {
1499
+ case 'create': {
1500
+ const text = contractGiven?.startsWith('@')
1501
+ ? await readFile(contractGiven.slice(1), 'utf8')
1502
+ : (contractGiven ?? '');
1503
+ let contract;
1504
+ try {
1505
+ contract = JSON.parse(text);
1506
+ }
1507
+ catch {
1508
+ throw Object.assign(new Error('Slotlock partner contract: --contract is not JSON'), { code: 'invalid_partner_contract' });
1509
+ }
1510
+ const partner = await putSlotlockPartner(owner, { id, name: second, contract });
1511
+ line({ id: partner.id, name: partner.name, status: partner.status, contract: slotlockPartnerContractJson(partner.contract) });
1512
+ return 0;
1513
+ }
1514
+ case 'list':
1515
+ for (const partner of await listSlotlockPartners(owner)) {
1516
+ line({
1517
+ id: partner.id,
1518
+ name: partner.name,
1519
+ status: partner.status,
1520
+ stripe_customer: partner.stripeCustomer,
1521
+ webhook_url: partner.webhookUrl,
1522
+ plans: Object.keys(partner.contract.plans),
1523
+ });
1524
+ }
1525
+ return 0;
1526
+ case 'suspend':
1527
+ case 'unsuspend': {
1528
+ const changed = await setSlotlockPartnerStatus(owner, id, action === 'suspend' ? 'suspended' : 'active');
1529
+ if (!changed) {
1530
+ io.stderr.write('slotlock: no partner with that id\n');
1531
+ return 1;
1532
+ }
1533
+ line({ id, status: action === 'suspend' ? 'suspended' : 'active' });
1534
+ return 0;
1535
+ }
1536
+ case 'key': {
1537
+ const created = await createSlotlockPartnerKey(owner, id, second);
1538
+ line({ partner: id, id: created.id, prefix: created.prefix, key: created.key });
1539
+ io.stderr.write('slotlock: the partner key is shown once and only its SHA-256 is stored; send it to the partner securely\n');
1540
+ return 0;
1541
+ }
1542
+ case 'revoke-key': {
1543
+ if (!(await revokeSlotlockPartnerKey(owner, id, second))) {
1544
+ io.stderr.write('slotlock: no key with that id for that partner\n');
1545
+ return 1;
1546
+ }
1547
+ line({ partner: id, id: second, revoked: true });
1548
+ return 0;
1549
+ }
1550
+ case 'customer': {
1551
+ if (!(await setSlotlockPartnerBilling(owner, id, { stripeCustomer: second }))) {
1552
+ io.stderr.write('slotlock: no partner with that id\n');
1553
+ return 1;
1554
+ }
1555
+ line({ id, stripe_customer: second });
1556
+ return 0;
1557
+ }
1558
+ case 'webhook': {
1559
+ const secret = `whsec_${randomBytes(32).toString('base64')}`;
1560
+ if (!(await setSlotlockPartnerBilling(owner, id, { webhookUrl: second, webhookSecret: secret }))) {
1561
+ io.stderr.write('slotlock: no partner with that id\n');
1562
+ return 1;
1563
+ }
1564
+ line({ id, webhook_url: second, webhook_secret: secret });
1565
+ io.stderr.write('slotlock: the webhook secret is shown once; the partner verifies usage.period_closed with it\n');
1566
+ return 0;
1567
+ }
1568
+ case 'close': {
1569
+ const closed = await closeSlotlockPartnerMonth(owner, {
1570
+ partnerId: id,
1571
+ month: close.month ?? slotlockPreviousMonth(Date.now()),
1572
+ ...(close.stripeSecretKey ? { stripe: { secretKey: close.stripeSecretKey } } : {}),
1573
+ });
1574
+ line({
1575
+ partner: closed.partner,
1576
+ month: closed.month,
1577
+ accounts: closed.accounts.length,
1578
+ lines: closed.lines.map((entry) => ({
1579
+ line: entry.line,
1580
+ quantity: entry.quantity,
1581
+ amount: entry.amount,
1582
+ currency: entry.currency,
1583
+ stripe_item: entry.stripeItem,
1584
+ })),
1585
+ checksum: closed.checksum,
1586
+ closed_now: closed.closedNow,
1587
+ webhook: closed.webhook,
1588
+ ...(closed.webhookStatus !== undefined ? { webhook_status: closed.webhookStatus } : {}),
1589
+ invoiced: closed.invoiced,
1590
+ pending: closed.pending,
1591
+ });
1592
+ if (closed.webhook === 'failed') {
1593
+ io.stderr.write('slotlock: the partner\'s endpoint did not take usage.period_closed; run partner close again for the same month\n');
1594
+ }
1595
+ if (closed.pending > 0) {
1596
+ io.stderr.write(close.stripeSecretKey
1597
+ ? 'slotlock: some lines did not reach Stripe; run partner close again for the same month\n'
1598
+ : 'slotlock: lines recorded, not invoiced: set STRIPE_SECRET_KEY (and partner customer) and run it again\n');
1599
+ }
1600
+ return closed.pending > 0 || closed.webhook === 'failed' ? 1 : 0;
1601
+ }
1602
+ default:
1603
+ return 2;
1604
+ }
1605
+ }
1606
+ finally {
1607
+ await owner.end({ timeout: 5 });
1608
+ }
1609
+ }
1074
1610
  /**
1075
1611
  * `tenant status|suspend|unsuspend|erase` for SLOTLOCK_TENANT. Suspending and lifting a suspension
1076
1612
  * are the deployment role's alone, so they connect as SLOTLOCK_MIGRATE_DATABASE_URL when it is set;
@@ -1107,6 +1643,52 @@ async function runTenantCommand(config, out, action, reason) {
1107
1643
  await sql.end({ timeout: 5 });
1108
1644
  }
1109
1645
  }
1646
+ /**
1647
+ * `plan status` prints the default plan and SLOTLOCK_TENANT's own; `plan default` sets or removes
1648
+ * the default, as the deployment role (SLOTLOCK_MIGRATE_DATABASE_URL when set), and then prints both.
1649
+ */
1650
+ async function runPlanCommand(config, out, planDefault, set) {
1651
+ if (set) {
1652
+ const owner = connect(config.migrateDatabaseUrl ?? config.databaseUrl, 1);
1653
+ try {
1654
+ await setSlotlockPlanDefault(owner, planDefault);
1655
+ }
1656
+ finally {
1657
+ await owner.end({ timeout: 5 });
1658
+ }
1659
+ }
1660
+ const sql = connect(config.databaseUrl, 1);
1661
+ try {
1662
+ const fallback = await readSlotlockPlanDefault(sql);
1663
+ const own = await readSlotlockTenantPlan(sql, config.tenantRef);
1664
+ out.write(`${JSON.stringify({
1665
+ default: fallback
1666
+ ? {
1667
+ plan: fallback.plan,
1668
+ applies_from: fallback.appliesFrom.toISOString(),
1669
+ resources: fallback.limits.resources,
1670
+ api_keys: fallback.limits.apiKeys,
1671
+ webhook_endpoints: fallback.limits.webhookEndpoints,
1672
+ }
1673
+ : null,
1674
+ tenant: config.tenantRef,
1675
+ plan: own
1676
+ ? {
1677
+ plan: own.plan,
1678
+ status: own.status,
1679
+ resources: own.limits.resources,
1680
+ api_keys: own.limits.apiKeys,
1681
+ webhook_endpoints: own.limits.webhookEndpoints,
1682
+ paid: own.stripeSubscription !== null,
1683
+ current_period_end: own.currentPeriodEnd?.toISOString() ?? null,
1684
+ }
1685
+ : null,
1686
+ })}\n`);
1687
+ }
1688
+ finally {
1689
+ await sql.end({ timeout: 5 });
1690
+ }
1691
+ }
1110
1692
  /** One key as `key create`, `key list` and `key revoke` print it: never the digest. */
1111
1693
  function apiKeyLine(apiKey, key) {
1112
1694
  return `${JSON.stringify({
@@ -1119,9 +1701,55 @@ function apiKeyLine(apiKey, key) {
1119
1701
  expires_at: apiKey.expiresAt?.toISOString() ?? null,
1120
1702
  last_used_at: apiKey.lastUsedAt?.toISOString() ?? null,
1121
1703
  revoked_at: apiKey.revokedAt?.toISOString() ?? null,
1704
+ ...(apiKey.resourceScope ? { resource_scope: apiKey.resourceScope } : {}),
1705
+ ...(apiKey.limits ? { limits: apiKeyLimitsJson(apiKey.limits) } : {}),
1122
1706
  ...(key !== undefined ? { key } : {}),
1123
1707
  })}\n`;
1124
1708
  }
1709
+ /** A key's limits as the CLI prints them: snake_case, as the API names them. */
1710
+ function apiKeyLimitsJson(limits) {
1711
+ return {
1712
+ ...(limits.maxHolds !== undefined ? { max_holds: limits.maxHolds } : {}),
1713
+ ...(limits.maxHeldHours !== undefined ? { max_held_hours: limits.maxHeldHours } : {}),
1714
+ ...(limits.requestsPerMinute !== undefined ? { requests_per_minute: limits.requestsPerMinute } : {}),
1715
+ ...(limits.requiresApproval ? { requires_approval: true } : {}),
1716
+ };
1717
+ }
1718
+ /** `--max-holds`, `--max-held-hours`, `--requests-per-minute`, `--requires-approval` (RFC 0004 A5). */
1719
+ function parseKeyLimits(values) {
1720
+ const whole = (option, value, most) => {
1721
+ if (value === undefined)
1722
+ return undefined;
1723
+ const parsed = /^[0-9]{1,7}$/.test(value) ? Number(value) : Number.NaN;
1724
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > most) {
1725
+ throw new Error(`--${option} must be a whole number from 1 to ${most.toLocaleString('en-US')}`);
1726
+ }
1727
+ return parsed;
1728
+ };
1729
+ const maxHolds = whole('max-holds', values['max-holds'], 10_000);
1730
+ const maxHeldHours = whole('max-held-hours', values['max-held-hours'], 876_000);
1731
+ const requestsPerMinute = whole('requests-per-minute', values['requests-per-minute'], 100_000);
1732
+ return {
1733
+ ...(maxHolds !== undefined ? { maxHolds } : {}),
1734
+ ...(maxHeldHours !== undefined ? { maxHeldHours } : {}),
1735
+ ...(requestsPerMinute !== undefined ? { requestsPerMinute } : {}),
1736
+ ...(values['requires-approval'] === true ? { requiresApproval: true } : {}),
1737
+ };
1738
+ }
1739
+ /** `--locations` and `--groups`: references, comma-separated; neither for a key of the whole tenant. */
1740
+ function parseKeyResourceScope(locations, groups) {
1741
+ if (locations === undefined && groups === undefined)
1742
+ return undefined;
1743
+ const list = (value) => (value ?? '')
1744
+ .split(',')
1745
+ .map((ref) => ref.trim())
1746
+ .filter((ref) => ref !== '');
1747
+ const scope = { locations: list(locations), groups: list(groups) };
1748
+ if (!isSlotlockResourceScope(scope)) {
1749
+ throw new Error('--locations and --groups take up to 20 location or group references each, at least one in all');
1750
+ }
1751
+ return scope;
1752
+ }
1125
1753
  /** `--scope`: read, write and admin, comma-separated; read,write unless given. */
1126
1754
  function parseKeyScopes(value) {
1127
1755
  if (value === undefined)
@@ -1151,6 +1779,7 @@ async function maintainTenant(config, caps) {
1151
1779
  staleSources: 0,
1152
1780
  noShows: 0,
1153
1781
  waitlistOffers: 0,
1782
+ approvalsExpired: 0,
1154
1783
  capped: true,
1155
1784
  passes: 0,
1156
1785
  };
@@ -1165,6 +1794,7 @@ async function maintainTenant(config, caps) {
1165
1794
  sum.staleSources += pass.staleSources;
1166
1795
  sum.noShows += pass.noShows;
1167
1796
  sum.waitlistOffers += pass.waitlistOffers;
1797
+ sum.approvalsExpired += pass.approvalsExpired;
1168
1798
  // A capped pass that got nowhere met only series it cannot extend. While its roll stopped
1169
1799
  // before the series it had put back, the next pass meets new ones (and perhaps a healthy
1170
1800
  // one); once it reached them, another pass would meet them again.
@@ -1226,7 +1856,7 @@ Commands:
1226
1856
  migrate Apply the schema and row-level security, then grant the serving
1227
1857
  role when SLOTLOCK_MIGRATE_DATABASE_URL names another role
1228
1858
  serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);
1229
- maintains each tenant it serves every hour
1859
+ maintains every tenant with data every hour
1230
1860
  maintain Run maintenance for the tenant now: extend recurring events to the
1231
1861
  rolling horizon and prune past the retention window (for cron)
1232
1862
  resource add <ref> [--timezone <IANA zone>]
@@ -1235,6 +1865,10 @@ Commands:
1235
1865
  resource archive <ref> Take a resource out of use: no new bookings, holds or events, and
1236
1866
  no longer listed; what is on it stays
1237
1867
  resource restore <ref> Put an archived resource back in use
1868
+ resource parent <ref> (<parent-ref> | none)
1869
+ Make a resource a part of a composite (a room of a hall): booking
1870
+ the whole then takes every part, and a part's booking keeps the
1871
+ whole from being booked; none makes it no part
1238
1872
  resource delete <ref> Delete a resource and its history; refused while a booking, hold
1239
1873
  or event on it has not ended
1240
1874
  hours show [--resource <ref>]
@@ -1249,13 +1883,63 @@ Commands:
1249
1883
  rules clear --resource <ref>
1250
1884
  Remove the resource's booking rules
1251
1885
  key create <name> [--scope read,write,admin] [--expires-in-days <n>]
1886
+ [--locations <ref,...>] [--groups <ref,...>]
1252
1887
  Create an API key for the tenant (default scope read,write, no
1253
1888
  expiry; admin adds the application API) and print it once; only its
1254
- SHA-256 is stored
1889
+ SHA-256 is stored. With --locations or --groups, the key reaches
1890
+ only the resources there (each location with those in it), read and
1891
+ write only
1892
+ [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]
1893
+ [--requires-approval]
1894
+ ... and hold the key below the server's limits: its live holds, the
1895
+ hours they hold, its requests a minute, and every booking it makes
1896
+ waiting for a person (the dashboard's Approvals)
1897
+ key limits <id> [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]
1898
+ [--requires-approval]
1899
+ Replace an active key's limits; none at all gives it the server's
1255
1900
  key list Print the tenant's API keys, one JSON object per line
1256
1901
  key rotate <id> Give an active key a new secret and print it once; the old one stops
1257
1902
  working at once, and the key keeps its id and what it booked
1258
1903
  key revoke <id> Revoke one of the tenant's API keys at once
1904
+ sandbox seed <car-rental|clinic|desks>
1905
+ Fill an empty test environment (SLOTLOCK_TENANT=<tenant>~test) with a
1906
+ scenario: locations, groups, resources, hours, rules and bookings
1907
+ sandbox reset Empty the test environment; its keys, webhooks and plan stay
1908
+ sandbox fast-forward <minutes>
1909
+ Make what would lapse in the test environment within that time lapse
1910
+ now (holds, offers, approvals), then run maintenance
1911
+ sandbox race <resource-ref> [--agents <n>] [--start <date-time>]
1912
+ Race agents (default 100) for one hour on a test resource and print who
1913
+ booked, who was refused and why, and how long the answers took
1914
+ partner create <id> <name> --contract <json | @file>
1915
+ Create a partner reselling Slotlock, or change its name and contract:
1916
+ its plans (limits and quotas), its accounts' key scopes and its prices
1917
+ (as the schema owner, SLOTLOCK_MIGRATE_DATABASE_URL)
1918
+ partner list Print the partners, one JSON object per line
1919
+ partner suspend <id> Suspend a partner: every account with it, and its keys stop
1920
+ partner unsuspend <id> Make a suspended partner active again
1921
+ partner key <id> <name> Create a partner key (slk_partner_…) and print it once
1922
+ partner revoke-key <id> <key-id>
1923
+ Revoke one of a partner's keys at once
1924
+ partner customer <id> <cus_…>
1925
+ The Stripe customer its monthly invoice goes to
1926
+ partner webhook <id> <https-url>
1927
+ Where usage.period_closed goes; prints its signing secret once
1928
+ billing daily Once a day (cron), as the schema owner: close last month for every
1929
+ active partner once it has been over for 6 hours, report overage
1930
+ when SLOTLOCK_OVERAGE is on (with STRIPE_SECRET_KEY), and list the
1931
+ partners' reconciliation mismatches of the last 7 days; exits 1 when
1932
+ anything waits or differs. Safe to run again
1933
+ overage report [--month <YYYY-MM>] [--event <name>]
1934
+ With SLOTLOCK_OVERAGE: send Stripe each paying Developer tenant's
1935
+ operations above its quota this month (or --month) not yet sent, as
1936
+ Billing Meter events (--event, default slotlock_operations), once
1937
+ (as the schema owner, with STRIPE_SECRET_KEY)
1938
+ partner close <id> [--month <YYYY-MM>]
1939
+ Close an ended month (default: last month): price its accounts' use
1940
+ with the contract, record the lines once, send usage.period_closed,
1941
+ and with STRIPE_SECRET_KEY add the lines to the partner's next Stripe
1942
+ invoice; safe to run again
1259
1943
  tenant status Print whether the tenant is suspended
1260
1944
  tenant suspend [--reason <text>]
1261
1945
  Suspend the tenant: it may read, and writes nothing; its keys get
@@ -1264,7 +1948,13 @@ Commands:
1264
1948
  tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)
1265
1949
  tenant erase --confirm <tenant>
1266
1950
  Delete everything the tenant has, its API keys included, for good;
1267
- --confirm repeats SLOTLOCK_TENANT
1951
+ --confirm repeats SLOTLOCK_TENANT; refused while its hosted plan is
1952
+ paid for (cancel the subscription first)
1953
+ plan status Print the deployment's default plan and the tenant's own (hosted)
1954
+ plan default (free | developer | none) [--from <date>]
1955
+ Put every tenant without a plan of its own on this one from --from
1956
+ (an ISO date or time; default now), or none for no default; a new
1957
+ tenant gets it at once (as the schema owner, like tenant suspend)
1268
1958
  healthcheck Exit 0 when the local server reports ready
1269
1959
 
1270
1960
  Options:
@@ -1294,6 +1984,16 @@ Environment:
1294
1984
  SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,
1295
1985
  and retired answers (never under 30) (1-3650, default 30;
1296
1986
  serve and maintain)
1987
+ SLOTLOCK_USAGE on: count each tenant's operations, bookings and holds by day, API
1988
+ key and surface (slotlock.usage_daily, kept 400 days); off
1989
+ (default): record nothing
1990
+ SLOTLOCK_QUOTAS on (needs SLOTLOCK_USAGE=on): refuse a tenant on a plan past its
1991
+ operations or bookings this month (quota_exceeded, 429); off
1992
+ (default): no quota
1993
+ SLOTLOCK_OVERAGE off (default), or on (300) or a percent (111-1000): a paying
1994
+ Developer tenant is refused only past that share of its
1995
+ operations, and the rest above its quota is billed (overage
1996
+ report); needs SLOTLOCK_QUOTAS=on
1297
1997
  SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the
1298
1998
  database, so N servers allow a tenant its rate once; off (default):
1299
1999
  each server keeps its own
@@ -1321,6 +2021,17 @@ Environment:
1321
2021
  SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,
1322
2022
  such as CF-Connecting-IP (default: the connection's address);
1323
2023
  set it only when every request comes through that proxy
2024
+ SLOTLOCK_TRUSTED_PROXY_SECRET A secret the proxy adds to every request in x-slotlock-proxy-proof,
2025
+ 32+ random characters: without it there, the field above is
2026
+ ignored and the connection's address counts
2027
+ SLOTLOCK_KNOWN_CLIENTS Comma-separated integrator names /metrics counts requests by, from
2028
+ their Slotlock-Client field (any other: other; none sent: none)
2029
+ OTEL_EXPORTER_OTLP_ENDPOINT An OpenTelemetry collector (OTLP over HTTP, JSON): a span per
2030
+ call is sent to <endpoint>/v1/traces (off without it)
2031
+ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT The full traces URL instead of <endpoint>/v1/traces
2032
+ OTEL_EXPORTER_OTLP_METRICS_ENDPOINT The full metrics URL instead of <endpoint>/v1/metrics (sent each minute)
2033
+ OTEL_EXPORTER_OTLP_HEADERS name=value pairs sent with each export, such as an API key
2034
+ OTEL_SERVICE_NAME service.name of the spans (default slotlock)
1324
2035
  SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
1325
2036
  of a GitHub OAuth app with callback .../dashboard/callback
1326
2037
  SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
@@ -1328,13 +2039,24 @@ Environment:
1328
2039
  SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's
1329
2040
  model API key with it, 32+ random characters (dashboard; off
1330
2041
  without it)
2042
+ SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS Up to three earlier SLOTLOCK_ASSISTANT_SECRET values,
2043
+ comma-separated, newest first: keys sealed under one still open
2044
+ and are sealed again under the current secret when next used
1331
2045
  SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or
1332
2046
  resolves to, a loopback, private, link-local, CGNAT, unique-local
1333
2047
  or metadata address; on: allow them, for a self-hosted model
1334
2048
  (Ollama, vLLM) on this machine or network
1335
- SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)
1336
- SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant
2049
+ SLOTLOCK_GOOGLE_CLIENT_ID Sign-in with Google too: the client id of a Google OAuth client with
2050
+ redirect URI .../dashboard/callback (not with organization ids)
2051
+ SLOTLOCK_GOOGLE_CLIENT_SECRET Its client secret
2052
+ SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids (and google:<subject>) that may sign in and whose keys work
2053
+ SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant;
2054
+ or enabled: each person's own tenant, plus the organizations an
2055
+ owner turns on from the dashboard's Teams page
1337
2056
  SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)
2057
+ SLOTLOCK_DASHBOARD_TERMS_URL https URL of the terms people accept by signing in, linked on the
2058
+ sign-in page (none named without it)
2059
+ SLOTLOCK_DASHBOARD_PRIVACY_URL https URL of the privacy notice the sign-in page links
1338
2060
  HOST Listen address (default 127.0.0.1)
1339
2061
  PORT Listen port (default 8080)
1340
2062
 
@@ -1375,7 +2097,12 @@ export async function runSlotlockCli(argv, io) {
1375
2097
  command === 'key' ||
1376
2098
  command === 'hours' ||
1377
2099
  command === 'rules' ||
1378
- command === 'tenant'
2100
+ command === 'tenant' ||
2101
+ command === 'plan' ||
2102
+ command === 'partner' ||
2103
+ command === 'overage' ||
2104
+ command === 'billing' ||
2105
+ command === 'sandbox'
1379
2106
  ? `${command} ${operands[0] ?? ''}`.trim()
1380
2107
  : command;
1381
2108
  const expectedOperands = {
@@ -1386,21 +2113,40 @@ export async function runSlotlockCli(argv, io) {
1386
2113
  'resource add': 2,
1387
2114
  'resource list': 1,
1388
2115
  'resource archive': 2,
2116
+ 'resource parent': 3,
1389
2117
  'resource restore': 2,
1390
2118
  'resource delete': 2,
1391
2119
  'tenant status': 1,
1392
2120
  'tenant suspend': 1,
1393
2121
  'tenant unsuspend': 1,
1394
2122
  'tenant erase': 1,
2123
+ 'plan status': 1,
2124
+ 'plan default': 2,
1395
2125
  'key create': 2,
1396
2126
  'key list': 1,
1397
2127
  'key rotate': 2,
1398
2128
  'key revoke': 2,
2129
+ 'key limits': 2,
2130
+ 'sandbox seed': 2,
2131
+ 'sandbox reset': 1,
2132
+ 'sandbox fast-forward': 2,
2133
+ 'sandbox race': 2,
1399
2134
  'hours show': 1,
1400
2135
  'hours set': 2,
1401
2136
  'hours clear': 1,
1402
2137
  'rules set': 2,
1403
2138
  'rules clear': 1,
2139
+ 'partner create': 3,
2140
+ 'partner list': 1,
2141
+ 'partner suspend': 2,
2142
+ 'partner unsuspend': 2,
2143
+ 'partner key': 3,
2144
+ 'partner revoke-key': 3,
2145
+ 'partner customer': 3,
2146
+ 'partner webhook': 3,
2147
+ 'partner close': 2,
2148
+ 'overage report': 1,
2149
+ 'billing daily': 1,
1404
2150
  };
1405
2151
  const expected = expectedOperands[subcommand];
1406
2152
  if (expected === undefined)
@@ -1417,12 +2163,45 @@ export async function runSlotlockCli(argv, io) {
1417
2163
  return usage(`--${option} applies only to key create`);
1418
2164
  }
1419
2165
  }
2166
+ if (values.month !== undefined && subcommand !== 'partner close' && subcommand !== 'overage report') {
2167
+ return usage('--month applies only to partner close and overage report');
2168
+ }
2169
+ if (values.event !== undefined && subcommand !== 'overage report') {
2170
+ return usage('--event applies only to overage report');
2171
+ }
2172
+ if (values.month !== undefined && !/^\d{4}-(0[1-9]|1[0-2])$/.test(values.month)) {
2173
+ return usage('--month must be a month, like 2026-10');
2174
+ }
2175
+ if (values.contract !== undefined && subcommand !== 'partner create') {
2176
+ return usage('--contract applies only to partner create');
2177
+ }
2178
+ if (subcommand === 'partner create' && values.contract === undefined) {
2179
+ return usage('partner create needs --contract <json>, or --contract @<file>');
2180
+ }
1420
2181
  if (values.reason !== undefined && subcommand !== 'tenant suspend') {
1421
2182
  return usage('--reason applies only to tenant suspend');
1422
2183
  }
1423
2184
  if (values.confirm !== undefined && subcommand !== 'tenant erase') {
1424
2185
  return usage('--confirm applies only to tenant erase');
1425
2186
  }
2187
+ if (values.from !== undefined && subcommand !== 'plan default') {
2188
+ return usage('--from applies only to plan default');
2189
+ }
2190
+ let planDefault = null;
2191
+ if (subcommand === 'plan default') {
2192
+ const named = operands[1];
2193
+ if (named !== 'free' && named !== 'developer' && named !== 'none') {
2194
+ return usage('plan default takes free, developer or none');
2195
+ }
2196
+ if (named === 'none' && values.from !== undefined)
2197
+ return usage('--from applies only to a plan');
2198
+ const appliesFrom = values.from === undefined ? new Date() : new Date(values.from);
2199
+ if (Number.isNaN(appliesFrom.getTime()) || !/^\d{4}-\d{2}-\d{2}/.test(values.from ?? '2000-01-01')) {
2200
+ return usage('--from must be an ISO date or time, like 2026-12-09 or 2026-12-09T00:00:00Z');
2201
+ }
2202
+ if (named !== 'none')
2203
+ planDefault = { plan: named, appliesFrom };
2204
+ }
1426
2205
  if (subcommand === 'tenant erase' && values.confirm === undefined) {
1427
2206
  return usage('tenant erase needs --confirm <tenant>, the tenant it erases (SLOTLOCK_TENANT)');
1428
2207
  }
@@ -1470,15 +2249,36 @@ export async function runSlotlockCli(argv, io) {
1470
2249
  }
1471
2250
  let keyScopes = [];
1472
2251
  let keyLifetimeDays;
2252
+ let keyResourceScope;
2253
+ let keyLimits = {};
1473
2254
  if (subcommand === 'key create') {
1474
2255
  try {
1475
2256
  keyScopes = parseKeyScopes(values.scope);
1476
2257
  keyLifetimeDays = parseKeyLifetime(values['expires-in-days']);
2258
+ keyResourceScope = parseKeyResourceScope(values.locations, values.groups);
1477
2259
  }
1478
2260
  catch (error) {
1479
2261
  return usage(error.message);
1480
2262
  }
1481
2263
  }
2264
+ else if (values.locations !== undefined || values.groups !== undefined) {
2265
+ return usage('--locations and --groups are for key create');
2266
+ }
2267
+ if ((values.agents !== undefined || values.start !== undefined) && subcommand !== 'sandbox race') {
2268
+ return usage('--agents and --start apply only to sandbox race');
2269
+ }
2270
+ const limitOptions = ['max-holds', 'max-held-hours', 'requests-per-minute', 'requires-approval'];
2271
+ if (subcommand === 'key create' || subcommand === 'key limits') {
2272
+ try {
2273
+ keyLimits = parseKeyLimits(values);
2274
+ }
2275
+ catch (error) {
2276
+ return usage(error.message);
2277
+ }
2278
+ }
2279
+ else if (limitOptions.some((option) => values[option] !== undefined)) {
2280
+ return usage('--max-holds, --max-held-hours, --requests-per-minute and --requires-approval are for key create and key limits');
2281
+ }
1482
2282
  const log = createLogger(io);
1483
2283
  try {
1484
2284
  switch (subcommand) {
@@ -1507,6 +2307,11 @@ export async function runSlotlockCli(argv, io) {
1507
2307
  io.stdout.write(`${JSON.stringify(result)}\n`);
1508
2308
  return 0;
1509
2309
  }
2310
+ case 'sandbox seed':
2311
+ case 'sandbox reset':
2312
+ case 'sandbox fast-forward':
2313
+ case 'sandbox race':
2314
+ return await runSandboxCommand(subcommand, operands, values, io);
1510
2315
  case 'resource add': {
1511
2316
  const config = readSlotlockDatabaseConfig(io.env);
1512
2317
  const resource = await addResource(config, operands[1], values.timezone ?? 'UTC');
@@ -1516,6 +2321,78 @@ export async function runSlotlockCli(argv, io) {
1516
2321
  case 'resource list':
1517
2322
  await listResources(readSlotlockDatabaseConfig(io.env), io.stdout);
1518
2323
  return 0;
2324
+ case 'billing daily': {
2325
+ const database = readSlotlockDatabaseConfig(io.env);
2326
+ const owner = connect(database.migrateDatabaseUrl ?? database.databaseUrl, 1);
2327
+ try {
2328
+ return await runBillingDaily(owner, io, {
2329
+ now: Date.now(),
2330
+ stripeSecretKey: io.env.STRIPE_SECRET_KEY?.trim() || undefined,
2331
+ overage: (io.env.SLOTLOCK_OVERAGE?.trim() || 'off') !== 'off',
2332
+ });
2333
+ }
2334
+ catch (error) {
2335
+ if (error?.code === '42501') {
2336
+ io.stderr.write('slotlock: only the schema owner runs billing; set SLOTLOCK_MIGRATE_DATABASE_URL to its URL\n');
2337
+ return 1;
2338
+ }
2339
+ throw error;
2340
+ }
2341
+ finally {
2342
+ await owner.end({ timeout: 5 });
2343
+ }
2344
+ }
2345
+ case 'overage report': {
2346
+ const secretKey = io.env.STRIPE_SECRET_KEY?.trim();
2347
+ if (!secretKey) {
2348
+ io.stderr.write('slotlock: overage report needs STRIPE_SECRET_KEY, with write access to meter events\n');
2349
+ return 1;
2350
+ }
2351
+ const database = readSlotlockDatabaseConfig(io.env);
2352
+ const owner = connect(database.migrateDatabaseUrl ?? database.databaseUrl, 1);
2353
+ try {
2354
+ const report = await reportSlotlockOverage(owner, {
2355
+ month: values.month ?? slotlockMonthOf(Date.now()),
2356
+ stripe: { secretKey, eventName: values.event ?? 'slotlock_operations' },
2357
+ });
2358
+ io.stdout.write(`${JSON.stringify({ month: report.month, tenants: report.tenants.length, sent: report.sent, failed: report.failed })}\n`);
2359
+ if (report.failed > 0) {
2360
+ io.stderr.write('slotlock: Stripe did not take some events; run overage report again\n');
2361
+ return 1;
2362
+ }
2363
+ return 0;
2364
+ }
2365
+ catch (error) {
2366
+ const code = error?.code;
2367
+ if (code === 'invalid_meter_event' || code === 'invalid_month') {
2368
+ io.stderr.write(`slotlock: ${error.message}\n`);
2369
+ return 2;
2370
+ }
2371
+ if (code === '42501') {
2372
+ io.stderr.write('slotlock: only the schema owner reports overage; set SLOTLOCK_MIGRATE_DATABASE_URL to its URL\n');
2373
+ return 1;
2374
+ }
2375
+ throw error;
2376
+ }
2377
+ finally {
2378
+ await owner.end({ timeout: 5 });
2379
+ }
2380
+ }
2381
+ case 'resource parent': {
2382
+ const changed = await setResourceParent(readSlotlockDatabaseConfig(io.env), io.stdout, operands[1], operands[2]);
2383
+ if (changed === true)
2384
+ return 0;
2385
+ io.stderr.write(`slotlock: ${changed === false
2386
+ ? 'no resource with that reference or id in this tenant'
2387
+ : changed === 'parent_not_found'
2388
+ ? 'no parent resource with that reference or id in this tenant'
2389
+ : changed === 'composite_busy'
2390
+ ? 'one side has a booking or live hold still to come; a composite changes only while neither does'
2391
+ : changed === 'composite_too_deep'
2392
+ ? 'composites are one level deep: a part has no parts, and a parent is no part'
2393
+ : 'a composite has at most 50 parts'}\n`);
2394
+ return 1;
2395
+ }
1519
2396
  case 'resource archive':
1520
2397
  case 'resource restore':
1521
2398
  case 'resource delete': {
@@ -1551,6 +2428,53 @@ export async function runSlotlockCli(argv, io) {
1551
2428
  if (code === 'invalid_suspension_reason') {
1552
2429
  return usage('--reason must be 1-500 characters without control characters');
1553
2430
  }
2431
+ if (code === 'billing_active') {
2432
+ io.stderr.write('slotlock: the tenant has a live subscription; cancel it in Stripe first (its webhook moves the tenant to Free), then erase\n');
2433
+ return 1;
2434
+ }
2435
+ throw error;
2436
+ }
2437
+ return 0;
2438
+ }
2439
+ case 'partner create':
2440
+ case 'partner list':
2441
+ case 'partner suspend':
2442
+ case 'partner unsuspend':
2443
+ case 'partner key':
2444
+ case 'partner revoke-key':
2445
+ case 'partner customer':
2446
+ case 'partner webhook':
2447
+ case 'partner close': {
2448
+ try {
2449
+ return await runPartnerCommand(readSlotlockDatabaseConfig(io.env), io, subcommand.slice('partner '.length), operands.slice(1), values.contract, { month: values.month, stripeSecretKey: io.env.STRIPE_SECRET_KEY?.trim() || undefined });
2450
+ }
2451
+ catch (error) {
2452
+ const code = error?.code;
2453
+ if (code === 'partner_not_found') {
2454
+ io.stderr.write('slotlock: no partner with that id\n');
2455
+ return 1;
2456
+ }
2457
+ if (code === 'invalid_partner_contract' || code === 'invalid_partner' || code === 'month_not_ended' || code === 'invalid_month') {
2458
+ io.stderr.write(`slotlock: ${error.message}\n`);
2459
+ return 2;
2460
+ }
2461
+ if (code === '42501') {
2462
+ io.stderr.write('slotlock: only the schema owner runs partner commands; set SLOTLOCK_MIGRATE_DATABASE_URL to its URL\n');
2463
+ return 1;
2464
+ }
2465
+ throw error;
2466
+ }
2467
+ }
2468
+ case 'plan status':
2469
+ case 'plan default': {
2470
+ try {
2471
+ await runPlanCommand(readSlotlockDatabaseConfig(io.env), io.stdout, planDefault, subcommand === 'plan default');
2472
+ }
2473
+ catch (error) {
2474
+ if (error?.code === 'plan_default_not_permitted') {
2475
+ io.stderr.write('slotlock: only the schema owner sets the default plan; set SLOTLOCK_MIGRATE_DATABASE_URL to its URL\n');
2476
+ return 1;
2477
+ }
1554
2478
  throw error;
1555
2479
  }
1556
2480
  return 0;
@@ -1586,6 +2510,8 @@ export async function runSlotlockCli(argv, io) {
1586
2510
  name: operands[1],
1587
2511
  scopes: keyScopes,
1588
2512
  createdBy: CLI_KEY_CREATOR,
2513
+ ...(keyResourceScope ? { resourceScope: keyResourceScope } : {}),
2514
+ ...(Object.keys(keyLimits).length > 0 ? { limits: keyLimits } : {}),
1589
2515
  ...(keyLifetimeDays !== undefined
1590
2516
  ? { expiresAt: new Date(Date.now() + keyLifetimeDays * DAY_MS) }
1591
2517
  : {}),
@@ -1612,6 +2538,17 @@ export async function runSlotlockCli(argv, io) {
1612
2538
  io.stderr.write('slotlock: the new key is shown once and the old one no longer works; keep it in a secret manager\n');
1613
2539
  return 0;
1614
2540
  }
2541
+ case 'key limits': {
2542
+ const config = readSlotlockDatabaseConfig(io.env);
2543
+ const id = operands[1];
2544
+ const set = await withApiKeys(config, (keys) => keys.setLimits({ tenantRef: config.tenantRef, id, limits: keyLimits }));
2545
+ if (!set) {
2546
+ io.stderr.write('slotlock: no active API key with that id in this tenant\n');
2547
+ return 1;
2548
+ }
2549
+ io.stdout.write(`${JSON.stringify({ id, limits: apiKeyLimitsJson(keyLimits) })}\n`);
2550
+ return 0;
2551
+ }
1615
2552
  case 'key revoke': {
1616
2553
  const config = readSlotlockDatabaseConfig(io.env);
1617
2554
  const revoked = await withApiKeys(config, (keys) => keys.revoke({ tenantRef: config.tenantRef, id: operands[1], actor: CLI_KEY_CREATOR }));
@@ -1645,10 +2582,22 @@ function parseCliArguments(argv) {
1645
2582
  timezone: { type: 'string' },
1646
2583
  scope: { type: 'string' },
1647
2584
  'expires-in-days': { type: 'string' },
2585
+ locations: { type: 'string' },
2586
+ groups: { type: 'string' },
2587
+ 'max-holds': { type: 'string' },
2588
+ 'max-held-hours': { type: 'string' },
2589
+ 'requests-per-minute': { type: 'string' },
2590
+ 'requires-approval': { type: 'boolean' },
2591
+ agents: { type: 'string' },
2592
+ start: { type: 'string' },
2593
+ contract: { type: 'string' },
2594
+ month: { type: 'string' },
2595
+ event: { type: 'string' },
1648
2596
  resource: { type: 'string' },
1649
2597
  all: { type: 'boolean' },
1650
2598
  reason: { type: 'string' },
1651
2599
  confirm: { type: 'string' },
2600
+ from: { type: 'string' },
1652
2601
  help: { type: 'boolean', short: 'h' },
1653
2602
  version: { type: 'boolean', short: 'v' },
1654
2603
  },