slotlock 0.5.1 → 0.5.2

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/ddl.d.ts CHANGED
@@ -41,21 +41,21 @@ export declare const SLOTLOCK_SCHEMA_TABLES: readonly ["schema_version"];
41
41
  /** The SECURITY DEFINER function through which a server reads the version it is serving. */
42
42
  export declare const SLOTLOCK_SCHEMA_FUNCTIONS: readonly ["slotlock.installed_schema_version()"];
43
43
  /** The table of suspended tenants: they may read, and write nothing (store.ts withTenant). */
44
- export declare const SLOTLOCK_TENANT_ADMIN_TABLES: readonly ["tenant_suspensions", "webhook_tenants", "rate_buckets"];
44
+ export declare const SLOTLOCK_TENANT_ADMIN_TABLES: readonly ["tenant_suspensions", "webhook_tenants", "rate_buckets", "assistant_tenants"];
45
45
  /**
46
46
  * The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
47
47
  * tenant, by signature.
48
48
  */
49
- export declare const SLOTLOCK_TENANT_ADMIN_FUNCTIONS: readonly ["slotlock.tenant_suspended(text)", "slotlock.erase_tenant(text)", "slotlock.register_webhook_tenant(text)", "slotlock.webhook_tenants(text)", "slotlock.unregister_webhook_tenant(text)", "slotlock.take_rate_token(text, double precision, double precision)"];
49
+ export declare const SLOTLOCK_TENANT_ADMIN_FUNCTIONS: readonly ["slotlock.tenant_suspended(text)", "slotlock.erase_tenant(text)", "slotlock.register_webhook_tenant(text)", "slotlock.webhook_tenants(text)", "slotlock.unregister_webhook_tenant(text)", "slotlock.take_rate_token(text, double precision, double precision)", "slotlock.register_assistant_tenant(text, timestamptz)", "slotlock.sweep_assistant_conversations(integer)"];
50
50
  /**
51
51
  * Functions only the deployment role runs: suspending and lifting a suspension. No other role is
52
52
  * granted them, so a serving role cannot lift a suspension.
53
53
  */
54
54
  export declare const SLOTLOCK_OPERATOR_FUNCTIONS: readonly ["slotlock.suspend_tenant(text, text)", "slotlock.unsuspend_tenant(text)"];
55
55
  /** Every SECURITY DEFINER function the serving role may execute, by signature. */
56
- export declare const SLOTLOCK_DEFINER_FUNCTIONS: readonly ("slotlock.authenticate_api_key(bytea)" | "slotlock.create_api_key(text, text, text, bytea, text[], timestamptz, text)" | "slotlock.dashboard_session_ended(bytea, timestamptz)" | "slotlock.erase_api_keys(text)" | "slotlock.erase_tenant(text)" | "slotlock.installed_schema_version()" | "slotlock.list_api_keys(text)" | "slotlock.register_webhook_tenant(text)" | "slotlock.revoke_api_key(text, uuid)" | "slotlock.rotate_api_key(text, uuid, text, bytea)" | "slotlock.take_rate_token(text, double precision, double precision)" | "slotlock.tenant_suspended(text)" | "slotlock.unregister_webhook_tenant(text)" | "slotlock.use_dashboard_token(text, bytea, timestamptz, bytea)" | "slotlock.webhook_tenants(text)")[];
56
+ export declare const SLOTLOCK_DEFINER_FUNCTIONS: readonly ("slotlock.authenticate_api_key(bytea)" | "slotlock.create_api_key(text, text, text, bytea, text[], timestamptz, text)" | "slotlock.dashboard_session_ended(bytea, timestamptz)" | "slotlock.erase_api_keys(text)" | "slotlock.erase_tenant(text)" | "slotlock.installed_schema_version()" | "slotlock.list_api_keys(text)" | "slotlock.register_assistant_tenant(text, timestamptz)" | "slotlock.register_webhook_tenant(text)" | "slotlock.revoke_api_key(text, uuid)" | "slotlock.rotate_api_key(text, uuid, text, bytea)" | "slotlock.sweep_assistant_conversations(integer)" | "slotlock.take_rate_token(text, double precision, double precision)" | "slotlock.tenant_suspended(text)" | "slotlock.unregister_webhook_tenant(text)" | "slotlock.use_dashboard_token(text, bytea, timestamptz, bytea)" | "slotlock.webhook_tenants(text)")[];
57
57
  /** Tables no role but their owner reads or writes, reached only through SLOTLOCK_DEFINER_FUNCTIONS. */
