slotlock 0.3.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 +252 -0
- package/README.md +226 -17
- package/agent-server.d.ts +51 -8
- package/agent-server.js +478 -36
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +771 -30
- package/application-api.d.ts +22 -1
- package/application-api.js +412 -3
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +20 -1
- package/config.js +33 -0
- package/dashboard.d.ts +61 -5
- package/dashboard.js +995 -139
- package/ddl.d.ts +5 -5
- package/ddl.js +590 -5
- package/engine.d.ts +5 -0
- package/engine.js +7 -4
- package/event-feed.d.ts +178 -0
- package/event-feed.js +435 -0
- package/index.d.ts +8 -2
- package/index.js +6 -1
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +14 -2
- package/maintenance.js +35 -3
- 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/openapi.json +8346 -3400
- package/package.json +1 -1
- package/rate-limit.d.ts +10 -1
- package/rate-limit.js +25 -2
- package/rest-api.d.ts +22 -0
- package/rest-api.js +160 -15
- package/self-host.d.ts +26 -3
- package/self-host.js +316 -19
- package/slotlock-manifest.schema.json +1 -0
- package/slotlock.manifest.json +2 -1
- package/store.d.ts +317 -8
- package/store.js +1005 -89
- package/types.d.ts +87 -0
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
package/self-host.js
CHANGED
|
@@ -3,18 +3,21 @@
|
|
|
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, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, slotlockKeySubscriptions, } 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
22
|
import { createSlotlockRequestLimits } from './rate-limit.js';
|
|
20
23
|
import { assertSlotlockSchemaCurrent, createSlotlockStore, } from './store.js';
|
|
@@ -27,16 +30,22 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
|
27
30
|
'SLOTLOCK_AUTH_TOKEN',
|
|
28
31
|
'SLOTLOCK_CONFIRMATION_SECRET',
|
|
29
32
|
'SLOTLOCK_CONFIRM_WRITES',
|
|
33
|
+
'SLOTLOCK_METRICS_TOKEN',
|
|
30
34
|
'SLOTLOCK_PUBLIC_URL',
|
|
31
35
|
'SLOTLOCK_TENANT',
|
|
32
36
|
'SLOTLOCK_AVAILABILITY',
|
|
33
37
|
'SLOTLOCK_REST_API',
|
|
38
|
+
'SLOTLOCK_RETENTION_DAYS',
|
|
39
|
+
'SLOTLOCK_WEBHOOKS',
|
|
40
|
+
'SLOTLOCK_SHARED_LIMITS',
|
|
34
41
|
'SLOTLOCK_LIMITS',
|
|
35
42
|
'SLOTLOCK_TRUSTED_PROXY_HEADER',
|
|
36
43
|
'SLOTLOCK_GITHUB_CLIENT_ID',
|
|
37
44
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
38
45
|
'SLOTLOCK_SESSION_SECRET',
|
|
39
46
|
'SLOTLOCK_DASHBOARD_USERS',
|
|
47
|
+
'SLOTLOCK_DASHBOARD_ORGS',
|
|
48
|
+
'SLOTLOCK_DASHBOARD_NAME',
|
|
40
49
|
'HOST',
|
|
41
50
|
'PORT',
|
|
42
51
|
]);
|
|
@@ -59,6 +68,8 @@ const SERVE_MAINTENANCE_INTERVAL_MS = 60 * 60 * 1_000;
|
|
|
59
68
|
const MAINTAIN_MAX_PASSES = 100;
|
|
60
69
|
const WRITE_OPERATIONS = [
|
|
61
70
|
'slotlock_create_event',
|
|
71
|
+
'slotlock_book_together',
|
|
72
|
+
'slotlock_book_pool',
|
|
62
73
|
'slotlock_update_event',
|
|
63
74
|
'slotlock_delete_event',
|
|
64
75
|
];
|
|
@@ -70,6 +81,12 @@ const SERVED_OPERATIONS = new Set([
|
|
|
70
81
|
'slotlock_list_resources',
|
|
71
82
|
'slotlock_get_free_busy',
|
|
72
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',
|
|
73
90
|
'slotlock_create_event',
|
|
74
91
|
'slotlock_get_event',
|
|
75
92
|
'slotlock_list_events',
|
|
@@ -151,11 +168,38 @@ export function readSlotlockDatabaseConfig(env) {
|
|
|
151
168
|
throw new SlotlockConfigError('DATABASE_URL is required: the postgres:// URL of the role Slotlock serves as');
|
|
152
169
|
}
|
|
153
170
|
const config = { databaseUrl, tenantRef: readTenant(env) };
|
|
171
|
+
const retentionDays = readRetentionDays(env);
|
|
172
|
+
if (retentionDays !== undefined)
|
|
173
|
+
config.retentionDays = retentionDays;
|
|
154
174
|
const migrateDatabaseUrl = readDatabaseUrl(env, 'SLOTLOCK_MIGRATE_DATABASE_URL');
|
|
155
175
|
if (migrateDatabaseUrl !== undefined)
|
|
156
176
|
config.migrateDatabaseUrl = migrateDatabaseUrl;
|
|
157
177
|
return config;
|
|
158
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
|
+
}
|
|
159
203
|
function readRestApi(env) {
|
|
160
204
|
const value = optional(env, 'SLOTLOCK_REST_API') ?? 'on';
|
|
161
205
|
if (value !== 'on' && value !== 'off') {
|
|
@@ -185,6 +229,8 @@ export function readSlotlockServeConfig(env) {
|
|
|
185
229
|
confirmWrites,
|
|
186
230
|
availability: readAvailability(env),
|
|
187
231
|
restApi: readRestApi(env),
|
|
232
|
+
webhooks: readWebhooks(env),
|
|
233
|
+
sharedLimits: readSharedLimits(env),
|
|
188
234
|
limits: readLimits(env),
|
|
189
235
|
};
|
|
190
236
|
const trustedProxyHeader = readTrustedProxyHeader(env);
|
|
@@ -192,9 +238,25 @@ export function readSlotlockServeConfig(env) {
|
|
|
192
238
|
config.trustedProxyHeader = trustedProxyHeader;
|
|
193
239
|
if (authToken !== undefined)
|
|
194
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
|
+
}
|
|
195
254
|
const dashboard = readDashboardConfig(env);
|
|
196
255
|
if (dashboard !== undefined)
|
|
197
256
|
config.dashboard = dashboard;
|
|
257
|
+
const organizations = readDashboardOrganizations(env);
|
|
258
|
+
if (organizations !== undefined)
|
|
259
|
+
config.organizations = organizations;
|
|
198
260
|
if (confirmWrites.length > 0 && confirmationSecret !== undefined) {
|
|
199
261
|
config.confirmationSecret = confirmationSecret;
|
|
200
262
|
}
|
|
@@ -246,6 +308,7 @@ function secretsIn(env) {
|
|
|
246
308
|
'SLOTLOCK_CONFIRMATION_SECRET',
|
|
247
309
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
248
310
|
'SLOTLOCK_SESSION_SECRET',
|
|
311
|
+
'SLOTLOCK_METRICS_TOKEN',
|
|
249
312
|
]) {
|
|
250
313
|
const value = env[name]?.trim();
|
|
251
314
|
if (value)
|
|
@@ -364,10 +427,48 @@ async function databaseReachable(sql) {
|
|
|
364
427
|
}
|
|
365
428
|
}
|
|
366
429
|
/**
|
|
367
|
-
*
|
|
368
|
-
*
|
|
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.
|
|
369
467
|
*/
|
|
370
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;
|
|
371
472
|
const users = config.dashboard?.allowedUsers;
|
|
372
473
|
if (users === undefined || users === '*')
|
|
373
474
|
return true;
|
|
@@ -387,13 +488,29 @@ function serveTenantLimits(caps) {
|
|
|
387
488
|
* The store `serve` answers requests from. Every surface writes through it, so its tenant limits
|
|
388
489
|
* bound what any key can add, and its transaction limits what one tenant holds of the shared pool.
|
|
389
490
|
*/
|
|
390
|
-
export function createSlotlockServeStore(sql, caps) {
|
|
491
|
+
export function createSlotlockServeStore(sql, caps, availability = [], retentionDays) {
|
|
391
492
|
return createSlotlockStore(sql, {
|
|
493
|
+
...(retentionDays !== undefined ? { retentionDays } : {}),
|
|
494
|
+
defaultAvailabilityRules: availability,
|
|
392
495
|
tenantLimits: serveTenantLimits(caps),
|
|
393
496
|
maxTenantTransactions: caps.tenant_transactions,
|
|
497
|
+
maxHoldsPerHolder: caps.holder_holds,
|
|
498
|
+
maxHeldHoursPerHolder: caps.holder_held_hours,
|
|
394
499
|
tenantLockTimeoutMs: SERVE_LOCK_TIMEOUT_MS,
|
|
395
500
|
});
|
|
396
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
|
+
}
|
|
397
514
|
/** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
|
|
398
515
|
export async function startSlotlockServer(config, log) {
|
|
399
516
|
const sql = connect(config.databaseUrl, POOL_SIZE);
|
|
@@ -439,8 +556,22 @@ export async function startSlotlockServer(config, log) {
|
|
|
439
556
|
}
|
|
440
557
|
}
|
|
441
558
|
}
|
|
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
|
+
}
|
|
442
572
|
const { caps } = config.limits;
|
|
443
|
-
const store = createSlotlockServeStore(sql, caps);
|
|
573
|
+
const store = createSlotlockServeStore(sql, caps, config.availability, config.retentionDays);
|
|
574
|
+
const retention = config.retentionDays !== undefined ? { retentionDays: config.retentionDays } : {};
|
|
444
575
|
const publicUrl = new URL(config.publicUrl);
|
|
445
576
|
const authenticateKey = createSlotlockApiKeyAuthenticator(createSlotlockApiKeyStore(sql), {
|
|
446
577
|
maxConcurrentLookups: caps.key_lookups,
|
|
@@ -457,9 +588,29 @@ export async function startSlotlockServer(config, log) {
|
|
|
457
588
|
});
|
|
458
589
|
// Set once the listener is up; the authenticator tells it which tenants this server serves.
|
|
459
590
|
let maintenance;
|
|
591
|
+
let webhooks;
|
|
460
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
|
+
});
|
|
461
599
|
const limits = createSlotlockRequestLimits({
|
|
462
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
|
+
: {}),
|
|
463
614
|
...(config.trustedProxyHeader !== undefined
|
|
464
615
|
? { trustedProxyHeader: config.trustedProxyHeader }
|
|
465
616
|
: {}),
|
|
@@ -475,7 +626,10 @@ export async function startSlotlockServer(config, log) {
|
|
|
475
626
|
return principal;
|
|
476
627
|
},
|
|
477
628
|
// Counts and pseudonyms only: never an address, a credential or a tenant.
|
|
478
|
-
onRefusals: (counts) =>
|
|
629
|
+
onRefusals: (counts) => {
|
|
630
|
+
metrics?.refusals(counts);
|
|
631
|
+
log.info('refusals', { ...counts });
|
|
632
|
+
},
|
|
479
633
|
// Behind a proxy without the setting, every client shares the proxy's address budget.
|
|
480
634
|
onIgnoredClientField: (field) => log.warn('client_address_field_ignored', { field, setting: 'SLOTLOCK_TRUSTED_PROXY_HEADER' }),
|
|
481
635
|
});
|
|
@@ -511,7 +665,11 @@ export async function startSlotlockServer(config, log) {
|
|
|
511
665
|
: { ready: false, checks: ['database_unreachable'] };
|
|
512
666
|
},
|
|
513
667
|
// Outcomes and counts only: the server never reports arguments or identities.
|
|
514
|
-
onEvent: (
|
|
668
|
+
onEvent: (event) => {
|
|
669
|
+
metrics?.event(event);
|
|
670
|
+
const { type, ...fields } = event;
|
|
671
|
+
log.info(type, snakeCaseKeys(fields));
|
|
672
|
+
},
|
|
515
673
|
// What an agent sees only as internal_error: the operation and the error's message and code,
|
|
516
674
|
// never the arguments or the request, with every configured secret redacted.
|
|
517
675
|
onError: (error, { operation }) => log.error('agent_error', { operation, ...describeError(error) }),
|
|
@@ -537,6 +695,7 @@ export async function startSlotlockServer(config, log) {
|
|
|
537
695
|
confirmation: {
|
|
538
696
|
operations: config.confirmWrites,
|
|
539
697
|
secrets: [config.confirmationSecret],
|
|
698
|
+
consumeAcceptance: createSlotlockAcceptanceRecords(sql),
|
|
540
699
|
},
|
|
541
700
|
}
|
|
542
701
|
: {}),
|
|
@@ -551,6 +710,10 @@ export async function startSlotlockServer(config, log) {
|
|
|
551
710
|
},
|
|
552
711
|
sessionSecret: config.dashboard.sessionSecret,
|
|
553
712
|
allowedUsers: config.dashboard.allowedUsers,
|
|
713
|
+
...(config.dashboard.name ? { name: config.dashboard.name } : {}),
|
|
714
|
+
...(config.dashboard.organizations
|
|
715
|
+
? { organizations: config.dashboard.organizations }
|
|
716
|
+
: {}),
|
|
554
717
|
keys: createSlotlockApiKeyStore(sql),
|
|
555
718
|
resources: createSlotlockDashboardResources(store),
|
|
556
719
|
state: createSlotlockDashboardState(sql),
|
|
@@ -564,21 +727,58 @@ export async function startSlotlockServer(config, log) {
|
|
|
564
727
|
shutdown: () => agentServer.shutdown(),
|
|
565
728
|
}
|
|
566
729
|
: agentServer);
|
|
567
|
-
const
|
|
730
|
+
const served = metrics && config.metricsToken !== undefined
|
|
731
|
+
? serveMetrics(target, metrics, `${basePath}/metrics`, config.metricsToken)
|
|
732
|
+
: target;
|
|
733
|
+
const listener = createSlotlockNodeServer(served, {
|
|
568
734
|
requestOrigin: publicUrl.origin,
|
|
569
735
|
onError: (error, phase) => log.error('http_error', { phase, ...describeError(error) }),
|
|
570
736
|
});
|
|
571
737
|
const address = await listener.listen({ host: config.host, port: config.port });
|
|
572
738
|
maintenance = startSlotlockMaintenance({
|
|
573
739
|
// One tenant at a time, and its long passes may wait on a request's locks for longer.
|
|
574
|
-
store:
|
|
740
|
+
store: createSlotlockMaintenanceStore(sql, caps, retention),
|
|
575
741
|
sql,
|
|
576
742
|
tenants: [config.tenantRef],
|
|
577
743
|
intervalMs: SERVE_MAINTENANCE_INTERVAL_MS,
|
|
578
744
|
// Counts only, never the tenant.
|
|
579
|
-
onPass: (result) =>
|
|
745
|
+
onPass: (result) => {
|
|
746
|
+
metrics?.maintenance(result);
|
|
747
|
+
log.info('maintenance', { ...result });
|
|
748
|
+
},
|
|
580
749
|
onError: (error) => log.error('maintenance_failed', describeError(error)),
|
|
581
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
|
+
}
|
|
582
782
|
log.info('listening', {
|
|
583
783
|
address: address.origin,
|
|
584
784
|
mcp: `${config.publicUrl}/mcp`,
|
|
@@ -589,6 +789,9 @@ export async function startSlotlockServer(config, log) {
|
|
|
589
789
|
confirm_writes: config.confirmWrites,
|
|
590
790
|
availability_rules: config.availability.length,
|
|
591
791
|
rest: config.restApi ? `${config.publicUrl}/openapi.json` : null,
|
|
792
|
+
webhooks: config.webhooks,
|
|
793
|
+
shared_limits: config.sharedLimits,
|
|
794
|
+
tzdata: process.versions.tz ?? null,
|
|
592
795
|
maintenance_interval_minutes: SERVE_MAINTENANCE_INTERVAL_MS / 60_000,
|
|
593
796
|
limits: {
|
|
594
797
|
...Object.fromEntries(Object.entries(config.limits.rates).map(([name, rate]) => [name, formatSlotlockRate(rate)])),
|
|
@@ -604,6 +807,7 @@ export async function startSlotlockServer(config, log) {
|
|
|
604
807
|
const result = await listener.close();
|
|
605
808
|
limits.close();
|
|
606
809
|
await maintenance?.close();
|
|
810
|
+
await webhooks?.close();
|
|
607
811
|
await sql.end({ timeout: 5 });
|
|
608
812
|
return result;
|
|
609
813
|
},
|
|
@@ -718,6 +922,34 @@ async function runHours(config, out, target, rules) {
|
|
|
718
922
|
await sql.end({ timeout: 5 });
|
|
719
923
|
}
|
|
720
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
|
+
}
|
|
721
953
|
/** A resource as `resource archive` and `resource restore` print it. */
|
|
722
954
|
function resourceStateLine(resource) {
|
|
723
955
|
return `${JSON.stringify({
|
|
@@ -832,12 +1064,23 @@ function parseKeyScopes(value) {
|
|
|
832
1064
|
* `maintain`: maintenance passes for the tenant until nothing is left, under the tenant's lock, so
|
|
833
1065
|
* it never repeats what a server is doing. Null when another server holds the lock.
|
|
834
1066
|
*/
|
|
835
|
-
async function maintainTenant(config) {
|
|
1067
|
+
async function maintainTenant(config, caps) {
|
|
836
1068
|
const sql = connect(config.databaseUrl, 2);
|
|
837
1069
|
try {
|
|
838
|
-
const store =
|
|
1070
|
+
const store = createSlotlockMaintenanceStore(sql, caps, config.retentionDays !== undefined ? { retentionDays: config.retentionDays } : {});
|
|
839
1071
|
const total = await withSlotlockMaintenanceLock(sql, config.tenantRef, async () => {
|
|
840
|
-
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
|
+
};
|
|
841
1084
|
while (sum.capped && sum.passes < MAINTAIN_MAX_PASSES) {
|
|
842
1085
|
const pass = await maintainSlotlockTenant(store, config.tenantRef);
|
|
843
1086
|
sum.passes += 1;
|
|
@@ -845,6 +1088,10 @@ async function maintainTenant(config) {
|
|
|
845
1088
|
sum.conflicts += pass.conflicts;
|
|
846
1089
|
sum.refused += pass.refused;
|
|
847
1090
|
sum.pruned += pass.pruned;
|
|
1091
|
+
sum.zoneShifts += pass.zoneShifts;
|
|
1092
|
+
sum.staleSources += pass.staleSources;
|
|
1093
|
+
sum.noShows += pass.noShows;
|
|
1094
|
+
sum.waitlistOffers += pass.waitlistOffers;
|
|
848
1095
|
// A capped pass that got nowhere met only series it cannot extend. While its roll stopped
|
|
849
1096
|
// before the series it had put back, the next pass meets new ones (and perhaps a healthy
|
|
850
1097
|
// one); once it reached them, another pass would meet them again.
|
|
@@ -920,6 +1167,11 @@ Commands:
|
|
|
920
1167
|
Give one resource, or every one, weekly hours of its own; [] closes
|
|
921
1168
|
hours clear (--resource <ref> | --all)
|
|
922
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
|
|
923
1175
|
key create <name> [--scope read,write,admin] [--expires-in-days <n>]
|
|
924
1176
|
Create an API key for the tenant (default scope read,write, no
|
|
925
1177
|
expiry; admin adds the application API) and print it once; only its
|
|
@@ -952,6 +1204,8 @@ Environment:
|
|
|
952
1204
|
work with or without it
|
|
953
1205
|
SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;
|
|
954
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)
|
|
955
1209
|
SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools
|
|
956
1210
|
that wait for a person (MCP 2026-07-28 form elicitation; refused
|
|
957
1211
|
with confirmation_required on REST, A2A and older MCP)
|
|
@@ -961,6 +1215,15 @@ Environment:
|
|
|
961
1215
|
SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and
|
|
962
1216
|
the application API for admin keys, described at /openapi.json;
|
|
963
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
|
|
964
1227
|
SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:
|
|
965
1228
|
rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,
|
|
966
1229
|
every request per client, before any key lookup), key_read,
|
|
@@ -977,7 +1240,8 @@ Environment:
|
|
|
977
1240
|
tenant_transactions (2: one tenant's database transactions at
|
|
978
1241
|
once, of the pool's 10); what one tenant may store:
|
|
979
1242
|
tenant_holds (10000), tenant_reservations, tenant_events and
|
|
980
|
-
tenant_coverage (100000)
|
|
1243
|
+
tenant_coverage (100000), holder_holds (20) and
|
|
1244
|
+
holder_held_hours (2160), which maintain keeps too
|
|
981
1245
|
SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,
|
|
982
1246
|
such as CF-Connecting-IP (default: the connection's address);
|
|
983
1247
|
set it only when every request comes through that proxy
|
|
@@ -986,6 +1250,8 @@ Environment:
|
|
|
986
1250
|
SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
|
|
987
1251
|
SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)
|
|
988
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)
|
|
989
1255
|
HOST Listen address (default 127.0.0.1)
|
|
990
1256
|
PORT Listen port (default 8080)
|
|
991
1257
|
|
|
@@ -1022,7 +1288,11 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1022
1288
|
io.stderr.write(SLOTLOCK_CLI_HELP);
|
|
1023
1289
|
return 2;
|
|
1024
1290
|
}
|
|
1025
|
-
const subcommand = command === 'resource' ||
|
|
1291
|
+
const subcommand = command === 'resource' ||
|
|
1292
|
+
command === 'key' ||
|
|
1293
|
+
command === 'hours' ||
|
|
1294
|
+
command === 'rules' ||
|
|
1295
|
+
command === 'tenant'
|
|
1026
1296
|
? `${command} ${operands[0] ?? ''}`.trim()
|
|
1027
1297
|
: command;
|
|
1028
1298
|
const expectedOperands = {
|
|
@@ -1046,6 +1316,8 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1046
1316
|
'hours show': 1,
|
|
1047
1317
|
'hours set': 2,
|
|
1048
1318
|
'hours clear': 1,
|
|
1319
|
+
'rules set': 2,
|
|
1320
|
+
'rules clear': 1,
|
|
1049
1321
|
};
|
|
1050
1322
|
const expected = expectedOperands[subcommand];
|
|
1051
1323
|
if (expected === undefined)
|
|
@@ -1074,8 +1346,24 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1074
1346
|
if (values.all && subcommand !== 'hours set' && subcommand !== 'hours clear') {
|
|
1075
1347
|
return usage('--all applies only to hours set and hours clear');
|
|
1076
1348
|
}
|
|
1077
|
-
if (values.resource !== undefined &&
|
|
1078
|
-
|
|
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
|
+
}
|
|
1079
1367
|
}
|
|
1080
1368
|
if ((subcommand === 'hours set' || subcommand === 'hours clear') &&
|
|
1081
1369
|
(values.resource === undefined) === (values.all !== true)) {
|
|
@@ -1128,7 +1416,7 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1128
1416
|
return 0;
|
|
1129
1417
|
}
|
|
1130
1418
|
case 'maintain': {
|
|
1131
|
-
const result = await maintainTenant(readSlotlockDatabaseConfig(io.env));
|
|
1419
|
+
const result = await maintainTenant(readSlotlockDatabaseConfig(io.env), readLimits(io.env).caps);
|
|
1132
1420
|
if (!result) {
|
|
1133
1421
|
io.stderr.write('slotlock: another server is maintaining this tenant now; nothing to do\n');
|
|
1134
1422
|
return 0;
|
|
@@ -1199,6 +1487,15 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1199
1487
|
}
|
|
1200
1488
|
return 0;
|
|
1201
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
|
+
}
|
|
1202
1499
|
case 'key create': {
|
|
1203
1500
|
const config = readSlotlockDatabaseConfig(io.env);
|
|
1204
1501
|
const created = await withApiKeys(config, (keys) => keys.create({
|
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.5.0",
|
|
7
7
|
"description": "Resource-first, deterministic calendar and availability engine for AI agents.",
|
|
8
8
|
"intervalSemantics": {
|
|
9
9
|
"boundary": "[)",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"capabilities": [
|
|
14
14
|
"availability-rules",
|
|
15
15
|
"find-next-available",
|
|
16
|
+
"find-available-slots",
|
|
16
17
|
"atomic-holds",
|
|
17
18
|
"conflict-safe-reservations",
|
|
18
19
|
"revisioned-external-commands",
|