slotlock 0.5.0 → 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
@@ -1094,6 +1209,8 @@ CREATE INDEX IF NOT EXISTS slotlock_outbox_tenant_time_idx
1094
1209
  DO $slotlock_outbox_pruned$
1095
1210
  BEGIN
1096
1211
  IF pg_catalog.to_regclass('slotlock.outbox_pruned') IS NULL THEN
1212
+ -- No change is deleted between reading what is kept and the mark it starts at.
1213
+ LOCK TABLE slotlock.outbox IN SHARE ROW EXCLUSIVE MODE;
1097
1214
  CREATE TABLE IF NOT EXISTS slotlock.outbox_pruned (
1098
1215
  tenant_ref text PRIMARY KEY,
1099
1216
  txid xid8 NOT NULL,
@@ -1128,8 +1245,7 @@ BEGIN
1128
1245
  RETURN NULL;
1129
1246
  END
1130
1247
  $slotlock_outbox_mark_pruned$;
1131
- DROP TRIGGER IF EXISTS slotlock_outbox_mark_pruned ON slotlock.outbox;
1132
- CREATE TRIGGER slotlock_outbox_mark_pruned
1248
+ CREATE OR REPLACE TRIGGER slotlock_outbox_mark_pruned
1133
1249
  AFTER DELETE ON slotlock.outbox REFERENCING OLD TABLE AS pruned
1134
1250
  FOR EACH STATEMENT EXECUTE FUNCTION slotlock.outbox_mark_pruned();
1135
1251
 
@@ -1205,6 +1321,54 @@ CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_queue_idx
1205
1321
  CREATE INDEX IF NOT EXISTS slotlock_waitlist_entries_tenant_idx
1206
1322
  ON slotlock.waitlist_entries (tenant_ref, starts_at);
1207
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
+
1208
1372
  -- The tenant a reservation row belongs to: its own, or (a hold) its resource's.
1209
1373
  CREATE OR REPLACE FUNCTION slotlock.outbox_reservation_change()
1210
1374
  RETURNS trigger
@@ -2632,6 +2796,8 @@ export const SLOTLOCK_TENANT_TABLES = Object.freeze([
2632
2796
  'outbox_pruned',
2633
2797
  'webhook_endpoints',
2634
2798
  'waitlist_entries',
2799
+ 'assistant_settings',
2800
+ 'assistant_conversations',
2635
2801
  ]);
2636
2802
  const POSTGRES_SETTING_NAME = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
2637
2803
  const POSTGRES_ROLE_NAME = /^[a-z_][a-z0-9_]{0,62}$/;
@@ -2701,6 +2867,10 @@ ALTER TABLE slotlock.webhook_endpoints ENABLE ROW LEVEL SECURITY;
2701
2867
  ALTER TABLE slotlock.webhook_endpoints FORCE ROW LEVEL SECURITY;
2702
2868
  ALTER TABLE slotlock.waitlist_entries ENABLE ROW LEVEL SECURITY;
2703
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;
2704
2874
 
2705
2875
  -- One database must have one tenant-context contract. Re-applying with an adapter-specific
2706
2876
  -- setting deliberately rebinds the stable policies instead of silently keeping a prior setting.
@@ -2729,7 +2899,9 @@ BEGIN
2729
2899
  'outbox',
2730
2900
  'outbox_pruned',
2731
2901
  'webhook_endpoints',
2732
- 'waitlist_entries'
2902
+ 'waitlist_entries',
2903
+ 'assistant_settings',
2904
+ 'assistant_conversations'
2733
2905
  )
2734
2906
  LOOP
2735
2907
  EXECUTE format(
@@ -2940,6 +3112,26 @@ BEGIN
2940
3112
  USING (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''))
2941
3113
  WITH CHECK (tenant_ref = NULLIF(current_setting('${tenantContextSetting}', true), ''));
2942
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;
2943
3135
  END
2944
3136
  $slotlock_rls$;
2945
3137
 
@@ -2977,6 +3169,10 @@ COMMENT ON POLICY tenant_isolation_v1 ON slotlock.webhook_endpoints
2977
3169
  IS 'slotlock:tenant-context:${tenantContextSetting}';
2978
3170
  COMMENT ON POLICY tenant_isolation_v1 ON slotlock.waitlist_entries
2979
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}';
2980
3176
 
