@promptowl/contextnest-community 1.8.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.
package/CONFIGURATION.md CHANGED
@@ -66,7 +66,16 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
66
66
  | `CORS_ORIGINS` | `*` in open mode; `http://localhost:5173,http://localhost:3838` in key mode | Comma-separated allowlist. Set to `*` to allow any origin (**only** safe in open mode — in key mode with Bearer tokens this enables CSRF). |
67
67
  | `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. |
68
68
  | `LOGO_URL` | _(unset)_ | Custom logo shown in the UI header + login screen. Must start with `https://`, `http://`, or `data:image/` — other schemes (`file://`, relative, `javascript:`) are rejected with a warning and the bundled icon is used. |
69
+ | `FEATURE_WORKFLOW_PLANE` | _(unset — off)_ | Enables the workflow plane: typed edges, edge-type registry, governed runs. Optional feature; also toggleable from Settings. |
70
+ | `FEATURE_SUBAGENT_RUNS` | _(unset — off)_ | Lets runs spawn nested sub-agent runs (recursion). Gated separately from the plane; requires `FEATURE_WORKFLOW_PLANE`. Also toggleable from Settings → Advanced (turning the plane off forces this off too). Opens a recursion surface — enable only after reviewing the depth/fan-out caps. |
71
+ | `SUBAGENT_MAX_DEPTH` | `8` | Max sub-agent nesting depth (clamped 1..32) — bounds the call tree's HEIGHT so it stays finite/haltable. |
72
+ | `SUBAGENT_MAX_CHILDREN` | `16` | Max direct children a single run may spawn (clamped 1..128) — bounds the call tree's WIDTH. Together with `SUBAGENT_MAX_DEPTH` this caps total tree size so a runner can't fork-bomb the DB. |
73
+ | `RUN_MAX_STEPS` | `10000` | Max steps a single run may accumulate (clamped 10..100000) — bounds a runaway/hostile runner's step log. |
69
74
  | `SLACK_WEBHOOK_URL` | _(unset — connector off)_ | Slack incoming-webhook URL for governance-event notifications (review submitted/approved/rejected, collaborator added). `https://` only — the URL embeds a secret. Also editable from Settings in the UI. |
75
+ | `SMTP_URL` | _(unset — connector off)_ | SMTP connection URL for email notifications (`smtp://` or `smtps://`, credentials inline). Requires `NOTIFY_EMAIL_FROM` and `NOTIFY_EMAIL_TO`. Also editable from Settings. |
76
+ | `NOTIFY_EMAIL_FROM` | _(unset)_ | From address for notification emails. |
77
+ | `NOTIFY_EMAIL_TO` | _(unset)_ | Comma-separated recipients for notification emails. |
78
+ | `NOTIFY_DEBOUNCE_MS` | `15000` | Per-nest buffer window before notifications flush; bursts collapse into one digest message. |
70
79
 
71
80
  ---
72
81
 
@@ -0,0 +1,48 @@
1
+ // src/shared/errors.ts
2
+ var AppError = class extends Error {
3
+ constructor(statusCode, message) {
4
+ super(message);
5
+ this.statusCode = statusCode;
6
+ this.name = "AppError";
7
+ }
8
+ statusCode;
9
+ };
10
+ var NotFoundError = class extends AppError {
11
+ constructor(message = "Not found") {
12
+ super(404, message);
13
+ this.name = "NotFoundError";
14
+ }
15
+ };
16
+ var ForbiddenError = class extends AppError {
17
+ constructor(message = "Forbidden") {
18
+ super(403, message);
19
+ this.name = "ForbiddenError";
20
+ }
21
+ };
22
+ var ValidationError = class extends AppError {
23
+ constructor(message) {
24
+ super(400, message);
25
+ this.name = "ValidationError";
26
+ }
27
+ };
28
+ var ConflictError = class extends AppError {
29
+ constructor(message) {
30
+ super(409, message);
31
+ this.name = "ConflictError";
32
+ }
33
+ };
34
+ var LockedError = class extends AppError {
35
+ constructor(message = "Locked") {
36
+ super(423, message);
37
+ this.name = "LockedError";
38
+ }
39
+ };
40
+
41
+ export {
42
+ AppError,
43
+ NotFoundError,
44
+ ForbiddenError,
45
+ ValidationError,
46
+ ConflictError,
47
+ LockedError
48
+ };
@@ -1,9 +1,15 @@
1
1
  import {
2
- config,
3
- getDb,
2
+ ConflictError,
3
+ ValidationError
4
+ } from "./chunk-3JTODC3Y.js";
5
+ import {
4
6
  insertOrReplace,
5
7
  nowExpr
6
- } from "./chunk-PLAHCDQG.js";
8
+ } from "./chunk-XQ46F76G.js";
9
+ import {
10
+ config,
11
+ getDb
12
+ } from "./chunk-DPHV6Q26.js";
7
13
  import {
8
14
  ANON_USER_ID
9
15
  } from "./chunk-SLTQACJW.js";
