slotlock 0.1.1 → 0.3.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 +455 -0
- package/CONTRIBUTING.md +55 -19
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +403 -103
- package/SECURITY.md +70 -5
- package/SPEC.md +161 -21
- package/agent-server.d.ts +76 -10
- package/agent-server.js +381 -146
- package/agent-store-backend.d.ts +22 -0
- package/agent-store-backend.js +598 -24
- package/api-keys.d.ts +22 -7
- package/api-keys.js +38 -8
- package/application-api.d.ts +67 -0
- package/application-api.js +552 -0
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +10 -1
- package/dashboard.js +23 -5
- package/ddl.d.ts +47 -4
- package/ddl.js +679 -25
- package/engine.js +20 -6
- package/event-schema.d.ts +59 -0
- package/event-schema.js +85 -0
- package/hooks.d.ts +7 -0
- package/hooks.js +13 -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 -8
- package/index.js +7 -5
- package/maintenance.d.ts +67 -0
- package/maintenance.js +213 -0
- package/node-server.d.ts +9 -1
- package/node-server.js +127 -6
- package/openapi.json +6024 -0
- package/package.json +3 -2
- package/rate-limit.d.ts +71 -0
- package/rate-limit.js +718 -0
- package/rest-api.d.ts +33 -18
- package/rest-api.js +141 -63
- package/rules.d.ts +19 -0
- package/rules.js +38 -7
- package/self-host.d.ts +26 -9
- package/self-host.js +427 -47
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +359 -27
- package/store.js +2077 -296
- package/sync.d.ts +43 -2
- package/sync.js +1950 -277
- package/timezone.d.ts +77 -3
- package/timezone.js +265 -21
- package/types.d.ts +217 -10
- package/weekly-hours.js +2 -2
package/self-host.js
CHANGED
|
@@ -7,14 +7,17 @@ import { createHash, timingSafeEqual } from 'node:crypto';
|
|
|
7
7
|
import { parseArgs } from 'node:util';
|
|
8
8
|
import postgres from 'postgres';
|
|
9
9
|
import { createSlotlockAgentServer, slotlockAgentOperationScope, } from './agent-server.js';
|
|
10
|
-
import { createSlotlockStoreAgentBackend } from './agent-store-backend.js';
|
|
11
|
-
import { SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
|
|
10
|
+
import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, } from './agent-store-backend.js';
|
|
11
|
+
import { SLOTLOCK_API_KEY_DEFAULT_SCOPES, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
|
|
12
12
|
import { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockDashboardState, } from './dashboard.js';
|
|
13
|
-
import { SECRET_HINT, SlotlockConfigError, optional, readAvailability, readDashboardConfig, readSecret, readServerUrl, } from './config.js';
|
|
14
|
-
import {
|
|
13
|
+
import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, slotlockKeySubscriptions, } from './config.js';
|
|
14
|
+
import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
|
|
15
|
+
import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_TENANT_TABLES } from './ddl.js';
|
|
16
|
+
import { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
|
|
15
17
|
import { createSlotlockNodeServer, } from './node-server.js';
|
|
16
18
|
import { slotlockPackageVersion } from './package-version.js';
|
|
17
|
-
import {
|
|
19
|
+
import { createSlotlockRequestLimits } from './rate-limit.js';
|
|
20
|
+
import { assertSlotlockSchemaCurrent, createSlotlockStore, } from './store.js';
|
|
18
21
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
19
22
|
export { SlotlockConfigError };
|
|
20
23
|
/** Every variable the commands read. The README, `.env.example` and `server.json` document these. */
|
|
@@ -28,6 +31,8 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
|
28
31
|
'SLOTLOCK_TENANT',
|
|
29
32
|
'SLOTLOCK_AVAILABILITY',
|
|
30
33
|
'SLOTLOCK_REST_API',
|
|
34
|
+
'SLOTLOCK_LIMITS',
|
|
35
|
+
'SLOTLOCK_TRUSTED_PROXY_HEADER',
|
|
31
36
|
'SLOTLOCK_GITHUB_CLIENT_ID',
|
|
32
37
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
33
38
|
'SLOTLOCK_SESSION_SECRET',
|
|
@@ -38,11 +43,20 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
|
38
43
|
const DEFAULT_HOST = '127.0.0.1';
|
|
39
44
|
const DEFAULT_PORT = 8080;
|
|
40
45
|
const DEFAULT_TENANT = 'default';
|
|
41
|
-
/** The principal the server token resolves to: it acts in SLOTLOCK_TENANT with
|
|
46
|
+
/** The principal the server token resolves to: it acts in SLOTLOCK_TENANT with read and write. */
|
|
42
47
|
const SELF_HOST_SUBJECT = 'self-host';
|
|
43
48
|
/** Who `key create` records as a key's creator. */
|
|
44
49
|
const CLI_KEY_CREATOR = 'cli';
|
|
45
50
|
const DAY_MS = 86_400_000;
|
|
51
|
+
/**
|
|
52
|
+
* The most resources `serve` lets one tenant create through the application API. A dashboard open to
|
|
53
|
+
* every GitHub user gives each a tenant, so the cap keeps one from filling the database.
|
|
54
|
+
*/
|
|
55
|
+
const SERVE_MAX_TENANT_RESOURCES = 10_000;
|
|
56
|
+
/** How often `serve` maintains each tenant it serves (README, Operations: at least daily). */
|
|
57
|
+
const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
|
|
58
|
+
/** Passes `slotlock maintain` runs at most, each as big as `serve`'s, until nothing is left. */
|
|
59
|
+
const MAINTAIN_MAX_PASSES = 100;
|
|
46
60
|
const WRITE_OPERATIONS = [
|
|
47
61
|
'slotlock_create_event',
|
|
48
62
|
'slotlock_update_event',
|
|
@@ -63,8 +77,13 @@ const SERVED_OPERATIONS = new Set([
|
|
|
63
77
|
'slotlock_delete_event',
|
|
64
78
|
]);
|
|
65
79
|
const HEALTH_QUERY_TIMEOUT_MS = 2_000;
|
|
80
|
+
/** How long one database check answers /healthz, so a flood of health checks is not one of queries. */
|
|
81
|
+
const HEALTH_RESULT_MS = 1_000;
|
|
66
82
|
const HEALTHCHECK_TIMEOUT_MS = 5_000;
|
|
83
|
+
/** One pool for every tenant; SLOTLOCK_LIMITS `tenant_transactions` (config.ts) goes up to it. */
|
|
67
84
|
const POOL_SIZE = 10;
|
|
85
|
+
/** How long a request's transaction waits for any one lock before it answers 503 `server_busy`. */
|
|
86
|
+
const SERVE_LOCK_TIMEOUT_MS = 5_000;
|
|
68
87
|
const RESOURCE_PAGE_SIZE = 1_000;
|
|
69
88
|
function readDatabaseUrl(env, name) {
|
|
70
89
|
const value = optional(env, name);
|
|
@@ -110,7 +129,7 @@ function readTenant(env) {
|
|
|
110
129
|
return value;
|
|
111
130
|
}
|
|
112
131
|
function readConfirmWrites(env) {
|
|
113
|
-
const value = optional(env, 'SLOTLOCK_CONFIRM_WRITES') ?? '
|
|
132
|
+
const value = optional(env, 'SLOTLOCK_CONFIRM_WRITES') ?? 'none';
|
|
114
133
|
if (value === 'all')
|
|
115
134
|
return [...WRITE_OPERATIONS];
|
|
116
135
|
if (value === 'none')
|
|
@@ -138,7 +157,7 @@ export function readSlotlockDatabaseConfig(env) {
|
|
|
138
157
|
return config;
|
|
139
158
|
}
|
|
140
159
|
function readRestApi(env) {
|
|
141
|
-
const value = optional(env, 'SLOTLOCK_REST_API') ?? '
|
|
160
|
+
const value = optional(env, 'SLOTLOCK_REST_API') ?? 'on';
|
|
142
161
|
if (value !== 'on' && value !== 'off') {
|
|
143
162
|
throw new SlotlockConfigError('SLOTLOCK_REST_API must be on or off');
|
|
144
163
|
}
|
|
@@ -150,7 +169,7 @@ export function readSlotlockServeConfig(env) {
|
|
|
150
169
|
const confirmWrites = readConfirmWrites(env);
|
|
151
170
|
const confirmationSecret = readSecret(env, 'SLOTLOCK_CONFIRMATION_SECRET');
|
|
152
171
|
if (confirmWrites.length > 0 && confirmationSecret === undefined) {
|
|
153
|
-
throw new SlotlockConfigError(`SLOTLOCK_CONFIRMATION_SECRET is required while SLOTLOCK_CONFIRM_WRITES guards writes
|
|
172
|
+
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`);
|
|
154
173
|
}
|
|
155
174
|
if (confirmationSecret !== undefined &&
|
|
156
175
|
authToken !== undefined &&
|
|
@@ -166,7 +185,11 @@ export function readSlotlockServeConfig(env) {
|
|
|
166
185
|
confirmWrites,
|
|
167
186
|
availability: readAvailability(env),
|
|
168
187
|
restApi: readRestApi(env),
|
|
188
|
+
limits: readLimits(env),
|
|
169
189
|
};
|
|
190
|
+
const trustedProxyHeader = readTrustedProxyHeader(env);
|
|
191
|
+
if (trustedProxyHeader !== undefined)
|
|
192
|
+
config.trustedProxyHeader = trustedProxyHeader;
|
|
170
193
|
if (authToken !== undefined)
|
|
171
194
|
config.authToken = authToken;
|
|
172
195
|
const dashboard = readDashboardConfig(env);
|
|
@@ -208,8 +231,8 @@ export function createSlotlockTokenAuthenticator(token, principal) {
|
|
|
208
231
|
}
|
|
209
232
|
/**
|
|
210
233
|
* What `serve` lets a principal call: a served operation its scopes cover. The server token's
|
|
211
|
-
* principal carries
|
|
212
|
-
* agent server alone would leave it to this function.
|
|
234
|
+
* principal carries read and write, never admin; a principal without scopes is refused everything
|
|
235
|
+
* here, though the agent server alone would leave it to this function.
|
|
213
236
|
*/
|
|
214
237
|
export function authorizeSlotlockServeOperation(args) {
|
|
215
238
|
return (SERVED_OPERATIONS.has(args.operation) &&
|
|
@@ -245,7 +268,10 @@ function secretsIn(env) {
|
|
|
245
268
|
// Longest first, so a URL is replaced whole rather than around its password.
|
|
246
269
|
return [...new Set(secrets)].sort((a, b) => b.length - a.length);
|
|
247
270
|
}
|
|
248
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* One JSON object per line: info on stdout, warnings and errors on stderr, secrets replaced by
|
|
273
|
+
* `[redacted]`.
|
|
274
|
+
*/
|
|
249
275
|
function createLogger(io) {
|
|
250
276
|
const secrets = secretsIn(io.env);
|
|
251
277
|
const write = (stream, level, event, fields = {}) => {
|
|
@@ -256,6 +282,7 @@ function createLogger(io) {
|
|
|
256
282
|
};
|
|
257
283
|
return {
|
|
258
284
|
info: (event, fields) => write(io.stdout, 'info', event, fields),
|
|
285
|
+
warn: (event, fields) => write(io.stderr, 'warn', event, fields),
|
|
259
286
|
error: (event, fields) => write(io.stderr, 'error', event, fields),
|
|
260
287
|
};
|
|
261
288
|
}
|
|
@@ -272,6 +299,13 @@ function describeError(error) {
|
|
|
272
299
|
}
|
|
273
300
|
return detail;
|
|
274
301
|
}
|
|
302
|
+
/** An agent server event's fields under the log's snake_case keys (`durationMs` → `duration_ms`). */
|
|
303
|
+
function snakeCaseKeys(fields) {
|
|
304
|
+
return Object.fromEntries(Object.entries(fields).map(([key, value]) => [
|
|
305
|
+
key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`),
|
|
306
|
+
value,
|
|
307
|
+
]));
|
|
308
|
+
}
|
|
275
309
|
function connect(url, max) {
|
|
276
310
|
return postgres(url, { max, onnotice: () => { }, connect_timeout: 10 });
|
|
277
311
|
}
|
|
@@ -340,6 +374,26 @@ function dashboardAllows(config, tenantRef) {
|
|
|
340
374
|
const githubId = /^github:(\d+)$/.exec(tenantRef)?.[1];
|
|
341
375
|
return githubId === undefined || users.includes(githubId);
|
|
342
376
|
}
|
|
377
|
+
function serveTenantLimits(caps) {
|
|
378
|
+
return {
|
|
379
|
+
resources: SERVE_MAX_TENANT_RESOURCES,
|
|
380
|
+
holds: caps.tenant_holds,
|
|
381
|
+
reservations: caps.tenant_reservations,
|
|
382
|
+
events: caps.tenant_events,
|
|
383
|
+
coverage: caps.tenant_coverage,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The store `serve` answers requests from. Every surface writes through it, so its tenant limits
|
|
388
|
+
* bound what any key can add, and its transaction limits what one tenant holds of the shared pool.
|
|
389
|
+
*/
|
|
390
|
+
export function createSlotlockServeStore(sql, caps) {
|
|
391
|
+
return createSlotlockStore(sql, {
|
|
392
|
+
tenantLimits: serveTenantLimits(caps),
|
|
393
|
+
maxTenantTransactions: caps.tenant_transactions,
|
|
394
|
+
tenantLockTimeoutMs: SERVE_LOCK_TIMEOUT_MS,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
343
397
|
/** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
|
|
344
398
|
export async function startSlotlockServer(config, log) {
|
|
345
399
|
const sql = connect(config.databaseUrl, POOL_SIZE);
|
|
@@ -358,6 +412,23 @@ export async function startSlotlockServer(config, log) {
|
|
|
358
412
|
if (!schema?.present) {
|
|
359
413
|
throw Object.assign(new Error('The slotlock schema is missing: run `slotlock migrate` or `slotlock serve --migrate`'), { code: 'schema_missing' });
|
|
360
414
|
}
|
|
415
|
+
// Migrated by another release: an older schema needs this release's migrate first, and a newer
|
|
416
|
+
// one has tables and rules this release's code does not know.
|
|
417
|
+
await assertSlotlockSchemaCurrent(sql);
|
|
418
|
+
// A deployment run statement by statement lifts forced row-level security for its data steps
|
|
419
|
+
// and, stopped part way, leaves it lifted until the next one puts it back. The owning role then
|
|
420
|
+
// reads every tenant's rows: refuse to serve until then.
|
|
421
|
+
const lifted = await sql `
|
|
422
|
+
SELECT rel.relname AS name
|
|
423
|
+
FROM pg_catalog.pg_class rel
|
|
424
|
+
JOIN pg_catalog.pg_namespace ns ON ns.oid = rel.relnamespace
|
|
425
|
+
WHERE ns.nspname = 'slotlock'
|
|
426
|
+
AND rel.relname IN ${sql(SLOTLOCK_TENANT_TABLES)}
|
|
427
|
+
AND rel.relrowsecurity AND NOT rel.relforcerowsecurity
|
|
428
|
+
ORDER BY rel.relname`;
|
|
429
|
+
if (lifted.length > 0) {
|
|
430
|
+
throw Object.assign(new Error(`Row-level security is enabled but not forced on ${lifted.map(({ name }) => `slotlock.${name}`).join(', ')}: a deployment stopped part way. Run \`slotlock migrate\` (or \`slotlock serve --migrate\`) to put it back`), { code: 'row_security_lifted' });
|
|
431
|
+
}
|
|
361
432
|
if (config.dashboard) {
|
|
362
433
|
for (const signature of SLOTLOCK_DASHBOARD_FUNCTIONS) {
|
|
363
434
|
const [granted] = await sql `
|
|
@@ -368,16 +439,47 @@ export async function startSlotlockServer(config, log) {
|
|
|
368
439
|
}
|
|
369
440
|
}
|
|
370
441
|
}
|
|
371
|
-
const
|
|
442
|
+
const { caps } = config.limits;
|
|
443
|
+
const store = createSlotlockServeStore(sql, caps);
|
|
372
444
|
const publicUrl = new URL(config.publicUrl);
|
|
373
|
-
const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql)
|
|
445
|
+
const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql), {
|
|
446
|
+
maxConcurrentLookups: caps.key_lookups,
|
|
447
|
+
maxWaitingLookups: caps.key_lookup_queue,
|
|
448
|
+
});
|
|
374
449
|
const authenticateToken = config.authToken === undefined
|
|
375
450
|
? undefined
|
|
376
|
-
:
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
451
|
+
: // Read and write, never admin: this is the token the docs put in agents' configuration,
|
|
452
|
+
// and the application API's writes ask no person. An admin key is made for the purpose.
|
|
453
|
+
createSlotlockTokenAuthenticator(config.authToken, {
|
|
454
|
+
subject: SELF_HOST_SUBJECT,
|
|
455
|
+
tenantRef: config.tenantRef,
|
|
456
|
+
scopes: SLOTLOCK_API_KEY_DEFAULT_SCOPES,
|
|
457
|
+
});
|
|
458
|
+
// Set once the listener is up; the authenticator tells it which tenants this server serves.
|
|
459
|
+
let maintenance;
|
|
460
|
+
const basePath = publicUrl.pathname.replace(/\/$/, '');
|
|
461
|
+
const limits = createSlotlockRequestLimits({
|
|
462
|
+
limits: config.limits,
|
|
463
|
+
...(config.trustedProxyHeader !== undefined
|
|
464
|
+
? { trustedProxyHeader: config.trustedProxyHeader }
|
|
465
|
+
: {}),
|
|
466
|
+
basePath,
|
|
467
|
+
signInPaths: config.dashboard ? ['/dashboard/sign-in', '/dashboard/callback'] : [],
|
|
468
|
+
// The token is compared in constant time first; only then is a key looked up. A person the
|
|
469
|
+
// dashboard no longer allows loses their keys with their session.
|
|
470
|
+
authenticate: async (request) => {
|
|
471
|
+
const principal = (await authenticateToken?.(request)) ?? (await authenticateKey(request));
|
|
472
|
+
if (!principal || !dashboardAllows(config, principal.tenantRef))
|
|
473
|
+
return null;
|
|
474
|
+
maintenance?.touch(principal.tenantRef);
|
|
475
|
+
return principal;
|
|
476
|
+
},
|
|
477
|
+
// Counts and pseudonyms only: never an address, a credential or a tenant.
|
|
478
|
+
onRefusals: (counts) => log.info('refusals', { ...counts }),
|
|
479
|
+
// Behind a proxy without the setting, every client shares the proxy's address budget.
|
|
480
|
+
onIgnoredClientField: (field) => log.warn('client_address_field_ignored', { field, setting: 'SLOTLOCK_TRUSTED_PROXY_HEADER' }),
|
|
481
|
+
});
|
|
482
|
+
let health;
|
|
381
483
|
const agentServer = createSlotlockAgentServer({
|
|
382
484
|
publicBaseUrl: config.publicUrl,
|
|
383
485
|
// readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
|
|
@@ -385,22 +487,51 @@ export async function startSlotlockServer(config, log) {
|
|
|
385
487
|
backend: createSlotlockStoreAgentBackend(store, {
|
|
386
488
|
availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
|
|
387
489
|
}),
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
490
|
+
authenticate: limits.authenticate,
|
|
491
|
+
consumeRateLimit: limits.consumeRateLimit,
|
|
492
|
+
// A write that may expand a recurring series also waits for one of a few slots, so one
|
|
493
|
+
// tenant's expansions cannot hold the event loop from every other tenant's requests.
|
|
494
|
+
authorize: async ({ principal, operation, input }) => {
|
|
495
|
+
if (!authorizeSlotlockServeOperation({ principal, operation }))
|
|
496
|
+
return false;
|
|
497
|
+
await limits.admit({ principal, operation, input });
|
|
498
|
+
return true;
|
|
499
|
+
},
|
|
500
|
+
subscriptions: {
|
|
501
|
+
maxTotal: caps.subscriptions,
|
|
502
|
+
maxPerTenant: caps.tenant_subscriptions,
|
|
503
|
+
maxPerPrincipal: slotlockKeySubscriptions(caps),
|
|
504
|
+
},
|
|
505
|
+
health: async () => {
|
|
506
|
+
if (!health || performance.now() - health.at > HEALTH_RESULT_MS) {
|
|
507
|
+
health = { at: performance.now(), ready: databaseReachable(sql) };
|
|
508
|
+
}
|
|
509
|
+
return (await health.ready)
|
|
510
|
+
? { ready: true, checks: ['database'] }
|
|
511
|
+
: { ready: false, checks: ['database_unreachable'] };
|
|
393
512
|
},
|
|
394
|
-
authorize: async ({ principal, operation }) => authorizeSlotlockServeOperation({ principal, operation }),
|
|
395
|
-
health: async () => (await databaseReachable(sql))
|
|
396
|
-
? { ready: true, checks: ['database'] }
|
|
397
|
-
: { ready: false, checks: ['database_unreachable'] },
|
|
398
513
|
// Outcomes and counts only: the server never reports arguments or identities.
|
|
399
|
-
onEvent: ({ type, ...fields }) => log.info(type, fields),
|
|
514
|
+
onEvent: ({ type, ...fields }) => log.info(type, snakeCaseKeys(fields)),
|
|
400
515
|
// What an agent sees only as internal_error: the operation and the error's message and code,
|
|
401
516
|
// never the arguments or the request, with every configured secret redacted.
|
|
402
517
|
onError: (error, { operation }) => log.error('agent_error', { operation, ...describeError(error) }),
|
|
403
|
-
...(config.restApi
|
|
518
|
+
...(config.restApi
|
|
519
|
+
? {
|
|
520
|
+
rest: true,
|
|
521
|
+
// Resources, hours, holds, reservations, coverage and maintenance: admin keys only.
|
|
522
|
+
application: {
|
|
523
|
+
backend: createSlotlockStoreApplicationBackend(store, {
|
|
524
|
+
maxTenantResources: SERVE_MAX_TENANT_RESOURCES,
|
|
525
|
+
}),
|
|
526
|
+
authorize: async ({ principal, operation, input }) => {
|
|
527
|
+
if (principal.scopes?.includes(SLOTLOCK_APPLICATION_SCOPE) !== true)
|
|
528
|
+
return false;
|
|
529
|
+
await limits.admit({ principal, operation, input });
|
|
530
|
+
return true;
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
: {}),
|
|
404
535
|
...(config.confirmationSecret !== undefined
|
|
405
536
|
? {
|
|
406
537
|
confirmation: {
|
|
@@ -424,19 +555,30 @@ export async function startSlotlockServer(config, log) {
|
|
|
424
555
|
resources: createSlotlockDashboardResources(store),
|
|
425
556
|
state: createSlotlockDashboardState(sql),
|
|
426
557
|
availability: config.availability,
|
|
558
|
+
clientAddress: (request) => limits.clientAddress(request),
|
|
427
559
|
onError: (error) => log.error('dashboard_error', describeError(error)),
|
|
428
560
|
});
|
|
429
|
-
const target = dashboard
|
|
561
|
+
const target = limits.serve(dashboard
|
|
430
562
|
? {
|
|
431
563
|
fetch: (request) => dashboard.handles(request) ? dashboard.fetch(request) : agentServer.fetch(request),
|
|
432
564
|
shutdown: () => agentServer.shutdown(),
|
|
433
565
|
}
|
|
434
|
-
: agentServer;
|
|
566
|
+
: agentServer);
|
|
435
567
|
const listener = createSlotlockNodeServer(target, {
|
|
436
568
|
requestOrigin: publicUrl.origin,
|
|
437
569
|
onError: (error, phase) => log.error('http_error', { phase, ...describeError(error) }),
|
|
438
570
|
});
|
|
439
571
|
const address = await listener.listen({ host: config.host, port: config.port });
|
|
572
|
+
maintenance = startSlotlockMaintenance({
|
|
573
|
+
// One tenant at a time, and its long passes may wait on a request's locks for longer.
|
|
574
|
+
store: createSlotlockStore(sql, { tenantLimits: serveTenantLimits(caps) }),
|
|
575
|
+
sql,
|
|
576
|
+
tenants: [config.tenantRef],
|
|
577
|
+
intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
|
|
578
|
+
// Counts only, never the tenant.
|
|
579
|
+
onPass: (result) => log.info('maintenance', { ...result }),
|
|
580
|
+
onError: (error) => log.error('maintenance_failed', describeError(error)),
|
|
581
|
+
});
|
|
440
582
|
log.info('listening', {
|
|
441
583
|
address: address.origin,
|
|
442
584
|
mcp: `${config.publicUrl}/mcp`,
|
|
@@ -446,11 +588,22 @@ export async function startSlotlockServer(config, log) {
|
|
|
446
588
|
dashboard: dashboard ? `${config.publicUrl}/dashboard` : null,
|
|
447
589
|
confirm_writes: config.confirmWrites,
|
|
448
590
|
availability_rules: config.availability.length,
|
|
591
|
+
rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
|
|
592
|
+
maintenance_interval_minutes: SERVE_MAINTENANCE_INTERVAL_MS / 60_000,
|
|
593
|
+
limits: {
|
|
594
|
+
...Object.fromEntries(Object.entries(config.limits.rates).map(([name, rate]) => [name, formatSlotlockRate(rate)])),
|
|
595
|
+
...caps,
|
|
596
|
+
// What a key may hold, which is below key_subscriptions when the tenant's cap is lower.
|
|
597
|
+
key_subscriptions: slotlockKeySubscriptions(caps),
|
|
598
|
+
},
|
|
599
|
+
client_address: config.trustedProxyHeader ?? 'socket',
|
|
449
600
|
});
|
|
450
601
|
return {
|
|
451
602
|
address,
|
|
452
603
|
async close() {
|
|
453
604
|
const result = await listener.close();
|
|
605
|
+
limits.close();
|
|
606
|
+
await maintenance?.close();
|
|
454
607
|
await sql.end({ timeout: 5 });
|
|
455
608
|
return result;
|
|
456
609
|
},
|
|
@@ -509,8 +662,8 @@ function hoursLine(resource) {
|
|
|
509
662
|
hours: resource.availabilityRules ?? null,
|
|
510
663
|
})}\n`;
|
|
511
664
|
}
|
|
512
|
-
/** Every resource of the tenant, a page at a time. */
|
|
513
|
-
async function tenantResources(store, tenantRef) {
|
|
665
|
+
/** Every resource of the tenant in use (with `includeArchived`, every one), a page at a time. */
|
|
666
|
+
async function tenantResources(store, tenantRef, includeArchived = false) {
|
|
514
667
|
const all = [];
|
|
515
668
|
let after;
|
|
516
669
|
for (;;) {
|
|
@@ -518,6 +671,7 @@ async function tenantResources(store, tenantRef) {
|
|
|
518
671
|
tenantRef,
|
|
519
672
|
limit: RESOURCE_PAGE_SIZE,
|
|
520
673
|
...(after !== undefined ? { after } : {}),
|
|
674
|
+
...(includeArchived ? { includeArchived } : {}),
|
|
521
675
|
}));
|
|
522
676
|
all.push(...page);
|
|
523
677
|
after = page.at(-1)?.id;
|
|
@@ -564,6 +718,90 @@ async function runHours(config, out, target, rules) {
|
|
|
564
718
|
await sql.end({ timeout: 5 });
|
|
565
719
|
}
|
|
566
720
|
}
|
|
721
|
+
/** A resource as `resource archive` and `resource restore` print it. */
|
|
722
|
+
function resourceStateLine(resource) {
|
|
723
|
+
return `${JSON.stringify({
|
|
724
|
+
id: resource.id,
|
|
725
|
+
external_ref: resource.externalRef,
|
|
726
|
+
timezone: resource.timezone,
|
|
727
|
+
visibility: resource.visibility ?? 'shared',
|
|
728
|
+
archived_at: resource.archivedAt?.toISOString() ?? null,
|
|
729
|
+
})}\n`;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* `resource archive|restore|delete`: the tenant's resource named by id, else by reference, archived
|
|
733
|
+
* ones included. Returns false when no resource has that name, and `in_use` when a delete is
|
|
734
|
+
* refused because something on the resource has not ended.
|
|
735
|
+
*/
|
|
736
|
+
async function changeResource(config, out, name, action) {
|
|
737
|
+
const sql = connect(config.databaseUrl, 1);
|
|
738
|
+
try {
|
|
739
|
+
const store = createSlotlockStore(sql);
|
|
740
|
+
const tenantRef = config.tenantRef;
|
|
741
|
+
const resources = await tenantResources(store, tenantRef, true);
|
|
742
|
+
// An id first: another resource's reference may spell it.
|
|
743
|
+
const named = resources.find((resource) => resource.id === name) ??
|
|
744
|
+
resources.find((resource) => resource.externalRef === name);
|
|
745
|
+
if (!named)
|
|
746
|
+
return false;
|
|
747
|
+
const id = named.id;
|
|
748
|
+
if (action === 'delete') {
|
|
749
|
+
const result = await store.withTenant(tenantRef, (tenant) => tenant.deleteResource({ tenantRef, id }));
|
|
750
|
+
if (!result.ok)
|
|
751
|
+
return 'in_use';
|
|
752
|
+
if (!result.deleted)
|
|
753
|
+
return false;
|
|
754
|
+
out.write(`${JSON.stringify({ id, external_ref: named.externalRef, deleted: true })}\n`);
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
const changed = await store.withTenant(tenantRef, (tenant) => action === 'archive'
|
|
758
|
+
? tenant.archiveResource({ tenantRef, id })
|
|
759
|
+
: tenant.restoreResource({ tenantRef, id }));
|
|
760
|
+
if (!changed)
|
|
761
|
+
return false;
|
|
762
|
+
out.write(resourceStateLine(changed));
|
|
763
|
+
return true;
|
|
764
|
+
}
|
|
765
|
+
finally {
|
|
766
|
+
await sql.end({ timeout: 5 });
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* `tenant status|suspend|unsuspend|erase` for SLOTLOCK_TENANT. Suspending and lifting a suspension
|
|
771
|
+
* are the deployment role's alone, so they connect as SLOTLOCK_MIGRATE_DATABASE_URL when it is set;
|
|
772
|
+
* the serving role reads a suspension and may erase.
|
|
773
|
+
*/
|
|
774
|
+
async function runTenantCommand(config, out, action, reason) {
|
|
775
|
+
const owner = action === 'suspend' || action === 'unsuspend';
|
|
776
|
+
const sql = connect(owner ? (config.migrateDatabaseUrl ?? config.databaseUrl) : config.databaseUrl, 1);
|
|
777
|
+
try {
|
|
778
|
+
const store = createSlotlockStore(sql);
|
|
779
|
+
const tenantRef = config.tenantRef;
|
|
780
|
+
let line;
|
|
781
|
+
if (action === 'status') {
|
|
782
|
+
line = { tenant: tenantRef, suspended: await store.isTenantSuspended({ tenantRef }) };
|
|
783
|
+
}
|
|
784
|
+
else if (action === 'suspend') {
|
|
785
|
+
const { suspended } = await store.suspendTenant({
|
|
786
|
+
tenantRef,
|
|
787
|
+
...(reason !== undefined ? { reason } : {}),
|
|
788
|
+
});
|
|
789
|
+
line = { tenant: tenantRef, suspended: true, changed: suspended };
|
|
790
|
+
}
|
|
791
|
+
else if (action === 'unsuspend') {
|
|
792
|
+
const { unsuspended } = await store.unsuspendTenant({ tenantRef });
|
|
793
|
+
line = { tenant: tenantRef, suspended: false, changed: unsuspended };
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
const erased = await store.eraseTenant({ tenantRef });
|
|
797
|
+
line = { tenant: tenantRef, total: erased.total, rows: erased.rows };
|
|
798
|
+
}
|
|
799
|
+
out.write(`${JSON.stringify(line)}\n`);
|
|
800
|
+
}
|
|
801
|
+
finally {
|
|
802
|
+
await sql.end({ timeout: 5 });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
567
805
|
/** One key as `key create`, `key list` and `key revoke` print it: never the digest. */
|
|
568
806
|
function apiKeyLine(apiKey, key) {
|
|
569
807
|
return `${JSON.stringify({
|
|
@@ -579,17 +817,49 @@ function apiKeyLine(apiKey, key) {
|
|
|
579
817
|
...(key !== undefined ? { key } : {}),
|
|
580
818
|
})}\n`;
|
|
581
819
|
}
|
|
582
|
-
/** `--scope`: read, write
|
|
820
|
+
/** `--scope`: read, write and admin, comma-separated; read,write unless given. */
|
|
583
821
|
function parseKeyScopes(value) {
|
|
584
822
|
if (value === undefined)
|
|
585
|
-
return [...
|
|
823
|
+
return [...SLOTLOCK_API_KEY_DEFAULT_SCOPES];
|
|
586
824
|
const names = value.split(',').map((name) => name.trim());
|
|
587
825
|
const scopes = SLOTLOCK_API_KEY_SCOPES.filter((scope) => names.includes(scope));
|
|
588
826
|
if (names.length === 0 || names.some((name) => !scopes.includes(name))) {
|
|
589
|
-
throw new Error('--scope must be read, write
|
|
827
|
+
throw new Error('--scope must be a comma-separated list of read, write and admin');
|
|
590
828
|
}
|
|
591
829
|
return scopes;
|
|
592
830
|
}
|
|
831
|
+
/**
|
|
832
|
+
* `maintain`: maintenance passes for the tenant until nothing is left, under the tenant's lock, so
|
|
833
|
+
* it never repeats what a server is doing. Null when another server holds the lock.
|
|
834
|
+
*/
|
|
835
|
+
async function maintainTenant(config) {
|
|
836
|
+
const sql = connect(config.databaseUrl, 2);
|
|
837
|
+
try {
|
|
838
|
+
const store = createSlotlockStore(sql);
|
|
839
|
+
const total = await withSlotlockMaintenanceLock(sql, config.tenantRef, async () => {
|
|
840
|
+
const sum = { extended: 0, conflicts: 0, refused: 0, pruned: 0, capped: true, passes: 0 };
|
|
841
|
+
while (sum.capped && sum.passes < MAINTAIN_MAX_PASSES) {
|
|
842
|
+
const pass = await maintainSlotlockTenant(store, config.tenantRef);
|
|
843
|
+
sum.passes += 1;
|
|
844
|
+
sum.extended += pass.extended;
|
|
845
|
+
sum.conflicts += pass.conflicts;
|
|
846
|
+
sum.refused += pass.refused;
|
|
847
|
+
sum.pruned += pass.pruned;
|
|
848
|
+
// A capped pass that got nowhere met only series it cannot extend. While its roll stopped
|
|
849
|
+
// before the series it had put back, the next pass meets new ones (and perhaps a healthy
|
|
850
|
+
// one); once it reached them, another pass would meet them again.
|
|
851
|
+
sum.capped = pass.capped;
|
|
852
|
+
if (pass.capped && pass.extended + pass.pruned === 0 && !pass.rollPending)
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
return sum;
|
|
856
|
+
});
|
|
857
|
+
return total ?? null;
|
|
858
|
+
}
|
|
859
|
+
finally {
|
|
860
|
+
await sql.end({ timeout: 5 });
|
|
861
|
+
}
|
|
862
|
+
}
|
|
593
863
|
/** `--expires-in-days`: a whole number of days up to the longest a key may live. */
|
|
594
864
|
function parseKeyLifetime(value) {
|
|
595
865
|
if (value === undefined)
|
|
@@ -632,23 +902,41 @@ export const SLOTLOCK_CLI_HELP = `Usage: slotlock <command> [options]
|
|
|
632
902
|
Commands:
|
|
633
903
|
migrate Apply the schema and row-level security, then grant the serving
|
|
634
904
|
role when SLOTLOCK_MIGRATE_DATABASE_URL names another role
|
|
635
|
-
serve [--migrate] Serve MCP and
|
|
905
|
+
serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);
|
|
906
|
+
maintains each tenant it serves every hour
|
|
907
|
+
maintain Run maintenance for the tenant now: extend recurring events to the
|
|
908
|
+
rolling horizon and prune past the retention window (for cron)
|
|
636
909
|
resource add <ref> [--timezone <IANA zone>]
|
|
637
910
|
Create a calendar resource (a car, room, person or machine)
|
|
638
911
|
resource list Print the tenant's resources, one JSON object per line
|
|
912
|
+
resource archive <ref> Take a resource out of use: no new bookings, holds or events, and
|
|
913
|
+
no longer listed; what is on it stays
|
|
914
|
+
resource restore <ref> Put an archived resource back in use
|
|
915
|
+
resource delete <ref> Delete a resource and its history; refused while a booking, hold
|
|
916
|
+
or event on it has not ended
|
|
639
917
|
hours show [--resource <ref>]
|
|
640
918
|
Print each resource's bookable hours (null: the server's)
|
|
641
919
|
hours set <json> (--resource <ref> | --all)
|
|
642
920
|
Give one resource, or every one, weekly hours of its own; [] closes
|
|
643
921
|
hours clear (--resource <ref> | --all)
|
|
644
922
|
Hand one resource, or every one, back to the server's hours
|
|
645
|
-
key create <name> [--scope read
|
|
923
|
+
key create <name> [--scope read,write,admin] [--expires-in-days <n>]
|
|
646
924
|
Create an API key for the tenant (default scope read,write, no
|
|
647
|
-
expiry) and print it once; only its
|
|
925
|
+
expiry; admin adds the application API) and print it once; only its
|
|
926
|
+
SHA-256 is stored
|
|
648
927
|
key list Print the tenant's API keys, one JSON object per line
|
|
649
928
|
key rotate <id> Give an active key a new secret and print it once; the old one stops
|
|
650
929
|
working at once, and the key keeps its id and what it booked
|
|
651
930
|
key revoke <id> Revoke one of the tenant's API keys at once
|
|
931
|
+
tenant status Print whether the tenant is suspended
|
|
932
|
+
tenant suspend [--reason <text>]
|
|
933
|
+
Suspend the tenant: it may read, and writes nothing; its keys get
|
|
934
|
+
no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:
|
|
935
|
+
only the schema owner suspends)
|
|
936
|
+
tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)
|
|
937
|
+
tenant erase --confirm <tenant>
|
|
938
|
+
Delete everything the tenant has, its API keys included, for good;
|
|
939
|
+
--confirm repeats SLOTLOCK_TENANT
|
|
652
940
|
healthcheck Exit 0 when the local server reports ready
|
|
653
941
|
|
|
654
942
|
Options:
|
|
@@ -659,15 +947,40 @@ Environment:
|
|
|
659
947
|
DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve
|
|
660
948
|
refuses a superuser or a role with BYPASSRLS
|
|
661
949
|
SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)
|
|
662
|
-
SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with
|
|
663
|
-
32+ random characters (serve); API keys
|
|
664
|
-
|
|
665
|
-
|
|
950
|
+
SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write
|
|
951
|
+
access (never admin), 32+ random characters (serve); API keys
|
|
952
|
+
work with or without it
|
|
953
|
+
SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;
|
|
954
|
+
required when SLOTLOCK_CONFIRM_WRITES is not none)
|
|
955
|
+
SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools
|
|
956
|
+
that wait for a person (MCP 2026-07-28 form elicitation; refused
|
|
957
|
+
with confirmation_required on REST, A2A and older MCP)
|
|
666
958
|
SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)
|
|
667
959
|
SLOTLOCK_TENANT Tenant of the token, resource and key commands (default "default")
|
|
668
960
|
SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)
|
|
669
|
-
SLOTLOCK_REST_API on: also serve the tools as a REST API under /v1,
|
|
670
|
-
|
|
961
|
+
SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and
|
|
962
|
+
the application API for admin keys, described at /openapi.json;
|
|
963
|
+
off: MCP and A2A only
|
|
964
|
+
SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:
|
|
965
|
+
rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,
|
|
966
|
+
every request per client, before any key lookup), key_read,
|
|
967
|
+
tenant_read (20/s:40), key_write, tenant_write (5/s:20),
|
|
968
|
+
global_read (500/s:1000), global_write (50/s:100), app_read
|
|
969
|
+
(20/s:40) and app_write (5/s:20) per tenant for the
|
|
970
|
+
application API, sign_in (20/m:20); counts for
|
|
971
|
+
tenant_series_writes and series_writes
|
|
972
|
+
(1: writes that may expand a recurring series at once),
|
|
973
|
+
maintenance_runs (1: application API maintenance runs at once),
|
|
974
|
+
subscriptions (256), tenant_subscriptions (16),
|
|
975
|
+
key_subscriptions (4, and fewer than tenant_subscriptions),
|
|
976
|
+
key_lookups (2), key_lookup_queue (256),
|
|
977
|
+
tenant_transactions (2: one tenant's database transactions at
|
|
978
|
+
once, of the pool's 10); what one tenant may store:
|
|
979
|
+
tenant_holds (10000), tenant_reservations, tenant_events and
|
|
980
|
+
tenant_coverage (100000)
|
|
981
|
+
SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,
|
|
982
|
+
such as CF-Connecting-IP (default: the connection's address);
|
|
983
|
+
set it only when every request comes through that proxy
|
|
671
984
|
SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
|
|
672
985
|
of a GitHub OAuth app with callback .../dashboard/callback
|
|
673
986
|
SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
|
|
@@ -709,15 +1022,23 @@ export async function runSlotlockCli(argv, io) {
|
|
|
709
1022
|
io.stderr.write(SLOTLOCK_CLI_HELP);
|
|
710
1023
|
return 2;
|
|
711
1024
|
}
|
|
712
|
-
const subcommand = command === 'resource' || command === 'key' || command === 'hours'
|
|
1025
|
+
const subcommand = command === 'resource' || command === 'key' || command === 'hours' || command === 'tenant'
|
|
713
1026
|
? `${command} ${operands[0] ?? ''}`.trim()
|
|
714
1027
|
: command;
|
|
715
1028
|
const expectedOperands = {
|
|
716
1029
|
migrate: 0,
|
|
717
1030
|
serve: 0,
|
|
1031
|
+
maintain: 0,
|
|
718
1032
|
healthcheck: 0,
|
|
719
1033
|
'resource add': 2,
|
|
720
1034
|
'resource list': 1,
|
|
1035
|
+
'resource archive': 2,
|
|
1036
|
+
'resource restore': 2,
|
|
1037
|
+
'resource delete': 2,
|
|
1038
|
+
'tenant status': 1,
|
|
1039
|
+
'tenant suspend': 1,
|
|
1040
|
+
'tenant unsuspend': 1,
|
|
1041
|
+
'tenant erase': 1,
|
|
721
1042
|
'key create': 2,
|
|
722
1043
|
'key list': 1,
|
|
723
1044
|
'key rotate': 2,
|
|
@@ -741,6 +1062,15 @@ export async function runSlotlockCli(argv, io) {
|
|
|
741
1062
|
return usage(`--${option} applies only to key create`);
|
|
742
1063
|
}
|
|
743
1064
|
}
|
|
1065
|
+
if (values.reason !== undefined && subcommand !== 'tenant suspend') {
|
|
1066
|
+
return usage('--reason applies only to tenant suspend');
|
|
1067
|
+
}
|
|
1068
|
+
if (values.confirm !== undefined && subcommand !== 'tenant erase') {
|
|
1069
|
+
return usage('--confirm applies only to tenant erase');
|
|
1070
|
+
}
|
|
1071
|
+
if (subcommand === 'tenant erase' && values.confirm === undefined) {
|
|
1072
|
+
return usage('tenant erase needs --confirm <tenant>, the tenant it erases (SLOTLOCK_TENANT)');
|
|
1073
|
+
}
|
|
744
1074
|
if (values.all && subcommand !== 'hours set' && subcommand !== 'hours clear') {
|
|
745
1075
|
return usage('--all applies only to hours set and hours clear');
|
|
746
1076
|
}
|
|
@@ -797,6 +1127,15 @@ export async function runSlotlockCli(argv, io) {
|
|
|
797
1127
|
log.info('stopped', { forced });
|
|
798
1128
|
return 0;
|
|
799
1129
|
}
|
|
1130
|
+
case 'maintain': {
|
|
1131
|
+
const result = await maintainTenant(readSlotlockDatabaseConfig(io.env));
|
|
1132
|
+
if (!result) {
|
|
1133
|
+
io.stderr.write('slotlock: another server is maintaining this tenant now; nothing to do\n');
|
|
1134
|
+
return 0;
|
|
1135
|
+
}
|
|
1136
|
+
io.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1137
|
+
return 0;
|
|
1138
|
+
}
|
|
800
1139
|
case 'resource add': {
|
|
801
1140
|
const config = readSlotlockDatabaseConfig(io.env);
|
|
802
1141
|
const resource = await addResource(config, operands[1], values.timezone ?? 'UTC');
|
|
@@ -806,6 +1145,45 @@ export async function runSlotlockCli(argv, io) {
|
|
|
806
1145
|
case 'resource list':
|
|
807
1146
|
await listResources(readSlotlockDatabaseConfig(io.env), io.stdout);
|
|
808
1147
|
return 0;
|
|
1148
|
+
case 'resource archive':
|
|
1149
|
+
case 'resource restore':
|
|
1150
|
+
case 'resource delete': {
|
|
1151
|
+
const changed = await changeResource(readSlotlockDatabaseConfig(io.env), io.stdout, operands[1], subcommand.slice('resource '.length));
|
|
1152
|
+
if (changed === 'in_use') {
|
|
1153
|
+
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');
|
|
1154
|
+
return 1;
|
|
1155
|
+
}
|
|
1156
|
+
if (!changed) {
|
|
1157
|
+
io.stderr.write('slotlock: no resource with that reference or id in this tenant\n');
|
|
1158
|
+
return 1;
|
|
1159
|
+
}
|
|
1160
|
+
return 0;
|
|
1161
|
+
}
|
|
1162
|
+
case 'tenant status':
|
|
1163
|
+
case 'tenant suspend':
|
|
1164
|
+
case 'tenant unsuspend':
|
|
1165
|
+
case 'tenant erase': {
|
|
1166
|
+
const config = readSlotlockDatabaseConfig(io.env);
|
|
1167
|
+
// The tenant named twice, so erasing the one SLOTLOCK_TENANT happens to name is deliberate.
|
|
1168
|
+
if (subcommand === 'tenant erase' && values.confirm !== config.tenantRef) {
|
|
1169
|
+
return usage('--confirm must repeat SLOTLOCK_TENANT, the tenant tenant erase deletes');
|
|
1170
|
+
}
|
|
1171
|
+
try {
|
|
1172
|
+
await runTenantCommand(config, io.stdout, subcommand.slice('tenant '.length), values.reason);
|
|
1173
|
+
}
|
|
1174
|
+
catch (error) {
|
|
1175
|
+
const code = error?.code;
|
|
1176
|
+
if (code === 'tenant_suspension_not_permitted') {
|
|
1177
|
+
io.stderr.write('slotlock: only the schema owner suspends a tenant or lifts a suspension; set SLOTLOCK_MIGRATE_DATABASE_URL to its URL\n');
|
|
1178
|
+
return 1;
|
|
1179
|
+
}
|
|
1180
|
+
if (code === 'invalid_suspension_reason') {
|
|
1181
|
+
return usage('--reason must be 1-500 characters without control characters');
|
|
1182
|
+
}
|
|
1183
|
+
throw error;
|
|
1184
|
+
}
|
|
1185
|
+
return 0;
|
|
1186
|
+
}
|
|
809
1187
|
case 'hours show':
|
|
810
1188
|
case 'hours set':
|
|
811
1189
|
case 'hours clear': {
|
|
@@ -889,6 +1267,8 @@ function parseCliArguments(argv) {
|
|
|
889
1267
|
'expires-in-days': { type: 'string' },
|
|
890
1268
|
resource: { type: 'string' },
|
|
891
1269
|
all: { type: 'boolean' },
|
|
1270
|
+
reason: { type: 'string' },
|
|
1271
|
+
confirm: { type: 'string' },
|
|
892
1272
|
help: { type: 'boolean', short: 'h' },
|
|
893
1273
|
version: { type: 'boolean', short: 'v' },
|
|
894
1274
|
},
|