58
- export declare const SLOTLOCK_DEFINER_TABLES: readonly ("api_key_retired_digests" | "api_keys" | "dashboard_tokens" | "rate_buckets" | "schema_version" | "tenant_suspensions" | "webhook_tenants")[];
58
+ export declare const SLOTLOCK_DEFINER_TABLES: readonly ("api_key_retired_digests" | "api_keys" | "assistant_tenants" | "dashboard_tokens" | "rate_buckets" | "schema_version" | "tenant_suspensions" | "webhook_tenants")[];
59
59
  /**
60
60
  * The advisory lock every server transaction for a tenant holds shared (store.ts withTenant), and
61
61
  * suspension and erasure take exclusively, keyed by this text's hashtextextended(…, 0).
@@ -76,7 +76,7 @@ export declare const SLOTLOCK_TENANT_CONTEXT_SETTING = "slotlock.tenant_ref";
76
76
  * `slotlock.rls_policy_contracts` and `slotlock.deployment_lifted_row_security` are
77
77
  * deployment-only and deliberately absent.
78
78
  */
79
- export declare const SLOTLOCK_TENANT_TABLES: readonly ["resources", "reservations", "reservation_tombstones", "reservation_conflict_archive", "calendar_events", "calendar_event_attendees", "calendar_event_reminders", "calendar_event_exceptions", "calendar_event_occurrences", "calendar_event_tombstones", "calendar_event_commands", "calendar_coverage", "hold_commands", "outbox", "outbox_pruned", "webhook_endpoints", "waitlist_entries"];
79
+ export declare const SLOTLOCK_TENANT_TABLES: readonly ["resources", "reservations", "reservation_tombstones", "reservation_conflict_archive", "calendar_events", "calendar_event_attendees", "calendar_event_reminders", "calendar_event_exceptions", "calendar_event_occurrences", "calendar_event_tombstones", "calendar_event_commands", "calendar_coverage", "hold_commands", "outbox", "outbox_pruned", "webhook_endpoints", "waitlist_entries", "assistant_settings", "assistant_conversations"];
80
80
  /**
81
81
  * Build the grants for the application role: USAGE on the `slotlock` schema, DML on the store's
82
82
  * tables and EXECUTE on the API key and dashboard functions. Nothing else: no ownership, DDL,
package/ddl.js CHANGED
@@ -239,6 +239,7 @@ export const SLOTLOCK_TENANT_ADMIN_TABLES = Object.freeze([
239
239
  'tenant_suspensions',
240
240
  'webhook_tenants',
241
241
  'rate_buckets',
242
+ 'assistant_tenants',
242
243
  ]);
243
244
  /**
244
245
  * The SECURITY DEFINER functions through which a server checks a tenant's suspension and erases a
@@ -251,6 +252,8 @@ export const SLOTLOCK_TENANT_ADMIN_FUNCTIONS = Object.freeze([
251
252
  'slotlock.webhook_tenants(text)',
252
253
  'slotlock.unregister_webhook_tenant(text)',
253
254
  'slotlock.take_rate_token(text, double precision, double precision)',
255
+ 'slotlock.register_assistant_tenant(text, timestamptz)',
256
+ 'slotlock.sweep_assistant_conversations(integer)',
254
257
  ]);
255
258
  /**
256
259
  * Functions only the deployment role runs: suspending and lifting a suspension. No other role is
@@ -872,6 +875,14 @@ BEGIN
872
875
  DELETE FROM slotlock.waitlist_entries WHERE tenant_ref = requested_tenant_ref;
873
876
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
874
877
  RETURN NEXT;
878
+ erased_table := 'assistant_conversations';
879
+ DELETE FROM slotlock.assistant_conversations WHERE tenant_ref = requested_tenant_ref;
880
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
881
+ RETURN NEXT;
882
+ erased_table := 'assistant_settings';
883
+ DELETE FROM slotlock.assistant_settings WHERE tenant_ref = requested_tenant_ref;
884
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
885
+ RETURN NEXT;
875
886
  erased_table := 'resources';
876
887
  DELETE FROM slotlock.resources WHERE tenant_ref = requested_tenant_ref;
877
888
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
@@ -889,6 +900,10 @@ BEGIN
889
900
  DELETE FROM slotlock.webhook_endpoints WHERE tenant_ref = requested_tenant_ref;
890
901
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
891
902
  RETURN NEXT;
903
+ erased_table := 'assistant_tenants';
904
+ DELETE FROM slotlock.assistant_tenants WHERE tenant_ref = requested_tenant_ref;
905
+ GET DIAGNOSTICS erased_rows = ROW_COUNT;
906
+ RETURN NEXT;
892
907
  erased_table := 'webhook_tenants';
893
908
  DELETE FROM slotlock.webhook_tenants WHERE tenant_ref = requested_tenant_ref;
894
909
  GET DIAGNOSTICS erased_rows = ROW_COUNT;
@@ -1015,6 +1030,106 @@ BEGIN
1015
1030
  END
1016
1031
  $slotlock_take_rate_token$;
1017
1032
  REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[5]} FROM PUBLIC;
1033
+
1034
+ -- The tenants with a dashboard assistant conversation, and when the first of them expires. Forced
1035
+ -- row-level security hides every other tenant's conversations, from the owner too, so this list
1036
+ -- (names and a time only) is how maintenance finds the tenants whose conversations have expired,
1037
+ -- whichever server stored them: one only the dashboard (or its Worker) ever used included. No role
1038
+ -- but its owner reads it.
1039
+ CREATE TABLE IF NOT EXISTS slotlock.assistant_tenants (
1040
+ tenant_ref text PRIMARY KEY,
1041
+ next_expiry timestamptz NOT NULL,
1042
+ CONSTRAINT slotlock_assistant_tenants_tenant_ref_valid
1043
+ CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500)
1044
+ );
1045
+ CREATE INDEX IF NOT EXISTS slotlock_assistant_tenants_expiry_idx
1046
+ ON slotlock.assistant_tenants (next_expiry);
1047
+ ALTER TABLE slotlock.assistant_tenants ENABLE ROW LEVEL SECURITY;
1048
+ REVOKE ALL ON slotlock.assistant_tenants FROM PUBLIC;
1049
+
1050
+ -- Record that the tenant has a conversation expiring at expires (the earliest is kept).
1051
+ CREATE OR REPLACE FUNCTION slotlock.register_assistant_tenant(requested_tenant_ref text, expires timestamptz)
1052
+ RETURNS void
1053
+ LANGUAGE plpgsql
1054
+ VOLATILE
1055
+ SECURITY DEFINER
1056
+ SET search_path = pg_catalog, slotlock, pg_temp
1057
+ AS $slotlock_register_assistant_tenant$
1058
+ BEGIN
1059
+ IF requested_tenant_ref IS NULL OR octet_length(requested_tenant_ref) NOT BETWEEN 1 AND 500
1060
+ OR expires IS NULL THEN
1061
+ RAISE EXCEPTION 'Slotlock assistant tenant arguments are invalid' USING ERRCODE = 'invalid_parameter_value';
1062
+ END IF;
1063
+ INSERT INTO slotlock.assistant_tenants AS kept (tenant_ref, next_expiry)
1064
+ VALUES (requested_tenant_ref, expires)
1065
+ ON CONFLICT (tenant_ref) DO UPDATE SET next_expiry = LEAST(kept.next_expiry, EXCLUDED.next_expiry);
1066
+ END
1067
+ $slotlock_register_assistant_tenant$;
1068
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[6]} FROM PUBLIC;
1069
+
1070
+ -- Delete expired assistant conversations across every tenant: up to max_rows of them, from the
1071
+ -- tenants whose earliest expiry has passed, longest overdue first, each under its own tenant
1072
+ -- context (forced row-level security binds the owner too), putting the caller's settings back
1073
+ -- after. A tenant left with none leaves the list; one with more is listed at its next expiry.
1074
+ -- Only a count comes back.
1075
+ CREATE OR REPLACE FUNCTION slotlock.sweep_assistant_conversations(max_rows integer)
1076
+ RETURNS bigint
1077
+ LANGUAGE plpgsql
1078
+ VOLATILE
1079
+ SECURITY DEFINER
1080
+ SET search_path = pg_catalog, slotlock, pg_temp
1081
+ AS $slotlock_sweep_assistant_conversations$
1082
+ DECLARE
1083
+ setting text;
1084
+ previous jsonb := '{}'::jsonb;
1085
+ due record;
1086
+ deleted bigint := 0;
1087
+ step bigint;
1088
+ upcoming timestamptz;
1089
+ BEGIN
1090
+ IF max_rows IS NULL OR max_rows NOT BETWEEN 1 AND 100000 THEN
1091
+ RAISE EXCEPTION 'Slotlock assistant sweep size must be 1-100,000' USING ERRCODE = 'invalid_parameter_value';
1092
+ END IF;
1093
+ FOR setting IN
1094
+ SELECT DISTINCT contract.tenant_context_setting FROM slotlock.rls_policy_contracts contract
1095
+ LOOP
1096
+ previous := previous || jsonb_build_object(setting, current_setting(setting, true));
1097
+ END LOOP;
1098
+ FOR due IN
1099
+ SELECT listed.tenant_ref FROM slotlock.assistant_tenants listed
1100
+ WHERE listed.next_expiry <= now()
1101
+ ORDER BY listed.next_expiry
1102
+ LIMIT 1000
1103
+ FOR UPDATE SKIP LOCKED
1104
+ LOOP
1105
+ EXIT WHEN deleted >= max_rows;
1106
+ FOR setting IN SELECT jsonb_object_keys(previous) LOOP
1107
+ PERFORM set_config(setting, due.tenant_ref, true);
1108
+ END LOOP;
1109
+ DELETE FROM slotlock.assistant_conversations gone
1110
+ WHERE gone.tenant_ref = due.tenant_ref
1111
+ AND gone.member_ref IN (
1112
+ SELECT expired.member_ref FROM slotlock.assistant_conversations expired
1113
+ WHERE expired.tenant_ref = due.tenant_ref AND expired.expires_at <= now()
1114
+ LIMIT max_rows - deleted)
1115
+ AND gone.expires_at <= now();
1116
+ GET DIAGNOSTICS step = ROW_COUNT;
1117
+ deleted := deleted + step;
1118
+ SELECT min(live.expires_at) INTO upcoming FROM slotlock.assistant_conversations live
1119
+ WHERE live.tenant_ref = due.tenant_ref;
1120
+ IF upcoming IS NULL THEN
1121
+ DELETE FROM slotlock.assistant_tenants WHERE tenant_ref = due.tenant_ref;
1122
+ ELSE
1123
+ UPDATE slotlock.assistant_tenants SET next_expiry = upcoming WHERE tenant_ref = due.tenant_ref;
1124
+ END IF;
1125
+ END LOOP;
1126
+ FOR setting IN SELECT jsonb_object_keys(previous) LOOP
1127
+ PERFORM set_config(setting, coalesce(previous ->> setting, ''), true);
1128
+ END LOOP;
1129
+ RETURN deleted;
1130
+ END
1131
+ $slotlock_sweep_assistant_conversations$;
1132
+ REVOKE ALL ON FUNCTION ${SLOTLOCK_TENANT_ADMIN_FUNCTIONS[7]} FROM PUBLIC;
1018
1133
  `;
1019
1134
  // A constraint an upgrade added NOT VALID (no table scan under its ACCESS EXCLUSIVE lock) is checked
1020
1135
  // here. applySchema runs this in a transaction of its own once the DDL has committed: inside the
@@ -1206,6 +1321,54 @@ CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_queue_idx
1206
1321
  CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
1207
1322
  ON slotlock.waitlist_entries (tenant_ref, starts_at);
1208
1323
 
1324
+ -- The model a tenant's dashboard assistant uses (dashboard.ts, assistant.ts), one row a tenant.
1325
+ -- The API key is stored only sealed (AES-256-GCM under the deployment's SLOTLOCK_ASSISTANT_SECRET,
1326
+ -- bound to the tenant), with its last four characters to show.
1327
+ CREATE TABLE IF NOT EXISTS slotlock.assistant_settings (
1328
+ tenant_ref text PRIMARY KEY,
1329
+ provider text NOT NULL,
1330
+ base_url text NOT NULL,
1331
+ model text NOT NULL,
1332
+ sealed_key text,
1333
+ key_hint text,
1334
+ updated_by text,
1335
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1336
+ CONSTRAINT slotlock_assistant_settings_tenant_ref_valid CHECK (octet_length(tenant_ref) BETWEEN 1 AND 500),
1337
+ CONSTRAINT slotlock_assistant_settings_provider_valid CHECK (provider IN ('anthropic', 'openai')),
1338
+ CONSTRAINT slotlock_assistant_settings_base_url_valid CHECK (octet_length(base_url) BETWEEN 1 AND 2048),
1339
+ CONSTRAINT slotlock_assistant_settings_model_valid CHECK (octet_length(model) BETWEEN 1 AND 200),
1340
+ CONSTRAINT slotlock_assistant_settings_key_valid CHECK (
1341
+ (sealed_key IS NULL) = (key_hint IS NULL)
1342
+ AND (sealed_key IS NULL OR octet_length(sealed_key) BETWEEN 1 AND 8192)
1343
+ AND (key_hint IS NULL OR char_length(key_hint) BETWEEN 1 AND 4)
1344
+ ),
1345
+ CONSTRAINT slotlock_assistant_settings_updated_by_valid
1346
+ CHECK (updated_by IS NULL OR octet_length(updated_by) BETWEEN 1 AND 200)
1347
+ );
1348
+
1349
+ -- One member's short-lived conversation with the dashboard assistant, and the writes it proposed
1350
+ -- (actions, by id, each pending until the member approves or dismisses it).
1351
+ CREATE TABLE IF NOT EXISTS slotlock.assistant_conversations (
1352
+ tenant_ref text NOT NULL,
1353
+ member_ref text NOT NULL,
1354
+ messages jsonb NOT NULL DEFAULT '[]'::jsonb,
1355
+ actions jsonb NOT NULL DEFAULT '{}'::jsonb,
1356
+ updated_at timestamptz NOT NULL DEFAULT statement_timestamp(),
1357
+ expires_at timestamptz NOT NULL,
1358
+ CONSTRAINT slotlock_assistant_conversations_pkey PRIMARY KEY (tenant_ref, member_ref),
1359
+ CONSTRAINT slotlock_assistant_conversations_identity_valid CHECK (
1360
+ octet_length(tenant_ref) BETWEEN 1 AND 500 AND octet_length(member_ref) BETWEEN 1 AND 500
1361
+ ),
1362
+ CONSTRAINT slotlock_assistant_conversations_messages_valid CHECK (
1363
+ jsonb_typeof(messages) = 'array' AND octet_length(messages::text) <= 262144
1364
+ ),
1365
+ CONSTRAINT slotlock_assistant_conversations_actions_valid CHECK (
1366
+ jsonb_typeof(actions) = 'object' AND octet_length(actions::text) <= 131072
1367
+ )
1368
+ );
1369
+ CREATE INDEX IF NOT EXISTS slotlock_assistant_conversations_expiry_idx
1370
+ ON slotlock.assistant_conversations (tenant_ref, expires_at);
1371
+
1209
1372
  -- The tenant a reservation row belongs to: its own, or (a hold) its resource's.
1210
1373
  CREATE OR REPLACE FUNCTION slotlock.outbox_reservation_change()
1211
1374
  RETURNS trigger
@@ -2633,6 +2796,8 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
2633
2796
  'outbox_pruned',
2634
2797
  'webhook_endpoints',
2635
2798
  'waitlist_entries',
2799
+ 'assistant_settings',
2800
+ 'assistant_conversations',
2636
2801
  ]);
2637
2802
  const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
2638
2803
  const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
@@ -2702,6 +2867,10 @@ ALTER TABLE slotlock.webhook_endpoints ENABLE ROW LEVEL SECURITY;
2702
2867
  ALTER TABLE slotlock.webhook_endpoints FORCE ROW LEVEL SECURITY;
2703
2868
  ALTER TABLE slotlock.waitlist_entries ENABLE ROW LEVEL SECURITY;
2704
2869
  ALTER TABLE slotlock.waitlist_entries FORCE ROW LEVEL SECURITY;
2870
+ ALTER TABLE slotlock.assistant_settings ENABLE ROW LEVEL SECURITY;
2871
+ ALTER TABLE slotlock.assistant_settings FORCE ROW LEVEL SECURITY;
2872
+ ALTER TABLE slotlock.assistant_conversations ENABLE ROW LEVEL SECURITY;
2873
+ ALTER TABLE slotlock.assistant_conversations FORCE ROW LEVEL SECURITY;
2705
2874
 
2706
2875
  -- One database must have one tenant-context contract. Re-applying with an adapter-specific
2707
2876
  -- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
@@ -2730,7 +2899,9 @@ BEGIN
2730
2899
  'outbox',
2731
2900
  'outbox_pruned',
2732
2901
  'webhook_endpoints',
2733
- 'waitlist_entries'
2902
+ 'waitlist_entries',
2903
+ 'assistant_settings',
2904
+ 'assistant_conversations'
2734
2905
  )
2735
2906
  LOOP
2736
2907
  EXECUTE format(
@@ -2941,6 +3112,26 @@ BEGIN
2941
3112
  USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
2942
3113
  WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
2943
3114
  END IF;
3115
+
3116
+ IF NOT EXISTS (
3117
+ SELECT 1 FROM pg_policies
3118
+ WHERE schemaname = 'slotlock' AND tablename = 'assistant_settings'
3119
+ AND policyname = 'tenant_isolation_v1'
3120
+ ) THEN
3121
+ CREATE POLICY tenant_isolation_v1 ON slotlock.assistant_settings
3122
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
3123
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
3124
+ END IF;
3125
+
3126
+ IF NOT EXISTS (
3127
+ SELECT 1 FROM pg_policies
3128
+ WHERE schemaname = 'slotlock' AND tablename = 'assistant_conversations'
3129
+ AND policyname = 'tenant_isolation_v1'
3130
+ ) THEN
3131
+ CREATE POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
3132
+ USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
3133
+ WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
3134
+ END IF;
2944
3135
  END
2945
3136
  $slotlock_rls$;
2946
3137
 
@@ -2978,6 +3169,10 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.webhook_endpoints
2978
3169
  IS 'slotlock:tenant-context:${tenantContextSetting}';
2979
3170
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.waitlist_entries
2980
3171
  IS 'slotlock:tenant-context:${tenantContextSetting}';
3172
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_settings
3173
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
3174
+ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.assistant_conversations
3175
+ IS 'slotlock:tenant-context:${tenantContextSetting}';
2981
3176
 
2982
3177
  INSERT INTO slotlock.rls_policy_contracts (
2983
3178
  table_name, tenant_context_setting, policy_fingerprint, installed_at
@@ -3017,7 +3212,9 @@ WHERE schemaname = 'slotlock'
3017
3212
  'outbox',
3018
3213
  'outbox_pruned',
3019
3214
  'webhook_endpoints',
3020
- 'waitlist_entries'
3215
+ 'waitlist_entries',
3216
+ 'assistant_settings',
3217
+ 'assistant_conversations'
3021
3218
  )
3022
3219
  AND policyname = 'tenant_isolation_v1'
3023
3220
  ON CONFLICT (table_name) DO UPDATE
package/index.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  export { findNextAvailable, mergeIntervals, subtractBusy } from './engine.js';
3
3
  export { type SlotlockBookingRule, type SlotlockBookingRules, bookingRuleBroken, bookingSearch, nextStepStart, parseBookingRules, } from './booking-rules.js';
4
4
  export { type CreateWebhookEndpointResult, type ListChangesResult, SLOTLOCK_CHANGE_RETENTION_DAYS, SLOTLOCK_CHANGE_TYPES, SLOTLOCK_MAX_WEBHOOK_ENDPOINTS, SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS, type SlotlockChange, type SlotlockChangeType, type SlotlockEventFeed, type SlotlockWebhookClaim, type SlotlockWebhookEndpoint, } from './event-feed.js';
5
- export { type SlotlockCloudEvent, type SlotlockWebhookPassResult, type SlotlockWebhookResponse, type SlotlockWebhookScheduler, type SlotlockWebhookSender, createSlotlockWebhookSender, deliverSlotlockWebhooks, isPrivateAddress, signSlotlockWebhook, slotlockCloudEvent, slotlockWebhookRetryDelayMs, startSlotlockWebhookDelivery, } from './webhooks.js';
5
+ export { type SlotlockCloudEvent, type SlotlockWebhookPassResult, type SlotlockWebhookResponse, type SlotlockWebhookScheduler, type SlotlockWebhookSender, createSlotlockGuardedFetch, createSlotlockWebhookSender, deliverSlotlockWebhooks, isPrivateAddress, signSlotlockWebhook, slotlockCloudEvent, slotlockWebhookRetryDelayMs, startSlotlockWebhookDelivery, } from './webhooks.js';
6
6
  export { type SlotlockMetrics, createSlotlockMetrics } from './metrics.js';
7
7
  export { expandRules } from './rules.js';
8
8
  export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SLOTLOCK_MAX_AVAILABILITY_RULES, weeklyHoursToRules, } from './weekly-hours.js';
@@ -23,7 +23,9 @@ export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenan
23
23
  export type { SlotlockMaintenanceResult, SlotlockMaintenanceScheduler, SlotlockMaintenanceSchedulerOptions, } from './maintenance.js';
24
24
  export { createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, generateSlotlockApiKey, isSlotlockApiKey, slotlockApiKeyDigest, SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_PREFIX, SLOTLOCK_API_KEY_RETAINED_LIMIT, SLOTLOCK_API_KEY_SCOPES, } from './api-keys.js';
25
25
  export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockAcceptanceRecords, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
26
- export type { SlotlockDashboard, SlotlockDashboardOptions, SlotlockDashboardResources, SlotlockDashboardState, } from './dashboard.js';
26
+ export type { SlotlockDashboard, SlotlockDashboardAssistantOptions, SlotlockDashboardOptions, SlotlockDashboardResources, SlotlockDashboardState, } from './dashboard.js';
27
+ export { createSlotlockAssistantStore, createSlotlockMemoryAssistantStore, SLOTLOCK_ASSISTANT_TOOL_USE, } from './assistant.js';
28
+ export type { SlotlockAssistantStore } from './assistant.js';
27
29
  export type { CreateSlotlockApiKeyInput, SlotlockApiKey, SlotlockApiKeyAuthenticatorOptions, SlotlockApiKeyIdentity, SlotlockApiKeyScope, SlotlockApiKeyStore, } from './api-keys.js';
28
30
  export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
29
31
  export type { ICalendarWorkerPool, ICalendarWorkerPoolOptions } from './ical-worker.js';
package/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  export { findNextAvailable, mergeIntervals, subtractBusy } from './engine.js';
7
7
  export { bookingRuleBroken, bookingSearch, nextStepStart, parseBookingRules, } from './booking-rules.js';
8
8
  export { SLOTLOCK_CHANGE_RETENTION_DAYS, SLOTLOCK_CHANGE_TYPES, SLOTLOCK_MAX_WEBHOOK_ENDPOINTS, SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS, } from './event-feed.js';
9
- export { createSlotlockWebhookSender, deliverSlotlockWebhooks, isPrivateAddress, signSlotlockWebhook, slotlockCloudEvent, slotlockWebhookRetryDelayMs, startSlotlockWebhookDelivery, } from './webhooks.js';
9
+ export { createSlotlockGuardedFetch, createSlotlockWebhookSender, deliverSlotlockWebhooks, isPrivateAddress, signSlotlockWebhook, slotlockCloudEvent, slotlockWebhookRetryDelayMs, startSlotlockWebhookDelivery, } from './webhooks.js';
10
10
  export { createSlotlockMetrics } from './metrics.js';
11
11
  export { expandRules } from './rules.js';
12
12
  export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SLOTLOCK_MAX_AVAILABILITY_RULES, weeklyHoursToRules, } from './weekly-hours.js';
@@ -19,6 +19,7 @@ export { SLOTLOCK_APPLICATION_OPERATIONS, SLOTLOCK_APPLICATION_SCOPE, isSlotlock
19
19
  export { maintainSlotlockTenant, startSlotlockMaintenance, withSlotlockMaintenanceLock, } from './maintenance.js';
20
20
  export { createSlotlockApiKeyAuthenticator, createSlotlockApiKeyStore, generateSlotlockApiKey, isSlotlockApiKey, slotlockApiKeyDigest, SLOTLOCK_API_KEY_ACTIVE_LIMIT, SLOTLOCK_API_KEY_MAX_LIFETIME_DAYS, SLOTLOCK_API_KEY_PREFIX, SLOTLOCK_API_KEY_RETAINED_LIMIT, SLOTLOCK_API_KEY_SCOPES, } from './api-keys.js';
21
21
  export { createSlotlockDashboard, createSlotlockDashboardResources, createSlotlockAcceptanceRecords, createSlotlockDashboardState, createSlotlockMemoryDashboardState, SLOTLOCK_DASHBOARD_MAX_RESOURCES, } from './dashboard.js';
22
+ export { createSlotlockAssistantStore, createSlotlockMemoryAssistantStore, SLOTLOCK_ASSISTANT_TOOL_USE, } from './assistant.js';
22
23
  export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
23
24
  export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, recurrenceRuleProblem, storedCalendarEventProblem, } from './sync.js';
24
25
  export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_DEFINER_FUNCTIONS, SLOTLOCK_DEFINER_TABLES, SLOTLOCK_OPERATOR_FUNCTIONS, SLOTLOCK_SCHEMA_FUNCTIONS, SLOTLOCK_SCHEMA_TABLES, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_TENANT_ADMIN_FUNCTIONS, SLOTLOCK_TENANT_ADMIN_TABLES, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
package/maintenance.d.ts CHANGED
@@ -10,7 +10,8 @@ export interface SlotlockMaintenanceResult {
10
10
  /**
11
11
  * Expired holds and the answers kept for keyed ones, and past the retention window event
12
12
  * idempotency answers, agents' event cancellations, booking references retired with no
13
- * booking and feed changes, deleted. A cancelled booking's tombstone stays.
13
+ * booking and feed changes, deleted, and dashboard assistant conversations past their expiry.
14
+ * A cancelled booking's tombstone stays.
14
15
  */
