slotlock 0.2.0 → 0.5.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.
- package/CHANGELOG.md +582 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +498 -65
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +62 -10
- package/agent-server.js +577 -105
- package/agent-store-backend.d.ts +12 -0
- package/agent-store-backend.js +1115 -55
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +35 -1
- package/application-api.js +683 -12
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +112 -0
- package/config.js +225 -0
- package/dashboard.d.ts +67 -5
- package/dashboard.js +1012 -140
- package/ddl.d.ts +45 -3
- package/ddl.js +1124 -13
- package/engine.d.ts +5 -0
- package/engine.js +23 -6
- package/event-feed.d.ts +178 -0
- package/event-feed.js +435 -0
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/ical-worker-thread.js +4 -2
- package/ical-worker.d.ts +8 -5
- package/ical-worker.js +9 -2
- package/index.d.ts +12 -6
- package/index.js +9 -4
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +27 -5
- package/maintenance.js +92 -28
- package/mcp-app.d.ts +7 -1
- package/mcp-app.js +259 -40
- package/metrics.d.ts +24 -0
- package/metrics.js +167 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- package/openapi.json +10970 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +80 -0
- package/rate-limit.js +741 -0
- package/rest-api.d.ts +22 -0
- package/rest-api.js +166 -11
- package/rules.js +6 -3
- package/self-host.d.ts +45 -8
- package/self-host.js +616 -46
- package/slotlock-manifest.schema.json +7 -1
- package/slotlock.manifest.json +8 -2
- package/store.d.ts +647 -28
- package/store.js +2982 -327
- package/sync.d.ts +43 -2
- package/sync.js +1515 -323
- package/timezone.d.ts +58 -3
- package/timezone.js +249 -20
- package/types.d.ts +300 -9
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
package/self-host.js
CHANGED
|
@@ -3,20 +3,24 @@
|
|
|
3
3
|
// supplies it the same way and nothing is read from a file or a request. A missing or weak secret
|
|
4
4
|
// stops a command before it connects anywhere, and no secret is written to a log line or an error.
|
|
5
5
|
// The one exception is the API key `key create` prints: that output is the key's only copy.
|
|
6
|
+
import { createSlotlockWebhookSender, deliverSlotlockWebhooks, startSlotlockWebhookDelivery, } from './webhooks.js';
|
|
6
7
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
7
8
|
import { parseArgs } from 'node:util';
|
|
8
9
|
import postgres from 'postgres';
|
|
9
10
|
import { createSlotlockAgentServer, slotlockAgentOperationScope, } from './agent-server.js';
|
|
10
11
|
import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, } from './agent-store-backend.js';
|
|
11
12
|
import { SLOTLOCK_API_KEY_DEFAULT_SCOPES, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
|
|
12
|
-
import { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, } from './dashboard.js';
|
|
13
|
-
import { SECRET_HINT, SlotlockConfigError, optional, readAvailability, readDashboardConfig, readSecret, readServerUrl, } from './config.js';
|
|
13
|
+
import { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockAcceptanceRecords, createSlotlockDashboardState, } from './dashboard.js';
|
|
14
|
+
import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readDashboardOrganizations, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, slotlockKeySubscriptions, } from './config.js';
|
|
15
|
+
import { parseBookingRules } from './booking-rules.js';
|
|
14
16
|
import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
|
|
15
17
|
import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_TENANT_TABLES } from './ddl.js';
|
|
16
18
|
import { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
|
|
17
19
|
import { createSlotlockNodeServer, } from './node-server.js';
|
|
20
|
+
import { createSlotlockMetrics } from './metrics.js';
|
|
18
21
|
import { slotlockPackageVersion } from './package-version.js';
|
|
19
|
-
import {
|
|
22
|
+
import { createSlotlockRequestLimits } from './rate-limit.js';
|
|
23
|
+
import { assertSlotlockSchemaCurrent, createSlotlockStore, } from './store.js';
|
|
20
24
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
21
25
|
export { SlotlockConfigError };
|
|
22
26
|
/** Every variable the commands read. The README, `.env.example` and `server.json` document these. */
|
|
@@ -26,21 +30,29 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
|
26
30
|
'SLOTLOCK_AUTH_TOKEN',
|
|
27
31
|
'SLOTLOCK_CONFIRMATION_SECRET',
|
|
28
32
|
'SLOTLOCK_CONFIRM_WRITES',
|
|
33
|
+
'SLOTLOCK_METRICS_TOKEN',
|
|
29
34
|
'SLOTLOCK_PUBLIC_URL',
|
|
30
35
|
'SLOTLOCK_TENANT',
|
|
31
36
|
'SLOTLOCK_AVAILABILITY',
|
|
32
37
|
'SLOTLOCK_REST_API',
|
|
38
|
+
'SLOTLOCK_RETENTION_DAYS',
|
|
39
|
+
'SLOTLOCK_WEBHOOKS',
|
|
40
|
+
'SLOTLOCK_SHARED_LIMITS',
|
|
41
|
+
'SLOTLOCK_LIMITS',
|
|
42
|
+
'SLOTLOCK_TRUSTED_PROXY_HEADER',
|
|
33
43
|
'SLOTLOCK_GITHUB_CLIENT_ID',
|
|
34
44
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
35
45
|
'SLOTLOCK_SESSION_SECRET',
|
|
36
46
|
'SLOTLOCK_DASHBOARD_USERS',
|
|
47
|
+
'SLOTLOCK_DASHBOARD_ORGS',
|
|
48
|
+
'SLOTLOCK_DASHBOARD_NAME',
|
|
37
49
|
'HOST',
|
|
38
50
|
'PORT',
|
|
39
51
|
]);
|
|
40
52
|
const DEFAULT_HOST = '127.0.0.1';
|
|
41
53
|
const DEFAULT_PORT = 8080;
|
|
42
54
|
const DEFAULT_TENANT = 'default';
|
|
43
|
-
/** The principal the server token resolves to: it acts in SLOTLOCK_TENANT with
|
|
55
|
+
/** The principal the server token resolves to: it acts in SLOTLOCK_TENANT with read and write. */
|
|
44
56
|
const SELF_HOST_SUBJECT = 'self-host';
|
|
45
57
|
/** Who `key create` records as a key's creator. */
|
|
46
58
|
const CLI_KEY_CREATOR = 'cli';
|
|
@@ -56,6 +68,8 @@ const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
|
|
|
56
68
|
const MAINTAIN_MAX_PASSES = 100;
|
|
57
69
|
const WRITE_OPERATIONS = [
|
|
58
70
|
'slotlock_create_event',
|
|
71
|
+
'slotlock_book_together',
|
|
72
|
+
'slotlock_book_pool',
|
|
59
73
|
'slotlock_update_event',
|
|
60
74
|
'slotlock_delete_event',
|
|
61
75
|
];
|
|
@@ -67,6 +81,12 @@ const SERVED_OPERATIONS = new Set([
|
|
|
67
81
|
'slotlock_list_resources',
|
|
68
82
|
'slotlock_get_free_busy',
|
|
69
83
|
'slotlock_find_next_available',
|
|
84
|
+
'slotlock_find_available_slots',
|
|
85
|
+
'slotlock_hold_slots',
|
|
86
|
+
'slotlock_release_slots',
|
|
87
|
+
'slotlock_book_together',
|
|
88
|
+
'slotlock_book_pool',
|
|
89
|
+
'slotlock_get_pool_availability',
|
|
70
90
|
'slotlock_create_event',
|
|
71
91
|
'slotlock_get_event',
|
|
72
92
|
'slotlock_list_events',
|
|
@@ -74,8 +94,13 @@ const SERVED_OPERATIONS = new Set([
|
|
|
74
94
|
'slotlock_delete_event',
|
|
75
95
|
]);
|
|
76
96
|
const HEALTH_QUERY_TIMEOUT_MS = 2_000;
|
|
97
|
+
/** How long one database check answers /healthz, so a flood of health checks is not one of queries. */
|
|
98
|
+
const HEALTH_RESULT_MS = 1_000;
|
|
77
99
|
const HEALTHCHECK_TIMEOUT_MS = 5_000;
|
|
100
|
+
/** One pool for every tenant; SLOTLOCK_LIMITS `tenant_transactions` (config.ts) goes up to it. */
|
|
78
101
|
const POOL_SIZE = 10;
|
|
102
|
+
/** How long a request's transaction waits for any one lock before it answers 503 `server_busy`. */
|
|
103
|
+
const SERVE_LOCK_TIMEOUT_MS = 5_000;
|
|
79
104
|
const RESOURCE_PAGE_SIZE = 1_000;
|
|
80
105
|
function readDatabaseUrl(env, name) {
|
|
81
106
|
const value = optional(env, name);
|
|
@@ -121,7 +146,7 @@ function readTenant(env) {
|
|
|
121
146
|
return value;
|
|
122
147
|
}
|
|
123
148
|
function readConfirmWrites(env) {
|
|
124
|
-
const value = optional(env, 'SLOTLOCK_CONFIRM_WRITES') ?? '
|
|
149
|
+
const value = optional(env, 'SLOTLOCK_CONFIRM_WRITES') ?? 'none';
|
|
125
150
|
if (value === 'all')
|
|
126
151
|
return [...WRITE_OPERATIONS];
|
|
127
152
|
if (value === 'none')
|
|
@@ -143,11 +168,38 @@ export function readSlotlockDatabaseConfig(env) {
|
|
|
143
168
|
throw new SlotlockConfigError('DATABASE_URL is required: the postgres:// URL of the role Slotlock serves as');
|
|
144
169
|
}
|
|
145
170
|
const config = { databaseUrl, tenantRef: readTenant(env) };
|
|
171
|
+
const retentionDays = readRetentionDays(env);
|
|
172
|
+
if (retentionDays !== undefined)
|
|
173
|
+
config.retentionDays = retentionDays;
|
|
146
174
|
const migrateDatabaseUrl = readDatabaseUrl(env, 'SLOTLOCK_MIGRATE_DATABASE_URL');
|
|
147
175
|
if (migrateDatabaseUrl !== undefined)
|
|
148
176
|
config.migrateDatabaseUrl = migrateDatabaseUrl;
|
|
149
177
|
return config;
|
|
150
178
|
}
|
|
179
|
+
function readWebhooks(env) {
|
|
180
|
+
const value = optional(env, 'SLOTLOCK_WEBHOOKS') ?? 'on';
|
|
181
|
+
if (value !== 'on' && value !== 'off' && value !== 'private') {
|
|
182
|
+
throw new SlotlockConfigError('SLOTLOCK_WEBHOOKS must be on, off or private');
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
/** SLOTLOCK_RETENTION_DAYS: how long history is kept, in days (createSlotlockStore's `retentionDays`). */
|
|
187
|
+
function readRetentionDays(env) {
|
|
188
|
+
const value = optional(env, 'SLOTLOCK_RETENTION_DAYS');
|
|
189
|
+
if (value === undefined)
|
|
190
|
+
return undefined;
|
|
191
|
+
if (!/^[1-9]\d{0,3}$/.test(value) || Number(value) > 3_650) {
|
|
192
|
+
throw new SlotlockConfigError('SLOTLOCK_RETENTION_DAYS must be a whole number of days from 1 to 3650');
|
|
193
|
+
}
|
|
194
|
+
return Number(value);
|
|
195
|
+
}
|
|
196
|
+
function readSharedLimits(env) {
|
|
197
|
+
const value = optional(env, 'SLOTLOCK_SHARED_LIMITS') ?? 'off';
|
|
198
|
+
if (value !== 'on' && value !== 'off') {
|
|
199
|
+
throw new SlotlockConfigError('SLOTLOCK_SHARED_LIMITS must be on or off');
|
|
200
|
+
}
|
|
201
|
+
return value === 'on';
|
|
202
|
+
}
|
|
151
203
|
function readRestApi(env) {
|
|
152
204
|
const value = optional(env, 'SLOTLOCK_REST_API') ?? 'on';
|
|
153
205
|
if (value !== 'on' && value !== 'off') {
|
|
@@ -161,7 +213,7 @@ export function readSlotlockServeConfig(env) {
|
|
|
161
213
|
const confirmWrites = readConfirmWrites(env);
|
|
162
214
|
const confirmationSecret = readSecret(env, 'SLOTLOCK_CONFIRMATION_SECRET');
|
|
163
215
|
if (confirmWrites.length > 0 && confirmationSecret === undefined) {
|
|
164
|
-
throw new SlotlockConfigError(`SLOTLOCK_CONFIRMATION_SECRET is required while SLOTLOCK_CONFIRM_WRITES guards writes
|
|
216
|
+
throw new SlotlockConfigError(`SLOTLOCK_CONFIRMATION_SECRET is required while SLOTLOCK_CONFIRM_WRITES guards writes: it signs each pending confirmation; ${SECRET_HINT}, or set SLOTLOCK_CONFIRM_WRITES=none (the default) to let agents write without asking a person`);
|
|
165
217
|
}
|
|
166
218
|
if (confirmationSecret !== undefined &&
|
|
167
219
|
authToken !== undefined &&
|
|
@@ -177,12 +229,34 @@ export function readSlotlockServeConfig(env) {
|
|
|
177
229
|
confirmWrites,
|
|
178
230
|
availability: readAvailability(env),
|
|
179
231
|
restApi: readRestApi(env),
|
|
232
|
+
webhooks: readWebhooks(env),
|
|
233
|
+
sharedLimits: readSharedLimits(env),
|
|
234
|
+
limits: readLimits(env),
|
|
180
235
|
};
|
|
236
|
+
const trustedProxyHeader = readTrustedProxyHeader(env);
|
|
237
|
+
if (trustedProxyHeader !== undefined)
|
|
238
|
+
config.trustedProxyHeader = trustedProxyHeader;
|
|
181
239
|
if (authToken !== undefined)
|
|
182
240
|
config.authToken = authToken;
|
|
241
|
+
const metricsToken = readSecret(env, 'SLOTLOCK_METRICS_TOKEN');
|
|
242
|
+
if (metricsToken !== undefined) {
|
|
243
|
+
const others = [
|
|
244
|
+
authToken,
|
|
245
|
+
confirmationSecret,
|
|
246
|
+
env.SLOTLOCK_SESSION_SECRET?.trim(),
|
|
247
|
+
env.SLOTLOCK_GITHUB_CLIENT_SECRET?.trim(),
|
|
248
|
+
];
|
|
249
|
+
if (others.includes(metricsToken)) {
|
|
250
|
+
throw new SlotlockConfigError('SLOTLOCK_METRICS_TOKEN must differ from every other secret');
|
|
251
|
+
}
|
|
252
|
+
config.metricsToken = metricsToken;
|
|
253
|
+
}
|
|
183
254
|
const dashboard = readDashboardConfig(env);
|
|
184
255
|
if (dashboard !== undefined)
|
|
185
256
|
config.dashboard = dashboard;
|
|
257
|
+
const organizations = readDashboardOrganizations(env);
|
|
258
|
+
if (organizations !== undefined)
|
|
259
|
+
config.organizations = organizations;
|
|
186
260
|
if (confirmWrites.length > 0 && confirmationSecret !== undefined) {
|
|
187
261
|
config.confirmationSecret = confirmationSecret;
|
|
188
262
|
}
|
|
@@ -219,8 +293,8 @@ export function createSlotlockTokenAuthenticator(token, principal) {
|
|
|
219
293
|
}
|
|
220
294
|
/**
|
|
221
295
|
* What `serve` lets a principal call: a served operation its scopes cover. The server token's
|
|
222
|
-
* principal carries
|
|
223
|
-
* agent server alone would leave it to this function.
|
|
296
|
+
* principal carries read and write, never admin; a principal without scopes is refused everything
|
|
297
|
+
* here, though the agent server alone would leave it to this function.
|
|
224
298
|
*/
|
|
225
299
|
export function authorizeSlotlockServeOperation(args) {
|
|
226
300
|
return (SERVED_OPERATIONS.has(args.operation) &&
|
|
@@ -234,6 +308,7 @@ function secretsIn(env) {
|
|
|
234
308
|
'SLOTLOCK_CONFIRMATION_SECRET',
|
|
235
309
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
236
310
|
'SLOTLOCK_SESSION_SECRET',
|
|
311
|
+
'SLOTLOCK_METRICS_TOKEN',
|
|
237
312
|
]) {
|
|
238
313
|
const value = env[name]?.trim();
|
|
239
314
|
if (value)
|
|
@@ -256,7 +331,10 @@ function secretsIn(env) {
|
|
|
256
331
|
// Longest first, so a URL is replaced whole rather than around its password.
|
|
257
332
|
return [...new Set(secrets)].sort((a, b) => b.length - a.length);
|
|
258
333
|
}
|
|
259
|
-
/**
|
|
334
|
+
/**
|
|
335
|
+
* One JSON object per line: info on stdout, warnings and errors on stderr, secrets replaced by
|
|
336
|
+
* `[redacted]`.
|
|
337
|
+
*/
|
|
260
338
|
function createLogger(io) {
|
|
261
339
|
const secrets = secretsIn(io.env);
|
|
262
340
|
const write = (stream, level, event, fields = {}) => {
|
|
@@ -267,6 +345,7 @@ function createLogger(io) {
|
|
|
267
345
|
};
|
|
268
346
|
return {
|
|
269
347
|
info: (event, fields) => write(io.stdout, 'info', event, fields),
|
|
348
|
+
warn: (event, fields) => write(io.stderr, 'warn', event, fields),
|
|
270
349
|
error: (event, fields) => write(io.stderr, 'error', event, fields),
|
|
271
350
|
};
|
|
272
351
|
}
|
|
@@ -348,16 +427,90 @@ async function databaseReachable(sql) {
|
|
|
348
427
|
}
|
|
349
428
|
}
|
|
350
429
|
/**
|
|
351
|
-
*
|
|
352
|
-
*
|
|
430
|
+
* `GET <path>` answers the metrics to `Authorization: Bearer <token>`, before the request limits:
|
|
431
|
+
* a scrape is in memory and names no tenant. Anything else goes on to `target`.
|
|
432
|
+
*/
|
|
433
|
+
function serveMetrics(target, metrics, path, token) {
|
|
434
|
+
const expected = createHash('sha256').update(token).digest();
|
|
435
|
+
return {
|
|
436
|
+
fetch(request, connection) {
|
|
437
|
+
if (new URL(request.url).pathname !== path)
|
|
438
|
+
return target.fetch(request, connection);
|
|
439
|
+
// The scheme is case-insensitive (RFC 9110); the token is compared as digests, in constant time.
|
|
440
|
+
const presented = /^bearer +(\S+) *$/i.exec(request.headers.get('authorization') ?? '')?.[1] ?? '';
|
|
441
|
+
const given = createHash('sha256').update(presented).digest();
|
|
442
|
+
if (!timingSafeEqual(given, expected)) {
|
|
443
|
+
metrics.refusedScrape();
|
|
444
|
+
return Promise.resolve(new Response('Unauthorized\n', {
|
|
445
|
+
status: 401,
|
|
446
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8', 'WWW-Authenticate': 'Bearer' },
|
|
447
|
+
}));
|
|
448
|
+
}
|
|
449
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
450
|
+
return Promise.resolve(new Response(null, { status: 405, headers: { Allow: 'GET, HEAD' } }));
|
|
451
|
+
}
|
|
452
|
+
return Promise.resolve(new Response(request.method === 'HEAD' ? null : metrics.render(), {
|
|
453
|
+
headers: {
|
|
454
|
+
'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
|
|
455
|
+
'Cache-Control': 'no-store',
|
|
456
|
+
},
|
|
457
|
+
}));
|
|
458
|
+
},
|
|
459
|
+
...(target.shutdown ? { shutdown: () => target.shutdown?.() } : {}),
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Whether the dashboard's allowlists let a tenant act: a `github-org:<id>` tenant only while
|
|
464
|
+
* SLOTLOCK_DASHBOARD_ORGS lists that organization, wherever the dashboard runs; otherwise every
|
|
465
|
+
* tenant when there is no dashboard or it allows everyone, and a `github:<id>` tenant only while
|
|
466
|
+
* that id is listed.
|
|
353
467
|
*/
|
|
354
468
|
function dashboardAllows(config, tenantRef) {
|
|
469
|
+
const orgId = /^github-org:(\d+)$/.exec(tenantRef)?.[1];
|
|
470
|
+
if (orgId !== undefined)
|
|
471
|
+
return config.organizations?.includes(orgId) === true;
|
|
355
472
|
const users = config.dashboard?.allowedUsers;
|
|
356
473
|
if (users === undefined || users === '*')
|
|
357
474
|
return true;
|
|
358
475
|
const githubId = /^github:(\d+)$/.exec(tenantRef)?.[1];
|
|
359
476
|
return githubId === undefined || users.includes(githubId);
|
|
360
477
|
}
|
|
478
|
+
function serveTenantLimits(caps) {
|
|
479
|
+
return {
|
|
480
|
+
resources: SERVE_MAX_TENANT_RESOURCES,
|
|
481
|
+
holds: caps.tenant_holds,
|
|
482
|
+
reservations: caps.tenant_reservations,
|
|
483
|
+
events: caps.tenant_events,
|
|
484
|
+
coverage: caps.tenant_coverage,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* The store `serve` answers requests from. Every surface writes through it, so its tenant limits
|
|
489
|
+
* bound what any key can add, and its transaction limits what one tenant holds of the shared pool.
|
|
490
|
+
*/
|
|
491
|
+
export function createSlotlockServeStore(sql, caps, availability = [], retentionDays) {
|
|
492
|
+
return createSlotlockStore(sql, {
|
|
493
|
+
...(retentionDays !== undefined ? { retentionDays } : {}),
|
|
494
|
+
defaultAvailabilityRules: availability,
|
|
495
|
+
tenantLimits: serveTenantLimits(caps),
|
|
496
|
+
maxTenantTransactions: caps.tenant_transactions,
|
|
497
|
+
maxHoldsPerHolder: caps.holder_holds,
|
|
498
|
+
maxHeldHoursPerHolder: caps.holder_held_hours,
|
|
499
|
+
tenantLockTimeoutMs: SERVE_LOCK_TIMEOUT_MS,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* The store maintenance writes through, `serve`'s and `maintain`'s: the same tenant and per-holder
|
|
504
|
+
* limits as requests, so a waitlist offer is a hold like any other.
|
|
505
|
+
*/
|
|
506
|
+
export function createSlotlockMaintenanceStore(sql, caps, options = {}) {
|
|
507
|
+
return createSlotlockStore(sql, {
|
|
508
|
+
...options,
|
|
509
|
+
tenantLimits: serveTenantLimits(caps),
|
|
510
|
+
maxHoldsPerHolder: caps.holder_holds,
|
|
511
|
+
maxHeldHoursPerHolder: caps.holder_held_hours,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
361
514
|
/** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
|
|
362
515
|
export async function startSlotlockServer(config, log) {
|
|
363
516
|
const sql = connect(config.databaseUrl, POOL_SIZE);
|
|
@@ -376,6 +529,9 @@ export async function startSlotlockServer(config, log) {
|
|
|
376
529
|
if (!schema?.present) {
|
|
377
530
|
throw Object.assign(new Error('The slotlock schema is missing: run `slotlock migrate` or `slotlock serve --migrate`'), { code: 'schema_missing' });
|
|
378
531
|
}
|
|
532
|
+
// Migrated by another release: an older schema needs this release's migrate first, and a newer
|
|
533
|
+
// one has tables and rules this release's code does not know.
|
|
534
|
+
await assertSlotlockSchemaCurrent(sql);
|
|
379
535
|
// A deployment run statement by statement lifts forced row-level security for its data steps
|
|
380
536
|
// and, stopped part way, leaves it lifted until the next one puts it back. The owning role then
|
|
381
537
|
// reads every tenant's rows: refuse to serve until then.
|
|
@@ -400,9 +556,27 @@ export async function startSlotlockServer(config, log) {
|
|
|
400
556
|
}
|
|
401
557
|
}
|
|
402
558
|
}
|
|
403
|
-
|
|
559
|
+
if (config.confirmationSecret !== undefined) {
|
|
560
|
+
// Accepted confirmations are recorded once each (0.3.1); an older schema cannot hold them.
|
|
561
|
+
const [recordable] = await sql `
|
|
562
|
+
SELECT EXISTS (
|
|
563
|
+
SELECT 1 FROM pg_catalog.pg_constraint
|
|
564
|
+
WHERE conname = 'slotlock_dashboard_tokens_kind_valid'
|
|
565
|
+
AND conrelid = to_regclass('slotlock.dashboard_tokens')
|
|
566
|
+
AND pg_catalog.pg_get_constraintdef(oid) LIKE '%confirmation%'
|
|
567
|
+
) AND has_function_privilege(to_regprocedure(${SLOTLOCK_DASHBOARD_FUNCTIONS[0]}), 'EXECUTE') AS ready`;
|
|
568
|
+
if (!recordable?.ready) {
|
|
569
|
+
throw Object.assign(new Error('SLOTLOCK_CONFIRM_WRITES records each accepted confirmation, which this schema cannot: run `slotlock migrate` (or `slotlock serve --migrate`) with this release'), { code: 'schema_outdated' });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const { caps } = config.limits;
|
|
573
|
+
const store = createSlotlockServeStore(sql, caps, config.availability, config.retentionDays);
|
|
574
|
+
const retention = config.retentionDays !== undefined ? { retentionDays: config.retentionDays } : {};
|
|
404
575
|
const publicUrl = new URL(config.publicUrl);
|
|
405
|
-
const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql)
|
|
576
|
+
const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql), {
|
|
577
|
+
maxConcurrentLookups: caps.key_lookups,
|
|
578
|
+
maxWaitingLookups: caps.key_lookup_queue,
|
|
579
|
+
});
|
|
406
580
|
const authenticateToken = config.authToken === undefined
|
|
407
581
|
? undefined
|
|
408
582
|
: // Read and write, never admin: this is the token the docs put in agents' configuration,
|
|
@@ -414,13 +588,34 @@ export async function startSlotlockServer(config, log) {
|
|
|
414
588
|
});
|
|
415
589
|
// Set once the listener is up; the authenticator tells it which tenants this server serves.
|
|
416
590
|
let maintenance;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
591
|
+
let webhooks;
|
|
592
|
+
const basePath = publicUrl.pathname.replace(/\/$/, '');
|
|
593
|
+
const metrics = config.metricsToken === undefined
|
|
594
|
+
? undefined
|
|
595
|
+
: createSlotlockMetrics({
|
|
596
|
+
version: slotlockPackageVersion(),
|
|
597
|
+
...(process.versions.tz ? { tzdata: process.versions.tz } : {}),
|
|
598
|
+
});
|
|
599
|
+
const limits = createSlotlockRequestLimits({
|
|
600
|
+
limits: config.limits,
|
|
601
|
+
...(config.sharedLimits
|
|
602
|
+
? {
|
|
603
|
+
shared: {
|
|
604
|
+
// Keyed by a digest: the table keeps no tenant name.
|
|
605
|
+
take: async (bucket, rate) => {
|
|
606
|
+
const digest = createHash('sha256').update(bucket).digest('hex');
|
|
607
|
+
const [row] = await sql `
|
|
608
|
+
SELECT slotlock.take_rate_token(${digest}, ${rate.perSecond}, ${rate.burst}) AS taken`;
|
|
609
|
+
return row?.taken === true;
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
}
|
|
613
|
+
: {}),
|
|
614
|
+
...(config.trustedProxyHeader !== undefined
|
|
615
|
+
? { trustedProxyHeader: config.trustedProxyHeader }
|
|
616
|
+
: {}),
|
|
617
|
+
basePath,
|
|
618
|
+
signInPaths: config.dashboard ? ['/dashboard/sign-in', '/dashboard/callback'] : [],
|
|
424
619
|
// The token is compared in constant time first; only then is a key looked up. A person the
|
|
425
620
|
// dashboard no longer allows loses their keys with their session.
|
|
426
621
|
authenticate: async (request) => {
|
|
@@ -430,12 +625,51 @@ export async function startSlotlockServer(config, log) {
|
|
|
430
625
|
maintenance?.touch(principal.tenantRef);
|
|
431
626
|
return principal;
|
|
432
627
|
},
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
628
|
+
// Counts and pseudonyms only: never an address, a credential or a tenant.
|
|
629
|
+
onRefusals: (counts) => {
|
|
630
|
+
metrics?.refusals(counts);
|
|
631
|
+
log.info('refusals', { ...counts });
|
|
632
|
+
},
|
|
633
|
+
// Behind a proxy without the setting, every client shares the proxy's address budget.
|
|
634
|
+
onIgnoredClientField: (field) => log.warn('client_address_field_ignored', { field, setting: 'SLOTLOCK_TRUSTED_PROXY_HEADER' }),
|
|
635
|
+
});
|
|
636
|
+
let health;
|
|
637
|
+
const agentServer = createSlotlockAgentServer({
|
|
638
|
+
publicBaseUrl: config.publicUrl,
|
|
639
|
+
// readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
|
|
640
|
+
allowInsecureLocalhost: publicUrl.protocol === 'http:',
|
|
641
|
+
backend: createSlotlockStoreAgentBackend(store, {
|
|
642
|
+
availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
|
|
643
|
+
}),
|
|
644
|
+
authenticate: limits.authenticate,
|
|
645
|
+
consumeRateLimit: limits.consumeRateLimit,
|
|
646
|
+
// A write that may expand a recurring series also waits for one of a few slots, so one
|
|
647
|
+
// tenant's expansions cannot hold the event loop from every other tenant's requests.
|
|
648
|
+
authorize: async ({ principal, operation, input }) => {
|
|
649
|
+
if (!authorizeSlotlockServeOperation({ principal, operation }))
|
|
650
|
+
return false;
|
|
651
|
+
await limits.admit({ principal, operation, input });
|
|
652
|
+
return true;
|
|
653
|
+
},
|
|
654
|
+
subscriptions: {
|
|
655
|
+
maxTotal: caps.subscriptions,
|
|
656
|
+
maxPerTenant: caps.tenant_subscriptions,
|
|
657
|
+
maxPerPrincipal: slotlockKeySubscriptions(caps),
|
|
658
|
+
},
|
|
659
|
+
health: async () => {
|
|
660
|
+
if (!health || performance.now() - health.at > HEALTH_RESULT_MS) {
|
|
661
|
+
health = { at: performance.now(), ready: databaseReachable(sql) };
|
|
662
|
+
}
|
|
663
|
+
return (await health.ready)
|
|
664
|
+
? { ready: true, checks: ['database'] }
|
|
665
|
+
: { ready: false, checks: ['database_unreachable'] };
|
|
666
|
+
},
|
|
437
667
|
// Outcomes and counts only: the server never reports arguments or identities.
|
|
438
|
-
onEvent: (
|
|
668
|
+
onEvent: (event) => {
|
|
669
|
+
metrics?.event(event);
|
|
670
|
+
const { type, ...fields } = event;
|
|
671
|
+
log.info(type, snakeCaseKeys(fields));
|
|
672
|
+
},
|
|
439
673
|
// What an agent sees only as internal_error: the operation and the error's message and code,
|
|
440
674
|
// never the arguments or the request, with every configured secret redacted.
|
|
441
675
|
onError: (error, { operation }) => log.error('agent_error', { operation, ...describeError(error) }),
|
|
@@ -447,7 +681,12 @@ export async function startSlotlockServer(config, log) {
|
|
|
447
681
|
backend: createSlotlockStoreApplicationBackend(store, {
|
|
448
682
|
maxTenantResources: SERVE_MAX_TENANT_RESOURCES,
|
|
449
683
|
}),
|
|
450
|
-
authorize: async ({ principal }) =>
|
|
684
|
+
authorize: async ({ principal, operation, input }) => {
|
|
685
|
+
if (principal.scopes?.includes(SLOTLOCK_APPLICATION_SCOPE) !== true)
|
|
686
|
+
return false;
|
|
687
|
+
await limits.admit({ principal, operation, input });
|
|
688
|
+
return true;
|
|
689
|
+
},
|
|
451
690
|
},
|
|
452
691
|
}
|
|
453
692
|
: {}),
|
|
@@ -456,6 +695,7 @@ export async function startSlotlockServer(config, log) {
|
|
|
456
695
|
confirmation: {
|
|
457
696
|
operations: config.confirmWrites,
|
|
458
697
|
secrets: [config.confirmationSecret],
|
|
698
|
+
consumeAcceptance: createSlotlockAcceptanceRecords(sql),
|
|
459
699
|
},
|
|
460
700
|
}
|
|
461
701
|
: {}),
|
|
@@ -470,32 +710,75 @@ export async function startSlotlockServer(config, log) {
|
|
|
470
710
|
},
|
|
471
711
|
sessionSecret: config.dashboard.sessionSecret,
|
|
472
712
|
allowedUsers: config.dashboard.allowedUsers,
|
|
713
|
+
...(config.dashboard.name ? { name: config.dashboard.name } : {}),
|
|
714
|
+
...(config.dashboard.organizations
|
|
715
|
+
? { organizations: config.dashboard.organizations }
|
|
716
|
+
: {}),
|
|
473
717
|
keys: createSlotlockApiKeyStore(sql),
|
|
474
718
|
resources: createSlotlockDashboardResources(store),
|
|
475
719
|
state: createSlotlockDashboardState(sql),
|
|
476
720
|
availability: config.availability,
|
|
721
|
+
clientAddress: (request) => limits.clientAddress(request),
|
|
477
722
|
onError: (error) => log.error('dashboard_error', describeError(error)),
|
|
478
723
|
});
|
|
479
|
-
const target = dashboard
|
|
724
|
+
const target = limits.serve(dashboard
|
|
480
725
|
? {
|
|
481
726
|
fetch: (request) => dashboard.handles(request) ? dashboard.fetch(request) : agentServer.fetch(request),
|
|
482
727
|
shutdown: () => agentServer.shutdown(),
|
|
483
728
|
}
|
|
484
|
-
: agentServer;
|
|
485
|
-
const
|
|
729
|
+
: agentServer);
|
|
730
|
+
const served = metrics && config.metricsToken !== undefined
|
|
731
|
+
? serveMetrics(target, metrics, `${basePath}/metrics`, config.metricsToken)
|
|
732
|
+
: target;
|
|
733
|
+
const listener = createSlotlockNodeServer(served, {
|
|
486
734
|
requestOrigin: publicUrl.origin,
|
|
487
735
|
onError: (error, phase) => log.error('http_error', { phase, ...describeError(error) }),
|
|
488
736
|
});
|
|
489
737
|
const address = await listener.listen({ host: config.host, port: config.port });
|
|
490
738
|
maintenance = startSlotlockMaintenance({
|
|
491
|
-
|
|
739
|
+
// One tenant at a time, and its long passes may wait on a request's locks for longer.
|
|
740
|
+
store: createSlotlockMaintenanceStore(sql, caps, retention),
|
|
492
741
|
sql,
|
|
493
742
|
tenants: [config.tenantRef],
|
|
494
743
|
intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
|
|
495
744
|
// Counts only, never the tenant.
|
|
496
|
-
onPass: (result) =>
|
|
745
|
+
onPass: (result) => {
|
|
746
|
+
metrics?.maintenance(result);
|
|
747
|
+
log.info('maintenance', { ...result });
|
|
748
|
+
},
|
|
497
749
|
onError: (error) => log.error('maintenance_failed', describeError(error)),
|
|
498
750
|
});
|
|
751
|
+
if (config.webhooks !== 'off') {
|
|
752
|
+
const deliveryStore = createSlotlockStore(sql, retention);
|
|
753
|
+
const send = createSlotlockWebhookSender({ allowPrivateNetworks: config.webhooks === 'private' });
|
|
754
|
+
webhooks = startSlotlockWebhookDelivery({
|
|
755
|
+
// The tenants with a webhook, from the database: a restarted server finds them all.
|
|
756
|
+
tenants: async () => {
|
|
757
|
+
const tenants = [];
|
|
758
|
+
for (;;) {
|
|
759
|
+
const page = await sql `
|
|
760
|
+
SELECT slotlock.webhook_tenants(${tenants.at(-1) ?? null}::text) AS tenant`;
|
|
761
|
+
tenants.push(...page.map(({ tenant }) => tenant));
|
|
762
|
+
if (page.length < 1_000)
|
|
763
|
+
return tenants;
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
deliver: (tenantRef) => deliverSlotlockWebhooks({
|
|
767
|
+
tenantRef,
|
|
768
|
+
send,
|
|
769
|
+
feed: {
|
|
770
|
+
claimWebhookDeliveries: (params) => deliveryStore.withTenant(tenantRef, (tenant) => tenant.claimWebhookDeliveries(params)),
|
|
771
|
+
settleWebhookDelivery: (params) => deliveryStore.withTenant(tenantRef, (tenant) => tenant.settleWebhookDelivery(params)),
|
|
772
|
+
},
|
|
773
|
+
}),
|
|
774
|
+
// Counts only, never the tenant or a URL.
|
|
775
|
+
onPass: (_tenant, result) => {
|
|
776
|
+
metrics?.webhooks(result);
|
|
777
|
+
log.info('webhooks', { ...result });
|
|
778
|
+
},
|
|
779
|
+
onError: (error) => log.error('webhooks_failed', describeError(error)),
|
|
780
|
+
});
|
|
781
|
+
}
|
|
499
782
|
log.info('listening', {
|
|
500
783
|
address: address.origin,
|
|
501
784
|
mcp: `${config.publicUrl}/mcp`,
|
|
@@ -506,13 +789,25 @@ export async function startSlotlockServer(config, log) {
|
|
|
506
789
|
confirm_writes: config.confirmWrites,
|
|
507
790
|
availability_rules: config.availability.length,
|
|
508
791
|
rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
|
|
792
|
+
webhooks: config.webhooks,
|
|
793
|
+
shared_limits: config.sharedLimits,
|
|
794
|
+
tzdata: process.versions.tz ?? null,
|
|
509
795
|
maintenance_interval_minutes: SERVE_MAINTENANCE_INTERVAL_MS / 60_000,
|
|
796
|
+
limits: {
|
|
797
|
+
...Object.fromEntries(Object.entries(config.limits.rates).map(([name, rate]) => [name, formatSlotlockRate(rate)])),
|
|
798
|
+
...caps,
|
|
799
|
+
// What a key may hold, which is below key_subscriptions when the tenant's cap is lower.
|
|
800
|
+
key_subscriptions: slotlockKeySubscriptions(caps),
|
|
801
|
+
},
|
|
802
|
+
client_address: config.trustedProxyHeader ?? 'socket',
|
|
510
803
|
});
|
|
511
804
|
return {
|
|
512
805
|
address,
|
|
513
806
|
async close() {
|
|
514
807
|
const result = await listener.close();
|
|
808
|
+
limits.close();
|
|
515
809
|
await maintenance?.close();
|
|
810
|
+
await webhooks?.close();
|
|
516
811
|
await sql.end({ timeout: 5 });
|
|
517
812
|
return result;
|
|
518
813
|
},
|
|
@@ -571,8 +866,8 @@ function hoursLine(resource) {
|
|
|
571
866
|
hours: resource.availabilityRules ?? null,
|
|
572
867
|
})}\n`;
|
|
573
868
|
}
|
|
574
|
-
/** Every resource of the tenant, a page at a time. */
|
|
575
|
-
async function tenantResources(store, tenantRef) {
|
|
869
|
+
/** Every resource of the tenant in use (with `includeArchived`, every one), a page at a time. */
|
|
870
|
+
async function tenantResources(store, tenantRef, includeArchived = false) {
|
|
576
871
|
const all = [];
|
|
577
872
|
let after;
|
|
578
873
|
for (;;) {
|
|
@@ -580,6 +875,7 @@ async function tenantResources(store, tenantRef) {
|
|
|
580
875
|
tenantRef,
|
|
581
876
|
limit: RESOURCE_PAGE_SIZE,
|
|
582
877
|
...(after !== undefined ? { after } : {}),
|
|
878
|
+
...(includeArchived ? { includeArchived } : {}),
|
|
583
879
|
}));
|
|
584
880
|
all.push(...page);
|
|
585
881
|
after = page.at(-1)?.id;
|
|
@@ -626,6 +922,118 @@ async function runHours(config, out, target, rules) {
|
|
|
626
922
|
await sql.end({ timeout: 5 });
|
|
627
923
|
}
|
|
628
924
|
}
|
|
925
|
+
/**
|
|
926
|
+
* `rules set|clear`: set or clear (`null`) the booking rules of the tenant's resource named by id,
|
|
927
|
+
* else by reference, and print it. Returns false when no resource has that name.
|
|
928
|
+
*/
|
|
929
|
+
async function runRules(config, out, name, rules) {
|
|
930
|
+
const sql = connect(config.databaseUrl, 1);
|
|
931
|
+
try {
|
|
932
|
+
const store = createSlotlockStore(sql);
|
|
933
|
+
const tenantRef = config.tenantRef;
|
|
934
|
+
const resources = await tenantResources(store, tenantRef);
|
|
935
|
+
const named = resources.find((resource) => resource.id === name) ??
|
|
936
|
+
resources.find((resource) => resource.externalRef === name);
|
|
937
|
+
if (!named)
|
|
938
|
+
return false;
|
|
939
|
+
const updated = await store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id: named.id, rules }));
|
|
940
|
+
if (!updated)
|
|
941
|
+
return false;
|
|
942
|
+
out.write(`${JSON.stringify({
|
|
943
|
+
id: updated.id,
|
|
944
|
+
external_ref: updated.externalRef,
|
|
945
|
+
booking_rules: updated.bookingRules ?? null,
|
|
946
|
+
})}\n`);
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
finally {
|
|
950
|
+
await sql.end({ timeout: 5 });
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
/** A resource as `resource archive` and `resource restore` print it. */
|
|
954
|
+
function resourceStateLine(resource) {
|
|
955
|
+
return `${JSON.stringify({
|
|
956
|
+
id: resource.id,
|
|
957
|
+
external_ref: resource.externalRef,
|
|
958
|
+
timezone: resource.timezone,
|
|
959
|
+
visibility: resource.visibility ?? 'shared',
|
|
960
|
+
archived_at: resource.archivedAt?.toISOString() ?? null,
|
|
961
|
+
})}\n`;
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* `resource archive|restore|delete`: the tenant's resource named by id, else by reference, archived
|
|
965
|
+
* ones included. Returns false when no resource has that name, and `in_use` when a delete is
|
|
966
|
+
* refused because something on the resource has not ended.
|
|
967
|
+
*/
|
|
968
|
+
async function changeResource(config, out, name, action) {
|
|
969
|
+
const sql = connect(config.databaseUrl, 1);
|
|
970
|
+
try {
|
|
971
|
+
const store = createSlotlockStore(sql);
|
|
972
|
+
const tenantRef = config.tenantRef;
|
|
973
|
+
const resources = await tenantResources(store, tenantRef, true);
|
|
974
|
+
// An id first: another resource's reference may spell it.
|
|
975
|
+
const named = resources.find((resource) => resource.id === name) ??
|
|
976
|
+
resources.find((resource) => resource.externalRef === name);
|
|
977
|
+
if (!named)
|
|
978
|
+
return false;
|
|
979
|
+
const id = named.id;
|
|
980
|
+
if (action === 'delete') {
|
|
981
|
+
const result = await store.withTenant(tenantRef, (tenant) => tenant.deleteResource({ tenantRef, id }));
|
|
982
|
+
if (!result.ok)
|
|
983
|
+
return 'in_use';
|
|
984
|
+
if (!result.deleted)
|
|
985
|
+
return false;
|
|
986
|
+
out.write(`${JSON.stringify({ id, external_ref: named.externalRef, deleted: true })}\n`);
|
|
987
|
+
return true;
|
|
988
|
+
}
|
|
989
|
+
const changed = await store.withTenant(tenantRef, (tenant) => action === 'archive'
|
|
990
|
+
? tenant.archiveResource({ tenantRef, id })
|
|
991
|
+
: tenant.restoreResource({ tenantRef, id }));
|
|
992
|
+
if (!changed)
|
|
993
|
+
return false;
|
|
994
|
+
out.write(resourceStateLine(changed));
|
|
995
|
+
return true;
|
|
996
|
+
}
|
|
997
|
+
finally {
|
|
998
|
+
await sql.end({ timeout: 5 });
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* `tenant status|suspend|unsuspend|erase` for SLOTLOCK_TENANT. Suspending and lifting a suspension
|
|
1003
|
+
* are the deployment role's alone, so they connect as SLOTLOCK_MIGRATE_DATABASE_URL when it is set;
|
|
1004
|
+
* the serving role reads a suspension and may erase.
|
|
1005
|
+
*/
|
|
1006
|
+
async function runTenantCommand(config, out, action, reason) {
|
|
1007
|
+
const owner = action === 'suspend' || action === 'unsuspend';
|
|
1008
|
+
const sql = connect(owner ? (config.migrateDatabaseUrl ?? config.databaseUrl) : config.databaseUrl, 1);
|
|
1009
|
+
try {
|
|
1010
|
+
const store = createSlotlockStore(sql);
|
|
1011
|
+
const tenantRef = config.tenantRef;
|
|
1012
|
+
let line;
|
|
1013
|
+
if (action === 'status') {
|
|
1014
|
+
line = { tenant: tenantRef, suspended: await store.isTenantSuspended({ tenantRef }) };
|
|
1015
|
+
}
|
|
1016
|
+
else if (action === 'suspend') {
|
|
1017
|
+
const { suspended } = await store.suspendTenant({
|
|
1018
|
+
tenantRef,
|
|
1019
|
+
...(reason !== undefined ? { reason } : {}),
|
|
1020
|
+
});
|
|
1021
|
+
line = { tenant: tenantRef, suspended: true, changed: suspended };
|
|
1022
|
+
}
|
|
1023
|
+
else if (action === 'unsuspend') {
|
|
1024
|
+
const { unsuspended } = await store.unsuspendTenant({ tenantRef });
|
|
1025
|
+
line = { tenant: tenantRef, suspended: false, changed: unsuspended };
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
const erased = await store.eraseTenant({ tenantRef });
|
|
1029
|
+
line = { tenant: tenantRef, total: erased.total, rows: erased.rows };
|
|
1030
|
+
}
|
|
1031
|
+
out.write(`${JSON.stringify(line)}\n`);
|
|
1032
|
+
}
|
|
1033
|
+
finally {
|
|
1034
|
+
await sql.end({ timeout: 5 });
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
629
1037
|
/** One key as `key create`, `key list` and `key revoke` print it: never the digest. */
|
|
630
1038
|
function apiKeyLine(apiKey, key) {
|
|
631
1039
|
return `${JSON.stringify({
|
|
@@ -656,12 +1064,23 @@ function parseKeyScopes(value) {
|
|
|
656
1064
|
* `maintain`: maintenance passes for the tenant until nothing is left, under the tenant's lock, so
|
|
657
1065
|
* it never repeats what a server is doing. Null when another server holds the lock.
|
|
658
1066
|
*/
|
|
659
|
-
async function maintainTenant(config) {
|
|
1067
|
+
async function maintainTenant(config, caps) {
|
|
660
1068
|
const sql = connect(config.databaseUrl, 2);
|
|
661
1069
|
try {
|
|
662
|
-
const store =
|
|
1070
|
+
const store = createSlotlockMaintenanceStore(sql, caps, config.retentionDays !== undefined ? { retentionDays: config.retentionDays } : {});
|
|
663
1071
|
const total = await withSlotlockMaintenanceLock(sql, config.tenantRef, async () => {
|
|
664
|
-
const sum = {
|
|
1072
|
+
const sum = {
|
|
1073
|
+
extended: 0,
|
|
1074
|
+
conflicts: 0,
|
|
1075
|
+
refused: 0,
|
|
1076
|
+
pruned: 0,
|
|
1077
|
+
zoneShifts: 0,
|
|
1078
|
+
staleSources: 0,
|
|
1079
|
+
noShows: 0,
|
|
1080
|
+
waitlistOffers: 0,
|
|
1081
|
+
capped: true,
|
|
1082
|
+
passes: 0,
|
|
1083
|
+
};
|
|
665
1084
|
while (sum.capped && sum.passes < MAINTAIN_MAX_PASSES) {
|
|
666
1085
|
const pass = await maintainSlotlockTenant(store, config.tenantRef);
|
|
667
1086
|
sum.passes += 1;
|
|
@@ -669,9 +1088,15 @@ async function maintainTenant(config) {
|
|
|
669
1088
|
sum.conflicts += pass.conflicts;
|
|
670
1089
|
sum.refused += pass.refused;
|
|
671
1090
|
sum.pruned += pass.pruned;
|
|
672
|
-
|
|
1091
|
+
sum.zoneShifts += pass.zoneShifts;
|
|
1092
|
+
sum.staleSources += pass.staleSources;
|
|
1093
|
+
sum.noShows += pass.noShows;
|
|
1094
|
+
sum.waitlistOffers += pass.waitlistOffers;
|
|
1095
|
+
// A capped pass that got nowhere met only series it cannot extend. While its roll stopped
|
|
1096
|
+
// before the series it had put back, the next pass meets new ones (and perhaps a healthy
|
|
1097
|
+
// one); once it reached them, another pass would meet them again.
|
|
673
1098
|
sum.capped = pass.capped;
|
|
674
|
-
if (pass.capped && pass.extended + pass.pruned === 0)
|
|
1099
|
+
if (pass.capped && pass.extended + pass.pruned === 0 && !pass.rollPending)
|
|
675
1100
|
break;
|
|
676
1101
|
}
|
|
677
1102
|
return sum;
|
|
@@ -731,12 +1156,22 @@ Commands:
|
|
|
731
1156
|
resource add <ref> [--timezone <IANA zone>]
|
|
732
1157
|
Create a calendar resource (a car, room, person or machine)
|
|
733
1158
|
resource list Print the tenant's resources, one JSON object per line
|
|
1159
|
+
resource archive <ref> Take a resource out of use: no new bookings, holds or events, and
|
|
1160
|
+
no longer listed; what is on it stays
|
|
1161
|
+
resource restore <ref> Put an archived resource back in use
|
|
1162
|
+
resource delete <ref> Delete a resource and its history; refused while a booking, hold
|
|
1163
|
+
or event on it has not ended
|
|
734
1164
|
hours show [--resource <ref>]
|
|
735
1165
|
Print each resource's bookable hours (null: the server's)
|
|
736
1166
|
hours set <json> (--resource <ref> | --all)
|
|
737
1167
|
Give one resource, or every one, weekly hours of its own; [] closes
|
|
738
1168
|
hours clear (--resource <ref> | --all)
|
|
739
1169
|
Hand one resource, or every one, back to the server's hours
|
|
1170
|
+
rules set <json> --resource <ref>
|
|
1171
|
+
Set what a booking on the resource must satisfy, like
|
|
1172
|
+
{"minNoticeMinutes":120,"slotStepMinutes":30,"turnaroundMinutes":30,"enforceHours":true}
|
|
1173
|
+
rules clear --resource <ref>
|
|
1174
|
+
Remove the resource's booking rules
|
|
740
1175
|
key create <name> [--scope read,write,admin] [--expires-in-days <n>]
|
|
741
1176
|
Create an API key for the tenant (default scope read,write, no
|
|
742
1177
|
expiry; admin adds the application API) and print it once; only its
|
|
@@ -745,6 +1180,15 @@ Commands:
|
|
|
745
1180
|
key rotate <id> Give an active key a new secret and print it once; the old one stops
|
|
746
1181
|
working at once, and the key keeps its id and what it booked
|
|
747
1182
|
key revoke <id> Revoke one of the tenant's API keys at once
|
|
1183
|
+
tenant status Print whether the tenant is suspended
|
|
1184
|
+
tenant suspend [--reason <text>]
|
|
1185
|
+
Suspend the tenant: it may read, and writes nothing; its keys get
|
|
1186
|
+
no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:
|
|
1187
|
+
only the schema owner suspends)
|
|
1188
|
+
tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)
|
|
1189
|
+
tenant erase --confirm <tenant>
|
|
1190
|
+
Delete everything the tenant has, its API keys included, for good;
|
|
1191
|
+
--confirm repeats SLOTLOCK_TENANT
|
|
748
1192
|
healthcheck Exit 0 when the local server reports ready
|
|
749
1193
|
|
|
750
1194
|
Options:
|
|
@@ -755,21 +1199,59 @@ Environment:
|
|
|
755
1199
|
DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve
|
|
756
1200
|
refuses a superuser or a role with BYPASSRLS
|
|
757
1201
|
SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)
|
|
758
|
-
SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with
|
|
759
|
-
32+ random characters (serve); API keys
|
|
760
|
-
|
|
761
|
-
|
|
1202
|
+
SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write
|
|
1203
|
+
access (never admin), 32+ random characters (serve); API keys
|
|
1204
|
+
work with or without it
|
|
1205
|
+
SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;
|
|
1206
|
+
required when SLOTLOCK_CONFIRM_WRITES is not none)
|
|
1207
|
+
SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+
|
|
1208
|
+
random characters (serve; off without it)
|
|
1209
|
+
SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools
|
|
1210
|
+
that wait for a person (MCP 2026-07-28 form elicitation; refused
|
|
1211
|
+
with confirmation_required on REST, A2A and older MCP)
|
|
762
1212
|
SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)
|
|
763
1213
|
SLOTLOCK_TENANT Tenant of the token, resource and key commands (default "default")
|
|
764
1214
|
SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)
|
|
765
1215
|
SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and
|
|
766
1216
|
the application API for admin keys, described at /openapi.json;
|
|
767
1217
|
off: MCP and A2A only
|
|
1218
|
+
SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,
|
|
1219
|
+
and retired answers (never under 30) (1-3650, default 30;
|
|
1220
|
+
serve and maintain)
|
|
1221
|
+
SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the
|
|
1222
|
+
database, so N servers allow a tenant its rate once; off (default):
|
|
1223
|
+
each server keeps its own
|
|
1224
|
+
SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,
|
|
1225
|
+
never to a private network address; private: those too, for
|
|
1226
|
+
testing; off: keep the change feed, send nothing
|
|
1227
|
+
SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:
|
|
1228
|
+
rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,
|
|
1229
|
+
every request per client, before any key lookup), key_read,
|
|
1230
|
+
tenant_read (20/s:40), key_write, tenant_write (5/s:20),
|
|
1231
|
+
global_read (500/s:1000), global_write (50/s:100), app_read
|
|
1232
|
+
(20/s:40) and app_write (5/s:20) per tenant for the
|
|
1233
|
+
application API, sign_in (20/m:20); counts for
|
|
1234
|
+
tenant_series_writes and series_writes
|
|
1235
|
+
(1: writes that may expand a recurring series at once),
|
|
1236
|
+
maintenance_runs (1: application API maintenance runs at once),
|
|
1237
|
+
subscriptions (256), tenant_subscriptions (16),
|
|
1238
|
+
key_subscriptions (4, and fewer than tenant_subscriptions),
|
|
1239
|
+
key_lookups (2), key_lookup_queue (256),
|
|
1240
|
+
tenant_transactions (2: one tenant's database transactions at
|
|
1241
|
+
once, of the pool's 10); what one tenant may store:
|
|
1242
|
+
tenant_holds (10000), tenant_reservations, tenant_events and
|
|
1243
|
+
tenant_coverage (100000), holder_holds (20) and
|
|
1244
|
+
holder_held_hours (2160), which maintain keeps too
|
|
1245
|
+
SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,
|
|
1246
|
+
such as CF-Connecting-IP (default: the connection's address);
|
|
1247
|
+
set it only when every request comes through that proxy
|
|
768
1248
|
SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
|
|
769
1249
|
of a GitHub OAuth app with callback .../dashboard/callback
|
|
770
1250
|
SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
|
|
771
1251
|
SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)
|
|
772
1252
|
SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)
|
|
1253
|
+
SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant
|
|
1254
|
+
SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)
|
|
773
1255
|
HOST Listen address (default 127.0.0.1)
|
|
774
1256
|
PORT Listen port (default 8080)
|
|
775
1257
|
|
|
@@ -806,7 +1288,11 @@ export async function runSlotlockCli(argv, io) {
|
|
|
806
1288
|
io.stderr.write(SLOTLOCK_CLI_HELP);
|
|
807
1289
|
return 2;
|
|
808
1290
|
}
|
|
809
|
-
const subcommand = command === 'resource' ||
|
|
1291
|
+
const subcommand = command === 'resource' ||
|
|
1292
|
+
command === 'key' ||
|
|
1293
|
+
command === 'hours' ||
|
|
1294
|
+
command === 'rules' ||
|
|
1295
|
+
command === 'tenant'
|
|
810
1296
|
? `${command} ${operands[0] ?? ''}`.trim()
|
|
811
1297
|
: command;
|
|
812
1298
|
const expectedOperands = {
|
|
@@ -816,6 +1302,13 @@ export async function runSlotlockCli(argv, io) {
|
|
|
816
1302
|
healthcheck: 0,
|
|
817
1303
|
'resource add': 2,
|
|
818
1304
|
'resource list': 1,
|
|
1305
|
+
'resource archive': 2,
|
|
1306
|
+
'resource restore': 2,
|
|
1307
|
+
'resource delete': 2,
|
|
1308
|
+
'tenant status': 1,
|
|
1309
|
+
'tenant suspend': 1,
|
|
1310
|
+
'tenant unsuspend': 1,
|
|
1311
|
+
'tenant erase': 1,
|
|
819
1312
|
'key create': 2,
|
|
820
1313
|
'key list': 1,
|
|
821
1314
|
'key rotate': 2,
|
|
@@ -823,6 +1316,8 @@ export async function runSlotlockCli(argv, io) {
|
|
|
823
1316
|
'hours show': 1,
|
|
824
1317
|
'hours set': 2,
|
|
825
1318
|
'hours clear': 1,
|
|
1319
|
+
'rules set': 2,
|
|
1320
|
+
'rules clear': 1,
|
|
826
1321
|
};
|
|
827
1322
|
const expected = expectedOperands[subcommand];
|
|
828
1323
|
if (expected === undefined)
|
|
@@ -839,11 +1334,36 @@ export async function runSlotlockCli(argv, io) {
|
|
|
839
1334
|
return usage(`--${option} applies only to key create`);
|
|
840
1335
|
}
|
|
841
1336
|
}
|
|
1337
|
+
if (values.reason !== undefined && subcommand !== 'tenant suspend') {
|
|
1338
|
+
return usage('--reason applies only to tenant suspend');
|
|
1339
|
+
}
|
|
1340
|
+
if (values.confirm !== undefined && subcommand !== 'tenant erase') {
|
|
1341
|
+
return usage('--confirm applies only to tenant erase');
|
|
1342
|
+
}
|
|
1343
|
+
if (subcommand === 'tenant erase' && values.confirm === undefined) {
|
|
1344
|
+
return usage('tenant erase needs --confirm <tenant>, the tenant it erases (SLOTLOCK_TENANT)');
|
|
1345
|
+
}
|
|
842
1346
|
if (values.all && subcommand !== 'hours set' && subcommand !== 'hours clear') {
|
|
843
1347
|
return usage('--all applies only to hours set and hours clear');
|
|
844
1348
|
}
|
|
845
|
-
if (values.resource !== undefined &&
|
|
846
|
-
|
|
1349
|
+
if (values.resource !== undefined &&
|
|
1350
|
+
!subcommand.startsWith('hours ') &&
|
|
1351
|
+
!subcommand.startsWith('rules ')) {
|
|
1352
|
+
return usage('--resource applies only to hours and rules commands');
|
|
1353
|
+
}
|
|
1354
|
+
if (subcommand.startsWith('rules ') && values.resource === undefined) {
|
|
1355
|
+
return usage(`${subcommand} needs --resource <ref>`);
|
|
1356
|
+
}
|
|
1357
|
+
let bookingRules = null;
|
|
1358
|
+
if (subcommand === 'rules set') {
|
|
1359
|
+
try {
|
|
1360
|
+
bookingRules = parseBookingRules(JSON.parse(operands[1]));
|
|
1361
|
+
}
|
|
1362
|
+
catch (error) {
|
|
1363
|
+
return usage(error instanceof SyntaxError
|
|
1364
|
+
? 'rules must be a JSON object like {"minNoticeMinutes":120,"slotStepMinutes":30}'
|
|
1365
|
+
: error.message);
|
|
1366
|
+
}
|
|
847
1367
|
}
|
|
848
1368
|
if ((subcommand === 'hours set' || subcommand === 'hours clear') &&
|
|
849
1369
|
(values.resource === undefined) === (values.all !== true)) {
|
|
@@ -896,7 +1416,7 @@ export async function runSlotlockCli(argv, io) {
|
|
|
896
1416
|
return 0;
|
|
897
1417
|
}
|
|
898
1418
|
case 'maintain': {
|
|
899
|
-
const result = await maintainTenant(readSlotlockDatabaseConfig(io.env));
|
|
1419
|
+
const result = await maintainTenant(readSlotlockDatabaseConfig(io.env), readLimits(io.env).caps);
|
|
900
1420
|
if (!result) {
|
|
901
1421
|
io.stderr.write('slotlock: another server is maintaining this tenant now; nothing to do\n');
|
|
902
1422
|
return 0;
|
|
@@ -913,6 +1433,45 @@ export async function runSlotlockCli(argv, io) {
|
|
|
913
1433
|
case 'resource list':
|
|
914
1434
|
await listResources(readSlotlockDatabaseConfig(io.env), io.stdout);
|
|
915
1435
|
return 0;
|
|
1436
|
+
case 'resource archive':
|
|
1437
|
+
case 'resource restore':
|
|
1438
|
+
case 'resource delete': {
|
|
1439
|
+
const changed = await changeResource(readSlotlockDatabaseConfig(io.env), io.stdout, operands[1], subcommand.slice('resource '.length));
|
|
1440
|
+
if (changed === 'in_use') {
|
|
1441
|
+
io.stderr.write('slotlock: a booking, hold or calendar event on that resource has not ended; cancel or move it first, or archive the resource\n');
|
|
1442
|
+
return 1;
|
|
1443
|
+
}
|
|
1444
|
+
if (!changed) {
|
|
1445
|
+
io.stderr.write('slotlock: no resource with that reference or id in this tenant\n');
|
|
1446
|
+
return 1;
|
|
1447
|
+
}
|
|
1448
|
+
return 0;
|
|
1449
|
+
}
|
|
1450
|
+
case 'tenant status':
|
|
1451
|
+
case 'tenant suspend':
|
|
1452
|
+
case 'tenant unsuspend':
|
|
1453
|
+
case 'tenant erase': {
|
|
1454
|
+
const config = readSlotlockDatabaseConfig(io.env);
|
|
1455
|
+
// The tenant named twice, so erasing the one SLOTLOCK_TENANT happens to name is deliberate.
|
|
1456
|
+
if (subcommand === 'tenant erase' && values.confirm !== config.tenantRef) {
|
|
1457
|
+
return usage('--confirm must repeat SLOTLOCK_TENANT, the tenant tenant erase deletes');
|
|
1458
|
+
}
|
|
1459
|
+
try {
|
|
1460
|
+
await runTenantCommand(config, io.stdout, subcommand.slice('tenant '.length), values.reason);
|
|
1461
|
+
}
|
|
1462
|
+
catch (error) {
|
|
1463
|
+
const code = error?.code;
|
|
1464
|
+
if (code === 'tenant_suspension_not_permitted') {
|
|
1465
|
+
io.stderr.write('slotlock: only the schema owner suspends a tenant or lifts a suspension; set SLOTLOCK_MIGRATE_DATABASE_URL to its URL\n');
|
|
1466
|
+
return 1;
|
|
1467
|
+
}
|
|
1468
|
+
if (code === 'invalid_suspension_reason') {
|
|
1469
|
+
return usage('--reason must be 1-500 characters without control characters');
|
|
1470
|
+
}
|
|
1471
|
+
throw error;
|
|
1472
|
+
}
|
|
1473
|
+
return 0;
|
|
1474
|
+
}
|
|
916
1475
|
case 'hours show':
|
|
917
1476
|
case 'hours set':
|
|
918
1477
|
case 'hours clear': {
|
|
@@ -928,6 +1487,15 @@ export async function runSlotlockCli(argv, io) {
|
|
|
928
1487
|
}
|
|
929
1488
|
return 0;
|
|
930
1489
|
}
|
|
1490
|
+
case 'rules set':
|
|
1491
|
+
case 'rules clear': {
|
|
1492
|
+
const found = await runRules(readSlotlockDatabaseConfig(io.env), io.stdout, values.resource, bookingRules);
|
|
1493
|
+
if (!found) {
|
|
1494
|
+
io.stderr.write('slotlock: no resource with that reference or id in this tenant\n');
|
|
1495
|
+
return 1;
|
|
1496
|
+
}
|
|
1497
|
+
return 0;
|
|
1498
|
+
}
|
|
931
1499
|
case 'key create': {
|
|
932
1500
|
const config = readSlotlockDatabaseConfig(io.env);
|
|
933
1501
|
const created = await withApiKeys(config, (keys) => keys.create({
|
|
@@ -996,6 +1564,8 @@ function parseCliArguments(argv) {
|
|
|
996
1564
|
'expires-in-days': { type: 'string' },
|
|
997
1565
|
resource: { type: 'string' },
|
|
998
1566
|
all: { type: 'boolean' },
|
|
1567
|
+
reason: { type: 'string' },
|
|
1568
|
+
confirm: { type: 'string' },
|
|
999
1569
|
help: { type: 'boolean', short: 'h' },
|
|
1000
1570
|
version: { type: 'boolean', short: 'v' },
|
|
1001
1571
|
},
|