@promptowl/contextnest-community 1.7.0 → 1.9.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";
@@ -20,6 +25,15 @@ if (envFileLoaded && !isTestRun) {
20
25
  }
21
26
  var canonicalEnvFile = process.env.ENV_FILE_PATH || join(dataRoot(), ".env");
22
27
  var canonicalEnvLoaded = null;
28
+ var slackUrlWarned = false;
29
+ var emailFromWarned = false;
30
+ var emailToWarned = false;
31
+ function isEmailish(v) {
32
+ return /^[^\s@]+@[^\s@]+$/.test(v) && !/[\r\n]/.test(v);
33
+ }
34
+ function isEmailListish(v) {
35
+ return v.split(",").every((e) => isEmailish(e.trim()));
36
+ }
23
37
  if (!isTestRun && canonicalEnvFile !== envFileLoaded && existsSync(canonicalEnvFile)) {
24
38
  dotenv.config({ path: canonicalEnvFile, override: true });
25
39
  canonicalEnvLoaded = canonicalEnvFile;
@@ -186,9 +200,133 @@ var config = {
186
200
  }
187
201
  return raw;
188
202
  },
203
+ /**
204
+ * Workflow plane ("turing" track): typed edges, edge-type registry, runs.
205
+ * OFF by default — an optional feature an admin turns on (env or Settings).
206
+ * This flag is also the future license-tier hook: gating a paid tier here
207
+ * is a one-line change because every plane route checks it per request.
208
+ */
209
+ get FEATURE_WORKFLOW_PLANE() {
210
+ return process.env.FEATURE_WORKFLOW_PLANE === "true";
211
+ },
212
+ /**
213
+ * Sub-agent runs: lets a run spawn nested runs (an external runner calling
214
+ * back into POST /run for a child agent). Gated SEPARATELY from the base
215
+ * plane because recursion is the risky surface — unbounded fan-out,
216
+ * re-entrancy, resource exhaustion. Off by default; must clear a security
217
+ * review before an operator turns it on. Requires FEATURE_WORKFLOW_PLANE too.
218
+ */
219
+ get FEATURE_SUBAGENT_RUNS() {
220
+ return process.env.FEATURE_SUBAGENT_RUNS === "true";
221
+ },
222
+ /** Hard ceiling on sub-agent nesting depth — keeps the call tree finite
223
+ * (haltable / statically bounded). Clamped to [1, 32]. */
224
+ get SUBAGENT_MAX_DEPTH() {
225
+ const n = parseInt(process.env.SUBAGENT_MAX_DEPTH || "8", 10);
226
+ return Number.isFinite(n) ? Math.min(32, Math.max(1, n)) : 8;
227
+ },
228
+ /** Max direct children a single run may spawn. Depth bounds the tree's
229
+ * height; this bounds its width — together they cap total size so a
230
+ * runner can't fork-bomb the DB. Clamped to [1, 128]. */
231
+ get SUBAGENT_MAX_CHILDREN() {
232
+ const n = parseInt(process.env.SUBAGENT_MAX_CHILDREN || "16", 10);
233
+ return Number.isFinite(n) ? Math.min(128, Math.max(1, n)) : 16;
234
+ },
235
+ /** Max steps a single run may accumulate — bounds a runaway/hostile
236
+ * runner's step log. Clamped to [10, 100000]. */
237
+ get RUN_MAX_STEPS() {
238
+ const n = parseInt(process.env.RUN_MAX_STEPS || "10000", 10);
239
+ return Number.isFinite(n) ? Math.min(1e5, Math.max(10, n)) : 1e4;
240
+ },
189
241
  get AUTH_MODE() {
190
242
  return process.env.AUTH_MODE || "key";
191
243
  },