15
16
  pruned: number;
16
17
  /**
package/maintenance.js CHANGED
@@ -63,6 +63,9 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
63
63
  }
64
64
  }
65
65
  options.signal?.throwIfAborted();
66
+ // Conversations last 2 hours after their last message (dashboard.ts): an expired one is
67
+ // unreadable already, and deleted here.
68
+ pruned += await store.withTenant(tenantRef, (tenant) => tenant.pruneAssistantConversations({ tenantRef }));
66
69
  const zones = await store.withTenant(tenantRef, (tenant) => tenant.followZoneRules({ tenantRef, move: options.followZoneRules === true }));
67
70
  const staleSources = await store.withTenant(tenantRef, (tenant) => tenant.reportStaleCoverage({ tenantRef }));
68
71
  // No-shows first: the time they free is offered to the waitlist in the same pass.
@@ -97,6 +100,8 @@ export async function maintainSlotlockTenant(store, tenantRef, options = {}) {
97
100
  }
98
101
  /** Pages of 50 that cover a tenant's whole waitlist (SLOTLOCK_MAX_WAITLIST_PER_TENANT). */
99
102
  const WAITLIST_PAGES = Math.ceil(SLOTLOCK_MAX_WAITLIST_PER_TENANT / 50);
103
+ /** How often the scheduler sweeps every tenant's expired assistant conversations, at most. */
104
+ const SWEEP_INTERVAL_MS = 15 * 60_000;
100
105
  /** Feed changes past the retention deleted per batch. */
