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/sandbox.js ADDED
@@ -0,0 +1,295 @@
1
+ // The sandbox (RFC 0004 A6): a test environment (a tenant ending ~test) filled with a scenario an
2
+ // agent can be tried against, emptied again, moved forward so what would lapse lapses, and raced.
3
+ // None of it touches a live tenant: each refuses one (`sandbox_test_only`), and the database's
4
+ // reset does too.
5
+ import { randomUUID } from 'node:crypto';
6
+ import { invokeSlotlockAgentOperation, } from './agent-server.js';
7
+ import { SLOTLOCK_TEST_TENANT_SUFFIX } from './api-keys.js';
8
+ import { maintainSlotlockTenant } from './maintenance.js';
9
+ export const SLOTLOCK_SANDBOX_SCENARIOS = Object.freeze(['car-rental', 'clinic', 'desks']);
10
+ /** Agents one race runs at most, so a race stays a test of the server and not a load on it. */
11
+ export const SLOTLOCK_SANDBOX_MAX_RACERS = 200;
12
+ /** Refused outside a test environment. */
13
+ export class SlotlockSandboxError extends Error {
14
+ code;
15
+ constructor(code, message) {
16
+ super(message);
17
+ this.code = code;
18
+ }
19
+ }
20
+ function assertTestTenant(tenantRef) {
21
+ if (!tenantRef.endsWith(SLOTLOCK_TEST_TENANT_SUFFIX)) {
22
+ throw new SlotlockSandboxError('sandbox_test_only', `Slotlock's sandbox works only in a test environment (a tenant ending ${SLOTLOCK_TEST_TENANT_SUFFIX})`);
23
+ }
24
+ }
25
+ const EVERY_DAY = 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU';
26
+ const WEEKDAYS = 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR';
27
+ const hours = (rrule, from, to) => [
28
+ { rrule, startMinutes: from * 60, durationMinutes: (to - from) * 60 },
29
+ ];
30
+ const SCENARIOS = {
31
+ // A rental desk at two branches: cars and vans in pools, booked by the day or the hour, with
32
+ // time to clean each between hires.
33
+ 'car-rental': {
34
+ timezone: 'Europe/London',
35
+ locations: [
36
+ { ref: 'airport', name: 'Airport branch' },
37
+ { ref: 'city', name: 'City branch' },
38
+ ],
39
+ groups: [{ ref: 'electric', name: 'Electric' }],
40
+ resources: [
41
+ { ref: 'car-1', name: 'Compact 1', kind: 'vehicle', capacity: 5, location: 'airport', pool: 'compact', labels: ['automatic'] },
42
+ { ref: 'car-2', name: 'Compact 2', kind: 'vehicle', capacity: 5, location: 'airport', pool: 'compact' },
43
+ { ref: 'car-3', name: 'Compact 3 (EV)', kind: 'vehicle', capacity: 5, location: 'city', pool: 'compact', groups: ['electric'] },
44
+ { ref: 'van-1', name: 'Van 1', kind: 'vehicle', capacity: 3, location: 'city', pool: 'van' },
45
+ { ref: 'van-2', name: 'Van 2 (EV)', kind: 'vehicle', capacity: 3, location: 'airport', pool: 'van', groups: ['electric'] },
46
+ ],
47
+ hours: hours(EVERY_DAY, 7, 21),
48
+ rules: { minNoticeMinutes: 60, turnaroundMinutes: 30, slotStepMinutes: 30 },
49
+ bookings: [
50
+ { resource: 'car-1', hour: 9, length: 4 },
51
+ { resource: 'car-2', hour: 10, length: 2 },
52
+ { resource: 'van-1', hour: 8, length: 9 },
53
+ ],
54
+ },
55
+ // A physiotherapy clinic: practitioners and treatment rooms on weekdays, half-hour slots.
56
+ clinic: {
57
+ timezone: 'America/New_York',
58
+ locations: [{ ref: 'main-street', name: 'Main Street clinic' }],
59
+ groups: [{ ref: 'physio', name: 'Physiotherapists' }],
60
+ resources: [
61
+ { ref: 'dr-adams', name: 'Dr Adams', kind: 'person', location: 'main-street', groups: ['physio'] },
62
+ { ref: 'dr-baker', name: 'Dr Baker', kind: 'person', location: 'main-street', groups: ['physio'] },
63
+ { ref: 'room-1', name: 'Treatment room 1', kind: 'room', capacity: 2, location: 'main-street', pool: 'treatment-room' },
64
+ { ref: 'room-2', name: 'Treatment room 2', kind: 'room', capacity: 2, location: 'main-street', pool: 'treatment-room' },
65
+ ],
66
+ hours: hours(WEEKDAYS, 9, 17),
67
+ rules: { slotStepMinutes: 30, minNoticeMinutes: 120 },
68
+ bookings: [
69
+ { resource: 'dr-adams', hour: 9, length: 1 },
70
+ { resource: 'dr-baker', hour: 11, length: 1 },
71
+ { resource: 'room-1', hour: 9, length: 1 },
72
+ ],
73
+ },
74
+ // An office: desks on two floors, booked by the day, and a hall that splits into two rooms.
75
+ desks: {
76
+ timezone: 'Europe/Berlin',
77
+ locations: [
78
+ { ref: 'hq', name: 'Headquarters' },
79
+ { ref: 'floor-1', name: 'Floor 1', parent: 'hq' },
80
+ { ref: 'floor-2', name: 'Floor 2', parent: 'hq' },
81
+ ],
82
+ groups: [{ ref: 'standing', name: 'Standing desks' }],
83
+ resources: [
84
+ ...Array.from({ length: 6 }, (_, index) => ({
85
+ ref: `desk-${index + 1}`,
86
+ name: `Desk ${index + 1}`,
87
+ kind: 'desk',
88
+ location: index < 3 ? 'floor-1' : 'floor-2',
89
+ pool: 'desk',
90
+ ...(index % 3 === 0 ? { groups: ['standing'] } : {}),
91
+ })),
92
+ { ref: 'hall', name: 'Event hall', kind: 'room', capacity: 80, location: 'floor-1' },
93
+ { ref: 'hall-east', name: 'Event hall, east', kind: 'room', capacity: 40, location: 'floor-1', parent: 'hall' },
94
+ { ref: 'hall-west', name: 'Event hall, west', kind: 'room', capacity: 40, location: 'floor-1', parent: 'hall' },
95
+ ],
96
+ hours: hours(WEEKDAYS, 8, 18),
97
+ bookings: [
98
+ { resource: 'desk-1', hour: 8, length: 10 },
99
+ { resource: 'desk-4', hour: 8, length: 10 },
100
+ { resource: 'hall-east', hour: 14, length: 2 },
101
+ ],
102
+ },
103
+ };
104
+ /** The time `hour` o'clock local time falls on tomorrow, in `timezone`, as UTC. */
105
+ function tomorrowAt(now, timezone, hour) {
106
+ const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit' })
107
+ .formatToParts(new Date(now.getTime() + 86_400_000))
108
+ .filter(({ type }) => type !== 'literal')
109
+ .map(({ type, value }) => [type, Number(value)]));
110
+ // The zone's offset at that wall time, found by asking what the guess reads as there.
111
+ const guess = Date.UTC(parts.year, parts.month - 1, parts.day, hour);
112
+ const local = Object.fromEntries(new Intl.DateTimeFormat('en-US', {
113
+ timeZone: timezone,
114
+ hourCycle: 'h23',
115
+ year: 'numeric',
116
+ month: '2-digit',
117
+ day: '2-digit',
118
+ hour: '2-digit',
119
+ minute: '2-digit',
120
+ })
121
+ .formatToParts(new Date(guess))
122
+ .filter(({ type }) => type !== 'literal')
123
+ .map(({ type, value }) => [type, Number(value)]));
124
+ const shown = Date.UTC(local.year, local.month - 1, local.day, local.hour, local.minute);
125
+ return new Date(guess - (shown - guess));
126
+ }
127
+ /**
128
+ * Fill an empty test environment with a scenario, in one transaction: its locations, groups,
129
+ * resources (with profiles, pools and a composite where it has one), their hours and booking
130
+ * rules, and a few bookings tomorrow. Refused (`sandbox_not_empty`) where it has resources already:
131
+ * reset it first.
132
+ */
133
+ export async function seedSlotlockSandbox(store, tenantRef, scenario, options = {}) {
134
+ assertTestTenant(tenantRef);
135
+ const plan = SCENARIOS[scenario];
136
+ if (!plan)
137
+ throw new RangeError(`Slotlock knows the scenarios ${SLOTLOCK_SANDBOX_SCENARIOS.join(', ')}`);
138
+ const now = options.now ?? new Date();
139
+ return store.withTenant(tenantRef, async (tenant) => {
140
+ if ((await tenant.countResources({ tenantRef })) > 0) {
141
+ throw new SlotlockSandboxError('sandbox_not_empty', 'Slotlock seeds an empty test environment: reset it first');
142
+ }
143
+ for (const location of plan.locations) {
144
+ await tenant.putLocation({
145
+ tenantRef,
146
+ ref: location.ref,
147
+ name: location.name,
148
+ ...(location.parent ? { parentRef: location.parent } : { timezone: plan.timezone }),
149
+ });
150
+ }
151
+ for (const group of plan.groups)
152
+ await tenant.putGroup({ tenantRef, ref: group.ref, name: group.name });
153
+ const ids = new Map();
154
+ for (const resource of plan.resources) {
155
+ const created = await tenant.createResource({
156
+ tenantRef,
157
+ externalRef: resource.ref,
158
+ timezone: plan.timezone,
159
+ profile: {
160
+ name: resource.name,
161
+ kind: resource.kind,
162
+ ...(resource.capacity !== undefined ? { capacity: resource.capacity } : {}),
163
+ ...(resource.labels ? { labels: resource.labels } : {}),
164
+ },
165
+ ...(resource.location ? { location: resource.location } : {}),
166
+ });
167
+ ids.set(resource.ref, created.id);
168
+ await tenant.setResourceAvailability({ tenantRef, id: created.id, rules: plan.hours });
169
+ if (plan.rules)
170
+ await tenant.setResourceBookingRules({ tenantRef, id: created.id, rules: plan.rules });
171
+ if (resource.pool)
172
+ await tenant.setResourcePool({ tenantRef, id: created.id, pool: resource.pool });
173
+ for (const group of resource.groups ?? []) {
174
+ await tenant.addGroupMember({ tenantRef, group, resourceId: created.id });
175
+ }
176
+ }
177
+ for (const resource of plan.resources) {
178
+ if (resource.parent) {
179
+ await tenant.setResourceParent({
180
+ tenantRef,
181
+ id: ids.get(resource.ref),
182
+ parentId: ids.get(resource.parent),
183
+ });
184
+ }
185
+ }
186
+ let bookings = 0;
187
+ for (const [index, booking] of plan.bookings.entries()) {
188
+ const start = tomorrowAt(now, plan.timezone, booking.hour);
189
+ const made = await tenant.createExternalReservation({
190
+ tenantRef,
191
+ externalRef: `sandbox-${scenario}-${index + 1}`,
192
+ resourceId: ids.get(booking.resource),
193
+ start,
194
+ end: new Date(start.getTime() + booking.length * 3_600_000),
195
+ bufferAfterMs: 0,
196
+ revision: 1,
197
+ source: 'sandbox',
198
+ });
199
+ if (made.ok)
200
+ bookings += 1;
201
+ }
202
+ return {
203
+ scenario,
204
+ resources: plan.resources.length,
205
+ locations: plan.locations.length,
206
+ groups: plan.groups.length,
207
+ pools: new Set(plan.resources.flatMap((resource) => (resource.pool ? [resource.pool] : []))).size,
208
+ bookings,
209
+ };
210
+ }, { origin: 'maintenance', actor: 'slotlock:sandbox', isolation: 'read committed' });
211
+ }
212
+ /** Empty a test environment: what seeding made, and every booking since. Keys, webhooks and plan stay. */
213
+ export async function resetSlotlockSandbox(store, tenantRef) {
214
+ assertTestTenant(tenantRef);
215
+ return store.withTenant(tenantRef, (tenant) => tenant.resetTestTenant({ tenantRef }), {
216
+ origin: 'maintenance',
217
+ actor: 'slotlock:sandbox',
218
+ });
219
+ }
220
+ /**
221
+ * Move a test environment's waiting forward: what would lapse within `minutes` lapses now, then a
222
+ * maintenance pass reaps it as it would have (holds released, waitlist offers passed on, approvals
223
+ * expired). Bookings and events keep their times; no-shows and series rolls follow the real clock.
224
+ */
225
+ export async function fastForwardSlotlockSandbox(store, tenantRef, minutes) {
226
+ assertTestTenant(tenantRef);
227
+ const lapsed = await store.withTenant(tenantRef, (tenant) => tenant.fastForwardTestTenant({ tenantRef, minutes }), {
228
+ origin: 'maintenance',
229
+ actor: 'slotlock:sandbox',
230
+ });
231
+ const maintenance = await maintainSlotlockTenant(store, tenantRef);
232
+ return { minutes, ...lapsed, maintenance };
233
+ }
234
+ /**
235
+ * Race `agents` agents (2-200), each its own principal, for one time on one resource of a test
236
+ * environment, through the same operation boundary MCP, A2A and REST use: who booked, who was
237
+ * refused and why, and how long each answer took.
238
+ */
239
+ export async function raceSlotlockSandbox(params) {
240
+ assertTestTenant(params.tenantRef);
241
+ const agents = params.agents ?? 100;
242
+ if (!Number.isSafeInteger(agents) || agents < 2 || agents > SLOTLOCK_SANDBOX_MAX_RACERS) {
243
+ throw new RangeError(`A sandbox race runs 2 to ${SLOTLOCK_SANDBOX_MAX_RACERS} agents`);
244
+ }
245
+ const race = randomUUID().slice(0, 8);
246
+ const answers = await Promise.all(Array.from({ length: agents }, async (_, index) => {
247
+ const started = performance.now();
248
+ const answer = await invokeSlotlockAgentOperation({
249
+ operation: 'slotlock_create_event',
250
+ input: {
251
+ resource_id: params.resourceId,
252
+ starts_at: params.start.toISOString(),
253
+ ends_at: params.end.toISOString(),
254
+ timezone: 'UTC',
255
+ title: `Sandbox racer ${index + 1}`,
256
+ idempotency_key: `sandbox-race-${race}-${index}`,
257
+ },
258
+ request: new Request('https://sandbox.slotlock.invalid/race'),
259
+ options: {
260
+ backend: params.backend,
261
+ authenticate: async () => ({
262
+ subject: `sandbox-racer-${race}-${index + 1}`,
263
+ tenantRef: params.tenantRef,
264
+ scopes: ['read', 'write'],
265
+ }),
266
+ authorize: async () => true,
267
+ },
268
+ });
269
+ return { answer, ms: performance.now() - started };
270
+ }));
271
+ const refused = {};
272
+ let withAlternatives = 0;
273
+ let serverErrors = 0;
274
+ for (const { answer } of answers) {
275
+ if (answer.ok)
276
+ continue;
277
+ if (answer.status >= 500)
278
+ serverErrors += 1;
279
+ refused[answer.code] = (refused[answer.code] ?? 0) + 1;
280
+ if (answer.alternatives?.length)
281
+ withAlternatives += 1;
282
+ }
283
+ const times = answers.map(({ ms }) => ms).sort((a, b) => a - b);
284
+ const at = (share) => Math.round(times[Math.min(times.length - 1, Math.ceil(times.length * share) - 1)]);
285
+ return {
286
+ agents,
287
+ booked: answers.filter(({ answer }) => answer.ok).length,
288
+ refused,
289
+ withAlternatives,
290
+ serverErrors,
291
+ p50Ms: at(0.5),
292
+ p99Ms: at(0.99),
293
+ maxMs: Math.round(times.at(-1)),
294
+ };
295
+ }
package/self-host.d.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import { type SlotlockAgentOperation, type SlotlockAgentPrincipal, type SlotlockAgentWriteOperation } from './agent-server.js';
2
2
  import { SlotlockConfigError, type SlotlockEnv, type SlotlockLimits, type SlotlockServeDashboardConfig } from './config.js';
