slotlock 0.5.1 → 0.5.3
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 +122 -0
- package/README.md +152 -19
- package/SPEC.md +10 -7
- package/agent-server.d.ts +52 -4
- package/agent-server.js +479 -145
- package/agent-store-backend.d.ts +9 -0
- package/agent-store-backend.js +83 -15
- package/api-keys.d.ts +12 -2
- package/api-keys.js +36 -7
- package/application-api.d.ts +7 -1
- package/application-api.js +67 -7
- package/assistant.d.ts +263 -0
- package/assistant.js +772 -0
- package/booking-rules.d.ts +61 -9
- package/booking-rules.js +106 -16
- package/change-watch.d.ts +56 -0
- package/change-watch.js +213 -0
- package/config.d.ts +10 -0
- package/config.js +11 -0
- package/dashboard.d.ts +64 -1
- package/dashboard.js +687 -26
- package/ddl.d.ts +5 -5
- package/ddl.js +594 -6
- package/event-feed.d.ts +37 -4
- package/event-feed.js +74 -6
- package/index.d.ts +11 -8
- package/index.js +7 -5
- package/maintenance.d.ts +2 -1
- package/maintenance.js +33 -9
- package/openapi.json +1284 -82
- package/package.json +1 -1
- package/rate-limit.d.ts +8 -0
- package/rate-limit.js +20 -0
- package/self-host.d.ts +2 -2
- package/self-host.js +42 -7
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +132 -3
- package/store.js +482 -124
- package/types.d.ts +14 -1
- package/webhooks.d.ts +33 -2
- package/webhooks.js +102 -20
package/package.json
CHANGED
package/rate-limit.d.ts
CHANGED
|
@@ -76,5 +76,13 @@ export declare function slotlockClientKey(value: string): string | null;
|
|
|
76
76
|
* client shares; per /48, a site counts as one, as a site behind one IPv4 address does.
|
|
77
77
|
*/
|
|
78
78
|
export declare function slotlockClientNetwork(key: string): string;
|
|
79
|
+
/**
|
|
80
|
+
* One token bucket per key at `rate`, kept in this process like serve's own: `take` spends a token
|
|
81
|
+
* of the key's bucket and is false, spending nothing, when it has less than one. The dashboard
|
|
82
|
+
* assistant limits each member's messages with it.
|
|
83
|
+
*/
|
|
84
|
+
export declare function createSlotlockRateLimiter(rate: SlotlockRate, now?: () => number): {
|
|
85
|
+
take(key: string): boolean;
|
|
86
|
+
};
|
|
79
87
|
/** The limits `slotlock serve` puts in front of its agent server and dashboard. */
|
|
80
88
|
export declare function createSlotlockRequestLimits(options: SlotlockRequestLimitsOptions): SlotlockRequestLimits;
|
package/rate-limit.js
CHANGED
|
@@ -202,6 +202,26 @@ function bucketsFor(rate, now) {
|
|
|
202
202
|
},
|
|
203
203
|
};
|
|
204
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* One token bucket per key at `rate`, kept in this process like serve's own: `take` spends a token
|
|
207
|
+
* of the key's bucket and is false, spending nothing, when it has less than one. The dashboard
|
|
208
|
+
* assistant limits each member's messages with it.
|
|
209
|
+
*/
|
|
210
|
+
export function createSlotlockRateLimiter(rate, now = () => performance.now()) {
|
|
211
|
+
if (!(rate.perSecond > 0) || !(rate.burst >= 1)) {
|
|
212
|
+
throw new Error('Slotlock rate limiter needs a positive rate and a burst of at least 1');
|
|
213
|
+
}
|
|
214
|
+
const buckets = bucketsFor(rate, now);
|
|
215
|
+
return {
|
|
216
|
+
take(key) {
|
|
217
|
+
const bucket = buckets.get(key, now());
|
|
218
|
+
if (bucket.tokens < 1)
|
|
219
|
+
return false;
|
|
220
|
+
bucket.tokens -= 1;
|
|
221
|
+
return true;
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
205
225
|
/**
|
|
206
226
|
* Slots, at most `perKey` running for a key and `total` in all, shared between keys by how much
|
|
207
227
|
* each asks for. A key's use is how long the event loop was busy while its writes held a slot,
|
package/self-host.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { type SlotlockSql, type SlotlockStore } from './store.js';
|
|
|
5
5
|
import type { WeeklyAvailabilityRule } from './types.js';
|
|
6
6
|
export { SlotlockConfigError, type SlotlockEnv, type SlotlockServeDashboardConfig };
|
|
7
7
|
/** Every variable the commands read. The README, `.env.example` and `server.json` document these. */
|
|
8
|
-
export declare const SLOTLOCK_ENVIRONMENT_VARIABLES: readonly ["DATABASE_URL", "SLOTLOCK_MIGRATE_DATABASE_URL", "SLOTLOCK_AUTH_TOKEN", "SLOTLOCK_CONFIRMATION_SECRET", "SLOTLOCK_CONFIRM_WRITES", "SLOTLOCK_METRICS_TOKEN", "SLOTLOCK_PUBLIC_URL", "SLOTLOCK_TENANT", "SLOTLOCK_AVAILABILITY", "SLOTLOCK_REST_API", "SLOTLOCK_RETENTION_DAYS", "SLOTLOCK_WEBHOOKS", "SLOTLOCK_SHARED_LIMITS", "SLOTLOCK_LIMITS", "SLOTLOCK_TRUSTED_PROXY_HEADER", "SLOTLOCK_GITHUB_CLIENT_ID", "SLOTLOCK_GITHUB_CLIENT_SECRET", "SLOTLOCK_SESSION_SECRET", "SLOTLOCK_DASHBOARD_USERS", "SLOTLOCK_DASHBOARD_ORGS", "SLOTLOCK_DASHBOARD_NAME", "HOST", "PORT"];
|
|
8
|
+
export declare const SLOTLOCK_ENVIRONMENT_VARIABLES: readonly ["DATABASE_URL", "SLOTLOCK_MIGRATE_DATABASE_URL", "SLOTLOCK_AUTH_TOKEN", "SLOTLOCK_CONFIRMATION_SECRET", "SLOTLOCK_CONFIRM_WRITES", "SLOTLOCK_METRICS_TOKEN", "SLOTLOCK_PUBLIC_URL", "SLOTLOCK_TENANT", "SLOTLOCK_AVAILABILITY", "SLOTLOCK_REST_API", "SLOTLOCK_RETENTION_DAYS", "SLOTLOCK_WEBHOOKS", "SLOTLOCK_SHARED_LIMITS", "SLOTLOCK_LIMITS", "SLOTLOCK_TRUSTED_PROXY_HEADER", "SLOTLOCK_GITHUB_CLIENT_ID", "SLOTLOCK_GITHUB_CLIENT_SECRET", "SLOTLOCK_SESSION_SECRET", "SLOTLOCK_ASSISTANT_SECRET", "SLOTLOCK_ASSISTANT_PRIVATE_URLS", "SLOTLOCK_DASHBOARD_USERS", "SLOTLOCK_DASHBOARD_ORGS", "SLOTLOCK_DASHBOARD_NAME", "HOST", "PORT"];
|
|
9
9
|
export interface SlotlockDatabaseConfig {
|
|
10
10
|
/** The role `serve` and `resource` connect as. */
|
|
11
11
|
databaseUrl: string;
|
|
@@ -124,7 +124,7 @@ export declare function createSlotlockMaintenanceStore(sql: SlotlockSql, caps: S
|
|
|
124
124
|
}): SlotlockStore;
|
|
125
125
|
/** Serve MCP and A2A over HTTP from the Postgres store, as one token-authenticated tenant. */
|
|
126
126
|
export declare function startSlotlockServer(config: SlotlockServeConfig, log: SlotlockLogger): Promise<SlotlockRunningServer>;
|
|
127
|
-
export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains each tenant it serves every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n resource archive <ref> Take a resource out of use: no new bookings, holds or events, and\n no longer listed; what is on it stays\n resource restore <ref> Put an archived resource back in use\n resource delete <ref> Delete a resource and its history; refused while a booking, hold\n or event on it has not ended\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n rules set <json> --resource <ref>\n Set what a booking on the resource must satisfy, like\n {\"minNoticeMinutes\":120,\"slotStepMinutes\":30,\"turnaroundMinutes\":30,\"enforceHours\":true}\n rules clear --resource <ref>\n Remove the resource's booking rules\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n tenant status Print whether the tenant is suspended\n tenant suspend [--reason <text>]\n Suspend the tenant: it may read, and writes nothing; its keys get\n no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:\n only the schema owner suspends)\n tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)\n tenant erase --confirm <tenant>\n Delete everything the tenant has, its API keys included, for good;\n --confirm repeats SLOTLOCK_TENANT\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write\n access (never admin), 32+ random characters (serve); API keys\n work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;\n required when SLOTLOCK_CONFIRM_WRITES is not none)\n SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+\n random characters (serve; off without it)\n SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools\n that wait for a person (MCP 2026-07-28 form elicitation; refused\n with confirmation_required on REST, A2A and older MCP)\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,\n and retired answers (never under 30) (1-3650, default 30;\n serve and maintain)\n SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the\n database, so N servers allow a tenant its rate once; off (default):\n each server keeps its own\n SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,\n never to a private network address; private: those too, for\n testing; off: keep the change feed, send nothing\n SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:\n rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,\n every request per client, before any key lookup), key_read,\n tenant_read (20/s:40), key_write, tenant_write (5/s:20),\n global_read (500/s:1000), global_write (50/s:100), app_read\n (20/s:40) and app_write (5/s:20) per tenant for the\n application API, sign_in (20/m:20); counts for\n tenant_series_writes and series_writes\n (1: writes that may expand a recurring series at once),\n maintenance_runs (1: application API maintenance runs at once),\n subscriptions (256), tenant_subscriptions (16),\n key_subscriptions (4, and fewer than tenant_subscriptions),\n key_lookups (2), key_lookup_queue (256),\n tenant_transactions (2: one tenant's database transactions at\n once, of the pool's 10); what one tenant may store:\n tenant_holds (10000), tenant_reservations, tenant_events and\n tenant_coverage (100000), holder_holds (20) and\n holder_held_hours (2160), which maintain keeps too\n SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,\n such as CF-Connecting-IP (default: the connection's address);\n set it only when every request comes through that proxy\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)\n SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant\n SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
|
|
127
|
+
export declare const SLOTLOCK_CLI_HELP = "Usage: slotlock <command> [options]\n\nCommands:\n migrate Apply the schema and row-level security, then grant the serving\n role when SLOTLOCK_MIGRATE_DATABASE_URL names another role\n serve [--migrate] Serve MCP, A2A and REST over HTTP (--migrate runs migrate first);\n maintains each tenant it serves every hour\n maintain Run maintenance for the tenant now: extend recurring events to the\n rolling horizon and prune past the retention window (for cron)\n resource add <ref> [--timezone <IANA zone>]\n Create a calendar resource (a car, room, person or machine)\n resource list Print the tenant's resources, one JSON object per line\n resource archive <ref> Take a resource out of use: no new bookings, holds or events, and\n no longer listed; what is on it stays\n resource restore <ref> Put an archived resource back in use\n resource delete <ref> Delete a resource and its history; refused while a booking, hold\n or event on it has not ended\n hours show [--resource <ref>]\n Print each resource's bookable hours (null: the server's)\n hours set <json> (--resource <ref> | --all)\n Give one resource, or every one, weekly hours of its own; [] closes\n hours clear (--resource <ref> | --all)\n Hand one resource, or every one, back to the server's hours\n rules set <json> --resource <ref>\n Set what a booking on the resource must satisfy, like\n {\"minNoticeMinutes\":120,\"slotStepMinutes\":30,\"turnaroundMinutes\":30,\"enforceHours\":true}\n rules clear --resource <ref>\n Remove the resource's booking rules\n key create <name> [--scope read,write,admin] [--expires-in-days <n>]\n Create an API key for the tenant (default scope read,write, no\n expiry; admin adds the application API) and print it once; only its\n SHA-256 is stored\n key list Print the tenant's API keys, one JSON object per line\n key rotate <id> Give an active key a new secret and print it once; the old one stops\n working at once, and the key keeps its id and what it booked\n key revoke <id> Revoke one of the tenant's API keys at once\n tenant status Print whether the tenant is suspended\n tenant suspend [--reason <text>]\n Suspend the tenant: it may read, and writes nothing; its keys get\n no new secret (as SLOTLOCK_MIGRATE_DATABASE_URL's role when set:\n only the schema owner suspends)\n tenant unsuspend Lift the tenant's suspension (as the schema owner, likewise)\n tenant erase --confirm <tenant>\n Delete everything the tenant has, its API keys included, for good;\n --confirm repeats SLOTLOCK_TENANT\n healthcheck Exit 0 when the local server reports ready\n\nOptions:\n -h, --help Show this help\n -v, --version Print the version\n\nEnvironment:\n DATABASE_URL postgres:// URL of the role Slotlock serves as (required); serve\n refuses a superuser or a role with BYPASSRLS\n SLOTLOCK_MIGRATE_DATABASE_URL postgres:// URL of the schema owner, for migrate (optional)\n SLOTLOCK_AUTH_TOKEN Optional bearer token for SLOTLOCK_TENANT with read and write\n access (never admin), 32+ random characters (serve); API keys\n work with or without it\n SLOTLOCK_CONFIRMATION_SECRET Signs pending write confirmations, 32+ random characters (serve;\n required when SLOTLOCK_CONFIRM_WRITES is not none)\n SLOTLOCK_METRICS_TOKEN Serves Prometheus metrics at /metrics to this bearer token, 32+\n random characters (serve; off without it)\n SLOTLOCK_CONFIRM_WRITES none (default), all, or a comma-separated list of write tools\n that wait for a person (MCP 2026-07-28 form elicitation; refused\n with confirmation_required on REST, A2A and older MCP)\n SLOTLOCK_PUBLIC_URL URL clients use (default http://localhost:$PORT)\n SLOTLOCK_TENANT Tenant of the token, resource and key commands (default \"default\")\n SLOTLOCK_AVAILABILITY JSON array of weekly bookable-hours rules (default: none)\n SLOTLOCK_REST_API on (default): also serve the tools as a REST API under /v1, and\n the application API for admin keys, described at /openapi.json;\n off: MCP and A2A only\n SLOTLOCK_RETENTION_DAYS Days of history kept: the change feed, ended waitlist entries,\n and retired answers (never under 30) (1-3650, default 30;\n serve and maintain)\n SLOTLOCK_SHARED_LIMITS on: tenant request budgets are shared by every server through the\n database, so N servers allow a tenant its rate once; off (default):\n each server keeps its own\n SLOTLOCK_WEBHOOKS on (default): deliver each tenant's changes to its webhooks,\n never to a private network address; private: those too, for\n testing; off: keep the change feed, send nothing\n SLOTLOCK_LIMITS Request limits, comma-separated name=value over the defaults:\n rates as <count>/<s|m|h>[:<burst>] or off for address (50/s:100,\n every request per client, before any key lookup), key_read,\n tenant_read (20/s:40), key_write, tenant_write (5/s:20),\n global_read (500/s:1000), global_write (50/s:100), app_read\n (20/s:40) and app_write (5/s:20) per tenant for the\n application API, sign_in (20/m:20); counts for\n tenant_series_writes and series_writes\n (1: writes that may expand a recurring series at once),\n maintenance_runs (1: application API maintenance runs at once),\n subscriptions (256), tenant_subscriptions (16),\n key_subscriptions (4, and fewer than tenant_subscriptions),\n key_lookups (2), key_lookup_queue (256),\n tenant_transactions (2: one tenant's database transactions at\n once, of the pool's 10); what one tenant may store:\n tenant_holds (10000), tenant_reservations, tenant_events and\n tenant_coverage (100000), holder_holds (20) and\n holder_held_hours (2160), which maintain keeps too\n SLOTLOCK_TRUSTED_PROXY_HEADER The request field a proxy in front sets to the client address,\n such as CF-Connecting-IP (default: the connection's address);\n set it only when every request comes through that proxy\n SLOTLOCK_GITHUB_CLIENT_ID Serve the dashboard at $SLOTLOCK_PUBLIC_URL/dashboard: the Client ID\n of a GitHub OAuth app with callback .../dashboard/callback\n SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)\n SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)\n SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's\n model API key with it, 32+ random characters (dashboard; off\n without it)\n SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or\n resolves to, a loopback, private, link-local, CGNAT, unique-local\n or metadata address; on: allow them, for a self-hosted model\n (Ollama, vLLM) on this machine or network\n SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)\n SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant\n SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)\n HOST Listen address (default 127.0.0.1)\n PORT Listen port (default 8080)\n\nGenerate each secret with: openssl rand -hex 32\n";
|
|
128
128
|
/**
|
|
129
129
|
* Run one `slotlock` command and resolve to its exit code: 0 success, 1 failure, 2 a usage or
|
|
130
130
|
* configuration error (nothing was started). `serve` resolves once `io.signal` aborts and it has
|
package/self-host.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createHash, timingSafeEqual } from 'node:crypto';
|
|
|
8
8
|
import { parseArgs } from 'node:util';
|
|
9
9
|
import postgres from 'postgres';
|
|
10
10
|
import { createSlotlockAgentServer, slotlockAgentOperationScope, } from './agent-server.js';
|
|
11
|
-
import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, } from './agent-store-backend.js';
|
|
11
|
+
import { createSlotlockStoreAgentBackend, createSlotlockStoreApplicationBackend, createSlotlockStoreChangeSource, } from './agent-store-backend.js';
|
|
12
12
|
import { SLOTLOCK_API_KEY_DEFAULT_SCOPES, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_SCOPES, createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, slotlockBearerCredential, } from './api-keys.js';
|
|
13
13
|
import { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockAcceptanceRecords, createSlotlockDashboardState, } from './dashboard.js';
|
|
14
14
|
import { SECRET_HINT, SlotlockConfigError, formatSlotlockRate, optional, readAvailability, readDashboardConfig, readDashboardOrganizations, readLimits, readSecret, readServerUrl, readTrustedProxyHeader, slotlockKeySubscriptions, } from './config.js';
|
|
@@ -43,6 +43,8 @@ export const SLOTLOCK_ENVIRONMENT_VARIABLES = Object.freeze([
|
|
|
43
43
|
'SLOTLOCK_GITHUB_CLIENT_ID',
|
|
44
44
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
45
45
|
'SLOTLOCK_SESSION_SECRET',
|
|
46
|
+
'SLOTLOCK_ASSISTANT_SECRET',
|
|
47
|
+
'SLOTLOCK_ASSISTANT_PRIVATE_URLS',
|
|
46
48
|
'SLOTLOCK_DASHBOARD_USERS',
|
|
47
49
|
'SLOTLOCK_DASHBOARD_ORGS',
|
|
48
50
|
'SLOTLOCK_DASHBOARD_NAME',
|
|
@@ -245,6 +247,7 @@ export function readSlotlockServeConfig(env) {
|
|
|
245
247
|
confirmationSecret,
|
|
246
248
|
env.SLOTLOCK_SESSION_SECRET?.trim(),
|
|
247
249
|
env.SLOTLOCK_GITHUB_CLIENT_SECRET?.trim(),
|
|
250
|
+
env.SLOTLOCK_ASSISTANT_SECRET?.trim(),
|
|
248
251
|
];
|
|
249
252
|
if (others.includes(metricsToken)) {
|
|
250
253
|
throw new SlotlockConfigError('SLOTLOCK_METRICS_TOKEN must differ from every other secret');
|
|
@@ -308,6 +311,7 @@ function secretsIn(env) {
|
|
|
308
311
|
'SLOTLOCK_CONFIRMATION_SECRET',
|
|
309
312
|
'SLOTLOCK_GITHUB_CLIENT_SECRET',
|
|
310
313
|
'SLOTLOCK_SESSION_SECRET',
|
|
314
|
+
'SLOTLOCK_ASSISTANT_SECRET',
|
|
311
315
|
'SLOTLOCK_METRICS_TOKEN',
|
|
312
316
|
]) {
|
|
313
317
|
const value = env[name]?.trim();
|
|
@@ -634,13 +638,14 @@ export async function startSlotlockServer(config, log) {
|
|
|
634
638
|
onIgnoredClientField: (field) => log.warn('client_address_field_ignored', { field, setting: 'SLOTLOCK_TRUSTED_PROXY_HEADER' }),
|
|
635
639
|
});
|
|
636
640
|
let health;
|
|
641
|
+
const agentBackend = createSlotlockStoreAgentBackend(store, {
|
|
642
|
+
availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
|
|
643
|
+
});
|
|
637
644
|
const agentServer = createSlotlockAgentServer({
|
|
638
645
|
publicBaseUrl: config.publicUrl,
|
|
639
646
|
// readPublicUrl admits plain HTTP only on a loopback host, which is all this permits anyway.
|
|
640
647
|
allowInsecureLocalhost: publicUrl.protocol === 'http:',
|
|
641
|
-
backend:
|
|
642
|
-
availabilityRules: async () => config.availability.map((rule) => ({ ...rule })),
|
|
643
|
-
}),
|
|
648
|
+
backend: agentBackend,
|
|
644
649
|
authenticate: limits.authenticate,
|
|
645
650
|
consumeRateLimit: limits.consumeRateLimit,
|
|
646
651
|
// A write that may expand a recurring series also waits for one of a few slots, so one
|
|
@@ -652,6 +657,8 @@ export async function startSlotlockServer(config, log) {
|
|
|
652
657
|
return true;
|
|
653
658
|
},
|
|
654
659
|
subscriptions: {
|
|
660
|
+
// Live updates poll each tenant's change feed (one reader per tenant).
|
|
661
|
+
changes: createSlotlockStoreChangeSource(store, sql),
|
|
655
662
|
maxTotal: caps.subscriptions,
|
|
656
663
|
maxPerTenant: caps.tenant_subscriptions,
|
|
657
664
|
maxPerPrincipal: slotlockKeySubscriptions(caps),
|
|
@@ -718,6 +725,22 @@ export async function startSlotlockServer(config, log) {
|
|
|
718
725
|
resources: createSlotlockDashboardResources(store),
|
|
719
726
|
state: createSlotlockDashboardState(sql),
|
|
720
727
|
availability: config.availability,
|
|
728
|
+
...(config.dashboard.assistantSecret !== undefined
|
|
729
|
+
? {
|
|
730
|
+
// The assistant's reads and approved writes are agent calls: the same backend,
|
|
731
|
+
// operations, rate limits and series-write slots as an API key's.
|
|
732
|
+
assistant: {
|
|
733
|
+
secret: config.dashboard.assistantSecret,
|
|
734
|
+
...(config.dashboard.assistantPrivateUrls ? { privateUrls: true } : {}),
|
|
735
|
+
backend: agentBackend,
|
|
736
|
+
consumeRateLimit: limits.consumeRateLimit,
|
|
737
|
+
// Checks only: a proposal holds nothing. The series-write slot and the write
|
|
738
|
+
// token are taken when an approved change runs (admit, consumeRateLimit).
|
|
739
|
+
authorize: async ({ principal, operation }) => authorizeSlotlockServeOperation({ principal, operation }),
|
|
740
|
+
admit: (args) => limits.admit(args),
|
|
741
|
+
},
|
|
742
|
+
}
|
|
743
|
+
: {}),
|
|
721
744
|
clientAddress: (request) => limits.clientAddress(request),
|
|
722
745
|
onError: (error) => log.error('dashboard_error', describeError(error)),
|
|
723
746
|
});
|
|
@@ -768,7 +791,9 @@ export async function startSlotlockServer(config, log) {
|
|
|
768
791
|
send,
|
|
769
792
|
feed: {
|
|
770
793
|
claimWebhookDeliveries: (params) => deliveryStore.withTenant(tenantRef, (tenant) => tenant.claimWebhookDeliveries(params)),
|
|
771
|
-
settleWebhookDelivery: (params) => deliveryStore.withTenant(tenantRef, (tenant) => tenant.settleWebhookDelivery(params)
|
|
794
|
+
settleWebhookDelivery: (params) => deliveryStore.withTenant(tenantRef, (tenant) => tenant.settleWebhookDelivery(params), {
|
|
795
|
+
origin: 'maintenance',
|
|
796
|
+
}),
|
|
772
797
|
},
|
|
773
798
|
}),
|
|
774
799
|
// Counts only, never the tenant or a URL.
|
|
@@ -1099,6 +1124,9 @@ async function maintainTenant(config, caps) {
|
|
|
1099
1124
|
if (pass.capped && pass.extended + pass.pruned === 0 && !pass.rollPending)
|
|
1100
1125
|
break;
|
|
1101
1126
|
}
|
|
1127
|
+
// Every tenant's expired assistant conversations, a tenant only the dashboard serves
|
|
1128
|
+
// included. Counted nowhere: the counts above are this tenant's.
|
|
1129
|
+
await store.sweepAssistantConversations();
|
|
1102
1130
|
return sum;
|
|
1103
1131
|
});
|
|
1104
1132
|
return total ?? null;
|
|
@@ -1249,6 +1277,13 @@ Environment:
|
|
|
1249
1277
|
of a GitHub OAuth app with callback .../dashboard/callback
|
|
1250
1278
|
SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
|
|
1251
1279
|
SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)
|
|
1280
|
+
SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's
|
|
1281
|
+
model API key with it, 32+ random characters (dashboard; off
|
|
1282
|
+
without it)
|
|
1283
|
+
SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or
|
|
1284
|
+
resolves to, a loopback, private, link-local, CGNAT, unique-local
|
|
1285
|
+
or metadata address; on: allow them, for a self-hosted model
|
|
1286
|
+
(Ollama, vLLM) on this machine or network
|
|
1252
1287
|
SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)
|
|
1253
1288
|
SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant
|
|
1254
1289
|
SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)
|
|
@@ -1520,7 +1555,7 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1520
1555
|
}
|
|
1521
1556
|
case 'key rotate': {
|
|
1522
1557
|
const config = readSlotlockDatabaseConfig(io.env);
|
|
1523
|
-
const rotated = await withApiKeys(config, (keys) => keys.rotate({ tenantRef: config.tenantRef, id: operands[1] }));
|
|
1558
|
+
const rotated = await withApiKeys(config, (keys) => keys.rotate({ tenantRef: config.tenantRef, id: operands[1], actor: CLI_KEY_CREATOR }));
|
|
1524
1559
|
if (!rotated) {
|
|
1525
1560
|
io.stderr.write('slotlock: no active API key with that id in this tenant\n');
|
|
1526
1561
|
return 1;
|
|
@@ -1531,7 +1566,7 @@ export async function runSlotlockCli(argv, io) {
|
|
|
1531
1566
|
}
|
|
1532
1567
|
case 'key revoke': {
|
|
1533
1568
|
const config = readSlotlockDatabaseConfig(io.env);
|
|
1534
|
-
const revoked = await withApiKeys(config, (keys) => keys.revoke({ tenantRef: config.tenantRef, id: operands[1] }));
|
|
1569
|
+
const revoked = await withApiKeys(config, (keys) => keys.revoke({ tenantRef: config.tenantRef, id: operands[1], actor: CLI_KEY_CREATOR }));
|
|
1535
1570
|
if (!revoked) {
|
|
1536
1571
|
io.stderr.write('slotlock: no API key with that id in this tenant\n');
|
|
1537
1572
|
return 1;
|
package/slotlock.manifest.json
CHANGED
package/store.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { type SlotlockEventFeed } from './event-feed.js';
|
|
1
|
+
import { type SlotlockChangeOrigin, type SlotlockEventFeed } from './event-feed.js';
|
|
2
2
|
import { type SlotlockBookingRules } from './booking-rules.js';
|
|
3
3
|
import type { Sql, TransactionSql } from 'postgres';
|
|
4
4
|
import { SLOTLOCK_CALENDAR_HORIZON_DAYS } from './rules.js';
|
|
5
|
-
import type { AcquireHoldResult, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventRetentionResult, BookingRetentionResult, SlotlockTenantErasure, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CancelCalendarEventResult, CancelExternalReservationResult, ConfirmHoldResult, CreateReservationResult, DeleteResourceResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockCalendarEvent, SlotlockAgendaEntry, SlotlockHold, SlotlockReservation, SlotlockResource, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, WeeklyAvailabilityRule } from './types.js';
|
|
5
|
+
import type { AcquireHoldResult, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventRetentionResult, BookingRetentionResult, SlotlockTenantErasure, SlotlockBookingRuleDefaults, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CancelCalendarEventResult, CancelExternalReservationResult, ConfirmHoldResult, CreateReservationResult, DeleteResourceResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockCalendarEvent, SlotlockAgendaEntry, SlotlockHold, SlotlockReservation, SlotlockResource, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, WeeklyAvailabilityRule } from './types.js';
|
|
6
6
|
export { SLOTLOCK_CALENDAR_HORIZON_DAYS };
|
|
7
7
|
/**
|
|
8
8
|
* Longest one-off event the store accepts (ten years and some leap days): long enough for any lease
|
|
@@ -90,6 +90,8 @@ export declare function legacyPutCommandHash(params: Parameters<typeof putComman
|
|
|
90
90
|
}): string;
|
|
91
91
|
/** Members a pool booking or search considers at most, in id order. */
|
|
92
92
|
export declare const SLOTLOCK_MAX_POOL_MEMBERS = 100;
|
|
93
|
+
/** The most pools of one tenant that may have default booking rules (`setBookingRuleDefaults`). */
|
|
94
|
+
export declare const SLOTLOCK_MAX_POOL_RULE_DEFAULTS = 1000;
|
|
93
95
|
export declare function isPoolName(value: unknown): value is string;
|
|
94
96
|
export interface SlotlockStore extends SlotlockEventFeed {
|
|
95
97
|
/**
|
|
@@ -148,6 +150,13 @@ export interface SlotlockStore extends SlotlockEventFeed {
|
|
|
148
150
|
* without control characters: the history (listHistory) records it with each change.
|
|
149
151
|
*/
|
|
150
152
|
actor?: string;
|
|
153
|
+
/**
|
|
154
|
+
* Where the changes come from (SLOTLOCK_CHANGE_ORIGINS: `mcp`, `rest`, `dashboard`, ...),
|
|
155
|
+
* recorded with each beside the actor. Unset, the enclosing withTenant's, else `library`.
|
|
156
|
+
*/
|
|
157
|
+
origin?: SlotlockChangeOrigin;
|
|
158
|
+
/** The id (a UUID) of the API key making these changes, recorded with each. */
|
|
159
|
+
keyId?: string;
|
|
151
160
|
}): Promise<T>;
|
|
152
161
|
/**
|
|
153
162
|
* INVARIANT (erasure completeness): a tenant integration must use a stable, erasable business
|
|
@@ -228,6 +237,24 @@ export interface SlotlockStore extends SlotlockEventFeed {
|
|
|
228
237
|
id: string;
|
|
229
238
|
rules: SlotlockBookingRules | null;
|
|
230
239
|
}): Promise<SlotlockResource | null>;
|
|
240
|
+
/**
|
|
241
|
+
* Set (or clear, with `null`) the tenant's default booking rules, or one pool's (`pool`): a
|
|
242
|
+
* resource's rules as they apply (`effectiveBookingRules`) are the tenant's, overlaid by its
|
|
243
|
+
* pool's, overlaid by its own, a field set at a narrower level winning. They apply to bookings made
|
|
244
|
+
* from then on. `checkInMinutes` is only a resource's own (`invalid_booking_rules`, as for rules
|
|
245
|
+
* outside their bounds); `pool` is a pool name (`invalid_pool`). At most
|
|
246
|
+
* SLOTLOCK_MAX_POOL_RULE_DEFAULTS pools a tenant have them (`pool_rules_limit_reached` for one
|
|
247
|
+
* more). Returns the rules stored.
|
|
248
|
+
*/
|
|
249
|
+
setBookingRuleDefaults(params: {
|
|
250
|
+
tenantRef: string;
|
|
251
|
+
pool: string | null;
|
|
252
|
+
rules: SlotlockBookingRules | null;
|
|
253
|
+
}): Promise<SlotlockBookingRules | null>;
|
|
254
|
+
/** The tenant's default booking rules, and each pool's that has them, by pool name. */
|
|
255
|
+
getBookingRuleDefaults(params: {
|
|
256
|
+
tenantRef: string;
|
|
257
|
+
}): Promise<SlotlockBookingRuleDefaults>;
|
|
231
258
|
/**
|
|
232
259
|
* Put one resource in a pool, or take it out (`null`): `pool` is 1-100 characters of a-z, 0-9,
|
|
233
260
|
* `.`, `_` and `-`, starting with a letter or digit (`invalid_pool` otherwise). A resource is in
|
|
@@ -635,6 +662,83 @@ export interface SlotlockStore extends SlotlockEventFeed {
|
|
|
635
662
|
id: string;
|
|
636
663
|
holderRef?: string;
|
|
637
664
|
}): Promise<boolean>;
|
|
665
|
+
/** The tenant's dashboard assistant settings, or null when it has none. */
|
|
666
|
+
getAssistantSettings(params: {
|
|
667
|
+
tenantRef: string;
|
|
668
|
+
}): Promise<SlotlockAssistantSettingsRecord | null>;
|
|
669
|
+
/**
|
|
670
|
+
* Set the tenant's assistant settings. `key` is the sealed key and its hint, null for none, or
|
|
671
|
+
* `'keep'` to keep the one stored (none, for a tenant without settings). A stored key is kept only
|
|
672
|
+
* for the provider and base URL it was entered for: `'keep'` with another provider or URL changes
|
|
673
|
+
* nothing and answers `{ saved: false }`, decided in the one statement that writes, so racing
|
|
674
|
+
* saves cannot carry the key elsewhere.
|
|
675
|
+
*/
|
|
676
|
+
putAssistantSettings(params: {
|
|
677
|
+
tenantRef: string;
|
|
678
|
+
provider: 'anthropic' | 'openai';
|
|
679
|
+
baseUrl: string;
|
|
680
|
+
model: string;
|
|
681
|
+
key: {
|
|
682
|
+
sealed: string;
|
|
683
|
+
hint: string;
|
|
684
|
+
} | null | 'keep';
|
|
685
|
+
updatedBy?: string;
|
|
686
|
+
}): Promise<{
|
|
687
|
+
saved: boolean;
|
|
688
|
+
}>;
|
|
689
|
+
/** Remove the tenant's assistant settings, its key with them. False when it had none. */
|
|
690
|
+
deleteAssistantSettings(params: {
|
|
691
|
+
tenantRef: string;
|
|
692
|
+
}): Promise<boolean>;
|
|
693
|
+
/** A member's live assistant conversation, or null when none is (or it expired). */
|
|
694
|
+
getAssistantConversation(params: {
|
|
695
|
+
tenantRef: string;
|
|
696
|
+
memberRef: string;
|
|
697
|
+
}): Promise<SlotlockAssistantConversationRecord | null>;
|
|
698
|
+
/**
|
|
699
|
+
* Store a member's conversation for `ttlMs` (1 second to a day) from now, replacing its messages.
|
|
700
|
+
* Its actions become those stored already that `keep` names, in their stored state
|
|
701
|
+
* (settleAssistantAction changes it), and the `added` ones not stored already: an action is never
|
|
702
|
+
* put back into a conversation that no longer has it. The tenant's expired conversations are
|
|
703
|
+
* deleted with it, and the tenant is listed for sweepAssistantConversations.
|
|
704
|
+
*/
|
|
705
|
+
putAssistantConversation(params: {
|
|
706
|
+
tenantRef: string;
|
|
707
|
+
memberRef: string;
|
|
708
|
+
messages: unknown[];
|
|
709
|
+
added: Record<string, Record<string, unknown>>;
|
|
710
|
+
keep: readonly string[];
|
|
711
|
+
ttlMs: number;
|
|
712
|
+
}): Promise<void>;
|
|
713
|
+
/**
|
|
714
|
+
* Change one proposed action, only while its `status` is `from`: its fields are merged with
|
|
715
|
+
* `patch`. The action as changed, or null when it is not in that state (or not there): of two
|
|
716
|
+
* racing approvals, one wins.
|
|
717
|
+
*/
|
|
718
|
+
settleAssistantAction(params: {
|
|
719
|
+
tenantRef: string;
|
|
720
|
+
memberRef: string;
|
|
721
|
+
actionId: string;
|
|
722
|
+
from: string;
|
|
723
|
+
patch: Record<string, unknown>;
|
|
724
|
+
}): Promise<Record<string, unknown> | null>;
|
|
725
|
+
/** Forget a member's conversation. False when there was none. */
|
|
726
|
+
deleteAssistantConversation(params: {
|
|
727
|
+
tenantRef: string;
|
|
728
|
+
memberRef: string;
|
|
729
|
+
}): Promise<boolean>;
|
|
730
|
+
/** Delete up to `limit` (default 1,000) of the tenant's expired assistant conversations: how many. */
|
|
731
|
+
pruneAssistantConversations(params: {
|
|
732
|
+
tenantRef: string;
|
|
733
|
+
limit?: number;
|
|
734
|
+
}): Promise<number>;
|
|
735
|
+
/**
|
|
736
|
+
* Delete up to `limit` (1-100,000, default 10,000) expired assistant conversations of every
|
|
737
|
+
* tenant (slotlock.sweep_assistant_conversations): how many, and nothing else.
|
|
738
|
+
*/
|
|
739
|
+
sweepAssistantConversations(params?: {
|
|
740
|
+
limit?: number;
|
|
741
|
+
}): Promise<number>;
|
|
638
742
|
/**
|
|
639
743
|
* Offer freed time to the waitlist, in the order entries joined: each waiting entry whose window
|
|
640
744
|
* is free (on its resource, or on the first free member of its pool) gets a hold for its holder
|
|
@@ -735,6 +839,29 @@ export interface SlotlockStore extends SlotlockEventFeed {
|
|
|
735
839
|
coverageMaxAgeMs?: number;
|
|
736
840
|
}): Promise<Interval | null>;
|
|
737
841
|
}
|
|
842
|
+
/**
|
|
843
|
+
* The model a tenant's dashboard assistant uses (dashboard Settings, Assistant). The API key is held
|
|
844
|
+
* only sealed (assistant.ts sealSlotlockAssistantKey), never in the clear.
|
|
845
|
+
*/
|
|
846
|
+
export interface SlotlockAssistantSettingsRecord {
|
|
847
|
+
provider: 'anthropic' | 'openai';
|
|
848
|
+
baseUrl: string;
|
|
849
|
+
model: string;
|
|
850
|
+
/** The API key, sealed with the deployment's assistant secret; null when it has none. */
|
|
851
|
+
sealedKey: string | null;
|
|
852
|
+
/** The key's last four characters, to show; null when it has none. */
|
|
853
|
+
keyHint: string | null;
|
|
854
|
+
updatedBy: string | null;
|
|
855
|
+
updatedAt: Date;
|
|
856
|
+
}
|
|
857
|
+
/** One member's live conversation with the dashboard assistant, as assistant.ts keeps it. */
|
|
858
|
+
export interface SlotlockAssistantConversationRecord {
|
|
859
|
+
messages: unknown[];
|
|
860
|
+
/** The writes it proposed, by id. */
|
|
861
|
+
actions: Record<string, Record<string, unknown>>;
|
|
862
|
+
updatedAt: Date;
|
|
863
|
+
expiresAt: Date;
|
|
864
|
+
}
|
|
738
865
|
/** One entry of a tenant's waitlist (joinWaitlist). */
|
|
739
866
|
export interface SlotlockWaitlistEntry {
|
|
740
867
|
id: string;
|
|
@@ -770,6 +897,8 @@ export type CheckInReservationResult = {
|
|
|
770
897
|
/** One source's coverage of a resource, with its health (listCoverage). */
|
|
771
898
|
export interface SlotlockCoverageHealth {
|
|
772
899
|
source: string;
|
|
900
|
+
/** How the change feed names the source (slotlockCoverageSourceId), never the source itself. */
|
|
901
|
+
sourceId: string;
|
|
773
902
|
start: Date;
|
|
774
903
|
end: Date;
|
|
775
904
|
revision: number;
|
|
@@ -874,4 +1003,4 @@ export declare function createSlotlockStore(sql: StoreSql, options?: SlotlockSto
|
|
|
874
1003
|
* Every store method that writes for a tenant: what a suspended tenant's withTenant store refuses.
|
|
875
1004
|
* Maintenance (rolling series, pruning, purging) and erasure are not among them.
|
|
876
1005
|
*/
|
|
877
|
-
export declare const SLOTLOCK_TENANT_WRITE_METHODS: readonly ["createResource", "archiveResource", "restoreResource", "deleteResource", "setResourceAvailability", "setResourceBookingRules", "setResourcePool", "setTenantAvailability", "createReservation", "createExternalReservation", "rescheduleExternalReservation", "cancelExternalReservation", "acquireHold", "confirmHold", "releaseHold", "putCalendarEvent", "cancelCalendarEvent", "cancelCalendarEventById", "recordCalendarCoverage", "createWebhookEndpoint", "deleteWebhookEndpoint", "enableWebhookEndpoint", "recordCoverageFailure", "deleteCoverage", "checkInReservation", "joinWaitlist", "leaveWaitlist"];
|
|
1006
|
+
export declare const SLOTLOCK_TENANT_WRITE_METHODS: readonly ["createResource", "archiveResource", "restoreResource", "deleteResource", "setResourceAvailability", "setResourceBookingRules", "setBookingRuleDefaults", "setResourcePool", "setTenantAvailability", "createReservation", "createExternalReservation", "rescheduleExternalReservation", "cancelExternalReservation", "acquireHold", "confirmHold", "releaseHold", "putCalendarEvent", "cancelCalendarEvent", "cancelCalendarEventById", "recordCalendarCoverage", "createWebhookEndpoint", "deleteWebhookEndpoint", "enableWebhookEndpoint", "recordCoverageFailure", "deleteCoverage", "checkInReservation", "joinWaitlist", "leaveWaitlist", "putAssistantSettings", "deleteAssistantSettings", "putAssistantConversation", "settleAssistantAction", "deleteAssistantConversation"];
|