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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slotlock",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
6
6
  "license": "Apache-2.0",
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: createSlotlockStoreAgentBackend(store, {
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;
@@ -3,7 +3,7 @@
3
3
  "schemaVersion": "1.0",
4
4
  "name": "Slotlock",
5
5
  "package": "slotlock",
6
- "version": "0.5.1",
6
+ "version": "0.5.3",
7
7
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
8
8
  "intervalSemantics": {
9
9
  "boundary": "[)",
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"];