244
+ /**
245
+ * Optional SMTP connection URL for email notifications, e.g.
246
+ * smtps://user:pass@smtp.example.com:465. smtp:// or smtps:// only.
247
+ * Empty/unset = email connector off. Requires NOTIFY_EMAIL_FROM/_TO.
248
+ */
249
+ get SMTP_URL() {
250
+ const raw = process.env.SMTP_URL?.trim();
251
+ if (!raw) return null;
252
+ if (!/^smtps?:\/\//i.test(raw)) {
253
+ console.warn(
254
+ "[config] SMTP_URL rejected: must be an smtp:// or smtps:// URL. Email notifications disabled."
255
+ );
256
+ return null;
257
+ }
258
+ return raw;
259
+ },
260
+ /**
261
+ * From address for notification emails. Shape-validated here (not only in
262
+ * the /admin/settings PATCH) so a value set directly via env/Docker can't
263
+ * reach nodemailer's envelope unchecked. Invalid = connector off.
264
+ */
265
+ get NOTIFY_EMAIL_FROM() {
266
+ const raw = process.env.NOTIFY_EMAIL_FROM?.trim();
267
+ if (!raw) return null;
268
+ if (!isEmailish(raw)) {
269
+ if (!emailFromWarned) {
270
+ emailFromWarned = true;
271
+ console.warn(
272
+ "[config] NOTIFY_EMAIL_FROM rejected: must be a plain email address. Email notifications disabled."
273
+ );
274
+ }
275
+ return null;
276
+ }
277
+ return raw;
278
+ },
279
+ /**
280
+ * Comma-separated recipient list for notification emails. Shape-validated
281
+ * here for the same env-bypass reason as NOTIFY_EMAIL_FROM. Invalid (any
282
+ * entry malformed) = connector off.
283
+ */
284
+ get NOTIFY_EMAIL_TO() {
285
+ const raw = process.env.NOTIFY_EMAIL_TO?.trim();
286
+ if (!raw) return null;
287
+ if (!isEmailListish(raw)) {
288
+ if (!emailToWarned) {
289
+ emailToWarned = true;
290
+ console.warn(
291
+ "[config] NOTIFY_EMAIL_TO rejected: must be a comma-separated list of email addresses. Email notifications disabled."
292
+ );
293
+ }
294
+ return null;
295
+ }
296
+ return raw;
297
+ },
298
+ /**
299
+ * Optional Slack incoming-webhook URL for team notifications (review
300
+ * submitted/approved/rejected, nest shared). Empty/unset = connector off.
301
+ * https only — a webhook carries an implicit secret in its path, so it
302
+ * never travels plaintext.
303
+ */
304
+ get SLACK_WEBHOOK_URL() {
305
+ const raw = process.env.SLACK_WEBHOOK_URL?.trim();
306
+ if (!raw) return null;
307
+ if (!/^https:\/\//i.test(raw)) {
308
+ if (!slackUrlWarned) {
309
+ slackUrlWarned = true;
310
+ console.warn(
311
+ "[config] SLACK_WEBHOOK_URL rejected: must be an https:// URL. Slack notifications disabled."
312
+ );
313
+ }
314
+ return null;
315
+ }
316
+ return raw;
317
+ },
318
+ /**
319
+ * Per-nest notification digest window in milliseconds. A burst of governance
320
+ * events on one nest inside this window collapses into a single digest
321
+ * message instead of one post per event. Empty / non-numeric / negative /
322
+ * unset → 15000 (15s).
323
+ */
324
+ get NOTIFY_DEBOUNCE_MS() {
325
+ const raw = process.env.NOTIFY_DEBOUNCE_MS?.trim();
326
+ if (!raw) return 15e3;
327
+ const n = Number(raw);
328
+ return Number.isFinite(n) && n >= 0 ? n : 15e3;
329
+ },
192
330
  /**
193
331
  * CORS origin allowlist. Comma-separated list of origins or "*".
194
332
  * Defaults to "*" in open mode (read-heavy, no credentials to steal)
@@ -210,11 +348,6 @@ var config = {
210
348
  }
211
349
  };
212
350
 
213
- // src/db/client.ts
214
- import Database from "better-sqlite3";
215
- import { mkdirSync, readFileSync } from "fs";
216
- import { dirname as dirname2 } from "path";
217
-
218
351
  // src/db/migrations.ts
219
352
  function runMigrations(db) {
220
353
  db.exec(`
@@ -781,6 +914,170 @@ function runMigrations(db) {
781
914
  })();
782
915
  recordMigration("012_merge_case_colliding_users");
783
916
  }
917
+ if (!hasMigration("019_grants")) {
918
+ db.transaction(() => {
919
+ db.exec(`
920
+ CREATE TABLE IF NOT EXISTS grants (
921
+ id TEXT PRIMARY KEY,
922
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
923
+ target_type TEXT NOT NULL CHECK(target_type IN ('document', 'folder')),
924
+ target TEXT NOT NULL, -- node id (document) or path prefix (folder)
925
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
926
+ role TEXT NOT NULL CHECK(role IN ('read', 'write')),
927
+ granted_by TEXT NOT NULL,
928
+ created_at TEXT NOT NULL,
929
+ updated_at TEXT NOT NULL
930
+ );
931
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_grants_unique
932
+ ON grants(nest_id, target_type, target, user_id);
933
+ CREATE INDEX IF NOT EXISTS idx_grants_user ON grants(nest_id, user_id);
934
+ `);
935
+ })();
936
+ recordMigration("019_grants");
937
+ }
938
+ if (!hasMigration("013_node_deletion_tombstones")) {
939
+ db.transaction(() => {
940
+ db.exec(`
941
+ CREATE TABLE IF NOT EXISTS node_deletions (
942
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
943
+ node_id TEXT NOT NULL,
944
+ deleted_by TEXT,
945
+ deleted_at TEXT NOT NULL,
946
+ PRIMARY KEY (nest_id, node_id)
947
+ );
948
+ CREATE INDEX IF NOT EXISTS idx_node_deletions_time
949
+ ON node_deletions(nest_id, deleted_at);
950
+ `);
951
+ })();
952
+ recordMigration("013_node_deletion_tombstones");
953
+ }
954
+ if (!hasMigration("014_api_events")) {
955
+ db.transaction(() => {
956
+ db.exec(`
957
+ CREATE TABLE IF NOT EXISTS api_events (
958
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
959
+ ts TEXT NOT NULL,
960
+ kind TEXT NOT NULL CHECK(kind IN ('api', 'mcp')),
961
+ method TEXT, -- HTTP method (api rows)
962
+ path TEXT, -- request path (api rows)
963
+ tool TEXT, -- tool name (mcp rows)
964
+ nest_id TEXT,
965
+ user_id TEXT,
966
+ user_email TEXT,
967
+ status INTEGER, -- HTTP status (api rows)
968
+ duration_ms INTEGER
969
+ );
970
+ CREATE INDEX IF NOT EXISTS idx_api_events_ts ON api_events(ts);
971
+ `);
972
+ })();
973
+ recordMigration("014_api_events");
974
+ }
975
+ if (!hasMigration("015_definitions")) {
976
+ db.transaction(() => {
977
+ db.exec(`
978
+ CREATE TABLE IF NOT EXISTS definitions (
979
+ id TEXT PRIMARY KEY,
980
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
981
+ term TEXT NOT NULL,
982
+ definition TEXT NOT NULL,
983
+ linked_tag TEXT, -- optional #tag this term maps to
984
+ defined_by TEXT NOT NULL, -- email
985
+ created_at TEXT NOT NULL,
986
+ updated_at TEXT NOT NULL
987
+ );
988
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_definitions_term
989
+ ON definitions(nest_id, term COLLATE NOCASE);
990
+ `);
991
+ })();
992
+ recordMigration("015_definitions");
993
+ }
994
+ if (!hasMigration("016_workflow_edges")) {
995
+ db.transaction(() => {
996
+ db.exec(`
997
+ CREATE TABLE IF NOT EXISTS edge_types (
998
+ id TEXT PRIMARY KEY,
999
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1000
+ name TEXT NOT NULL,
1001
+ description TEXT NOT NULL,
1002
+ direction TEXT NOT NULL DEFAULT 'directed'
1003
+ CHECK(direction IN ('directed', 'undirected')),
1004
+ is_flow INTEGER NOT NULL DEFAULT 0, -- flow types form the DAG
1005
+ condition_schema TEXT, -- JSON template | NULL
1006
+ color TEXT,
1007
+ created_by TEXT NOT NULL,
1008
+ created_at TEXT NOT NULL,
1009
+ updated_at TEXT NOT NULL
1010
+ );
1011
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_edge_types_name
1012
+ ON edge_types(nest_id, LOWER(name));
1013
+
1014
+ CREATE TABLE IF NOT EXISTS edges (
1015
+ id TEXT PRIMARY KEY,
1016
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1017
+ from_node TEXT NOT NULL,
1018
+ to_node TEXT NOT NULL,
1019
+ -- RESTRICT (not CASCADE): the app refuses to delete an edge type
1020
+ -- while edges reference it; the DB backs that invariant instead of
1021
+ -- silently cascade-deleting live edges if the app check is bypassed.
1022
+ type_id TEXT NOT NULL REFERENCES edge_types(id) ON DELETE RESTRICT,
1023
+ condition_mode TEXT
1024
+ CHECK(condition_mode IN ('structured', 'nl', 'open')),
1025
+ condition TEXT,
1026
+ metadata TEXT,
1027
+ created_by TEXT NOT NULL,
1028
+ created_at TEXT NOT NULL,
1029
+ updated_at TEXT NOT NULL
1030
+ );
1031
+ CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(nest_id, from_node);
1032
+ CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(nest_id, to_node);
1033
+ CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(nest_id, type_id);
1034
+ `);
1035
+ })();
1036
+ recordMigration("016_workflow_edges");
1037
+ }
1038
+ if (!hasMigration("017_workflow_runs")) {
1039
+ db.transaction(() => {
1040
+ db.exec(`
1041
+ CREATE TABLE IF NOT EXISTS runs (
1042
+ id TEXT PRIMARY KEY,
1043
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1044
+ agent_node TEXT NOT NULL,
1045
+ triggered_by TEXT NOT NULL,
1046
+ status TEXT NOT NULL DEFAULT 'running'
1047
+ CHECK(status IN ('running', 'succeeded', 'failed', 'cancelled')),
1048
+ inputs TEXT,
1049
+ trace TEXT,
1050
+ started_at TEXT NOT NULL,
1051
+ finished_at TEXT
1052
+ );
1053
+ CREATE INDEX IF NOT EXISTS idx_runs_nest ON runs(nest_id, started_at);
1054
+ CREATE INDEX IF NOT EXISTS idx_runs_agent ON runs(nest_id, agent_node);
1055
+
1056
+ CREATE TABLE IF NOT EXISTS run_steps (
1057
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1058
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
1059
+ seq INTEGER NOT NULL,
1060
+ node_id TEXT,
1061
+ edge_id TEXT,
1062
+ action TEXT NOT NULL,
1063
+ detail TEXT,
1064
+ at TEXT NOT NULL
1065
+ );
1066
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_run_steps ON run_steps(run_id, seq);
1067
+ `);
1068
+ })();
1069
+ recordMigration("017_workflow_runs");
1070
+ }
1071
+ if (!hasMigration("018_subagent_runs")) {
1072
+ db.transaction(() => {
1073
+ db.exec(`
1074
+ ALTER TABLE runs ADD COLUMN parent_run_id TEXT;
1075
+ ALTER TABLE runs ADD COLUMN depth INTEGER NOT NULL DEFAULT 0;
1076
+ CREATE INDEX IF NOT EXISTS idx_runs_parent ON runs(parent_run_id);
1077
+ `);
1078
+ })();
1079
+ recordMigration("018_subagent_runs");
1080
+ }
784
1081
  }
785
1082
  function mergeCaseCollidingUsers(db) {
786
1083
  const groups = db.prepare(
@@ -998,7 +1295,7 @@ async function initDb() {
998
1295
  if (config.DB_DRIVER === "postgres") {
999
1296
  const { Pool } = await import("pg");
1000
1297
  const { PostgresAdapter } = await import("./adapter.postgres-YOODX2BI.js");
1001
- const { runPostgresMigrations } = await import("./migrations.postgres-3HMGLKOV.js");
1298
+ const { runPostgresMigrations } = await import("./migrations.postgres-ORSV7UFJ.js");
1002
1299
  const pool = new Pool(buildPgConfig());
1003
1300
  adapter = new PostgresAdapter(pool);
1004
1301
  await runPostgresMigrations(adapter);
@@ -1018,23 +1315,18 @@ function getDb() {
1018
1315
  }
1019
1316
  return adapter;
1020
1317
  }
1021
-
1022
- // src/db/sql.ts
1023
- function nowExpr(db) {
1024
- return db.dialect === "sqlite" ? "datetime('now')" : "to_char((now() AT TIME ZONE 'utc'), 'YYYY-MM-DD HH24:MI:SS')";
1025
- }
1026
- function insertOrIgnore(db, insertSql) {
1027
- return db.dialect === "sqlite" ? insertSql.replace(/^\s*INSERT\s+INTO/i, "INSERT OR IGNORE INTO") : `${insertSql} ON CONFLICT DO NOTHING`;
1028
- }
1029
- function insertOrReplace(db, insertSql, conflictCols, set) {
1030
- return db.dialect === "sqlite" ? insertSql.replace(/^\s*INSERT\s+INTO/i, "INSERT OR REPLACE INTO") : `${insertSql} ON CONFLICT (${conflictCols.join(", ")}) DO UPDATE SET ${set}`;
1318
+ async function resetDb() {
1319
+ if (adapter) {
1320
+ await adapter.close();
1321
+ adapter = null;
1322
+ }
1031
1323
  }
1032
1324
 
1033
1325
  export {
1326
+ isEmailish,
1327
+ isEmailListish,
1034
1328
  config,
1035
1329
  initDb,
1036
1330
  getDb,
1037
- nowExpr,
1038
- insertOrIgnore,
1039
- insertOrReplace
1331
+ resetDb
1040
1332
  };
@@ -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
+ };
@@ -1,8 +1,10 @@
1
1
  import {
2
- getDb,
3
2
  insertOrReplace,
4
3
  nowExpr
5
- } from "./chunk-QMLAXQES.js";
4
+ } from "./chunk-XQ46F76G.js";
5
+ import {
6
+ getDb
7
+ } from "./chunk-DPHV6Q26.js";
6
8
 
7
9
  // src/governance/version-service.ts
8
10
  import { createHash } from "crypto";
@@ -0,0 +1,103 @@
1
+ import {
2
+ ValidationError
3
+ } from "./chunk-3JTODC3Y.js";
4
+ import {
5
+ getDb
6
+ } from "./chunk-DPHV6Q26.js";
7
+
8
+ // src/governance/grants-service.ts
9
+ import { v4 as uuid } from "uuid";
10
+ var rank = (r) => r === "write" ? 2 : 1;
11
+ async function resolveNodeGrant(nestId, userId, nodeId) {
12
+ if (!userId || !nodeId) return null;
13
+ const rows = await getDb().all(
14
+ "SELECT target, role FROM grants WHERE nest_id = ? AND user_id = ?",
15
+ [nestId, userId]
16
+ );
17
+ let best = null;
18
+ for (const r of rows) {
19
+ if (nodeId === r.target || nodeId.startsWith(r.target + "/")) {
20
+ if (!best || rank(r.role) > rank(best)) best = r.role;
21
+ }
22
+ }
23
+ return best;
24
+ }
25
+ async function hasAnyGrant(nestId, userId) {
26
+ if (!userId) return false;
27
+ const row = await getDb().get(
28
+ "SELECT 1 AS x FROM grants WHERE nest_id = ? AND user_id = ? LIMIT 1",
29
+ [nestId, userId]
30
+ );
31
+ return !!row;
32
+ }
33
+ async function listUserGrants(nestId, userId) {
34
+ if (!userId) return [];
35
+ return await getDb().all(
36
+ "SELECT target, role FROM grants WHERE nest_id = ? AND user_id = ?",
37
+ [nestId, userId]
38
+ );
39
+ }
40
+ function grantCoversNode(grants, nodeId) {
41
+ return grants.some(
42
+ (g) => nodeId === g.target || nodeId.startsWith(g.target + "/")
43
+ );
44
+ }
45
+ async function createGrant(params) {
46
+ const { nestId, targetType, target, userId, role, grantedBy } = params;
47
+ if (!["document", "folder"].includes(targetType)) {
48
+ throw new ValidationError("target_type must be document | folder");
49
+ }
50
+ if (!["read", "write"].includes(role)) {
51
+ throw new ValidationError("role must be read | write");
52
+ }
53
+ if (!target.trim()) throw new ValidationError("target is required");
54
+ if (targetType === "folder") {
55
+ const t = target.trim().replace(/\/+$/, "");
56
+ if (t === "nodes" || t === "") {
57
+ throw new ValidationError("folder target must name a subfolder, not the nest root");
58
+ }
59
+ }
60
+ const db = getDb();
61
+ const now = (/* @__PURE__ */ new Date()).toISOString();
62
+ const existing = await db.get(
63
+ "SELECT id FROM grants WHERE nest_id = ? AND target_type = ? AND target = ? AND user_id = ?",
64
+ [nestId, targetType, target, userId]
65
+ );
66
+ if (existing) {
67
+ await db.run(
68
+ "UPDATE grants SET role = ?, granted_by = ?, updated_at = ? WHERE id = ?",
69
+ [role, grantedBy, now, existing.id]
70
+ );
71
+ return await db.get("SELECT * FROM grants WHERE id = ?", [existing.id]);
72
+ }
73
+ const id = uuid();
74
+ await db.run(
75
+ `INSERT INTO grants (id, nest_id, target_type, target, user_id, role, granted_by, created_at, updated_at)
76
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
77
+ [id, nestId, targetType, target, userId, role, grantedBy, now, now]
78
+ );
79
+ return await db.get("SELECT * FROM grants WHERE id = ?", [id]);
80
+ }
81
+ async function listGrants(nestId) {
82
+ return await getDb().all(
83
+ "SELECT * FROM grants WHERE nest_id = ? ORDER BY target_type, target",
84
+ [nestId]
85
+ );
86
+ }
87
+ async function deleteGrant(nestId, id) {
88
+ const db = getDb();
89
+ const row = await db.get("SELECT id FROM grants WHERE id = ? AND nest_id = ?", [id, nestId]);
90
+ if (!row) return false;
91
+ await db.run("DELETE FROM grants WHERE id = ?", [id]);
92
+ return true;
93
+ }
94
+
95
+ export {
96
+ resolveNodeGrant,
97
+ hasAnyGrant,
98
+ listUserGrants,
99
+ grantCoversNode,
100
+ createGrant,
101
+ listGrants,
102
+ deleteGrant
103
+ };