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/CHANGELOG.md +37 -0
- package/README.md +55 -1
- package/agent-server.d.ts +14 -0
- package/agent-server.js +15 -0
- package/assistant.d.ts +263 -0
- package/assistant.js +771 -0
- package/config.d.ts +10 -0
- package/config.js +11 -0
- package/dashboard.d.ts +61 -0
- package/dashboard.js +589 -11
- package/ddl.d.ts +5 -5
- package/ddl.js +202 -4
- package/event-feed.js +20 -8
- package/index.d.ts +4 -2
- package/index.js +2 -1
- package/maintenance.d.ts +2 -1
- package/maintenance.js +22 -0
- package/package.json +1 -1
- package/rate-limit.d.ts +8 -0
- package/rate-limit.js +20 -0
- package/self-host.d.ts +2 -2
- package/self-host.js +34 -3
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +101 -1
- package/store.js +158 -0
- package/webhooks.d.ts +24 -0
- package/webhooks.js +96 -19
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
|
-
|
|
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(`
|
|
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 = /^
|
|
45
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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({
|
|
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
|
|
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
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
|