slotlock 0.6.3 → 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 (70) hide show
  1. package/CHANGELOG.md +467 -0
  2. package/CONTRIBUTING.md +12 -5
  3. package/README.md +50 -1551
  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 +559 -44
  8. package/agent-store-backend.d.ts +34 -1
  9. package/agent-store-backend.js +1399 -38
  10. package/api-keys.d.ts +69 -3
  11. package/api-keys.js +217 -28
  12. package/application-api.d.ts +56 -3
  13. package/application-api.js +826 -15
  14. package/approvals.d.ts +88 -0
  15. package/approvals.js +293 -0
  16. package/assistant.d.ts +40 -0
  17. package/assistant.js +115 -0
  18. package/billing.d.ts +114 -0
  19. package/billing.js +258 -0
  20. package/config.d.ts +25 -4
  21. package/config.js +72 -3
  22. package/dashboard-icon.d.ts +1 -0
  23. package/dashboard-icon.js +5 -0
  24. package/dashboard.d.ts +203 -7
  25. package/dashboard.js +1545 -64
  26. package/ddl.d.ts +51 -12
  27. package/ddl.js +2566 -54
  28. package/event-feed.d.ts +30 -4
  29. package/event-feed.js +171 -19
  30. package/index.d.ts +15 -4
  31. package/index.js +8 -2
  32. package/maintenance.d.ts +10 -0
  33. package/maintenance.js +16 -2
  34. package/metrics.d.ts +41 -0
  35. package/metrics.js +22 -0
  36. package/openapi.json +14675 -5957
  37. package/otel.d.ts +86 -0
  38. package/otel.js +273 -0
  39. package/overage.d.ts +33 -0
  40. package/overage.js +115 -0
  41. package/package.json +1 -1
  42. package/partner-billing.d.ts +81 -0
  43. package/partner-billing.js +333 -0
  44. package/partner-names.d.ts +8 -0
  45. package/partner-names.js +10 -0
  46. package/partners.d.ts +147 -0
  47. package/partners.js +375 -0
  48. package/plans.d.ts +92 -0
  49. package/plans.js +115 -0
  50. package/quotas.d.ts +108 -0
  51. package/quotas.js +258 -0
  52. package/rate-limit.d.ts +21 -1
  53. package/rate-limit.js +82 -15
  54. package/resource-profile.d.ts +135 -0
  55. package/resource-profile.js +263 -0
  56. package/rest-api.d.ts +2 -0
  57. package/rest-api.js +68 -3
  58. package/sandbox.d.ts +70 -0
  59. package/sandbox.js +295 -0
  60. package/self-host.d.ts +33 -5
  61. package/self-host.js +950 -22
  62. package/slotlock.manifest.json +1 -1
  63. package/store.d.ts +373 -4
  64. package/store.js +1212 -118
  65. package/teams.d.ts +25 -0
  66. package/teams.js +63 -0
  67. package/timezone.js +13 -1
  68. package/types.d.ts +95 -1
  69. package/usage.d.ts +106 -0
  70. package/usage.js +238 -0
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
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,17 +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',
43
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',
44
68
  'SLOTLOCK_GITHUB_CLIENT_ID',
45
69
  'SLOTLOCK_GITHUB_CLIENT_SECRET',
70
+ 'SLOTLOCK_GOOGLE_CLIENT_ID',
71
+ 'SLOTLOCK_GOOGLE_CLIENT_SECRET',
46
72
  'SLOTLOCK_SESSION_SECRET',
47
73
  'SLOTLOCK_ASSISTANT_SECRET',
74
+ 'SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS',
48
75
  'SLOTLOCK_ASSISTANT_PRIVATE_URLS',
49
76
  'SLOTLOCK_DASHBOARD_USERS',
50
77
  'SLOTLOCK_DASHBOARD_ORGS',
51
78
  'SLOTLOCK_DASHBOARD_NAME',
79
+ 'SLOTLOCK_DASHBOARD_TERMS_URL',
80
+ 'SLOTLOCK_DASHBOARD_PRIVACY_URL',
52
81
  'HOST',
53
82
  'PORT',
54
83
  ]);
