@promptowl/contextnest-community 1.8.0 → 1.10.0

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.
@@ -2,6 +2,11 @@ import {
2
2
  ANON_USER_ID
3
3
  } from "./chunk-SLTQACJW.js";
4
4
 
5
+ // src/db/client.ts
6
+ import Database from "better-sqlite3";
7
+ import { mkdirSync, readFileSync } from "fs";
8
+ import { dirname as dirname2 } from "path";
9
+
5
10
  // src/config.ts
6
11
  import { join, dirname } from "path";
7
12
  import { existsSync } from "fs";
@@ -21,6 +26,14 @@ if (envFileLoaded && !isTestRun) {
21
26
  var canonicalEnvFile = process.env.ENV_FILE_PATH || join(dataRoot(), ".env");
22
27
  var canonicalEnvLoaded = null;
23
28
  var slackUrlWarned = false;
29
+ var emailFromWarned = false;
30
+ var emailToWarned = false;
31
+ function isEmailish(v) {
32
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) && !/[\r\n]/.test(v);
33
+ }
34
+ function isEmailListish(v) {
35
+ return v.split(",").every((e) => isEmailish(e.trim()));
36
+ }
24
37
  if (!isTestRun && canonicalEnvFile !== envFileLoaded && existsSync(canonicalEnvFile)) {
25
38
  dotenv.config({ path: canonicalEnvFile, override: true });
26
39
  canonicalEnvLoaded = canonicalEnvFile;
@@ -111,6 +124,18 @@ var config = {
111
124
  get PROMPTOWL_KEY() {
112
125
  return process.env.PROMPTOWL_KEY || "";
113
126
  },
127
+ /** Vercel automation-bypass secret for PO calls; unset = no bypass. */
128
+ get PROMPTOWL_BYPASS_SECRET() {
129
+ return process.env.PROMPTOWL_BYPASS_SECRET || "";
130
+ },
131
+ /** Outbound headers for PO fetches; bypass header only when secret set. */
132
+ get PROMPTOWL_FETCH_HEADERS() {
133
+ const h = { "Content-Type": "application/json" };
134
+ if (this.PROMPTOWL_BYPASS_SECRET) {
135
+ h["x-vercel-protection-bypass"] = this.PROMPTOWL_BYPASS_SECRET;
136
+ }
137
+ return h;
138
+ },
114
139
  /**
115
140
  * Shared secret for one-click SSO auto-login from PromptOwl (PO).
116
141
  *
@@ -151,6 +176,24 @@ var config = {
151
176
  const v = (process.env.PROMPTOWL_SIGN_IN_GATE || "open").trim().toLowerCase();
152
177
  return v === "admin-only" || v === "disabled" ? v : "open";
153
178
  },
179
+ /**
180
+ * Manual (email + password) sign-in. Three modes, independent of
181
+ * `PROMPTOWL_SIGN_IN_GATE` (the two methods are controlled separately):
182
+ * "open" — anyone may log in and self-register (default).
183
+ * "invite-only" — existing + admin-invited users may log in and an invited
184
+ * placeholder may set its password ("claim"), but brand-new
185
+ * self-registration is refused. Use this when the admin
186
+ * provisions accounts (invite / share / steward) and users
187
+ * shouldn't be able to create their own.
188
+ * "disabled" — no email/password sign-in at all (PromptOwl only).
189
+ * Enforced at POST /auth/login and POST /auth/register. The /admin/settings
190
+ * validator refuses "disabled" while PromptOwl is also disabled, so a server
191
+ * can never be left with no way to sign in.
192
+ */
193
+ get MANUAL_SIGN_IN() {
194
+ const v = (process.env.MANUAL_SIGN_IN || "open").trim().toLowerCase();
195
+ return v === "invite-only" || v === "disabled" ? v : "open";
196
+ },
154
197
  /**
155
198
  * Path to the .env file the server reads its config from and the license
156
199
  * install flow persists PROMPTOWL_KEY into. Defaults UNDER DATA_ROOT (not
@@ -168,6 +211,18 @@ var config = {
168
211
  get TELEMETRY_INTERVAL_MS() {
169
212
  return parseInt(process.env.TELEMETRY_INTERVAL_MS || "3600000", 10);
170
213
  },
214
+ /**
215
+ * Activity-trace retention window in days (the api_events table behind
216
+ * GET /admin/trace and GET /nests/:id/trace). 0 = keep forever (pruning is
217
+ * skipped entirely). Invalid / negative → default 14; capped at 3650 (ten
218
+ * years) so a typo can't schedule a prune cutoff in the distant past.
219
+ * Editable at runtime from the admin Settings page (/admin/settings).
220
+ */
221
+ get TRACE_RETENTION_DAYS() {
222
+ const n = parseInt(process.env.TRACE_RETENTION_DAYS || "14", 10);
223
+ if (!Number.isFinite(n) || n < 0) return 14;
224
+ return Math.min(n, 3650);
225
+ },
171
226
  /**
172
227
  * Optional custom logo URL shown in UI header + login screen.
173
228
  * Must be an absolute https://, http://, or data:image/… URL. Other
@@ -187,14 +242,17 @@ var config = {
187
242
  }
188
243
  return raw;
189
244
  },
190
- get AUTH_MODE() {
191
- return process.env.AUTH_MODE || "key";
192
- },
245
+ /**
246
+ * Workflow plane ("turing" track): typed edges, edge-type registry, runs.
247
+ * OFF by default — an optional feature an admin turns on (env or Settings).
248
+ * This flag is also the future license-tier hook: gating a paid tier here
249
+ * is a one-line change because every plane route checks it per request.
250
+ */
193
251
  /**
194
252
  * Optional Slack incoming-webhook URL for team notifications (review
195
- * submitted/approved/rejected, nest shared). Empty/unset = connector off.
196
- * https only — a webhook carries an implicit secret in its path, so it
197
- * never travels plaintext.
253
+ * submitted/approved/rejected). Empty/unset = connector off. https only —
254
+ * a webhook carries an implicit secret in its path, so it never travels
255
+ * plaintext. (Ported from development PR #105.)
198
256
  */
199
257
  get SLACK_WEBHOOK_URL() {
200
258
  const raw = process.env.SLACK_WEBHOOK_URL?.trim();
@@ -210,6 +268,114 @@ var config = {
210
268
  }
211
269
  return raw;
212
270
  },
271
+ get FEATURE_WORKFLOW_PLANE() {
272
+ return process.env.FEATURE_WORKFLOW_PLANE === "true";
273
+ },
274
+ /**
275
+ * Sub-agent runs: lets a run spawn nested runs (an external runner calling
276
+ * back into POST /run for a child agent). Gated SEPARATELY from the base
277
+ * plane because recursion is the risky surface — unbounded fan-out,
278
+ * re-entrancy, resource exhaustion. Off by default; must clear a security
279
+ * review before an operator turns it on. Requires FEATURE_WORKFLOW_PLANE too.
280
+ */
281
+ get FEATURE_SUBAGENT_RUNS() {
282
+ return process.env.FEATURE_SUBAGENT_RUNS === "true";
283
+ },
284
+ /** Hard ceiling on sub-agent nesting depth — keeps the call tree finite
285
+ * (haltable / statically bounded). Clamped to [1, 32]. */
286
+ get SUBAGENT_MAX_DEPTH() {
287
+ const n = parseInt(process.env.SUBAGENT_MAX_DEPTH || "8", 10);
288
+ return Number.isFinite(n) ? Math.min(32, Math.max(1, n)) : 8;
289
+ },
290
+ /** Max direct children a single run may spawn. Depth bounds the tree's
291
+ * height; this bounds its width — together they cap total size so a
292
+ * runner can't fork-bomb the DB. Clamped to [1, 128]. */
293
+ get SUBAGENT_MAX_CHILDREN() {
294
+ const n = parseInt(process.env.SUBAGENT_MAX_CHILDREN || "16", 10);
295
+ return Number.isFinite(n) ? Math.min(128, Math.max(1, n)) : 16;
296
+ },
297
+ /** Max steps a single run may accumulate — bounds a runaway/hostile
298
+ * runner's step log. Clamped to [10, 100000]. */
299
+ get RUN_MAX_STEPS() {
300
+ const n = parseInt(process.env.RUN_MAX_STEPS || "10000", 10);
301
+ return Number.isFinite(n) ? Math.min(1e5, Math.max(10, n)) : 1e4;
302
+ },
303
+ /** Max concurrently-RUNNING root (depth-0) runs per nest. The subagent
304
+ * depth/fan-out caps bound a single tree; this bounds how many trees a
305
+ * caller can start at once, so root triggers can't flood the nest. */
306
+ get RUN_MAX_CONCURRENT_ROOTS() {
307
+ const n = parseInt(process.env.RUN_MAX_CONCURRENT_ROOTS || "50", 10);
308
+ return Number.isFinite(n) ? Math.min(1e3, Math.max(1, n)) : 50;
309
+ },
310
+ get AUTH_MODE() {
311
+ return process.env.AUTH_MODE || "key";
312
+ },
313
+ /**
314
+ * Optional SMTP connection URL for email notifications, e.g.
315
+ * smtps://user:pass@smtp.example.com:465. smtp:// or smtps:// only.
316
+ * Empty/unset = email connector off. Requires NOTIFY_EMAIL_FROM/_TO.
317
+ */
318
+ get SMTP_URL() {
319
+ const raw = process.env.SMTP_URL?.trim();
320
+ if (!raw) return null;
321
+ if (!/^smtps?:\/\//i.test(raw)) {
322
+ console.warn(
323
+ "[config] SMTP_URL rejected: must be an smtp:// or smtps:// URL. Email notifications disabled."
324
+ );
325
+ return null;
326
+ }
327
+ return raw;
328
+ },
329
+ /**
330
+ * From address for notification emails. Shape-validated here (not only in
331
+ * the /admin/settings PATCH) so a value set directly via env/Docker can't
332
+ * reach nodemailer's envelope unchecked. Invalid = connector off.
333
+ */
334
+ get NOTIFY_EMAIL_FROM() {
335
+ const raw = process.env.NOTIFY_EMAIL_FROM?.trim();
336
+ if (!raw) return null;
337
+ if (!isEmailish(raw)) {
338
+ if (!emailFromWarned) {
339
+ emailFromWarned = true;
340
+ console.warn(
341
+ "[config] NOTIFY_EMAIL_FROM rejected: must be a plain email address. Email notifications disabled."
342
+ );
343
+ }
344
+ return null;
345
+ }
346
+ return raw;
347
+ },
348
+ /**
349
+ * Comma-separated recipient list for notification emails. Shape-validated
350
+ * here for the same env-bypass reason as NOTIFY_EMAIL_FROM. Invalid (any
351
+ * entry malformed) = connector off.
352
+ */
353
+ get NOTIFY_EMAIL_TO() {
354
+ const raw = process.env.NOTIFY_EMAIL_TO?.trim();
355
+ if (!raw) return null;
356
+ if (!isEmailListish(raw)) {
357
+ if (!emailToWarned) {
358
+ emailToWarned = true;
359
+ console.warn(
360
+ "[config] NOTIFY_EMAIL_TO rejected: must be a comma-separated list of email addresses. Email notifications disabled."
361
+ );
362
+ }
363
+ return null;
364
+ }
365
+ return raw;
366
+ },
367
+ /**
368
+ * Per-nest notification digest window in milliseconds. A burst of governance
369
+ * events on one nest inside this window collapses into a single digest
370
+ * message instead of one post per event. Empty / non-numeric / negative /
371
+ * unset → 15000 (15s).
372
+ */
373
+ get NOTIFY_DEBOUNCE_MS() {
374
+ const raw = process.env.NOTIFY_DEBOUNCE_MS?.trim();
375
+ if (!raw) return 15e3;
376
+ const n = Number(raw);
377
+ return Number.isFinite(n) && n >= 0 ? n : 15e3;
378
+ },
213
379
  /**
214
380
  * CORS origin allowlist. Comma-separated list of origins or "*".
215
381
  * Defaults to "*" in open mode (read-heavy, no credentials to steal)
@@ -231,11 +397,6 @@ var config = {
231
397
  }
232
398
  };
233
399
 
234
- // src/db/client.ts
235
- import Database from "better-sqlite3";
236
- import { mkdirSync, readFileSync } from "fs";
237
- import { dirname as dirname2 } from "path";
238
-
239
400
  // src/db/migrations.ts
240
401
  function runMigrations(db) {
241
402
  db.exec(`
@@ -802,6 +963,27 @@ function runMigrations(db) {
802
963
  })();
803
964
  recordMigration("012_merge_case_colliding_users");
804
965
  }
966
+ if (!hasMigration("019_grants")) {
967
+ db.transaction(() => {
968
+ db.exec(`
969
+ CREATE TABLE IF NOT EXISTS grants (
970
+ id TEXT PRIMARY KEY,
971
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
972
+ target_type TEXT NOT NULL CHECK(target_type IN ('document', 'folder')),
973
+ target TEXT NOT NULL, -- node id (document) or path prefix (folder)
974
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
975
+ role TEXT NOT NULL CHECK(role IN ('read', 'write')),
976
+ granted_by TEXT NOT NULL,
977
+ created_at TEXT NOT NULL,
978
+ updated_at TEXT NOT NULL
979
+ );
980
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_grants_unique
981
+ ON grants(nest_id, target_type, target, user_id);
982
+ CREATE INDEX IF NOT EXISTS idx_grants_user ON grants(nest_id, user_id);
983
+ `);
984
+ })();
985
+ recordMigration("019_grants");
986
+ }
805
987
  if (!hasMigration("013_node_deletion_tombstones")) {
806
988
  db.transaction(() => {
807
989
  db.exec(`
@@ -839,6 +1021,246 @@ function runMigrations(db) {
839
1021
  })();
840
1022
  recordMigration("014_api_events");
841
1023
  }
1024
+ if (!hasMigration("015_definitions")) {
1025
+ db.transaction(() => {
1026
+ db.exec(`
1027
+ CREATE TABLE IF NOT EXISTS definitions (
1028
+ id TEXT PRIMARY KEY,
1029
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1030
+ term TEXT NOT NULL,
1031
+ definition TEXT NOT NULL,
1032
+ linked_tag TEXT, -- optional #tag this term maps to
1033
+ defined_by TEXT NOT NULL, -- email
1034
+ created_at TEXT NOT NULL,
1035
+ updated_at TEXT NOT NULL
1036
+ );
1037
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_definitions_term
1038
+ ON definitions(nest_id, term COLLATE NOCASE);
1039
+ `);
1040
+ })();
1041
+ recordMigration("015_definitions");
1042
+ }
1043
+ if (!hasMigration("016_workflow_edges")) {
1044
+ db.transaction(() => {
1045
+ db.exec(`
1046
+ CREATE TABLE IF NOT EXISTS edge_types (
1047
+ id TEXT PRIMARY KEY,
1048
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1049
+ name TEXT NOT NULL,
1050
+ description TEXT NOT NULL,
1051
+ direction TEXT NOT NULL DEFAULT 'directed'
1052
+ CHECK(direction IN ('directed', 'undirected')),
1053
+ is_flow INTEGER NOT NULL DEFAULT 0, -- flow types form the DAG
1054
+ condition_schema TEXT, -- JSON template | NULL
1055
+ color TEXT,
1056
+ created_by TEXT NOT NULL,
1057
+ created_at TEXT NOT NULL,
1058
+ updated_at TEXT NOT NULL
1059
+ );
1060
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_edge_types_name
1061
+ ON edge_types(nest_id, LOWER(name));
1062
+
1063
+ CREATE TABLE IF NOT EXISTS edges (
1064
+ id TEXT PRIMARY KEY,
1065
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1066
+ from_node TEXT NOT NULL,
1067
+ to_node TEXT NOT NULL,
1068
+ -- RESTRICT (not CASCADE): the app refuses to delete an edge type
1069
+ -- while edges reference it; the DB backs that invariant instead of
1070
+ -- silently cascade-deleting live edges if the app check is bypassed.
1071
+ type_id TEXT NOT NULL REFERENCES edge_types(id) ON DELETE RESTRICT,
1072
+ condition_mode TEXT
1073
+ CHECK(condition_mode IN ('structured', 'nl', 'open')),
1074
+ condition TEXT,
1075
+ metadata TEXT,
1076
+ created_by TEXT NOT NULL,
1077
+ created_at TEXT NOT NULL,
1078
+ updated_at TEXT NOT NULL
1079
+ );
1080
+ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(nest_id, from_node);
1081
+ CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(nest_id, to_node);
1082
+ CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(nest_id, type_id);
1083
+ `);
1084
+ })();
1085
+ recordMigration("016_workflow_edges");
1086
+ }
1087
+ if (!hasMigration("017_workflow_runs")) {
1088
+ db.transaction(() => {
1089
+ db.exec(`
1090
+ CREATE TABLE IF NOT EXISTS runs (
1091
+ id TEXT PRIMARY KEY,
1092
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1093
+ agent_node TEXT NOT NULL,
1094
+ triggered_by TEXT NOT NULL,
1095
+ status TEXT NOT NULL DEFAULT 'running'
1096
+ CHECK(status IN ('running', 'succeeded', 'failed', 'cancelled')),
1097
+ inputs TEXT,
1098
+ trace TEXT,
1099
+ started_at TEXT NOT NULL,
1100
+ finished_at TEXT
1101
+ );
1102
+ CREATE INDEX IF NOT EXISTS idx_runs_nest ON runs(nest_id, started_at);
1103
+ CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(nest_id, agent_node);
1104
+
1105
+ CREATE TABLE IF NOT EXISTS run_steps (
1106
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1107
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
1108
+ seq INTEGER NOT NULL,
1109
+ node_id TEXT,
1110
+ edge_id TEXT,
1111
+ action TEXT NOT NULL,
1112
+ detail TEXT,
1113
+ at TEXT NOT NULL
1114
+ );
1115
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_steps ON run_steps(run_id, seq);
1116
+ `);
1117
+ })();
1118
+ recordMigration("017_workflow_runs");
1119
+ }
1120
+ if (!hasMigration("018_subagent_runs")) {
1121
+ db.transaction(() => {
1122
+ db.exec(`
1123
+ ALTER TABLE runs ADD COLUMN parent_run_id TEXT;
1124
+ ALTER TABLE runs ADD COLUMN depth INTEGER NOT NULL DEFAULT 0;
1125
+ CREATE INDEX IF NOT EXISTS idx_runs_parent ON runs(parent_run_id);
1126
+ `);
1127
+ })();
1128
+ recordMigration("018_subagent_runs");
1129
+ }
1130
+ if (!hasMigration("020_server_settings")) {
1131
+ db.transaction(() => {
1132
+ db.exec(`
1133
+ CREATE TABLE IF NOT EXISTS server_settings (
1134
+ key TEXT PRIMARY KEY,
1135
+ value TEXT,
1136
+ env_at_write TEXT,
1137
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1138
+ );
1139
+ `);
1140
+ recordMigration("020_server_settings");
1141
+ })();
1142
+ }
1143
+ if (!hasMigration("020_schedules")) {
1144
+ db.transaction(() => {
1145
+ db.exec(`
1146
+ CREATE TABLE IF NOT EXISTS schedules (
1147
+ id TEXT PRIMARY KEY,
1148
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1149
+ agent_node TEXT NOT NULL,
1150
+ every_minutes INTEGER NOT NULL CHECK(every_minutes >= 5),
1151
+ enabled INTEGER NOT NULL DEFAULT 1,
1152
+ last_run_at TEXT,
1153
+ created_by TEXT NOT NULL,
1154
+ created_at TEXT NOT NULL,
1155
+ updated_at TEXT NOT NULL
1156
+ );
1157
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_agent
1158
+ ON schedules(nest_id, agent_node);
1159
+ `);
1160
+ })();
1161
+ recordMigration("020_schedules");
1162
+ }
1163
+ if (!hasMigration("021_schedule_bounds")) {
1164
+ db.transaction(() => {
1165
+ db.exec(`
1166
+ ALTER TABLE schedules ADD COLUMN max_runs INTEGER;
1167
+ ALTER TABLE schedules ADD COLUMN runs_created INTEGER NOT NULL DEFAULT 0;
1168
+ `);
1169
+ })();
1170
+ recordMigration("021_schedule_bounds");
1171
+ }
1172
+ if (!hasMigration("022_tools_and_watchers")) {
1173
+ db.transaction(() => {
1174
+ db.exec(`
1175
+ CREATE TABLE IF NOT EXISTS nest_env (
1176
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1177
+ key TEXT NOT NULL,
1178
+ value TEXT NOT NULL,
1179
+ updated_by TEXT NOT NULL,
1180
+ updated_at TEXT NOT NULL,
1181
+ PRIMARY KEY (nest_id, key)
1182
+ );
1183
+ CREATE TABLE IF NOT EXISTS watchers (
1184
+ id TEXT PRIMARY KEY,
1185
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1186
+ node_id TEXT NOT NULL,
1187
+ user_email TEXT NOT NULL,
1188
+ created_by TEXT NOT NULL,
1189
+ created_at TEXT NOT NULL
1190
+ );
1191
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_watchers_unique
1192
+ ON watchers(nest_id, node_id, user_email);
1193
+ CREATE TABLE IF NOT EXISTS notifications (
1194
+ id TEXT PRIMARY KEY,
1195
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1196
+ user_email TEXT NOT NULL,
1197
+ kind TEXT NOT NULL,
1198
+ subject_id TEXT,
1199
+ message TEXT NOT NULL,
1200
+ created_at TEXT NOT NULL,
1201
+ read_at TEXT
1202
+ );
1203
+ CREATE INDEX IF NOT EXISTS idx_notifications_user
1204
+ ON notifications(user_email, read_at, created_at);
1205
+ `);
1206
+ })();
1207
+ recordMigration("022_tools_and_watchers");
1208
+ }
1209
+ if (!hasMigration("023_connectors")) {
1210
+ db.transaction(() => {
1211
+ db.exec(`
1212
+ CREATE TABLE IF NOT EXISTS connectors (
1213
+ id TEXT PRIMARY KEY,
1214
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1215
+ channel TEXT NOT NULL CHECK(channel IN ('slack', 'teams', 'webhook')),
1216
+ url TEXT NOT NULL, -- https://\u2026 or env:KEY
1217
+ events TEXT NOT NULL, -- JSON array of kinds, or ["*"]
1218
+ enabled INTEGER NOT NULL DEFAULT 1,
1219
+ created_by TEXT NOT NULL,
1220
+ created_at TEXT NOT NULL,
1221
+ updated_at TEXT NOT NULL
1222
+ );
1223
+ CREATE INDEX IF NOT EXISTS idx_connectors_nest ON connectors(nest_id);
1224
+ `);
1225
+ })();
1226
+ recordMigration("023_connectors");
1227
+ }
1228
+ if (!hasMigration("024_trigger_hooks")) {
1229
+ db.transaction(() => {
1230
+ db.exec(`
1231
+ CREATE TABLE IF NOT EXISTS trigger_hooks (
1232
+ id TEXT PRIMARY KEY, -- the token (hk_\u2026), capability auth
1233
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1234
+ agent_node TEXT NOT NULL,
1235
+ preset TEXT NOT NULL CHECK(preset IN ('slack', 'teams', 'webhook')),
1236
+ enabled INTEGER NOT NULL DEFAULT 1,
1237
+ fire_count INTEGER NOT NULL DEFAULT 0,
1238
+ last_fired_at TEXT,
1239
+ created_by TEXT NOT NULL,
1240
+ created_at TEXT NOT NULL
1241
+ );
1242
+ CREATE INDEX IF NOT EXISTS idx_hooks_nest ON trigger_hooks(nest_id);
1243
+ `);
1244
+ })();
1245
+ recordMigration("024_trigger_hooks");
1246
+ }
1247
+ if (!hasMigration("025_run_claims")) {
1248
+ db.transaction(() => {
1249
+ db.exec(`
1250
+ ALTER TABLE runs ADD COLUMN claimed_by TEXT;
1251
+ ALTER TABLE runs ADD COLUMN claimed_at TEXT;
1252
+ `);
1253
+ })();
1254
+ recordMigration("025_run_claims");
1255
+ }
1256
+ if (!hasMigration("026_api_events_nest_index")) {
1257
+ db.transaction(() => {
1258
+ db.exec(`
1259
+ CREATE INDEX IF NOT EXISTS idx_api_events_nest_ts ON api_events(nest_id, id);
1260
+ `);
1261
+ })();
1262
+ recordMigration("026_api_events_nest_index");
1263
+ }
842
1264
  }
843
1265
  function mergeCaseCollidingUsers(db) {
844
1266
  const groups = db.prepare(
@@ -1056,7 +1478,7 @@ async function initDb() {
1056
1478
  if (config.DB_DRIVER === "postgres") {
1057
1479
  const { Pool } = await import("pg");
1058
1480
  const { PostgresAdapter } = await import("./adapter.postgres-YOODX2BI.js");
1059
- const { runPostgresMigrations } = await import("./migrations.postgres-VCG45LMK.js");
1481
+ const { runPostgresMigrations } = await import("./migrations.postgres-4XYY3CTF.js");
1060
1482
  const pool = new Pool(buildPgConfig());
1061
1483
  adapter = new PostgresAdapter(pool);
1062
1484
  await runPostgresMigrations(adapter);
@@ -1076,23 +1498,18 @@ function getDb() {
1076
1498
  }
1077
1499
  return adapter;
1078
1500
  }
1079
-
1080
- // src/db/sql.ts
1081
- function nowExpr(db) {
1082
- return db.dialect === "sqlite" ? "datetime('now')" : "to_char((now() AT TIME ZONE 'utc'), 'YYYY-MM-DD HH24:MI:SS')";
1083
- }
1084
- function insertOrIgnore(db, insertSql) {
1085
- return db.dialect === "sqlite" ? insertSql.replace(/^\s*INSERT\s+INTO/i, "INSERT OR IGNORE INTO") : `${insertSql} ON CONFLICT DO NOTHING`;
1086
- }
1087
- function insertOrReplace(db, insertSql, conflictCols, set) {
1088
- return db.dialect === "sqlite" ? insertSql.replace(/^\s*INSERT\s+INTO/i, "INSERT OR REPLACE INTO") : `${insertSql} ON CONFLICT (${conflictCols.join(", ")}) DO UPDATE SET ${set}`;
1501
+ async function resetDb() {
1502
+ if (adapter) {
1503
+ await adapter.close();
1504
+ adapter = null;
1505
+ }
1089
1506
  }
1090
1507
 
1091
1508
  export {
1509
+ isEmailish,
1510
+ isEmailListish,
1092
1511
  config,
1093
1512
  initDb,
1094
1513
  getDb,
1095
- nowExpr,
1096
- insertOrIgnore,
1097
- insertOrReplace
1514
+ resetDb
1098
1515
  };
@@ -0,0 +1,8 @@
1
+ // src/shared/email.ts
2
+ function normalizeEmail(email) {
3
+ return email.trim().toLowerCase();
4
+ }
5
+
6
+ export {
7
+ normalizeEmail
8
+ };