101
106
  const CHANGE_PRUNE_BATCH = 1_000;
102
107
  /** The advisory lock key for one tenant's maintenance (64-bit, from the tenant reference). */
@@ -208,11 +213,28 @@ export function startSlotlockMaintenance(options) {
208
213
  working = null;
209
214
  }
210
215
  };
216
+ // Expired assistant conversations of every tenant, the ones whose keys never call included (a
217
+ // tenant only the dashboard serves): one bounded sweep at most every quarter hour.
218
+ let lastSweep = 0;
219
+ const sweep = () => {
220
+ if (controller.signal.aborted || Date.now() - lastSweep < Math.min(intervalMs, SWEEP_INTERVAL_MS))
221
+ return;
222
+ lastSweep = Date.now();
223
+ options.store.sweepAssistantConversations().catch((error) => {
224
+ try {
225
+ ignoreRejection(options.onError?.(error));
226
+ }
227
+ catch {
228
+ // Reporting must never stop maintenance.
229
+ }
230
+ });
231
+ };
211
232
  const tick = () => {
212
233
  for (const tenantRef of [...pinned.keys(), ...served.keys()]) {
213
234
  if (due(tenantRef))
214
235
  enqueue(tenantRef);
215
236
  }
237
+ sweep();
216
238
  };