@@ -65,7 +94,7 @@ const DAY_MS = 86_400_000;
65
94
  * every GitHub user gives each a tenant, so the cap keeps one from filling the database.
66
95
  */
67
96
  const SERVE_MAX_TENANT_RESOURCES = 10_000;
68
- /** How often `serve` maintains each tenant with data (README, Operations: at least daily). */
97
+ /** How often `serve` maintains each tenant with data (docs, Operations: at least daily). */
69
98
  const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
70
99
  /** Passes `slotlock maintain` runs at most, each as big as `serve`'s, until nothing is left. */
71
100
  const MAINTAIN_MAX_PASSES = 100;
@@ -95,6 +124,9 @@ const SERVED_OPERATIONS = new Set([
95
124
  'slotlock_list_events',
96
125
  'slotlock_update_event',
97
126
  'slotlock_delete_event',
127
+ 'slotlock_confirm_slot',
128
+ 'slotlock_reschedule',
129
+ 'slotlock_cancel',
98
130
  ]);
99
131
  const HEALTH_QUERY_TIMEOUT_MS = 2_000;
100
132
  /** How long one database check answers /healthz, so a flood of health checks is not one of queries. */
@@ -203,6 +235,39 @@ function readSharedLimits(env) {
203
235
  }
204
236
  return value === 'on';
