slotlock 0.2.0 → 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 +330 -0
- package/CONTRIBUTING.md +35 -8
- package/GOVERNANCE.md +4 -2
- package/MAINTAINERS.md +3 -2
- package/NOTICE +4 -4
- package/README.md +277 -53
- package/SECURITY.md +31 -2
- package/SPEC.md +142 -15
- package/agent-server.d.ts +12 -3
- package/agent-server.js +108 -78
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +355 -36
- package/api-keys.d.ts +11 -3
- package/api-keys.js +29 -6
- package/application-api.d.ts +14 -1
- package/application-api.js +274 -12
- package/config.d.ts +93 -0
- package/config.js +192 -0
- package/dashboard.d.ts +6 -0
- package/dashboard.js +17 -1
- package/ddl.d.ts +45 -3
- package/ddl.js +538 -12
- package/engine.js +20 -6
- 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 +5 -5
- package/index.js +3 -3
- package/maintenance.d.ts +15 -5
- package/maintenance.js +59 -27
- package/node-server.d.ts +9 -1
- package/node-server.js +7 -2
- 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.js +10 -0
- package/rules.js +6 -3
- package/self-host.d.ts +22 -8
- package/self-host.js +307 -34
- package/slotlock-manifest.schema.json +6 -1
- package/slotlock.manifest.json +7 -2
- package/store.d.ts +334 -24
- package/store.js +2029 -290
- 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 +213 -9
package/self-host.js
CHANGED
|
@@ -10,13 +10,14 @@ import { createSlotlockAgentServer, slotlockAgentOperationScope, } from './agent
|
|
|
10
10
|
import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, } from './agent-store-backend.js';
|
|
11
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';
|
|
13
|
+
import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, slotlockKeySubscriptions, } from './config.js';
|
|
14
14
|
import { SLOTLOCK_APPLICATION_SCOPE } from './application-api.js';
|
|
15
15
|
import { SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_TENANT_TABLES } from './ddl.js';
|
|
16
16
|
import { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
|
|
17
17
|
import { createSlotlockNodeServer, } from './node-server.js';
|
|
18
18
|
import { slotlockPackageVersion } from './package-version.js';
|
|
19
|
-
import {
|
|
19
|
+
import { createSlotlockRequestLimits } from './rate-limit.js';
|
|
20
|
+
import { assertSlotlockSchemaCurrent, createSlotlockStore, } from './store.js';
|
|
20
21
|
import { parseAvailabilityRules } from './weekly-hours.js';
|
|
21
22
|
export { SlotlockConfigError };
|
|
22
23
|
/** Every variable the commands read. The README, `.env.example` and `server.json` document these. */
|
|
@@ -30,6 +31,8 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
|
30
31
|
'SLOTLOCK_TENANT',
|
|
31
32
|
'SLOTLOCK_AVAILABILITY',
|
|
32
33
|
'SLOTLOCK_REST_API',
|
|
34
|
+
'SLOTLOCK_LIMITS',
|
|
35
|
+
'SLOTLOCK_TRUSTED_PROXY_HEADER',
|
|
33
36
|
'SLOTLOCK_GITHUB_CLIENT_ID',
|
|
34
37
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
35
38
|
'SLOTLOCK_SESSION_SECRET',
|
|
@@ -40,7 +43,7 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
|
40
43
|
const DEFAULT_HOST = '127.0.0.1';
|
|
41
44
|
const DEFAULT_PORT = 8080;
|
|
42
45
|
const DEFAULT_TENANT = 'default';
|
|
43
|
-
/** 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. */
|
|
44
47
|
const SELF_HOST_SUBJECT = 'self-host';
|
|
45
48
|
/** Who `key create` records as a key's creator. */
|
|
46
49
|
const CLI_KEY_CREATOR = 'cli';
|
|
@@ -74,8 +77,13 @@ const SERVED_OPERATIONS = new Set([
|
|
|
74
77
|
'slotlock_delete_event',
|
|
75
78
|
]);
|
|
76
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;
|
|
77
82
|
const HEALTHCHECK_TIMEOUT_MS = 5_000;
|
|
83
|
+
/** One pool for every tenant; SLOTLOCK_LIMITS `tenant_transactions` (config.ts) goes up to it. */
|
|
78
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;
|
|
79
87
|
const RESOURCE_PAGE_SIZE = 1_000;
|
|
80
88
|
function readDatabaseUrl(env, name) {
|
|
81
89
|
const value = optional(env, name);
|
|
@@ -121,7 +129,7 @@ function readTenant(env) {
|
|
|
121
129
|
return value;
|
|
122
130
|
}
|
|
123
131
|
function readConfirmWrites(env) {
|
|
124
|
-
const value = optional(env, 'SLOTLOCK_CONFIRM_WRITES') ?? '
|
|
132
|
+
const value = optional(env, 'SLOTLOCK_CONFIRM_WRITES') ?? 'none';
|
|
125
133
|
if (value === 'all')
|
|
126
134
|
return [...WRITE_OPERATIONS];
|
|
127
135
|
if (value === 'none')
|
|
@@ -161,7 +169,7 @@ export function readSlotlockServeConfig(env) {
|
|
|
161
169
|
const confirmWrites = readConfirmWrites(env);
|
|
162
170
|
const confirmationSecret = readSecret(env, 'SLOTLOCK_CONFIRMATION_SECRET');
|
|
163
171
|
if (confirmWrites.length > 0 && confirmationSecret === undefined) {
|
|
164
|
-
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`);
|
|
165
173
|
}
|
|
166
174
|
if (confirmationSecret !== undefined &&
|
|
167
175
|
authToken !== undefined &&
|
|
@@ -177,7 +185,11 @@ export function readSlotlockServeConfig(env) {
|
|
|
177
185
|
confirmWrites,
|
|
178
186
|
availability: readAvailability(env),
|
|
179
187
|
restApi: readRestApi(env),
|
|
188
|
+
limits: readLimits(env),
|
|
180
189
|
};
|
|
190
|
+
const trustedProxyHeader = readTrustedProxyHeader(env);
|
|
191
|
+
if (trustedProxyHeader !== undefined)
|
|
192
|
+
config.trustedProxyHeader = trustedProxyHeader;
|
|
181
193
|
if (authToken !== undefined)
|
|
182
194
|
config.authToken = authToken;
|
|
183
195
|
const dashboard = readDashboardConfig(env);
|
|
@@ -219,8 +231,8 @@ export function createSlotlockTokenAuthenticator(token, principal) {
|
|
|
219
231
|
}
|
|
220
232
|
/**
|
|
221
233
|
* 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.
|
|
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.
|
|
224
236
|
*/
|
|
225
237
|
export function authorizeSlotlockServeOperation(args) {
|
|
226
238
|
return (SERVED_OPERATIONS.has(args.operation) &&
|
|
@@ -256,7 +268,10 @@ function secretsIn(env) {
|
|
|
256
268
|
// Longest first, so a URL is replaced whole rather than around its password.
|
|
257
269
|
return [...new Set(secrets)].sort((a, b) => b.length - a.length);
|
|
258
270
|
}
|
|
259
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* One JSON object per line: info on stdout, warnings and errors on stderr, secrets replaced by
|
|
273
|
+
* `[redacted]`.
|
|
274
|
+
*/
|
|
260
275
|
function createLogger(io) {
|
|
261
276
|
const secrets = secretsIn(io.env);
|
|
262
277
|
const write = (stream, level, event, fields = {}) => {
|
|
@@ -267,6 +282,7 @@ function createLogger(io) {
|
|
|
267
282
|
};
|
|
268
283
|
return {
|
|
269
284
|
info: (event, fields) => write(io.stdout, 'info', event, fields),
|
|
285
|
+
warn: (event, fields) => write(io.stderr, 'warn', event, fields),
|
|
270
286
|
error: (event, fields) => write(io.stderr, 'error', event, fields),
|
|
271
287
|
};
|
|
272
288
|
}
|
|
@@ -358,6 +374,26 @@ function dashboardAllows(config, tenantRef) {
|
|
|
358
374
|
const githubId = /^github:(\d+)$/.exec(tenantRef)?.[1];
|
|
359
375
|
return githubId === undefined || users.includes(githubId);
|
|
360
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
|
+
}
|
|
361
397
|
/** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
|
|
362
398
|
export async function startSlotlockServer(config, log) {
|
|
363
399
|
const sql = connect(config.databaseUrl, POOL_SIZE);
|
|
@@ -376,6 +412,9 @@ export async function startSlotlockServer(config, log) {
|
|
|
376
412
|
if (!schema?.present) {
|
|
377
413
|
throw Object.assign(new Error('The slotlock schema is missing: run `slotlock migrate` or `slotlock serve --migrate`'), { code: 'schema_missing' });
|
|
378
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);
|
|
379
418
|
// A deployment run statement by statement lifts forced row-level security for its data steps
|
|
380
419
|
// and, stopped part way, leaves it lifted until the next one puts it back. The owning role then
|
|
381
420
|
// reads every tenant's rows: refuse to serve until then.
|
|
@@ -400,9 +439,13 @@ export async function startSlotlockServer(config, log) {
|
|
|
400
439
|
}
|
|
401
440
|
}
|
|
402
441
|
}
|
|
403
|
-
const
|
|
442
|
+
const { caps } = config.limits;
|
|
443
|
+
const store = createSlotlockServeStore(sql, caps);
|
|
404
444
|
const publicUrl = new URL(config.publicUrl);
|
|
405
|
-
const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql)
|
|
445
|
+
const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql), {
|
|
446
|
+
maxConcurrentLookups: caps.key_lookups,
|
|
447
|
+
maxWaitingLookups: caps.key_lookup_queue,
|
|
448
|
+
});
|
|
406
449
|
const authenticateToken = config.authToken === undefined
|
|
407
450
|
? undefined
|
|
408
451
|
: // Read and write, never admin: this is the token the docs put in agents' configuration,
|
|
@@ -414,13 +457,14 @@ export async function startSlotlockServer(config, log) {
|
|
|
414
457
|
});
|
|
415
458
|
// Set once the listener is up; the authenticator tells it which tenants this server serves.
|
|
416
459
|
let maintenance;
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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'] : [],
|
|
424
468
|
// The token is compared in constant time first; only then is a key looked up. A person the
|
|
425
469
|
// dashboard no longer allows loses their keys with their session.
|
|
426
470
|
authenticate: async (request) => {
|
|
@@ -430,10 +474,42 @@ export async function startSlotlockServer(config, log) {
|
|
|
430
474
|
maintenance?.touch(principal.tenantRef);
|
|
431
475
|
return principal;
|
|
432
476
|
},
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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;
|
|
483
|
+
const agentServer = createSlotlockAgentServer({
|
|
484
|
+
publicBaseUrl: config.publicUrl,
|
|
485
|
+
// readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
|
|
486
|
+
allowInsecureLocalhost: publicUrl.protocol === 'http:',
|
|
487
|
+
backend: createSlotlockStoreAgentBackend(store, {
|
|
488
|
+
availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
|
|
489
|
+
}),
|
|
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'] };
|
|
512
|
+
},
|
|
437
513
|
// Outcomes and counts only: the server never reports arguments or identities.
|
|
438
514
|
onEvent: ({ type, ...fields }) => log.info(type, snakeCaseKeys(fields)),
|
|
439
515
|
// What an agent sees only as internal_error: the operation and the error's message and code,
|
|
@@ -447,7 +523,12 @@ export async function startSlotlockServer(config, log) {
|
|
|
447
523
|
backend: createSlotlockStoreApplicationBackend(store, {
|
|
448
524
|
maxTenantResources: SERVE_MAX_TENANT_RESOURCES,
|
|
449
525
|
}),
|
|
450
|
-
authorize: async ({ principal }) =>
|
|
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
|
+
},
|
|
451
532
|
},
|
|
452
533
|
}
|
|
453
534
|
: {}),
|
|
@@ -474,21 +555,23 @@ export async function startSlotlockServer(config, log) {
|
|
|
474
555
|
resources: createSlotlockDashboardResources(store),
|
|
475
556
|
state: createSlotlockDashboardState(sql),
|
|
476
557
|
availability: config.availability,
|
|
558
|
+
clientAddress: (request) => limits.clientAddress(request),
|
|
477
559
|
onError: (error) => log.error('dashboard_error', describeError(error)),
|
|
478
560
|
});
|
|
479
|
-
const target = dashboard
|
|
561
|
+
const target = limits.serve(dashboard
|
|
480
562
|
? {
|
|
481
563
|
fetch: (request) => dashboard.handles(request) ? dashboard.fetch(request) : agentServer.fetch(request),
|
|
482
564
|
shutdown: () => agentServer.shutdown(),
|
|
483
565
|
}
|
|
484
|
-
: agentServer;
|
|
566
|
+
: agentServer);
|
|
485
567
|
const listener = createSlotlockNodeServer(target, {
|
|
486
568
|
requestOrigin: publicUrl.origin,
|
|
487
569
|
onError: (error, phase) => log.error('http_error', { phase, ...describeError(error) }),
|
|
488
570
|
});
|
|
489
571
|
const address = await listener.listen({ host: config.host, port: config.port });
|
|
490
572
|
maintenance = startSlotlockMaintenance({
|
|
491
|
-
|
|
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) }),
|
|
492
575
|
sql,
|
|
493
576
|
tenants: [config.tenantRef],
|
|
494
577
|
intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
|
|
@@ -507,11 +590,19 @@ export async function startSlotlockServer(config, log) {
|
|
|
507
590
|
availability_rules: config.availability.length,
|
|
508
591
|
rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
|
|
509
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',
|
|
510
600
|
});
|
|
511
601
|
return {
|
|
512
602
|
address,
|
|
513
603
|
async close() {
|
|
514
604
|
const result = await listener.close();
|
|
605
|
+
limits.close();
|
|
515
606
|
await maintenance?.close();
|
|
516
607
|
await sql.end({ timeout: 5 });
|
|
517
608
|
return result;
|
|
@@ -571,8 +662,8 @@ function hoursLine(resource) {
|
|
|
571
662
|
hours: resource.availabilityRules ?? null,
|
|
572
663
|
})}\n`;
|
|
573
664
|
}
|
|
574
|
-
/** Every resource of the tenant, a page at a time. */
|
|
575
|
-
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) {
|
|
576
667
|
const all = [];
|
|
577
668
|
let after;
|
|
578
669
|
for (;;) {
|
|
@@ -580,6 +671,7 @@ async function tenantResources(store, tenantRef) {
|
|
|
580
671
|
tenantRef,
|
|
581
672
|
limit: RESOURCE_PAGE_SIZE,
|
|
582
673
|
...(after !== undefined ? { after } : {}),
|
|
674
|
+
...(includeArchived ? { includeArchived } : {}),
|
|
583
675
|
}));
|
|
584
676
|
all.push(...page);
|
|
585
677
|
after = page.at(-1)?.id;
|
|
@@ -626,6 +718,90 @@ async function runHours(config, out, target, rules) {
|
|
|
626
718
|
await sql.end({ timeout: 5 });
|
|
627
719
|
}
|
|
628
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
|
+
}
|
|
629
805
|
/** One key as `key create`, `key list` and `key revoke` print it: never the digest. */
|
|
630
806
|
function apiKeyLine(apiKey, key) {
|
|
631
807
|
return `${JSON.stringify({
|
|
@@ -669,9 +845,11 @@ async function maintainTenant(config) {
|
|
|
669
845
|
sum.conflicts += pass.conflicts;
|
|
670
846
|
sum.refused += pass.refused;
|
|
671
847
|
sum.pruned += pass.pruned;
|
|
672
|
-
// A capped pass that got nowhere met only series it cannot extend
|
|
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.
|
|
673
851
|
sum.capped = pass.capped;
|
|
674
|
-
if (pass.capped && pass.extended + pass.pruned === 0)
|
|
852
|
+
if (pass.capped && pass.extended + pass.pruned === 0 && !pass.rollPending)
|
|
675
853
|
break;
|
|
676
854
|
}
|
|
677
855
|
return sum;
|
|
@@ -731,6 +909,11 @@ Commands:
|
|
|
731
909
|
resource add <ref> [--timezone <IANA zone>]
|
|
732
910
|
Create a calendar resource (a car, room, person or machine)
|
|
733
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
|
|
734
917
|
hours show [--resource <ref>]
|
|
735
918
|
Print each resource's bookable hours (null: the server's)
|
|
736
919
|
hours set <json> (--resource <ref> | --all)
|
|
@@ -745,6 +928,15 @@ Commands:
|
|
|
745
928
|
key rotate <id> Give an active key a new secret and print it once; the old one stops
|
|
746
929
|
working at once, and the key keeps its id and what it booked
|
|
747
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
|
|
748
940
|
healthcheck Exit 0 when the local server reports ready
|
|
749
941
|
|
|
750
942
|
Options:
|
|
@@ -755,16 +947,40 @@ Environment:
|
|
|
755
947
|
DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve
|
|
756
948
|
refuses a superuser or a role with BYPASSRLS
|
|
757
949
|
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
|
-
|
|
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)
|
|
762
958
|
SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)
|
|
763
959
|
SLOTLOCK_TENANT Tenant of the token, resource and key commands (default "default")
|
|
764
960
|
SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)
|
|
765
961
|
SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and
|
|
766
962
|
the application API for admin keys, described at /openapi.json;
|
|
767
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
|
|
768
984
|
SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID
|
|
769
985
|
of a GitHub OAuth app with callback .../dashboard/callback
|
|
770
986
|
SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
|
|
@@ -806,7 +1022,7 @@ export async function runSlotlockCli(argv, io) {
|
|
|
806
1022
|
io.stderr.write(SLOTLOCK_CLI_HELP);
|
|
807
1023
|
return 2;
|
|
808
1024
|
}
|
|
809
|
-
const subcommand = command === 'resource' || command === 'key' || command === 'hours'
|
|
1025
|
+
const subcommand = command === 'resource' || command === 'key' || command === 'hours' || command === 'tenant'
|
|
810
1026
|
? `${command} ${operands[0] ?? ''}`.trim()
|
|
811
1027
|
: command;
|
|
812
1028
|
const expectedOperands = {
|
|
@@ -816,6 +1032,13 @@ export async function runSlotlockCli(argv, io) {
|
|
|
816
1032
|
healthcheck: 0,
|
|
817
1033
|
'resource add': 2,
|
|
818
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,
|
|
819
1042
|
'key create': 2,
|
|
820
1043
|
'key list': 1,
|
|
821
1044
|
'key rotate': 2,
|
|
@@ -839,6 +1062,15 @@ export async function runSlotlockCli(argv, io) {
|
|
|
839
1062
|
return usage(`--${option} applies only to key create`);
|
|
840
1063
|
}
|
|
841
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
|
+
}
|
|
842
1074
|
if (values.all && subcommand !== 'hours set' && subcommand !== 'hours clear') {
|
|
843
1075
|
return usage('--all applies only to hours set and hours clear');
|
|
844
1076
|
}
|
|
@@ -913,6 +1145,45 @@ export async function runSlotlockCli(argv, io) {
|
|
|
913
1145
|
case 'resource list':
|
|
914
1146
|
await listResources(readSlotlockDatabaseConfig(io.env), io.stdout);
|
|
915
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
|
+
}
|
|
916
1187
|
case 'hours show':
|
|
917
1188
|
case 'hours set':
|
|
918
1189
|
case 'hours clear': {
|
|
@@ -996,6 +1267,8 @@ function parseCliArguments(argv) {
|
|
|
996
1267
|
'expires-in-days': { type: 'string' },
|
|
997
1268
|
resource: { type: 'string' },
|
|
998
1269
|
all: { type: 'boolean' },
|
|
1270
|
+
reason: { type: 'string' },
|
|
1271
|
+
confirm: { type: 'string' },
|
|
999
1272
|
help: { type: 'boolean', short: 'h' },
|
|
1000
1273
|
version: { type: 'boolean', short: 'v' },
|
|
1001
1274
|
},
|
package/slotlock.manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"schemaVersion": "1.0",
|
|
4
4
|
"name": "Slotlock",
|
|
5
5
|
"package": "slotlock",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.3.0",
|
|
7
7
|
"description": "Resource-first, deterministic calendar and availability engine for AI agents.",
|
|
8
8
|
"intervalSemantics": {
|
|
9
9
|
"boundary": "[)",
|
|
@@ -36,7 +36,12 @@
|
|
|
36
36
|
"mcp-human-confirmation",
|
|
37
37
|
"mcp-calendar-resources",
|
|
38
38
|
"mcp-live-updates",
|
|
39
|
-
"trace-context-propagation"
|
|
39
|
+
"trace-context-propagation",
|
|
40
|
+
"rest-api",
|
|
41
|
+
"application-api",
|
|
42
|
+
"api-keys",
|
|
43
|
+
"dashboard",
|
|
44
|
+
"maintenance"
|
|
40
45
|
],
|
|
41
46
|
"transportPolicy": "adapter-defined",
|
|
42
47
|
"externalCalendarsAreAuthority": false
|