217
239
  const timer = setInterval(tick, Math.min(intervalMs, 60_000));
218
240
  timer.unref();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slotlock",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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
@@ -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
@@ -718,6 +723,22 @@ export async function startSlotlockServer(config, log) {
718
723
  resources: createSlotlockDashboardResources(store),
719
724
  state: createSlotlockDashboardState(sql),
720
725
  availability: config.availability,
726
+ ...(config.dashboard.assistantSecret !== undefined
727
+ ? {
728
+ // The assistant's reads and approved writes are agent calls: the same backend,
729
+ // operations, rate limits and series-write slots as an API key's.
730
+ assistant: {
731
+ secret: config.dashboard.assistantSecret,
732
+ ...(config.dashboard.assistantPrivateUrls ? { privateUrls: true } : {}),
733
+ backend: agentBackend,
734
+ consumeRateLimit: limits.consumeRateLimit,
735
+ // Checks only: a proposal holds nothing. The series-write slot and the write
736
+ // token are taken when an approved change runs (admit, consumeRateLimit).
737
+ authorize: async ({ principal, operation }) => authorizeSlotlockServeOperation({ principal, operation }),
738
+ admit: (args) => limits.admit(args),
739
+ },
740
+ }
741
+ : {}),
721
742
  clientAddress: (request) => limits.clientAddress(request),
722
743
  onError: (error) => log.error('dashboard_error', describeError(error)),
723
744
  });