205
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
+ }
206
271
  function readRestApi(env) {
207
272
  const value = optional(env, 'SLOTLOCK_REST_API') ?? 'on';
208
273
  if (value !== 'on' && value !== 'off') {
@@ -234,14 +299,39 @@ export function readSlotlockServeConfig(env) {
234
299
  restApi: readRestApi(env),
235
300
  webhooks: readWebhooks(env),
236
301
  sharedLimits: readSharedLimits(env),
302
+ usage: readUsageMetering(env),
303
+ quotas: false,
237
304
  limits: readLimits(env),
238
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
+ }
239
317
  const trustedProxyHeader = readTrustedProxyHeader(env);
240
318
  if (trustedProxyHeader !== undefined)
241
319
  config.trustedProxyHeader = trustedProxyHeader;
242
320
  const trustedProxySecret = readTrustedProxySecret(env, trustedProxyHeader);
243
321
  if (trustedProxySecret !== undefined)
244
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;
245
335
  if (authToken !== undefined)
246
336
  config.authToken = authToken;
247
337
  const metricsToken = readSecret(env, 'SLOTLOCK_METRICS_TOKEN');
@@ -252,6 +342,7 @@ export function readSlotlockServeConfig(env) {
252
342
  env.SLOTLOCK_SESSION_SECRET?.trim(),
253
343
  env.SLOTLOCK_GITHUB_CLIENT_SECRET?.trim(),
254
344
  env.SLOTLOCK_ASSISTANT_SECRET?.trim(),
345
+ ...(env.SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS?.split(',').map((part) => part.trim()) ?? []),
255
346
  ];
256
347
  if (others.includes(metricsToken)) {
257
348
  throw new SlotlockConfigError('SLOTLOCK_METRICS_TOKEN must differ from every other secret');
@@ -326,6 +417,14 @@ function secretsIn(env) {
326
417
  if (value)
327
418
  secrets.push(value);
328
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
+ }
329
428
  for (const name of ['DATABASE_URL', 'SLOTLOCK_MIGRATE_DATABASE_URL']) {
330
429
  const value = env[name]?.trim();
331
430
  if (!value)
@@ -513,13 +612,19 @@ function serveMetrics(target, metrics, path, token) {
513
612
  * SLOTLOCK_DASHBOARD_USERS is not set, and every other tenant.
514
613
  */
515
614
  export function slotlockServeAllowsTenant(config, tenantRef) {
516
- const orgId = /^github-org:(\d+)$/.exec(tenantRef)?.[1];
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).
517
619
  if (orgId !== undefined)
518
- return config.organizations?.includes(orgId) === true;
620
+ return config.organizations === 'enabled' || config.organizations?.includes(orgId) === true;
519
621
  const users = config.allowedUsers;
520
622
  if (users === undefined || users === '*')
521
623
  return true;
522
- 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];
523
628
  return githubId === undefined || users.includes(githubId);
524
629
  }
525
630
  function serveTenantLimits(caps) {
@@ -639,10 +744,41 @@ export async function startSlotlockServer(config, log) {
639
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' });
640
745
  }
641
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;
642
776
  const { caps } = config.limits;
643
777
  const store = createSlotlockServeStore(sql, caps, config.availability, config.retentionDays);
644
778
  const retention = config.retentionDays !== undefined ? { retentionDays: config.retentionDays } : {};
645
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);
646
782
  const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql), {
647
783
  maxConcurrentLookups: caps.key_lookups,
648
784
  maxWaitingLookups: caps.key_lookup_queue,
@@ -660,12 +796,28 @@ export async function startSlotlockServer(config, log) {
660
796
  let maintenance;
661
797
  let webhooks;
662
798
  const basePath = publicUrl.pathname.replace(/\/$/, '');
663
- 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
664
801
  ? undefined
665
802
  : createSlotlockMetrics({
666
803
  version: slotlockPackageVersion(),
667
804
  ...(process.versions.tz ? { tzdata: process.versions.tz } : {}),
668
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;
669
821
  const limits = createSlotlockRequestLimits({
670
822
  limits: config.limits,
671
823
  ...(config.sharedLimits
@@ -688,14 +840,27 @@ export async function startSlotlockServer(config, log) {
688
840
  ? { trustedProxySecret: config.trustedProxySecret }
689
841
  : {}),
690
842
  basePath,
843
+ ...(quotas ? { quotas } : {}),
691
844
  signInPaths: config.dashboard ? ['/dashboard/sign-in', '/dashboard/callback'] : [],
692
845
  // The token is compared in constant time first; only then is a key looked up. A person the
693
846
  // dashboard no longer allows loses their keys with their session.
694
847
  authenticate: async (request) => {
695
- const principal = (await authenticateToken?.(request)) ?? (await authenticateKey(request));
848
+ const principal = (await authenticateToken?.(request)) ??
849
+ (await authenticatePartner(request)) ??
850
+ (await authenticateKey(request));
696
851
  if (!principal || !slotlockServeAllowsTenant(config, principal.tenantRef))
697
852
  return null;
698
- maintenance?.touch(principal.tenantRef);
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)
859
+ return null;
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));
699
864
  return principal;
700
865
  },
701
866
  // Counts and pseudonyms only: never an address, a credential or a tenant.
@@ -711,6 +876,7 @@ export async function startSlotlockServer(config, log) {
711
876
  availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
712
877
  });
713
878
  const agentServer = createSlotlockAgentServer({
879
+ ...(usage ? { onUsage: usage.count } : {}),
714
880
  publicBaseUrl: config.publicUrl,
715
881
  // readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
716
882
  allowInsecureLocalhost: publicUrl.protocol === 'http:',
@@ -722,6 +888,7 @@ export async function startSlotlockServer(config, log) {
722
888
  authorize: async ({ principal, operation, input }) => {
723
889
  if (!authorizeSlotlockServeOperation({ principal, operation }))
724
890
  return false;
891
+ await limits.quota({ principal, operation });
725
892
  await limits.admit({ principal, operation, input });
726
893
  return true;
727
894
  },
@@ -740,9 +907,13 @@ export async function startSlotlockServer(config, log) {
740
907
  ? { ready: true, checks: ['database'] }
741
908
  : { ready: false, checks: ['database_unreachable'] };
742
909
  },
910
+ ...(tracer ? { onCallTrace: (event, trace) => tracer.record(event, trace.parentSpanId) } : {}),
743
911
  // Outcomes and counts only: the server never reports arguments or identities.
744
912
  onEvent: (event) => {
745
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);
746
917
  const { type, ...fields } = event;
747
918
  log.info(type, snakeCaseKeys(fields));
748
919
  },
@@ -756,10 +927,19 @@ export async function startSlotlockServer(config, log) {
756
927
  application: {
757
928
  backend: createSlotlockStoreApplicationBackend(store, {
758
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
+ }),
759
936
  }),
760
937
  authorize: async ({ principal, operation, input }) => {
761
- 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)
762
941
  return false;
942
+ await limits.quota({ principal, operation });
763
943
  await limits.admit({ principal, operation, input });
764
944
  return true;
765
945
  },
@@ -786,27 +966,46 @@ export async function startSlotlockServer(config, log) {
786
966
  },
787
967
  sessionSecret: config.dashboard.sessionSecret,
788
968
  allowedUsers: config.dashboard.allowedUsers,
969
+ ...(config.dashboard.google ? { google: config.dashboard.google } : {}),
789
970
  ...(config.dashboard.name ? { name: config.dashboard.name } : {}),
971
+ ...(config.dashboard.termsUrl ? { termsUrl: config.dashboard.termsUrl } : {}),
972
+ ...(config.dashboard.privacyUrl ? { privacyUrl: config.dashboard.privacyUrl } : {}),
790
973
  ...(config.dashboard.organizations
791
974
  ? { organizations: config.dashboard.organizations }
792
975
  : {}),
976
+ ...(config.dashboard.organizations === 'enabled' ? { teams: createSlotlockDashboardTeams(sql) } : {}),
793
977
  keys: createSlotlockApiKeyStore(sql),
794
- resources: createSlotlockDashboardResources(store),
978
+ resources: createSlotlockDashboardResources(store, { agentBackend }),
795
979
  state: createSlotlockDashboardState(sql),
796
980
  availability: config.availability,
981
+ ...(config.usage ? { usage: true } : {}),
982
+ ...(config.quotas ? { quotas: true } : {}),
797
983
  ...(config.dashboard.assistantSecret !== undefined
798
984
  ? {
799
985
  // The assistant's reads and approved writes are agent calls: the same backend,
800
986
  // operations, rate limits and series-write slots as an API key's.
801
987
  assistant: {
802
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
+ : {}),
803
998
  ...(config.dashboard.assistantPrivateUrls ? { privateUrls: true } : {}),
804
999
  backend: agentBackend,
805
1000
  consumeRateLimit: limits.consumeRateLimit,
806
1001
  // Checks only: a proposal holds nothing. The series-write slot and the write
807
1002
  // token are taken when an approved change runs (admit, consumeRateLimit).
808
1003
  authorize: async ({ principal, operation }) => authorizeSlotlockServeOperation({ principal, operation }),
809
- 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
+ },
810
1009
  },
811
1010
  }
812
1011
  : {}),
@@ -837,6 +1036,7 @@ export async function startSlotlockServer(config, log) {
837
1036
  discover: () => pageTenants((after) => sql `
838
1037
  SELECT slotlock.maintenance_tenants(${after}::text) AS tenant`),
839
1038
  intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
1039
+ ...(config.usage ? { usage: true } : {}),
840
1040
  // Counts only, never the tenant.
841
1041
  onPass: (result) => {
842
1042
  metrics?.maintenance(result);
@@ -861,10 +1061,11 @@ export async function startSlotlockServer(config, log) {
861
1061
  }),
862
1062
  },
863
1063
  }),
864
- // Counts only, never the tenant or a URL.
865
- 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) => {
866
1066
  metrics?.webhooks(result);
867
1067
  log.info('webhooks', { ...result });
1068
+ usage?.add({ tenantRef, metric: 'webhook_deliveries', origin: 'webhooks', count: result.delivered });
868
1069
  },
869
1070
  onError: (error) => log.error('webhooks_failed', describeError(error)),
870
1071
  });