2981
3177
  INSERT INTO slotlock.rls_policy_contracts (
2982
3178
  table_name, tenant_context_setting, policy_fingerprint, installed_at
@@ -3016,7 +3212,9 @@ WHERE schemaname = 'slotlock'
3016
3212
  'outbox',
3017
3213
  'outbox_pruned',
3018
3214
  'webhook_endpoints',
3019
- 'waitlist_entries'
3215
+ 'waitlist_entries',
3216
+ 'assistant_settings',
3217
+ 'assistant_conversations'
3020
3218
  )
3021
3219
  AND policyname = 'tenant_isolation_v1'
3022
3220
  ON CONFLICT (table_name) DO UPDATE
package/event-feed.js CHANGED
@@ -36,13 +36,18 @@ export const SLOTLOCK_MAX_WEBHOOK_ENDPOINTS = 10;
36
36
  /** After this long failing, an endpoint is disabled and the feed says so (`webhook.disabled`). */
37
37
  export const SLOTLOCK_WEBHOOK_FAILING_LIMIT_MS = 24 * 60 * 60 * 1_000;
38
38
  function encodePosition(position) {
39
- return Buffer.from(`1:${position.txid}:${position.id}:${position.at}`).toString('base64url');
39
+ return Buffer.from(`2:${position.txid}:${position.id}:${position.at}`).toString('base64url');
40
40
  }
41
41
  function decodePosition(value) {
42
42
  if (value.length > 200 || !/^[A-Za-z0-9_-]+$/.test(value))
43
43
  return null;
44
- const match = /^1:(\d{1,20}):(\d{1,19}):(\d{1,15})$/.exec(Buffer.from(value, 'base64url').toString());
45
- return match ? { txid: match[1], id: match[2], at: Number(match[3]) } : null;
44
+ const match = /^([12]):(\d{1,20}):(\d{1,19}):(\d{1,15})$/.exec(Buffer.from(value, 'base64url').toString());
45
+ if (!match)
46
+ return null;
47
+ const position = { txid: match[2], id: match[3], at: Number(match[4]) };
48
+ if (match[1] === '1')
49
+ position.dated = true;
50
+ return position;
46
51
  }
47
52
  function toChange(row) {
48
53
  return {
@@ -131,6 +136,9 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
131
136
  AND txid < pg_snapshot_xmin(pg_current_snapshot())
132
137
  AND (${after?.txid ?? null}::xid8 IS NULL
133
138
  OR (txid, id) > (${after?.txid ?? null}::xid8, ${after?.id ?? null}::bigint))
139
+ -- Starting over: from the mark on. A change kept before it (one another server
140
+ -- left when it pruned past it) would end a page that is expired at once.
141
+ AND (mark.txid IS NULL OR (txid, id) > (mark.txid, mark.id))
134
142
  ORDER BY txid, id
135
143
  LIMIT ${limit}
136
144
  ) page ON true
@@ -139,11 +147,15 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
139
147
  if (!head)
140
148
  throw new Error('slotlock: change page returned no row');
141
149
  // Whichever server pruned it, and however long it keeps changes: reading on could skip one.
150
+ // A 0.4 cursor also by its date, as 0.4 did: 0.4 pruned by age, not in feed order, so the
151
+ // mark its changes started at does not cover every one it took.
142
152
  if (after &&
143
- head.pruned_txid !== null &&
144
- (BigInt(head.pruned_txid) > BigInt(after.txid) ||
145
- (BigInt(head.pruned_txid) === BigInt(after.txid) &&
146
- BigInt(head.pruned_id ?? 0) > BigInt(after.id)))) {
153
+ ((head.pruned_txid !== null &&
154
+ (BigInt(head.pruned_txid) > BigInt(after.txid) ||
155
+ (BigInt(head.pruned_txid) === BigInt(after.txid) &&
156
+ BigInt(head.pruned_id ?? 0) > BigInt(after.id)))) ||
157
+ (after.dated === true &&
158
+ after.at < Date.now() - SLOTLOCK_CHANGE_RETENTION_DAYS * 86_400_000))) {
147
159
  return { ok: false, code: 'cursor_expired' };
148
160
  }
149
161
  const changes = rows.flatMap((row) => (row.id === null ? [] : [toChange(row)]));
@@ -154,7 +166,7 @@ export function slotlockEventFeedMethods(sql, retentionDays = SLOTLOCK_CHANGE_RE
154
166
  return {
155
167
  ok: true,
156
168
  changes,
157
- next: last?.position ?? encodePosition({ ...here, at: Date.now() }),
169
+ next: last?.position ?? encodePosition({ txid: here.txid, id: here.id, at: Date.now() }),
158
170
  };
159
171
  },
160
172
  async listRecentChanges(params) {
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.0",
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