@@ -104,46 +110,6 @@ function parseAccessYaml(content) {
104
110
  return result;
105
111
  }
106
112
 
107
- // src/shared/errors.ts
108
- var AppError = class extends Error {
109
- constructor(statusCode, message) {
110
- super(message);
111
- this.statusCode = statusCode;
112
- this.name = "AppError";
113
- }
114
- statusCode;
115
- };
116
- var NotFoundError = class extends AppError {
117
- constructor(message = "Not found") {
118
- super(404, message);
119
- this.name = "NotFoundError";
120
- }
121
- };
122
- var ForbiddenError = class extends AppError {
123
- constructor(message = "Forbidden") {
124
- super(403, message);
125
- this.name = "ForbiddenError";
126
- }
127
- };
128
- var ValidationError = class extends AppError {
129
- constructor(message) {
130
- super(400, message);
131
- this.name = "ValidationError";
132
- }
133
- };
134
- var ConflictError = class extends AppError {
135
- constructor(message) {
136
- super(409, message);
137
- this.name = "ConflictError";
138
- }
139
- };
140
- var LockedError = class extends AppError {
141
- constructor(message = "Locked") {
142
- super(423, message);
143
- this.name = "LockedError";
144
- }
145
- };
146
-
147
113
  // src/nodes/engine.ts