@@ -881,6 +1082,8 @@ export async function startSlotlockServer(config, log) {
881
1082
  rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
882
1083
  webhooks: config.webhooks,
883
1084
  shared_limits: config.sharedLimits,
1085
+ usage: config.usage,
1086
+ quotas: config.quotas,
884
1087
  tzdata: process.versions.tz ?? null,
885
1088
  maintenance_interval_minutes: SERVE_MAINTENANCE_INTERVAL_MS / 60_000,
886
1089
  limits: {
@@ -897,6 +1100,10 @@ export async function startSlotlockServer(config, log) {
897
1100
  async close() {
898
1101
  const result = await listener.close();
899
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();
900
1107
  await maintenance?.close();
901
1108
  await webhooks?.close();
902
1109
  await sql.end({ timeout: 5 });
@@ -917,6 +1124,94 @@ function resourceLine(resource) {
917
1124
  })}\n`;
918
1125
  }
919
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
+ }
920
1215
  async function addResource(config, externalRef, timezone) {
921
1216
  const sql = connect(config.databaseUrl, 1);
922
1217
  try {
@@ -1089,6 +1384,229 @@ async function changeResource(config, out, name, action) {
1089
1384
  await sql.end({ timeout: 5 });
1090
1385
  }
1091
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
+ }
1092
1610
  /**
1093
1611
  * `tenant status|suspend|unsuspend|erase` for SLOTLOCK_TENANT. Suspending and lifting a suspension
1094
1612
  * are the deployment role's alone, so they connect as SLOTLOCK_MIGRATE_DATABASE_URL when it is set;
@@ -1125,6 +1643,52 @@ async function runTenantCommand(config, out, action, reason) {
1125
1643
  await sql.end({ timeout: 5 });
1126
1644
  }
1127
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
+ }
1128
1692
  /** One key as `key create`, `key list` and `key revoke` print it: never the digest. */
1129
1693
  function apiKeyLine(apiKey, key) {
1130
1694
  return `${JSON.stringify({
@@ -1137,9 +1701,55 @@ function apiKeyLine(apiKey, key) {
1137
1701
  expires_at: apiKey.expiresAt?.toISOString() ?? null,
1138
1702
  last_used_at: apiKey.lastUsedAt?.toISOString() ?? null,
1139
1703
  revoked_at: apiKey.revokedAt?.toISOString() ?? null,
1704
+ ...(apiKey.resourceScope ? { resource_scope: apiKey.resourceScope } : {}),
1705
+ ...(apiKey.limits ? { limits: apiKeyLimitsJson(apiKey.limits) } : {}),
1140
1706
  ...(key !== undefined ? { key } : {}),
1141
1707
  })}\n`;
1142
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
+ }
1143
1753
  /** `--scope`: read, write and admin, comma-separated; read,write unless given. */
1144
1754
  function parseKeyScopes(value) {
1145
1755
  if (value === undefined)
@@ -1169,6 +1779,7 @@ async function maintainTenant(config, caps) {
1169
1779
  staleSources: 0,
1170
1780
  noShows: 0,
1171
1781
  waitlistOffers: 0,
1782
+ approvalsExpired: 0,
1172
1783
  capped: true,
1173
1784
  passes: 0,
1174
1785
  };
@@ -1183,6 +1794,7 @@ async function maintainTenant(config, caps) {
1183
1794
  sum.staleSources += pass.staleSources;
1184
1795
  sum.noShows += pass.noShows;
1185
1796
  sum.waitlistOffers += pass.waitlistOffers;
1797
+ sum.approvalsExpired += pass.approvalsExpired;
1186
1798
  // A capped pass that got nowhere met only series it cannot extend. While its roll stopped
1187
1799
  // before the series it had put back, the next pass meets new ones (and perhaps a healthy
1188
1800
  // one); once it reached them, another pass would meet them again.
@@ -1253,6 +1865,10 @@ Commands:
1253
1865
  resource archive <ref> Take a resource out of use: no new bookings, holds or events, and
1254
1866
  no longer listed; what is on it stays
1255
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
1256
1872
  resource delete <ref> Delete a resource and its history; refused while a booking, hold
1257
1873
  or event on it has not ended
1258
1874
  hours show [--resource <ref>]
@@ -1267,13 +1883,63 @@ Commands:
1267
1883
  rules clear --resource <ref>
1268
1884
  Remove the resource's booking rules
1269
1885
  key create <name> [--scope read,write,admin] [--expires-in-days <n>]
1886
+ [--locations <ref,...>] [--groups <ref,...>]
1270
1887
  Create an API key for the tenant (default scope read,write, no
1271
1888
  expiry; admin adds the application API) and print it once; only its
1272
- 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
1273
1900
  key list Print the tenant's API keys, one JSON object per line
1274
1901
  key rotate <id> Give an active key a new secret and print it once; the old one stops
1275
1902
  working at once, and the key keeps its id and what it booked
1276
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
1277
1943
  tenant status Print whether the tenant is suspended
1278
1944
  tenant suspend [--reason <text>]
1279
1945
  Suspend the tenant: it may read, and writes nothing; its keys get
@@ -1282,7 +1948,13 @@ Commands:
1282
1948
  tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)
1283
1949
  tenant erase --confirm <tenant>
1284
1950
  Delete everything the tenant has, its API keys included, for good;
1285
- --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)
1286
1958
  healthcheck Exit 0 when the local server reports ready
1287
1959
 
1288
1960
  Options:
@@ -1312,6 +1984,16 @@ Environment:
1312
1984
  SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,
1313
1985
  and retired answers (never under 30) (1-3650, default 30;
1314
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
1315
1997
  SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the
1316
1998
  database, so N servers allow a tenant its rate once; off (default):
1317
1999
  each server keeps its own
@@ -1342,6 +2024,14 @@ Environment:
1342
2024
  SLOTLOCK_TRUSTED_PROXY_SECRET A secret the proxy adds to every request in x-slotlock-proxy-proof,
1343
2025
  32+ random characters: without it there, the field above is
1344
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)
1345
2035
  SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
1346
2036
  of a GitHub OAuth app with callback .../dashboard/callback
1347
2037
  SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
@@ -1349,13 +2039,24 @@ Environment:
1349
2039
  SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's
1350
2040
  model API key with it, 32+ random characters (dashboard; off
1351
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
1352
2045
  SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or
1353
2046
  resolves to, a loopback, private, link-local, CGNAT, unique-local
1354
2047
  or metadata address; on: allow them, for a self-hosted model
1355
2048
  (Ollama, vLLM) on this machine or network
1356
- SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in and whose keys work
1357
- 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
1358
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
1359
2060
  HOST Listen address (default 127.0.0.1)
1360
2061
  PORT Listen port (default 8080)
1361
2062
 
@@ -1396,7 +2097,12 @@ export async function runSlotlockCli(argv, io) {
1396
2097
  command === 'key' ||
1397
2098
  command === 'hours' ||
1398
2099
  command === 'rules' ||
1399
- command === 'tenant'
2100
+ command === 'tenant' ||
2101
+ command === 'plan' ||
2102
+ command === 'partner' ||
2103
+ command === 'overage' ||
2104
+ command === 'billing' ||
2105
+ command === 'sandbox'
1400
2106
  ? `${command} ${operands[0] ?? ''}`.trim()
1401
2107
  : command;
1402
2108
  const expectedOperands = {
@@ -1407,21 +2113,40 @@ export async function runSlotlockCli(argv, io) {
1407
2113
  'resource add': 2,
1408
2114
  'resource list': 1,
1409
2115
  'resource archive': 2,
2116
+ 'resource parent': 3,
1410
2117
  'resource restore': 2,
1411
2118
  'resource delete': 2,
1412
2119
  'tenant status': 1,
1413
2120
  'tenant suspend': 1,
1414
2121
  'tenant unsuspend': 1,
1415
2122
  'tenant erase': 1,
2123
+ 'plan status': 1,
2124
+ 'plan default': 2,
1416
2125
  'key create': 2,
1417
2126
  'key list': 1,
1418
2127
  'key rotate': 2,
1419
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,
1420
2134
  'hours show': 1,
1421
2135
  'hours set': 2,
1422
2136
  'hours clear': 1,
1423
2137
  'rules set': 2,
1424
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,
1425
2150
  };
1426
2151
  const expected = expectedOperands[subcommand];
1427
2152
  if (expected === undefined)
@@ -1438,12 +2163,45 @@ export async function runSlotlockCli(argv, io) {
1438
2163
  return usage(`--${option} applies only to key create`);
1439
2164
  }
1440
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
+ }
1441
2181
  if (values.reason !== undefined && subcommand !== 'tenant suspend') {
1442
2182
  return usage('--reason applies only to tenant suspend');
1443
2183
  }
1444
2184
  if (values.confirm !== undefined && subcommand !== 'tenant erase') {
1445
2185
  return usage('--confirm applies only to tenant erase');
1446
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
+ }
1447
2205
  if (subcommand === 'tenant erase' && values.confirm === undefined) {
1448
2206
  return usage('tenant erase needs --confirm <tenant>, the tenant it erases (SLOTLOCK_TENANT)');
1449
2207
  }
@@ -1491,15 +2249,36 @@ export async function runSlotlockCli(argv, io) {
1491
2249
  }
1492
2250
  let keyScopes = [];
1493
2251
  let keyLifetimeDays;
2252
+ let keyResourceScope;
2253
+ let keyLimits = {};
1494
2254
  if (subcommand === 'key create') {
1495
2255
  try {
1496
2256
  keyScopes = parseKeyScopes(values.scope);
1497
2257
  keyLifetimeDays = parseKeyLifetime(values['expires-in-days']);
2258
+ keyResourceScope = parseKeyResourceScope(values.locations, values.groups);
2259
+ }
2260
+ catch (error) {
2261
+ return usage(error.message);
2262
+ }
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);
1498
2274
  }
1499
2275
  catch (error) {
1500
2276
  return usage(error.message);
1501
2277
  }
1502
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
+ }
1503
2282
  const log = createLogger(io);
1504
2283
  try {
1505
2284
  switch (subcommand) {
@@ -1528,6 +2307,11 @@ export async function runSlotlockCli(argv, io) {
1528
2307
  io.stdout.write(`${JSON.stringify(result)}\n`);
1529
2308
  return 0;
1530
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);
1531
2315
  case 'resource add': {
1532
2316
  const config = readSlotlockDatabaseConfig(io.env);
1533
2317
  const resource = await addResource(config, operands[1], values.timezone ?? 'UTC');
@@ -1537,6 +2321,78 @@ export async function runSlotlockCli(argv, io) {
1537
2321
  case 'resource list':
1538
2322
  await listResources(readSlotlockDatabaseConfig(io.env), io.stdout);
1539
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
+ }
1540
2396
  case 'resource archive':
1541
2397
  case 'resource restore':
1542
2398
  case 'resource delete': {
@@ -1572,6 +2428,53 @@ export async function runSlotlockCli(argv, io) {
1572
2428
  if (code === 'invalid_suspension_reason') {
1573
2429
  return usage('--reason must be 1-500 characters without control characters');
1574
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
+ }
1575
2478
  throw error;
1576
2479
  }
1577
2480
  return 0;
@@ -1607,6 +2510,8 @@ export async function runSlotlockCli(argv, io) {
1607
2510
  name: operands[1],
1608
2511
  scopes: keyScopes,
1609
2512
  createdBy: CLI_KEY_CREATOR,
2513
+ ...(keyResourceScope ? { resourceScope: keyResourceScope } : {}),
2514
+ ...(Object.keys(keyLimits).length > 0 ? { limits: keyLimits } : {}),
1610
2515
  ...(keyLifetimeDays !== undefined
1611
2516
  ? { expiresAt: new Date(Date.now() + keyLifetimeDays * DAY_MS) }
1612
2517
  : {}),
@@ -1633,6 +2538,17 @@ export async function runSlotlockCli(argv, io) {
1633
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');
1634
2539
  return 0;
1635
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
+ }
1636
2552
  case 'key revoke': {
1637
2553
  const config = readSlotlockDatabaseConfig(io.env);
1638
2554
  const revoked = await withApiKeys(config, (keys) => keys.revoke({ tenantRef: config.tenantRef, id: operands[1], actor: CLI_KEY_CREATOR }));
@@ -1666,10 +2582,22 @@ function parseCliArguments(argv) {
1666
2582
  timezone: { type: 'string' },
1667
2583
  scope: { type: 'string' },
1668
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' },
1669
2596
  resource: { type: 'string' },
1670
2597
  all: { type: 'boolean' },
1671
2598
  reason: { type: 'string' },
1672
2599
  confirm: { type: 'string' },
2600
+ from: { type: 'string' },
1673
2601
  help: { type: 'boolean', short: 'h' },
1674
2602
  version: { type: 'boolean', short: 'v' },
1675
2603
  },