@@ -1099,6 +1120,9 @@ async function maintainTenant(config, caps) {
1099
1120
  if (pass.capped && pass.extended + pass.pruned === 0 && !pass.rollPending)
1100
1121
  break;
1101
1122
  }
1123
+ // Every tenant's expired assistant conversations, a tenant only the dashboard serves
1124
+ // included. Counted nowhere: the counts above are this tenant's.
1125
+ await store.sweepAssistantConversations();
1102
1126
  return sum;
1103
1127
  });
1104
1128
  return total ?? null;
@@ -1249,6 +1273,13 @@ Environment:
1249
1273
  of a GitHub OAuth app with callback .../dashboard/callback
1250
1274
  SLOTLOCK_GITHUB_CLIENT_SECRET That app's client secret (dashboard)
1251
1275
  SLOTLOCK_SESSION_SECRET Signs dashboard sessions, 32+ random characters (dashboard)
1276
+ SLOTLOCK_ASSISTANT_SECRET Turns on the dashboard assistant and seals each organization's
1277
+ model API key with it, 32+ random characters (dashboard; off
1278
+ without it)
1279
+ SLOTLOCK_ASSISTANT_PRIVATE_URLS off (default): refuse an assistant model URL whose host is, or
1280
+ resolves to, a loopback, private, link-local, CGNAT, unique-local
1281
+ or metadata address; on: allow them, for a self-hosted model
1282
+ (Ollama, vLLM) on this machine or network
1252
1283
  SLOTLOCK_DASHBOARD_USERS * or comma-separated GitHub user ids that may sign in (dashboard)
1253
1284
  SLOTLOCK_DASHBOARD_ORGS comma-separated GitHub organization ids: each is one shared tenant
1254
1285
  SLOTLOCK_DASHBOARD_NAME What the dashboard calls this deployment (default Slotlock)
@@ -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.2",
7
7
  "description": "Resource-first, deterministic calendar and availability engine for AI agents.",
8
8
  "intervalSemantics": {
9
9
  "boundary": "[)",