148
114
  import {
149
115
  NestStorage as NestStorage3,
@@ -924,11 +890,35 @@ async function buildTitleMap(nestId) {
924
890
  function folderForNode(nodeId) {
925
891
  return nodeId.replace(/^nodes\//, "").split("/").slice(0, -1).join("/");
926
892
  }
927
- async function describeNode(nestId, nodeId) {
893
+ async function nestName(nestId) {
894
+ try {
895
+ const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
896
+ nestId
897
+ ]);
898
+ return row?.name || nestId;
899
+ } catch {
900
+ return nestId;
901
+ }
902
+ }
903
+ function docLink(nestId, nodeId, baseUrl) {
904
+ const base = baseUrl || config.PUBLIC_BASE_URL;
905
+ if (!base) return "";
906
+ const q = new URLSearchParams({ nest: nestId });
907
+ if (nodeId) q.set("doc", nodeId);
908
+ return `${base.replace(/\/$/, "")}/?${q.toString()}`;
909
+ }
910
+ async function buildDocContext(nestId, nodeId, baseUrl) {
928
911
  const titles = await buildTitleMap(nestId);
929
- const title = titles.get(nodeId) || nodeId;
912
+ const docTitle = titles.get(nodeId) || nodeId;
930
913
  const folder = folderForNode(nodeId);
931
- return folder ? `${title} (in ${folder})` : title;
914
+ const name = await nestName(nestId);
915
+ return {
916
+ docTitle,
917
+ // Slack one-liner label ("Title (in Folder)"), same as describeNode().
918
+ label: folder ? `${docTitle} (in ${folder})` : docTitle,
919
+ path: folder ? `${name} / ${folder}` : name,
920
+ link: docLink(nestId, nodeId, baseUrl)
921
+ };
932
922
  }
933
923
 
934
924
  // src/shared/access.ts
@@ -1549,12 +1539,6 @@ function rowToSteward(row) {
1549
1539
  }
1550
1540
 
1551
1541
  export {
1552
- AppError,
1553
- NotFoundError,
1554
- ForbiddenError,
1555
- ValidationError,
1556
- ConflictError,
1557
- LockedError,
1558
1542
  trackEvent,
1559
1543
  startTelemetryLoop,
1560
1544
  getCurrentLicense,
@@ -1589,7 +1573,9 @@ export {
1589
1573
  deleteNest,
1590
1574
  engineCache,
1591
1575
  buildTitleMap,
1592
- describeNode,
1576
+ nestName,
1577
+ docLink,
1578
+ buildDocContext,
1593
1579
  collabPermToRole,
1594
1580
  assignSteward,
1595
1581
  removeSteward,
@@ -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@]+$/.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;
@@ -187,9 +200,101 @@ var config = {
187
200
  }
188
201
  return raw;
189
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
+ },
190
241
  get AUTH_MODE() {
191
242
  return process.env.AUTH_MODE || "key";
192
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
+ },
193
298
  /**
194
299
  * Optional Slack incoming-webhook URL for team notifications (review
195
300
  * submitted/approved/rejected, nest shared). Empty/unset = connector off.
@@ -210,6 +315,18 @@ var config = {
210
315
  }
211
316
  return raw;
212
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
+ },
213
330
  /**
214
331
  * CORS origin allowlist. Comma-separated list of origins or "*".
215
332
  * Defaults to "*" in open mode (read-heavy, no credentials to steal)
@@ -231,11 +348,6 @@ var config = {
231
348
  }
232
349
  };
233
350
 
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
351
  // src/db/migrations.ts
240
352
  function runMigrations(db) {
241
353
  db.exec(`
@@ -802,6 +914,27 @@ function runMigrations(db) {
802
914
  })();
803
915
  recordMigration("012_merge_case_colliding_users");
804
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
+ }
805
938
  if (!hasMigration("013_node_deletion_tombstones")) {
806
939
  db.transaction(() => {
807
940
  db.exec(`
@@ -839,6 +972,112 @@ function runMigrations(db) {
839
972
  })();
840
973
  recordMigration("014_api_events");
841
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
+ }
842
1081
  }
843
1082
  function mergeCaseCollidingUsers(db) {
844
1083
  const groups = db.prepare(
@@ -1056,7 +1295,7 @@ async function initDb() {
1056
1295
  if (config.DB_DRIVER === "postgres") {
1057
1296
  const { Pool } = await import("pg");
1058
1297
  const { PostgresAdapter } = await import("./adapter.postgres-YOODX2BI.js");
1059
- const { runPostgresMigrations } = await import("./migrations.postgres-VCG45LMK.js");
1298
+ const { runPostgresMigrations } = await import("./migrations.postgres-ORSV7UFJ.js");
1060
1299
  const pool = new Pool(buildPgConfig());
1061
1300
  adapter = new PostgresAdapter(pool);
1062
1301
  await runPostgresMigrations(adapter);
@@ -1076,23 +1315,18 @@ function getDb() {
1076
1315
  }
1077
1316
  return adapter;
1078
1317
  }
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}`;
1318
+ async function resetDb() {
1319
+ if (adapter) {
1320
+ await adapter.close();
1321
+ adapter = null;
1322
+ }
1089
1323
  }
1090
1324
 
1091
1325
  export {
1326
+ isEmailish,
1327
+ isEmailListish,
1092
1328
  config,
1093
1329
  initDb,
1094
1330
  getDb,
1095
- nowExpr,
1096
- insertOrIgnore,
1097
- insertOrReplace
1331
+ resetDb
1098
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-PLAHCDQG.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
+ };