3
+ import { type SlotlockOtlpConfig } from './otel.js';
3
4
  import { type SlotlockNodeServerAddress, type SlotlockNodeServerCloseResult } from './node-server.js';
4
5
  import { type SlotlockSql, type SlotlockStore } from './store.js';
5
6
  import type { WeeklyAvailabilityRule } from './types.js';
6
7
  export { SlotlockConfigError, type SlotlockEnv, type SlotlockServeDashboardConfig };
7
- /** Every variable the commands read. The README, `.env.example` and `server.json` document these. */
8
- export declare const SLOTLOCK_ENVIRONMENT_VARIABLES: readonly ["DATABASE_URL", "SLOTLOCK_MIGRATE_DATABASE_URL", "SLOTLOCK_AUTH_TOKEN", "SLOTLOCK_CONFIRMATION_SECRET", "SLOTLOCK_CONFIRM_WRITES", "SLOTLOCK_METRICS_TOKEN", "SLOTLOCK_PUBLIC_URL", "SLOTLOCK_TENANT", "SLOTLOCK_AVAILABILITY", "SLOTLOCK_REST_API", "SLOTLOCK_RETENTION_DAYS", "SLOTLOCK_WEBHOOKS", "SLOTLOCK_SHARED_LIMITS", "SLOTLOCK_LIMITS", "SLOTLOCK_TRUSTED_PROXY_HEADER", "SLOTLOCK_GITHUB_CLIENT_ID", "SLOTLOCK_GITHUB_CLIENT_SECRET", "SLOTLOCK_SESSION_SECRET", "SLOTLOCK_ASSISTANT_SECRET", "SLOTLOCK_ASSISTANT_PRIVATE_URLS", "SLOTLOCK_DASHBOARD_USERS", "SLOTLOCK_DASHBOARD_ORGS", "SLOTLOCK_DASHBOARD_NAME", "HOST", "PORT"];
8
+ /**
9
+ * Every variable the commands read. `slotlock --help`, `.env.example`, `server.json` and the docs
10
+ * (site/docs-src/self-host.md) document these. Not STRIPE_SECRET_KEY, which only the owner's
11
+ * `partner close` reads, to bill partners.
12
+ */
13
+ export declare const SLOTLOCK_ENVIRONMENT_VARIABLES: readonly ["DATABASE_URL", "SLOTLOCK_MIGRATE_DATABASE_URL", "SLOTLOCK_AUTH_TOKEN", "SLOTLOCK_CONFIRMATION_SECRET", "SLOTLOCK_CONFIRM_WRITES", "SLOTLOCK_METRICS_TOKEN", "SLOTLOCK_PUBLIC_URL", "SLOTLOCK_TENANT", "SLOTLOCK_AVAILABILITY", "SLOTLOCK_REST_API", "SLOTLOCK_RETENTION_DAYS", "SLOTLOCK_WEBHOOKS", "SLOTLOCK_SHARED_LIMITS", "SLOTLOCK_USAGE", "SLOTLOCK_QUOTAS", "SLOTLOCK_OVERAGE", "SLOTLOCK_LIMITS", "SLOTLOCK_TRUSTED_PROXY_HEADER", "SLOTLOCK_TRUSTED_PROXY_SECRET", "SLOTLOCK_KNOWN_CLIENTS", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", "OTEL_EXPORTER_OTLP_HEADERS", "OTEL_SERVICE_NAME", "SLOTLOCK_GITHUB_CLIENT_ID", "SLOTLOCK_GITHUB_CLIENT_SECRET", "SLOTLOCK_GOOGLE_CLIENT_ID", "SLOTLOCK_GOOGLE_CLIENT_SECRET", "SLOTLOCK_SESSION_SECRET", "SLOTLOCK_ASSISTANT_SECRET", "SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS", "SLOTLOCK_ASSISTANT_PRIVATE_URLS", "SLOTLOCK_DASHBOARD_USERS", "SLOTLOCK_DASHBOARD_ORGS", "SLOTLOCK_DASHBOARD_NAME", "SLOTLOCK_DASHBOARD_TERMS_URL", "SLOTLOCK_DASHBOARD_PRIVACY_URL", "HOST", "PORT"];
9
14
  export interface SlotlockDatabaseConfig {
10
15
  /** The role `serve` and `resource` connect as. */
11
16
  databaseUrl: string;
@@ -46,9 +51,15 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
46
51
  dashboard?: SlotlockServeDashboardConfig;
47
52
  /**
48
53
  * GitHub organization ids (SLOTLOCK_DASHBOARD_ORGS) whose `github-org:<id>` tenants this server's
54
+ * keys may act for, whether it serves the dashboard or another server (a Worker) does; or
55
+ * `'enabled'`, those an owner turned on from the dashboard (teams.ts), checked in the database.
56
+ */
57
+ organizations?: string[] | 'enabled';
58
+ /**
59
+ * GitHub user ids (SLOTLOCK_DASHBOARD_USERS), or `'*'`, whose `github:<id>` tenants this server's
49
60
  * keys may act for, whether it serves the dashboard or another server (a Worker) does.
50
61
  */
51
- organizations?: string[];
62
+ allowedUsers?: '*' | string[];
52
63
  /**
53
64
  * Serve the REST API under /v1, with the application API for admin keys, and its OpenAPI document
54
65
  * (SLOTLOCK_REST_API: on unless off).
@@ -58,10 +69,34 @@ export interface SlotlockServeConfig extends SlotlockDatabaseConfig {
58
69
  webhooks: 'on' | 'off' | 'private';
59
70
  /** Tenant request budgets shared with every server through PostgreSQL (SLOTLOCK_SHARED_LIMITS=on). */
60
71
  sharedLimits: boolean;
72
+ /**
73
+ * Usage metering (SLOTLOCK_USAGE=on; off by default): each tenant's operations, bookings and
74
+ * holds counted by day, key and surface in slotlock.usage_daily (usage.ts).
75
+ */
76
+ usage: boolean;
77
+ /**
78
+ * Monthly quotas (SLOTLOCK_QUOTAS=on, which needs SLOTLOCK_USAGE=on; off by default): a tenant on
79
+ * a plan is refused `quota_exceeded` past its plan's operations or bookings this month (quotas.ts).
80
+ */
81
+ quotas: boolean;
82
+ /**
83
+ * Metered overage (SLOTLOCK_OVERAGE: off, or the percent of its quota a paying Developer tenant
84
+ * is refused at, 111-1000; needs SLOTLOCK_QUOTAS=on). Off by default: overage.ts.
85
+ */
86
+ overageStopPercent?: number;
61
87
  /** Request limits (SLOTLOCK_LIMITS over SLOTLOCK_DEFAULT_LIMITS): the server's only rate limit. */
62
88
  limits: SlotlockLimits;
63
89
  /** The lower-case request field a trusted proxy sets to the client address; else the socket's. */
64
90
  trustedProxyHeader?: string;
91
+ /**
92
+ * Integrators metrics count by name from the `Slotlock-Client` field (SLOTLOCK_KNOWN_CLIENTS,
93
+ * RFC 0003 P5); any other is `other`. Empty: every named client is `other`.
94
+ */
95
+ knownClients?: readonly string[];
96
+ /** OpenTelemetry trace export (OTEL_EXPORTER_OTLP_ENDPOINT and the other OTEL_* variables). */
97
+ otlp?: SlotlockOtlpConfig;
98
+ /** The value that must accompany trustedProxyHeader in x-slotlock-proxy-proof; else the socket's. */
99
+ trustedProxySecret?: string;
65
100
  }
66
101
  export declare function readSlotlockDatabaseConfig(env: SlotlockEnv): SlotlockDatabaseConfig;
67
102
  export declare function readSlotlockServeConfig(env: SlotlockEnv): SlotlockServeConfig;
@@ -110,6 +145,13 @@ export interface SlotlockRunningServer {
110
145
  /** End open subscriptions, drain in-flight requests, then close the database pool. */
111
146
  close(): Promise<SlotlockNodeServerCloseResult>;
112
147
  }
148
+ /**
149
+ * Whether the dashboard's allowlists let a tenant act, wherever the dashboard runs: a
150
+ * `github-org:<id>` tenant only while SLOTLOCK_DASHBOARD_ORGS lists that organization; a
151
+ * `github:<id>` tenant only while SLOTLOCK_DASHBOARD_USERS is `*` or lists that id; every tenant when
152
+ * SLOTLOCK_DASHBOARD_USERS is not set, and every other tenant.
153
+ */
154
+ export declare function slotlockServeAllowsTenant(config: Pick<SlotlockServeConfig, 'organizations' | 'allowedUsers'>, tenantRef: string): boolean;
113
155
  /**
114
156
  * The store `serve` answers requests from. Every surface writes through it, so its tenant limits
115
157
  * bound what any key can add, and its transaction limits what one tenant holds of the shared pool.
@@ -124,7 +166,7 @@ export declare function createSlotlockMaintenanceStore(sql: SlotlockSql, caps: S
124
166
  }): SlotlockStore;
125
167
  /** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
126
168
  export declare function startSlotlockServer(config: SlotlockServeConfig, log: SlotlockLogger): Promise<SlotlockRunningServer>;
127
- export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains each tenant it serves every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n resource archive <ref> Take a resource out of use: no new bookings, holds or events, and\n no longer listed; what is on it stays\n resource restore <ref> Put an archived resource back in use\n resource delete <ref> Delete a resource and its history; refused while a booking, hold\n or event on it has not ended\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n rules set <json> --resource <ref>\n Set what a booking on the resource must satisfy, like\n {\"minNoticeMinutes\":120,\"slotStepMinutes\":30,\"turnaroundMinutes\":30,\"enforceHours\":true}\n rules clear --resource <ref>\n Remove the resource's booking rules\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n tenant status Print whether the tenant is suspended\n tenant suspend [--reason <text>]\n Suspend the tenant: it may read, and writes nothing; its keys get\n no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:\n only the schema owner suspends)\n tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)\n tenant erase --confirm <tenant>\n Delete everything the tenant has, its API keys included, for good;\n --confirm repeats SLOTLOCK_TENANT\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write\n access (never admin), 32+ random characters (serve); API keys\n work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;\n required when SLOTLOCK_CONFIRM_WRITES is not none)\n SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+\n random characters (serve; off without it)\n SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools\n that wait for a person (MCP 2026-07-28 form elicitation; refused\n with confirmation_required on REST, A2A and older MCP)\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,\n and retired answers (never under 30) (1-3650, default 30;\n serve and maintain)\n SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the\n database, so N servers allow a tenant its rate once; off (default):\n each server keeps its own\n SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,\n never to a private network address; private: those too, for\n testing; off: keep the change feed, send nothing\n SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:\n rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,\n every request per client, before any key lookup), key_read,\n tenant_read (20/s:40), key_write, tenant_write (5/s:20),\n global_read (500/s:1000), global_write (50/s:100), app_read\n (20/s:40) and app_write (5/s:20) per tenant for the\n application API, sign_in (20/m:20); counts for\n tenant_series_writes and series_writes\n (1: writes that may expand a recurring series at once),\n maintenance_runs (1: application API maintenance runs at once),\n subscriptions (256), tenant_subscriptions (16),\n key_subscriptions (4, and fewer than tenant_subscriptions),\n key_lookups (2), key_lookup_queue (256),\n tenant_transactions (2: one tenant's database transactions at\n once, of the pool's 10); what one tenant may store:\n tenant_holds (10000), tenant_reservations, tenant_events and\n tenant_coverage (100000), holder_holds (20) and\n holder_held_hours (2160), which maintain keeps too\n SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,\n such as CF-Connecting-IP (default: the connection's address);\n set it only when every request comes through that proxy\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's\n model API key with it, 32+ random characters (dashboard; off\n without it)\n SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or\n resolves to, a loopback, private, link-local, CGNAT, unique-local\n or metadata address; on: allow them, for a self-hosted model\n (Ollama, vLLM) on this machine or network\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)\n SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant\n SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
169
+ export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains every tenant with data every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n resource archive <ref> Take a resource out of use: no new bookings, holds or events, and\n no longer listed; what is on it stays\n resource restore <ref> Put an archived resource back in use\n resource parent <ref> (<parent-ref> | none)\n Make a resource a part of a composite (a room of a hall): booking\n the whole then takes every part, and a part's booking keeps the\n whole from being booked; none makes it no part\n resource delete <ref> Delete a resource and its history; refused while a booking, hold\n or event on it has not ended\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n rules set <json> --resource <ref>\n Set what a booking on the resource must satisfy, like\n {\"minNoticeMinutes\":120,\"slotStepMinutes\":30,\"turnaroundMinutes\":30,\"enforceHours\":true}\n rules clear --resource <ref>\n Remove the resource's booking rules\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n [--locations <ref,...>] [--groups <ref,...>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored. With --locations or --groups, the key reaches\n only the resources there (each location with those in it), read and\n write only\n [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]\n [--requires-approval]\n ... and hold the key below the server's limits: its live holds, the\n hours they hold, its requests a minute, and every booking it makes\n waiting for a person (the dashboard's Approvals)\n key limits <id> [--max-holds <n>] [--max-held-hours <n>] [--requests-per-minute <n>]\n [--requires-approval]\n Replace an active key's limits; none at all gives it the server's\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n sandbox seed <car-rental|clinic|desks>\n Fill an empty test environment (SLOTLOCK_TENANT=<tenant>~test) with a\n scenario: locations, groups, resources, hours, rules and bookings\n sandbox reset Empty the test environment; its keys, webhooks and plan stay\n sandbox fast-forward <minutes>\n Make what would lapse in the test environment within that time lapse\n now (holds, offers, approvals), then run maintenance\n sandbox race <resource-ref> [--agents <n>] [--start <date-time>]\n Race agents (default 100) for one hour on a test resource and print who\n booked, who was refused and why, and how long the answers took\n partner create <id> <name> --contract <json | @file>\n Create a partner reselling Slotlock, or change its name and contract:\n its plans (limits and quotas), its accounts' key scopes and its prices\n (as the schema owner, SLOTLOCK_MIGRATE_DATABASE_URL)\n partner list Print the partners, one JSON object per line\n partner suspend <id> Suspend a partner: every account with it, and its keys stop\n partner unsuspend <id> Make a suspended partner active again\n partner key <id> <name> Create a partner key (slk_partner_\u2026) and print it once\n partner revoke-key <id> <key-id>\n Revoke one of a partner's keys at once\n partner customer <id> <cus_\u2026>\n The Stripe customer its monthly invoice goes to\n partner webhook <id> <https-url>\n Where usage.period_closed goes; prints its signing secret once\n billing daily Once a day (cron), as the schema owner: close last month for every\n active partner once it has been over for 6 hours, report overage\n when SLOTLOCK_OVERAGE is on (with STRIPE_SECRET_KEY), and list the\n partners' reconciliation mismatches of the last 7 days; exits 1 when\n anything waits or differs. Safe to run again\n overage report [--month <YYYY-MM>] [--event <name>]\n With SLOTLOCK_OVERAGE: send Stripe each paying Developer tenant's\n operations above its quota this month (or --month) not yet sent, as\n Billing Meter events (--event, default slotlock_operations), once\n (as the schema owner, with STRIPE_SECRET_KEY)\n partner close <id> [--month <YYYY-MM>]\n Close an ended month (default: last month): price its accounts' use\n with the contract, record the lines once, send usage.period_closed,\n and with STRIPE_SECRET_KEY add the lines to the partner's next Stripe\n invoice; safe to run again\n tenant status Print whether the tenant is suspended\n tenant suspend [--reason <text>]\n Suspend the tenant: it may read, and writes nothing; its keys get\n no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:\n only the schema owner suspends)\n tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)\n tenant erase --confirm <tenant>\n Delete everything the tenant has, its API keys included, for good;\n --confirm repeats SLOTLOCK_TENANT; refused while its hosted plan is\n paid for (cancel the subscription first)\n plan status Print the deployment's default plan and the tenant's own (hosted)\n plan default (free | developer | none) [--from <date>]\n Put every tenant without a plan of its own on this one from --from\n (an ISO date or time; default now), or none for no default; a new\n tenant gets it at once (as the schema owner, like tenant suspend)\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write\n access (never admin), 32+ random characters (serve); API keys\n work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;\n required when SLOTLOCK_CONFIRM_WRITES is not none)\n SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+\n random characters (serve; off without it)\n SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools\n that wait for a person (MCP 2026-07-28 form elicitation; refused\n with confirmation_required on REST, A2A and older MCP)\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,\n and retired answers (never under 30) (1-3650, default 30;\n serve and maintain)\n SLOTLOCK_USAGE on: count each tenant's operations, bookings and holds by day, API\n key and surface (slotlock.usage_daily, kept 400 days); off\n (default): record nothing\n SLOTLOCK_QUOTAS on (needs SLOTLOCK_USAGE=on): refuse a tenant on a plan past its\n operations or bookings this month (quota_exceeded, 429); off\n (default): no quota\n SLOTLOCK_OVERAGE off (default), or on (300) or a percent (111-1000): a paying\n Developer tenant is refused only past that share of its\n operations, and the rest above its quota is billed (overage\n report); needs SLOTLOCK_QUOTAS=on\n SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the\n database, so N servers allow a tenant its rate once; off (default):\n each server keeps its own\n SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,\n never to a private network address; private: those too, for\n testing; off: keep the change feed, send nothing\n SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:\n rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,\n every request per client, before any key lookup), key_read,\n tenant_read (20/s:40), key_write, tenant_write (5/s:20),\n global_read (500/s:1000), global_write (50/s:100), app_read\n (20/s:40) and app_write (5/s:20) per tenant for the\n application API, sign_in (20/m:20); counts for\n tenant_series_writes and series_writes\n (1: writes that may expand a recurring series at once),\n maintenance_runs (1: application API maintenance runs at once),\n subscriptions (256), tenant_subscriptions (16),\n key_subscriptions (4, and fewer than tenant_subscriptions),\n key_lookups (2), key_lookup_queue (256),\n tenant_transactions (2: one tenant's database transactions at\n once, of the pool's 10); what one tenant may store:\n tenant_holds (10000), tenant_reservations, tenant_events and\n tenant_coverage (100000), holder_holds (20) and\n holder_held_hours (2160), which maintain keeps too\n SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,\n such as CF-Connecting-IP (default: the connection's address);\n set it only when every request comes through that proxy\n SLOTLOCK_TRUSTED_PROXY_SECRET A secret the proxy adds to every request in x-slotlock-proxy-proof,\n 32+ random characters: without it there, the field above is\n ignored and the connection's address counts\n SLOTLOCK_KNOWN_CLIENTS Comma-separated integrator names /metrics counts requests by, from\n their Slotlock-Client field (any other: other; none sent: none)\n OTEL_EXPORTER_OTLP_ENDPOINT An OpenTelemetry collector (OTLP over HTTP, JSON): a span per\n call is sent to <endpoint>/v1/traces (off without it)\n OTEL_EXPORTER_OTLP_TRACES_ENDPOINT The full traces URL instead of <endpoint>/v1/traces\n OTEL_EXPORTER_OTLP_METRICS_ENDPOINT The full metrics URL instead of <endpoint>/v1/metrics (sent each minute)\n OTEL_EXPORTER_OTLP_HEADERS name=value pairs sent with each export, such as an API key\n OTEL_SERVICE_NAME service.name of the spans (default slotlock)\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's\n model API key with it, 32+ random characters (dashboard; off\n without it)\n SLOTLOCK_ASSISTANT_PREVIOUS_SECRETS Up to three earlier SLOTLOCK_ASSISTANT_SECRET values,\n comma-separated, newest first: keys sealed under one still open\n and are sealed again under the current secret when next used\n SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or\n resolves to, a loopback, private, link-local, CGNAT, unique-local\n or metadata address; on: allow them, for a self-hosted model\n (Ollama, vLLM) on this machine or network\n SLOTLOCK_GOOGLE_CLIENT_ID Sign-in with Google too: the client id of a Google OAuth client with\n redirect URI .../dashboard/callback (not with organization ids)\n SLOTLOCK_GOOGLE_CLIENT_SECRET Its client secret\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids (and google:<subject>) that may sign in and whose keys work\n SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant;\n or enabled: each person's own tenant, plus the organizations an\n owner turns on from the dashboard's Teams page\n SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)\n SLOTLOCK_DASHBOARD_TERMS_URL https URL of the terms people accept by signing in, linked on the\n sign-in page (none named without it)\n SLOTLOCK_DASHBOARD_PRIVACY_URL https URL of the privacy notice the sign-in page links\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
128
170
  /**
129
171
  * Run one `slotlock` command and resolve to its exit code: 0 success, 1 failure, 2 a usage or
130
172
  * configuration error (nothing was started). `serve` resolves once `io.signal` aborts and it has