@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.
@@ -0,0 +1,1343 @@
1
+ import {
2
+ grantCoversNode,
3
+ listUserGrants,
4
+ resolveNodeGrant
5
+ } from "./chunk-7PQREKSM.js";
6
+ import {
7
+ createVersion,
8
+ getApprovedVersion,
9
+ setApprovedVersion
10
+ } from "./chunk-UHUU3VAK.js";
11
+ import {
12
+ buildDocContext,
13
+ buildTitleMap,
14
+ canUserAccess,
15
+ canUserApprove,
16
+ describeNode,
17
+ engineCache,
18
+ isPublicReader,
19
+ isStewardshipEnabled,
20
+ resolveStewardsForNode
21
+ } from "./chunk-KFGZIECB.js";
22
+ import {
23
+ ConflictError,
24
+ NotFoundError,
25
+ ValidationError
26
+ } from "./chunk-3JTODC3Y.js";
27
+ import {
28
+ insertOrReplace,
29
+ nowExpr
30
+ } from "./chunk-XQ46F76G.js";
31
+ import {
32
+ config,
33
+ getDb
34
+ } from "./chunk-BC6KFUZH.js";
35
+
36
+ // src/governance/review-service.ts
37
+ import { v4 as uuid3 } from "uuid";
38
+
39
+ // src/governance/notify-service.ts
40
+ import { v4 as uuid } from "uuid";
41
+ async function listWatchers(nestId, nodeId) {
42
+ return await getDb().all(
43
+ `SELECT id, node_id, user_email, created_by, created_at
44
+ FROM watchers WHERE nest_id = ? AND node_id = ? ORDER BY created_at`,
45
+ [nestId, nodeId]
46
+ );
47
+ }
48
+ async function addWatcher(nestId, nodeId, userEmail, createdBy) {
49
+ await getDb().run(
50
+ `INSERT INTO watchers (id, nest_id, node_id, user_email, created_by, created_at)
51
+ VALUES (?, ?, ?, ?, ?, ?)
52
+ ON CONFLICT(nest_id, node_id, user_email) DO NOTHING`,
53
+ [uuid(), nestId, nodeId, userEmail.toLowerCase(), createdBy, (/* @__PURE__ */ new Date()).toISOString()]
54
+ );
55
+ }
56
+ async function removeWatcher(nestId, nodeId, userEmail) {
57
+ const db = getDb();
58
+ const row = await db.get(
59
+ "SELECT id FROM watchers WHERE nest_id = ? AND node_id = ? AND LOWER(user_email) = LOWER(?)",
60
+ [nestId, nodeId, userEmail]
61
+ );
62
+ if (!row) return false;
63
+ await db.run("DELETE FROM watchers WHERE id = ?", [row.id]);
64
+ return true;
65
+ }
66
+ async function notifyReviewRequested(params) {
67
+ const { nestId, nodeId, requestedBy, reviewId } = params;
68
+ const db = getDb();
69
+ try {
70
+ const rows = await db.all(
71
+ `SELECT DISTINCT w.user_email, w.node_id AS via
72
+ FROM watchers w
73
+ WHERE w.nest_id = ?
74
+ AND (w.node_id = ?
75
+ OR w.node_id IN (
76
+ SELECT e.from_node FROM edges e
77
+ JOIN edge_types t ON t.id = e.type_id
78
+ WHERE e.nest_id = ? AND e.to_node = ? AND t.is_flow = 1
79
+ ))`,
80
+ [nestId, nodeId, nestId, nodeId]
81
+ );
82
+ const now = (/* @__PURE__ */ new Date()).toISOString();
83
+ let created = 0;
84
+ for (const r of rows) {
85
+ if (r.user_email.toLowerCase() === requestedBy.toLowerCase()) continue;
86
+ await db.run(
87
+ `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
88
+ VALUES (?, ?, ?, 'review_requested', ?, ?, ?)`,
89
+ [
90
+ uuid(),
91
+ nestId,
92
+ r.user_email.toLowerCase(),
93
+ reviewId,
94
+ r.via === nodeId ? `"${nodeId}" has a new version awaiting review (submitted by ${requestedBy})` : `Agent "${r.via}" produced output on "${nodeId}" \u2014 awaiting review`,
95
+ now
96
+ ]
97
+ );
98
+ created++;
99
+ }
100
+ return created;
101
+ } catch (err) {
102
+ console.error("[notify] review fan-out failed", nestId, nodeId, err);
103
+ return 0;
104
+ }
105
+ }
106
+ async function notifyReviewResolved(params) {
107
+ const { nestId, nodeId, status, resolvedBy, requestedBy, reviewId } = params;
108
+ const db = getDb();
109
+ try {
110
+ const rows = await db.all(
111
+ `SELECT DISTINCT w.user_email
112
+ FROM watchers w
113
+ WHERE w.nest_id = ?
114
+ AND (w.node_id = ?
115
+ OR w.node_id IN (
116
+ SELECT e.from_node FROM edges e
117
+ JOIN edge_types t ON t.id = e.type_id
118
+ WHERE e.nest_id = ? AND e.to_node = ? AND t.is_flow = 1
119
+ ))`,
120
+ [nestId, nodeId, nestId, nodeId]
121
+ );
122
+ const recipients = new Set(rows.map((r) => r.user_email.toLowerCase()));
123
+ recipients.add(requestedBy.toLowerCase());
124
+ recipients.delete(resolvedBy.toLowerCase());
125
+ const now = (/* @__PURE__ */ new Date()).toISOString();
126
+ let created = 0;
127
+ for (const email of recipients) {
128
+ await db.run(
129
+ `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
130
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
131
+ [
132
+ uuid(),
133
+ nestId,
134
+ email,
135
+ `review_${status}`,
136
+ reviewId,
137
+ `"${nodeId}" was ${status} by ${resolvedBy}`,
138
+ now
139
+ ]
140
+ );
141
+ created++;
142
+ }
143
+ return created;
144
+ } catch (err) {
145
+ console.error("[notify] resolution fan-out failed", nestId, nodeId, err);
146
+ return 0;
147
+ }
148
+ }
149
+ async function listNotifications(userEmail, opts = {}) {
150
+ const limit = Math.min(opts.limit ?? 50, 200);
151
+ return await getDb().all(
152
+ `SELECT id, nest_id, kind, subject_id, message, created_at, read_at
153
+ FROM notifications
154
+ WHERE LOWER(user_email) = LOWER(?)${opts.unreadOnly ? " AND read_at IS NULL" : ""}
155
+ ORDER BY created_at DESC LIMIT ${limit}`,
156
+ [userEmail]
157
+ );
158
+ }
159
+ async function markNotificationsRead(userEmail, ids) {
160
+ const db = getDb();
161
+ const now = (/* @__PURE__ */ new Date()).toISOString();
162
+ if (ids === "all") {
163
+ await db.run(
164
+ "UPDATE notifications SET read_at = ? WHERE LOWER(user_email) = LOWER(?) AND read_at IS NULL",
165
+ [now, userEmail]
166
+ );
167
+ return;
168
+ }
169
+ for (const id of ids.slice(0, 200)) {
170
+ await db.run(
171
+ "UPDATE notifications SET read_at = ? WHERE id = ? AND LOWER(user_email) = LOWER(?)",
172
+ [now, id, userEmail]
173
+ );
174
+ }
175
+ }
176
+
177
+ // src/workflow/env-routes.ts
178
+ import { Hono as Hono2 } from "hono";
179
+
180
+ // src/governance/access-guard.ts
181
+ async function resolveCallerEmail(userId) {
182
+ if (!userId) return "admin@localhost";
183
+ const db = getDb();
184
+ const row = await db.get(
185
+ "SELECT email FROM users WHERE id = ?",
186
+ [userId]
187
+ );
188
+ return row?.email || "admin@localhost";
189
+ }
190
+ async function canReadNode(nestId, nodeId, userId, userEmail) {
191
+ if (await isPublicReader(nestId, userId)) {
192
+ return await getApprovedVersion(nestId, nodeId) !== null;
193
+ }
194
+ if (!await isStewardshipEnabled(nestId)) return true;
195
+ if ((await canUserAccess(nestId, nodeId, userEmail)).allowed) return true;
196
+ return await resolveNodeGrant(nestId, userId, nodeId) !== null;
197
+ }
198
+ async function filterAccessible(nestId, userId, userEmail, nodes) {
199
+ if (await isPublicReader(nestId, userId)) {
200
+ const filtered = [];
201
+ for (const n of nodes) {
202
+ if (await getApprovedVersion(nestId, n.id) !== null) {
203
+ filtered.push(n);
204
+ }
205
+ }
206
+ return filtered;
207
+ }
208
+ if (!await isStewardshipEnabled(nestId)) return nodes;
209
+ const grants = await listUserGrants(nestId, userId);
210
+ const accessible = [];
211
+ for (const n of nodes) {
212
+ if ((await canUserAccess(nestId, n.id, userEmail)).allowed || grantCoversNode(grants, n.id)) {
213
+ accessible.push(n);
214
+ }
215
+ }
216
+ return accessible;
217
+ }
218
+
219
+ // src/workflow/edge-type-routes.ts
220
+ import { Hono } from "hono";
221
+ import { v4 as uuid2 } from "uuid";
222
+
223
+ // src/shared/json.ts
224
+ var safeJson = (s) => {
225
+ if (!s) return null;
226
+ try {
227
+ return JSON.parse(s);
228
+ } catch {
229
+ return null;
230
+ }
231
+ };
232
+
233
+ // src/workflow/flow-graph.ts
234
+ async function assertFlowGraphAcyclic(nestId, extraFlowTypeId) {
235
+ const db = getDb();
236
+ const rows = await db.all(
237
+ `SELECT e.from_node, e.to_node FROM edges e
238
+ JOIN edge_types t ON t.id = e.type_id
239
+ WHERE e.nest_id = ? AND (t.is_flow = 1 OR t.id = ?)`,
240
+ [nestId, extraFlowTypeId ?? ""]
241
+ );
242
+ const adj = /* @__PURE__ */ new Map();
243
+ const indeg = /* @__PURE__ */ new Map();
244
+ const nodes = /* @__PURE__ */ new Set();
245
+ for (const r of rows) {
246
+ if (r.from_node === r.to_node) {
247
+ throw new ConflictError(
248
+ `cycle: ["${r.from_node}"] \u2014 a flow edge cannot be a self-loop`
249
+ );
250
+ }
251
+ nodes.add(r.from_node);
252
+ nodes.add(r.to_node);
253
+ (adj.get(r.from_node) ?? adj.set(r.from_node, []).get(r.from_node)).push(
254
+ r.to_node
255
+ );
256
+ indeg.set(r.to_node, (indeg.get(r.to_node) ?? 0) + 1);
257
+ if (!indeg.has(r.from_node)) indeg.set(r.from_node, 0);
258
+ }
259
+ const queue = [...nodes].filter((n) => (indeg.get(n) ?? 0) === 0);
260
+ let removed = 0;
261
+ while (queue.length) {
262
+ const cur = queue.shift();
263
+ removed++;
264
+ for (const nb of adj.get(cur) ?? []) {
265
+ indeg.set(nb, (indeg.get(nb) ?? 0) - 1);
266
+ if ((indeg.get(nb) ?? 0) === 0) queue.push(nb);
267
+ }
268
+ }
269
+ if (removed < nodes.size) {
270
+ throw new ConflictError(
271
+ "cycle: enabling flow on this edge type would create a cycle in the flow graph \u2014 flow edges must form a DAG"
272
+ );
273
+ }
274
+ }
275
+
276
+ // src/workflow/edge-type-routes.ts
277
+ var requireWorkflowPlane = async (c, next) => {
278
+ if (!config.FEATURE_WORKFLOW_PLANE) {
279
+ return c.json(
280
+ {
281
+ error: "The workflow plane is not enabled on this server. An admin can turn it on with FEATURE_WORKFLOW_PLANE=true."
282
+ },
283
+ 404
284
+ );
285
+ }
286
+ return next();
287
+ };
288
+ async function seedDefaultEdgeTypes(nestId) {
289
+ const db = getDb();
290
+ const existing = await db.get(
291
+ "SELECT COUNT(*) as c FROM edge_types WHERE nest_id = ?",
292
+ [nestId]
293
+ );
294
+ if (existing.c > 0) return;
295
+ const owner = await db.get(
296
+ "SELECT u.email FROM nests n JOIN users u ON u.id = n.user_id WHERE n.id = ?",
297
+ [nestId]
298
+ );
299
+ const createdBy = owner?.email ?? "admin@localhost";
300
+ const now = (/* @__PURE__ */ new Date()).toISOString();
301
+ const defaults = [
302
+ ["next", "Unconditional flow: after the source completes, run the target.", 1, "#16a34a"],
303
+ ["on-success", "Follow only when the source step succeeded.", 1, "#16a34a"],
304
+ ["on-failure", "Follow only when the source step failed.", 1, "#ef4444"],
305
+ ["depends-on", "The source requires the target (ordering/lineage; not conditional flow).", 0, "#3b82f6"],
306
+ ["owned-by", "Ownership: the target person/team is accountable for the source node.", 0, "#64748b"]
307
+ ];
308
+ for (const [name, description, isFlow, color] of defaults) {
309
+ await db.run(
310
+ `INSERT INTO edge_types
311
+ (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
312
+ VALUES (?, ?, ?, ?, 'directed', ?, NULL, ?, ?, ?, ?)
313
+ ON CONFLICT DO NOTHING`,
314
+ [uuid2(), nestId, name, description, isFlow, color, createdBy, now, now]
315
+ );
316
+ }
317
+ }
318
+ function parseConditionSchema(raw) {
319
+ if (raw === void 0 || raw === null || raw === "") return null;
320
+ const obj = typeof raw === "string" ? safeJson(raw) : raw;
321
+ if (!obj || typeof obj !== "object" || !Array.isArray(obj.params) || !obj.params.every((p) => typeof p === "string")) {
322
+ throw new ValidationError(
323
+ 'condition_schema must be {"params": [string\u2026], "mode_default"?: "structured"|"nl"|"open"}'
324
+ );
325
+ }
326
+ const mode = obj.mode_default;
327
+ if (mode !== void 0 && !["structured", "nl", "open"].includes(mode)) {
328
+ throw new ValidationError(
329
+ "condition_schema.mode_default must be structured | nl | open"
330
+ );
331
+ }
332
+ return JSON.stringify({ params: obj.params, mode_default: mode ?? "structured" });
333
+ }
334
+ var edgeTypeToResponse = (r) => ({
335
+ ...r,
336
+ is_flow: !!r.is_flow,
337
+ condition_schema: r.condition_schema ? safeJson(r.condition_schema) : null
338
+ });
339
+ var edgeTypeRoutes = new Hono();
340
+ edgeTypeRoutes.get("/", requireWorkflowPlane, async (c) => {
341
+ const nestId = c.req.param("nestId");
342
+ await seedDefaultEdgeTypes(nestId);
343
+ const rows = await getDb().all(
344
+ "SELECT * FROM edge_types WHERE nest_id = ? ORDER BY LOWER(name)",
345
+ [nestId]
346
+ );
347
+ return c.json({ count: rows.length, edge_types: rows.map(edgeTypeToResponse) });
348
+ });
349
+ edgeTypeRoutes.post("/", requireWorkflowPlane, async (c) => {
350
+ const nestId = c.req.param("nestId");
351
+ const body = await c.req.json();
352
+ if (typeof body.name !== "string" || !body.name.trim()) {
353
+ throw new ValidationError("name is required");
354
+ }
355
+ if (typeof body.description !== "string" || !body.description.trim()) {
356
+ throw new ValidationError("description (the articulation) is required");
357
+ }
358
+ const name = body.name.trim();
359
+ if (name.length > 100 || !/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
360
+ throw new ValidationError(
361
+ "name must be alphanumeric-with-dashes, 100 chars max (e.g. escalates-when)"
362
+ );
363
+ }
364
+ const direction = String(body.direction ?? "directed");
365
+ if (!["directed", "undirected"].includes(direction)) {
366
+ throw new ValidationError("direction must be directed | undirected");
367
+ }
368
+ const conditionSchema = parseConditionSchema(body.condition_schema);
369
+ const isFlow = body.is_flow === true ? 1 : 0;
370
+ let color = null;
371
+ if (body.color !== void 0 && body.color !== null && body.color !== "") {
372
+ if (typeof body.color !== "string" || !/^#[0-9a-f]{6}$/i.test(body.color)) {
373
+ throw new ValidationError("color must be a 6-digit hex, e.g. #16a34a");
374
+ }
375
+ color = body.color;
376
+ }
377
+ const db = getDb();
378
+ const definedBy = await resolveCallerEmail(c.get("userId"));
379
+ const now = (/* @__PURE__ */ new Date()).toISOString();
380
+ await seedDefaultEdgeTypes(nestId);
381
+ let created = false;
382
+ await db.transaction(async (tx) => {
383
+ const existing = await tx.get(
384
+ "SELECT id, is_flow FROM edge_types WHERE nest_id = ? AND LOWER(name) = LOWER(?)",
385
+ [nestId, name]
386
+ );
387
+ if (existing && !existing.is_flow && isFlow) {
388
+ await assertFlowGraphAcyclic(nestId, existing.id);
389
+ }
390
+ if (existing) {
391
+ await tx.run(
392
+ `UPDATE edge_types SET name = ?, description = ?, direction = ?, is_flow = ?,
393
+ condition_schema = ?, color = ?, updated_at = ? WHERE id = ?`,
394
+ [name, body.description.trim(), direction, isFlow, conditionSchema, color, now, existing.id]
395
+ );
396
+ } else {
397
+ created = true;
398
+ await tx.run(
399
+ `INSERT INTO edge_types
400
+ (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
401
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
402
+ [uuid2(), nestId, name, body.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
403
+ );
404
+ }
405
+ });
406
+ const row = await db.get(
407
+ "SELECT * FROM edge_types WHERE nest_id = ? AND LOWER(name) = LOWER(?)",
408
+ [nestId, name]
409
+ );
410
+ return c.json({ edge_type: edgeTypeToResponse(row) }, created ? 201 : 200);
411
+ });
412
+ edgeTypeRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
413
+ const nestId = c.req.param("nestId");
414
+ const id = c.req.param("id");
415
+ await getDb().transaction(async (tx) => {
416
+ const row = await tx.get(
417
+ "SELECT id FROM edge_types WHERE id = ? AND nest_id = ?",
418
+ [id, nestId]
419
+ );
420
+ if (!row) throw new NotFoundError("Edge type not found");
421
+ const inUse = await tx.get(
422
+ "SELECT COUNT(*) as c FROM edges WHERE type_id = ?",
423
+ [id]
424
+ );
425
+ if (inUse.c > 0) {
426
+ throw new ValidationError(
427
+ `Edge type is in use by ${inUse.c} edge${inUse.c === 1 ? "" : "s"} \u2014 delete those first.`
428
+ );
429
+ }
430
+ await tx.run("DELETE FROM edge_types WHERE id = ?", [id]);
431
+ });
432
+ return c.json({ deleted: true });
433
+ });
434
+
435
+ // src/workflow/env-routes.ts
436
+ var KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
437
+ var mask = (v) => v.length <= 4 ? "\u2022\u2022\u2022\u2022" : `\u2022\u2022\u2022\u2022${v.slice(-4)}`;
438
+ var envRoutes = new Hono2();
439
+ envRoutes.get("/", requireWorkflowPlane, async (c) => {
440
+ const nestId = c.req.param("nestId");
441
+ const rows = await getDb().all(
442
+ "SELECT key, value, updated_by, updated_at FROM nest_env WHERE nest_id = ? ORDER BY key",
443
+ [nestId]
444
+ );
445
+ return c.json({
446
+ count: rows.length,
447
+ env: rows.map((r) => ({
448
+ key: r.key,
449
+ value_masked: mask(r.value),
450
+ updated_by: r.updated_by,
451
+ updated_at: r.updated_at
452
+ }))
453
+ });
454
+ });
455
+ envRoutes.put("/", requireWorkflowPlane, async (c) => {
456
+ const nestId = c.req.param("nestId");
457
+ const body = await c.req.json();
458
+ const key = typeof body.key === "string" ? body.key.trim() : "";
459
+ if (!KEY_RE.test(key)) {
460
+ throw new ValidationError(
461
+ "key must look like an env var: A-Z, digits, underscores (e.g. SLACK_TOKEN)"
462
+ );
463
+ }
464
+ if (typeof body.value !== "string" || !body.value) {
465
+ throw new ValidationError("value is required");
466
+ }
467
+ const now = (/* @__PURE__ */ new Date()).toISOString();
468
+ const by = await resolveCallerEmail(c.get("userId"));
469
+ const db = getDb();
470
+ await db.run(
471
+ `INSERT INTO nest_env (nest_id, key, value, updated_by, updated_at)
472
+ VALUES (?, ?, ?, ?, ?)
473
+ ON CONFLICT(nest_id, key) DO UPDATE SET value = ?, updated_by = ?, updated_at = ?`,
474
+ [nestId, key, body.value, by, now, body.value, by, now]
475
+ );
476
+ return c.json({ key, value_masked: mask(body.value) }, 201);
477
+ });
478
+ envRoutes.delete("/:key", requireWorkflowPlane, async (c) => {
479
+ const nestId = c.req.param("nestId");
480
+ const key = c.req.param("key");
481
+ const db = getDb();
482
+ const row = await db.get(
483
+ "SELECT key FROM nest_env WHERE nest_id = ? AND key = ?",
484
+ [nestId, key]
485
+ );
486
+ if (!row) throw new NotFoundError("No such env key");
487
+ await db.run("DELETE FROM nest_env WHERE nest_id = ? AND key = ?", [nestId, key]);
488
+ return c.json({ deleted: true });
489
+ });
490
+ async function envValues(nestId) {
491
+ const rows = await getDb().all(
492
+ "SELECT key, value FROM nest_env WHERE nest_id = ?",
493
+ [nestId]
494
+ );
495
+ const base = {};
496
+ if (process.env.ANTHROPIC_API_KEY) base.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
497
+ return { ...base, ...Object.fromEntries(rows.map((r) => [r.key, r.value])) };
498
+ }
499
+
500
+ // src/notify/dispatch.ts
501
+ function escapeMrkdwn(s) {
502
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
503
+ }
504
+ var EVENT_EMOJI = {
505
+ review_requested: ":memo:",
506
+ review_approved: ":white_check_mark:",
507
+ review_rejected: ":x:",
508
+ run_failed: ":rotating_light:"
509
+ };
510
+ function formatBody(channel, nestName, ev) {
511
+ const line = `[${nestName}] ${ev.message}`;
512
+ if (channel === "slack") {
513
+ return { text: escapeMrkdwn(`${EVENT_EMOJI[ev.kind] ?? ""} *[${nestName}]* ${ev.message}`) };
514
+ }
515
+ if (channel === "teams") {
516
+ return { text: line };
517
+ }
518
+ return {
519
+ kind: ev.kind,
520
+ nest_id: ev.nestId,
521
+ nest_name: nestName,
522
+ message: ev.message,
523
+ subject_id: ev.subjectId ?? null,
524
+ actor: ev.actor ?? null,
525
+ at: (/* @__PURE__ */ new Date()).toISOString()
526
+ };
527
+ }
528
+ function isForbiddenConnectorHost(hostname) {
529
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
530
+ if (h === "localhost" || h.endsWith(".localhost") || h.endsWith(".local") || h.endsWith(".internal"))
531
+ return true;
532
+ if (h === "::1" || h === "0.0.0.0" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd"))
533
+ return true;
534
+ const m = h.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
535
+ if (!m) return false;
536
+ const [a, b] = [Number(m[1]), Number(m[2])];
537
+ if (a === 127 || a === 10 || a === 0) return true;
538
+ if (a === 172 && b >= 16 && b <= 31) return true;
539
+ if (a === 192 && b === 168) return true;
540
+ if (a === 169 && b === 254) return true;
541
+ return false;
542
+ }
543
+ function isSafeConnectorUrl(raw) {
544
+ if (!/^https:\/\//i.test(raw)) return false;
545
+ try {
546
+ return !isForbiddenConnectorHost(new URL(raw).hostname);
547
+ } catch {
548
+ return false;
549
+ }
550
+ }
551
+ async function resolveUrl(nestId, raw) {
552
+ if (raw.startsWith("env:")) {
553
+ const key = raw.slice(4).trim();
554
+ const env = await envValues(nestId);
555
+ const v = env[key];
556
+ return v && isSafeConnectorUrl(v) ? v : null;
557
+ }
558
+ return isSafeConnectorUrl(raw) ? raw : null;
559
+ }
560
+ var warned = /* @__PURE__ */ new Set();
561
+ async function post(url, body, who) {
562
+ try {
563
+ const ctrl = new AbortController();
564
+ const timer = setTimeout(() => ctrl.abort(), 3e3);
565
+ try {
566
+ await fetch(url, {
567
+ method: "POST",
568
+ headers: { "Content-Type": "application/json" },
569
+ body: JSON.stringify(body),
570
+ signal: ctrl.signal
571
+ });
572
+ } finally {
573
+ clearTimeout(timer);
574
+ }
575
+ } catch (err) {
576
+ if (!warned.has(who)) {
577
+ warned.add(who);
578
+ console.warn(
579
+ `[connectors] delivery failed for ${who} (muting further warnings):`,
580
+ err instanceof Error ? err.message : err
581
+ );
582
+ }
583
+ }
584
+ }
585
+ async function dispatchEvent(ev) {
586
+ const db = getDb();
587
+ let nestName = ev.nestId;
588
+ try {
589
+ const row = await db.get("SELECT name FROM nests WHERE id = ?", [ev.nestId]);
590
+ if (row?.name) nestName = row.name;
591
+ } catch {
592
+ }
593
+ if (config.SLACK_WEBHOOK_URL) {
594
+ void post(
595
+ config.SLACK_WEBHOOK_URL,
596
+ formatBody("slack", nestName, ev),
597
+ "server-slack"
598
+ );
599
+ }
600
+ let rows = [];
601
+ try {
602
+ rows = await db.all(
603
+ "SELECT id, channel, url, events, enabled FROM connectors WHERE nest_id = ? AND enabled = 1",
604
+ [ev.nestId]
605
+ );
606
+ } catch {
607
+ return;
608
+ }
609
+ for (const r of rows) {
610
+ let kinds = [];
611
+ try {
612
+ kinds = JSON.parse(r.events);
613
+ } catch {
614
+ }
615
+ if (!kinds.includes("*") && !kinds.includes(ev.kind)) continue;
616
+ const url = await resolveUrl(ev.nestId, r.url);
617
+ if (!url) continue;
618
+ void post(url, formatBody(r.channel, nestName, ev), `connector:${r.id}`);
619
+ }
620
+ }
621
+
622
+ // src/notify/slack.ts
623
+ var warnedOnce = false;
624
+ async function notifySlackForNest(nestId, message) {
625
+ if (!config.SLACK_WEBHOOK_URL) return;
626
+ let nestName = nestId;
627
+ try {
628
+ const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
629
+ nestId
630
+ ]);
631
+ if (row?.name) nestName = row.name;
632
+ } catch {
633
+ }
634
+ return notifySlack(`*[${nestName}]* ${message}`);
635
+ }
636
+ function escapeMrkdwn2(s) {
637
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
638
+ }
639
+ async function notifySlack(text) {
640
+ const url = config.SLACK_WEBHOOK_URL;
641
+ if (!url) return;
642
+ try {
643
+ const ctrl = new AbortController();
644
+ const timer = setTimeout(() => ctrl.abort(), 3e3);
645
+ try {
646
+ await fetch(url, {
647
+ method: "POST",
648
+ headers: { "Content-Type": "application/json" },
649
+ body: JSON.stringify({ text: escapeMrkdwn2(text) }),
650
+ signal: ctrl.signal
651
+ });
652
+ } finally {
653
+ clearTimeout(timer);
654
+ }
655
+ } catch (err) {
656
+ if (!warnedOnce) {
657
+ warnedOnce = true;
658
+ console.warn(
659
+ "[slack] notification failed (suppressing further warnings):",
660
+ err instanceof Error ? err.message : err
661
+ );
662
+ }
663
+ }
664
+ }
665
+
666
+ // src/notify/email-render.ts
667
+ function escapeHtml(s) {
668
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
669
+ }
670
+ var EVENT = {
671
+ ":white_check_mark:": { icon: "\u2705", color: "#16a34a" },
672
+ ":x:": { icon: "\u274C", color: "#dc2626" },
673
+ ":memo:": { icon: "\u{1F4DD}", color: "#d97706" },
674
+ ":key:": { icon: "\u{1F511}", color: "#2563eb" }
675
+ };
676
+ var ALL_EMOJI = Object.fromEntries(
677
+ Object.entries(EVENT).map(([code, m]) => [code, m.icon])
678
+ );
679
+ function renderText(message) {
680
+ return message.replace(/:[a-z_]+:/g, (m) => ALL_EMOJI[m] ?? "").replace(/\*/g, "").replace(/[ \t]{2,}/g, " ").trim();
681
+ }
682
+ var STATUS_META = {
683
+ approved: { subjectWord: "approved", label: "Approved", color: "#16a34a" },
684
+ rejected: { subjectWord: "rejected", label: "Rejected", color: "#dc2626" },
685
+ pending_review: {
686
+ subjectWord: "awaiting review",
687
+ label: "Pending review",
688
+ color: "#d97706"
689
+ },
690
+ shared: { subjectWord: "shared with you", label: "Shared", color: "#2563eb" }
691
+ };
692
+ function renderEmail(d) {
693
+ const meta = STATUS_META[d.status];
694
+ const title = d.docTitle.replace(/[\r\n]+/g, " ").trim();
695
+ const heading = d.status === "shared" ? `New collaborator on ${title}` : `${title} is ${meta.subjectWord}`;
696
+ const subject = heading.replace(/[\r\n]+/g, " ").trim();
697
+ const vLabel = d.version != null ? `v${d.version}` : "";
698
+ const detailsHeader = d.status === "shared" ? "Details" : "Document details";
699
+ const linkLabel = d.status === "shared" ? "Open nest" : "Open document";
700
+ const byT = d.actor ? ` by ${d.actor}` : "";
701
+ const noteT = d.note ? ` with the note: "${d.note}"` : "";
702
+ const vT = vLabel ? ` ${vLabel}` : "";
703
+ let lineT;
704
+ switch (d.status) {
705
+ case "approved":
706
+ lineT = `${title}${vT} has been approved${byT}${noteT}.`;
707
+ break;
708
+ case "rejected":
709
+ lineT = `${title}${vT} has been rejected${byT}${noteT}.`;
710
+ break;
711
+ case "pending_review":
712
+ lineT = `A review has been requested for ${title}${vT}${byT}${noteT}.`;
713
+ break;
714
+ case "shared":
715
+ lineT = `${d.actor || "A user"} was added to ${title} as ${d.permission || "a collaborator"}${d.by ? ` by ${d.by}` : ""}.`;
716
+ break;
717
+ }
718
+ const text = [
719
+ "Hi,",
720
+ "",
721
+ lineT,
722
+ "",
723
+ "Document details:",
724
+ ` Path: ${d.path}`,
725
+ ` Status: ${meta.label}`,
726
+ ...d.version != null ? [` Version: v${d.version}`] : [],
727
+ ...d.link ? [` Open: ${d.link}`] : [],
728
+ "",
729
+ "Regards,",
730
+ "ContextNest"
731
+ ].join("\n");
732
+ const doc = `<strong>${escapeHtml(title)}</strong>`;
733
+ const byH = d.actor ? ` by ${escapeHtml(d.actor)}` : "";
734
+ const noteH = d.note ? ` with the note: &ldquo;${escapeHtml(d.note)}&rdquo;` : "";
735
+ const vH = vLabel ? ` ${vLabel}` : "";
736
+ let lineH;
737
+ switch (d.status) {
738
+ case "approved":
739
+ lineH = `${doc}${vH} has been approved${byH}${noteH}.`;
740
+ break;
741
+ case "rejected":
742
+ lineH = `${doc}${vH} has been rejected${byH}${noteH}.`;
743
+ break;
744
+ case "pending_review":
745
+ lineH = `A review has been requested for ${doc}${vH}${byH}${noteH}.`;
746
+ break;
747
+ case "shared":
748
+ lineH = `${escapeHtml(d.actor || "A user")} was added to ${doc} as <strong>${escapeHtml(d.permission || "a collaborator")}</strong>${d.by ? ` by ${escapeHtml(d.by)}` : ""}.`;
749
+ break;
750
+ }
751
+ const detailRow = (label, valueHtml) => `<tr>
752
+ <td style="padding:5px 0;font-size:13px;color:#6b7280;width:90px;vertical-align:top;">${label}</td>
753
+ <td style="padding:5px 0;font-size:13px;color:#111827;vertical-align:top;">${valueHtml}</td>
754
+ </tr>`;
755
+ const detailRows = detailRow("Path", escapeHtml(d.path)) + detailRow(
756
+ "Status",
757
+ `<span style="display:inline-block;padding:2px 9px;border-radius:9999px;background:#f3f4f6;color:${meta.color};font-size:12px;font-weight:600;">${meta.label}</span>`
758
+ ) + (d.version != null ? detailRow("Version", `v${d.version}`) : "");
759
+ const buttonRow = d.link ? `<tr><td style="padding:20px 24px 0;">
760
+ <table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr>
761
+ <td bgcolor="${meta.color}" style="border-radius:6px;">
762
+ <a href="${escapeHtml(d.link)}" target="_blank" style="display:inline-block;padding:10px 20px;font-size:14px;font-weight:600;color:#ffffff;text-decoration:none;">${linkLabel}</a>
763
+ </td>
764
+ </tr></table>
765
+ </td></tr>` : "";
766
+ const html = `<div style="margin:0;padding:24px;background:#f6f8fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#111827;">
767
+ <table role="presentation" width="480" cellpadding="0" cellspacing="0" border="0" align="center" style="width:100%;max-width:480px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;">
768
+ <tr><td style="padding:20px 24px 0;">
769
+ <div style="font-size:12px;font-weight:700;color:#6b7280;letter-spacing:0.06em;text-transform:uppercase;">ContextNest</div>
770
+ </td></tr>
771
+ <tr><td style="padding:10px 24px 0;">
772
+ <h1 style="margin:0;font-size:19px;line-height:1.35;font-weight:700;color:#111827;">${escapeHtml(heading)}</h1>
773
+ </td></tr>
774
+ <tr><td style="padding:16px 24px 0;font-size:14px;line-height:1.6;color:#374151;">
775
+ Hi,<br><br>${lineH}
776
+ </td></tr>
777
+ <tr><td style="padding:20px 24px 0;">
778
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #eef0f2;border-radius:8px;">
779
+ <tr><td style="padding:12px 16px;">
780
+ <div style="font-size:11px;font-weight:600;color:#9ca3af;letter-spacing:0.04em;text-transform:uppercase;padding-bottom:4px;">${detailsHeader}</div>
781
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">${detailRows}</table>
782
+ </td></tr>
783
+ </table>
784
+ </td></tr>
785
+ ${buttonRow}
786
+ <tr><td style="padding:22px 24px 24px;font-size:14px;line-height:1.6;color:#374151;">
787
+ Regards,<br>ContextNest
788
+ </td></tr>
789
+ <tr><td style="padding:14px 24px;border-top:1px solid #f0f0f0;font-size:11px;line-height:1.5;color:#9ca3af;">
790
+ Automated governance notification from ContextNest. You received this because email notifications are enabled on this server.
791
+ </td></tr>
792
+ </table>
793
+ </div>`;
794
+ return { subject, text, html };
795
+ }
796
+ function renderHtml(message, nestName) {
797
+ const lead = message.match(/^(:[a-z_]+:)\s*/);
798
+ const meta = lead && EVENT[lead[1]] || { icon: "\u{1F514}", color: "#475569" };
799
+ const rest = lead ? message.slice(lead[0].length) : message;
800
+ const body = escapeHtml(rest).replace(/\*([^*]+)\*/g, "<strong>$1</strong>").replace(/\n/g, "<br>");
801
+ const safeNest = escapeHtml(nestName);
802
+ return `<div style="background:#f6f8fa;padding:24px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
803
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:480px;margin:0 auto;background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden;">
804
+ <tr><td style="padding:20px 24px 16px;border-bottom:1px solid #f0f0f0;">
805
+ <div style="font-size:15px;font-weight:700;color:#111827;letter-spacing:-0.01em;">ContextNest</div>
806
+ <div style="font-size:13px;color:#6b7280;margin-top:2px;">${safeNest}</div>
807
+ </td></tr>
808
+ <tr><td style="padding:24px;">
809
+ <table role="presentation" cellpadding="0" cellspacing="0"><tr>
810
+ <td valign="top" style="font-size:22px;line-height:1;padding-right:14px;">${meta.icon}</td>
811
+ <td valign="top" style="font-size:15px;line-height:1.55;color:#111827;border-left:3px solid ${meta.color};padding-left:14px;">${body}</td>
812
+ </tr></table>
813
+ </td></tr>
814
+ <tr><td style="padding:14px 24px;border-top:1px solid #f0f0f0;font-size:11px;line-height:1.5;color:#9ca3af;">
815
+ Automated governance notification from ContextNest. You received this because email notifications are enabled on this server.
816
+ </td></tr>
817
+ </table>
818
+ </div>`;
819
+ }
820
+
821
+ // src/notify/email.ts
822
+ var transporterPromise = null;
823
+ var configuredUrl = null;
824
+ var warnedOnce2 = false;
825
+ var SMTP_TIMEOUT_DEFAULTS = [
826
+ ["connectionTimeout", 5e3],
827
+ ["greetingTimeout", 5e3],
828
+ ["socketTimeout", 1e4]
829
+ ];
830
+ function withTimeouts(url) {
831
+ const query = url.includes("?") ? url.slice(url.indexOf("?") + 1) : "";
832
+ const missing = SMTP_TIMEOUT_DEFAULTS.filter(
833
+ ([name]) => !new RegExp(`(^|&)${name}=`, "i").test(query)
834
+ ).map(([name, ms]) => `${name}=${ms}`);
835
+ if (missing.length === 0) return url;
836
+ return url + (url.includes("?") ? "&" : "?") + missing.join("&");
837
+ }
838
+ async function verifySmtp(url) {
839
+ let transporter = null;
840
+ try {
841
+ const nodemailer = await import("nodemailer");
842
+ transporter = nodemailer.default.createTransport(withTimeouts(url), {
843
+ disableFileAccess: true,
844
+ disableUrlAccess: true
845
+ });
846
+ await transporter.verify();
847
+ return { ok: true };
848
+ } catch (err) {
849
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
850
+ } finally {
851
+ try {
852
+ transporter?.close?.();
853
+ } catch {
854
+ }
855
+ }
856
+ }
857
+ async function getTransporter() {
858
+ const url = config.SMTP_URL;
859
+ if (!url) return null;
860
+ if (!transporterPromise || configuredUrl !== url) {
861
+ configuredUrl = url;
862
+ transporterPromise = import("nodemailer").then(
863
+ (nodemailer) => nodemailer.default.createTransport(withTimeouts(url), {
864
+ // Per-message defaults (belt-and-braces; also set on each sendMail).
865
+ disableFileAccess: true,
866
+ disableUrlAccess: true
867
+ })
868
+ );
869
+ transporterPromise.catch(() => {
870
+ transporterPromise = null;
871
+ configuredUrl = null;
872
+ });
873
+ }
874
+ return transporterPromise;
875
+ }
876
+ async function notifyEmailForNest(nestId, message, details) {
877
+ const from = config.NOTIFY_EMAIL_FROM;
878
+ const to = config.NOTIFY_EMAIL_TO;
879
+ if (!config.SMTP_URL || !from || !to) return;
880
+ try {
881
+ const transporter = await getTransporter();
882
+ if (!transporter) return;
883
+ let subject;
884
+ let text;
885
+ let html;
886
+ if (details) {
887
+ ({ subject, text, html } = renderEmail(details));
888
+ } else {
889
+ let name = nestId;
890
+ try {
891
+ const row = await getDb().get("SELECT name FROM nests WHERE id = ?", [
892
+ nestId
893
+ ]);
894
+ if (row?.name) name = row.name;
895
+ } catch {
896
+ }
897
+ const safeName = name.replace(/[\r\n]+/g, " ").trim();
898
+ subject = `ContextNest \xB7 ${safeName}`;
899
+ text = renderText(message);
900
+ html = renderHtml(message, safeName);
901
+ }
902
+ await transporter.sendMail({
903
+ from,
904
+ to,
905
+ subject,
906
+ text,
907
+ html,
908
+ disableFileAccess: true,
909
+ disableUrlAccess: true
910
+ });
911
+ } catch (err) {
912
+ if (!warnedOnce2) {
913
+ warnedOnce2 = true;
914
+ console.warn(
915
+ "[email] notification failed (suppressing further warnings):",
916
+ err instanceof Error ? err.message : err
917
+ );
918
+ }
919
+ }
920
+ }
921
+
922
+ // src/notify/notify.ts
923
+ var DIGEST_LINE_CAP = 10;
924
+ var buffers = /* @__PURE__ */ new Map();
925
+ function buildText(lines) {
926
+ if (lines.length === 1) return lines[0];
927
+ const shown = lines.slice(0, DIGEST_LINE_CAP).map((l) => `\u2022 ${l}`).join("\n");
928
+ const more = lines.length > DIGEST_LINE_CAP ? `
929
+ \u2026and ${lines.length - DIGEST_LINE_CAP} more` : "";
930
+ return `${lines.length} updates:
931
+ ${shown}${more}`;
932
+ }
933
+ async function flush(nestId) {
934
+ const buf = buffers.get(nestId);
935
+ if (!buf) return;
936
+ buffers.delete(nestId);
937
+ const text = buildText(buf.events.map((e) => e.line));
938
+ const details = buf.events.length === 1 ? buf.events[0].details : void 0;
939
+ await Promise.allSettled([
940
+ notifySlackForNest(nestId, text),
941
+ notifyEmailForNest(nestId, text, details)
942
+ ]);
943
+ }
944
+ async function notifyNestEvent(nestId, message, details) {
945
+ if (!config.SLACK_WEBHOOK_URL && !config.SMTP_URL) return;
946
+ const buf = buffers.get(nestId);
947
+ if (buf) {
948
+ buf.events.push({ line: message, details });
949
+ return;
950
+ }
951
+ const timer = setTimeout(() => void flush(nestId), config.NOTIFY_DEBOUNCE_MS);
952
+ timer.unref?.();
953
+ buffers.set(nestId, { events: [{ line: message, details }], timer });
954
+ }
955
+
956
+ // src/governance/safe-publish.ts
957
+ import {
958
+ publishDocument,
959
+ serializeDocument
960
+ } from "@promptowl/contextnest-engine";
961
+ async function safePublishDocument(storage, docId, options) {
962
+ const node = await storage.readDocument(docId);
963
+ const cleanedFrontmatter = stripUndefinedDeep(node.frontmatter);
964
+ const cleanedNode = { ...node, frontmatter: cleanedFrontmatter };
965
+ await storage.writeDocument(docId, serializeDocument(cleanedNode));
966
+ return publishDocument(storage, docId, options);
967
+ }
968
+ function stripUndefinedDeep(value) {
969
+ if (Array.isArray(value)) {
970
+ return value.filter((v) => v !== void 0).map((v) => stripUndefinedDeep(v));
971
+ }
972
+ if (value && typeof value === "object") {
973
+ const out = {};
974
+ for (const [k, v] of Object.entries(value)) {
975
+ if (v === void 0) continue;
976
+ out[k] = stripUndefinedDeep(v);
977
+ }
978
+ return out;
979
+ }
980
+ return value;
981
+ }
982
+
983
+ // src/governance/review-service.ts
984
+ async function submitForReview(params) {
985
+ const db = getDb();
986
+ const existing = await db.get(
987
+ "SELECT id FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending'",
988
+ [params.nestId, params.nodeId]
989
+ );
990
+ if (existing) {
991
+ throw new Error("A review is already pending for this node");
992
+ }
993
+ const id = uuid3();
994
+ await db.run(
995
+ `INSERT INTO review_requests
996
+ (id, nest_id, node_id, version, requested_by, request_note, priority)
997
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
998
+ [
999
+ id,
1000
+ params.nestId,
1001
+ params.nodeId,
1002
+ params.version,
1003
+ params.requestedBy,
1004
+ params.note || null,
1005
+ params.priority || "normal"
1006
+ ]
1007
+ );
1008
+ await db.run(
1009
+ "UPDATE node_versions SET status = 'pending_review' WHERE nest_id = ? AND node_id = ? AND version = ?",
1010
+ [params.nestId, params.nodeId, params.version]
1011
+ );
1012
+ const reviewCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
1013
+ void dispatchEvent({
1014
+ kind: "review_requested",
1015
+ nestId: params.nestId,
1016
+ subjectId: params.nodeId,
1017
+ actor: params.requestedBy,
1018
+ message: `Review requested on *${reviewCtx.label}* (v${params.version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
1019
+ });
1020
+ void notifyNestEvent(
1021
+ params.nestId,
1022
+ `:memo: Review requested on *${reviewCtx.label}* (v${params.version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`,
1023
+ {
1024
+ status: "pending_review",
1025
+ docTitle: reviewCtx.docTitle,
1026
+ path: reviewCtx.path,
1027
+ link: reviewCtx.link,
1028
+ actor: params.requestedBy,
1029
+ version: params.version,
1030
+ note: params.note || null
1031
+ }
1032
+ );
1033
+ await notifyReviewRequested({
1034
+ nestId: params.nestId,
1035
+ nodeId: params.nodeId,
1036
+ requestedBy: params.requestedBy,
1037
+ reviewId: id
1038
+ });
1039
+ return await getReviewRequest(id);
1040
+ }
1041
+ async function approve(params) {
1042
+ const db = getDb();
1043
+ const pending = await db.get(
1044
+ "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1045
+ [params.nestId, params.nodeId]
1046
+ );
1047
+ if (!pending) {
1048
+ throw new Error("No pending review for this node");
1049
+ }
1050
+ if (!params.override) {
1051
+ const check = await canUserApprove(
1052
+ params.nestId,
1053
+ params.nodeId,
1054
+ params.approvedBy
1055
+ );
1056
+ if (!check.allowed) {
1057
+ throw new Error(check.reason);
1058
+ }
1059
+ }
1060
+ await db.run(
1061
+ `UPDATE review_requests
1062
+ SET status = 'approved', resolved_by = ?, resolved_at = ${nowExpr(db)},
1063
+ resolution_note = ?, is_override = ?
1064
+ WHERE id = ?`,
1065
+ [params.approvedBy, params.note || null, params.override ? 1 : 0, pending.id]
1066
+ );
1067
+ void dispatchEvent({
1068
+ kind: "review_approved",
1069
+ nestId: params.nestId,
1070
+ subjectId: params.nodeId,
1071
+ actor: params.approvedBy,
1072
+ message: `*${await describeNode(params.nestId, params.nodeId)}* v${params.version} approved by ${params.approvedBy}`
1073
+ });
1074
+ await notifyReviewResolved({
1075
+ nestId: params.nestId,
1076
+ nodeId: params.nodeId,
1077
+ status: "approved",
1078
+ resolvedBy: params.approvedBy,
1079
+ requestedBy: pending.requested_by,
1080
+ reviewId: pending.id
1081
+ });
1082
+ await db.run(
1083
+ "UPDATE node_versions SET status = 'published' WHERE nest_id = ? AND node_id = ? AND version = ?",
1084
+ [params.nestId, params.nodeId, params.version]
1085
+ );
1086
+ await db.run(
1087
+ insertOrReplace(
1088
+ db,
1089
+ `INSERT INTO approved_versions (nest_id, node_id, approved_version, approved_by)
1090
+ VALUES (?, ?, ?, ?)`,
1091
+ ["nest_id", "node_id"],
1092
+ `approved_version = excluded.approved_version, approved_by = excluded.approved_by, approved_at = ${nowExpr(db)}`
1093
+ ),
1094
+ [params.nestId, params.nodeId, params.version, params.approvedBy]
1095
+ );
1096
+ try {
1097
+ const { storage } = await engineCache.get(params.nestId);
1098
+ const result = await safePublishDocument(storage, params.nodeId, {
1099
+ editedBy: params.approvedBy,
1100
+ note: params.note || `Approved review request ${pending.id}`
1101
+ });
1102
+ const engineVersion = result.versionEntry.version;
1103
+ if (engineVersion !== params.version) {
1104
+ const node = result.node;
1105
+ const tags = node.frontmatter.tags || [];
1106
+ await createVersion({
1107
+ nestId: params.nestId,
1108
+ nodeId: params.nodeId,
1109
+ version: engineVersion,
1110
+ content: node.body || "",
1111
+ author: params.approvedBy,
1112
+ status: "published",
1113
+ tags,
1114
+ changeNote: params.note || `Approved review request ${pending.id}`
1115
+ });
1116
+ await setApprovedVersion(
1117
+ params.nestId,
1118
+ params.nodeId,
1119
+ engineVersion,
1120
+ params.approvedBy
1121
+ );
1122
+ }
1123
+ } catch (err) {
1124
+ console.error(
1125
+ `publishDocument failed for ${params.nestId}/${params.nodeId} on approve:`,
1126
+ err
1127
+ );
1128
+ }
1129
+ const approveCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
1130
+ void notifyNestEvent(
1131
+ params.nestId,
1132
+ `:white_check_mark: *${approveCtx.label}* v${params.version} approved by ${params.approvedBy}`,
1133
+ {
1134
+ status: "approved",
1135
+ docTitle: approveCtx.docTitle,
1136
+ path: approveCtx.path,
1137
+ link: approveCtx.link,
1138
+ actor: params.approvedBy,
1139
+ version: params.version
1140
+ }
1141
+ );
1142
+ return await getReviewRequest(pending.id);
1143
+ }
1144
+ async function reject(params) {
1145
+ const db = getDb();
1146
+ const pending = await db.get(
1147
+ "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1148
+ [params.nestId, params.nodeId]
1149
+ );
1150
+ if (!pending) {
1151
+ throw new Error("No pending review for this node");
1152
+ }
1153
+ const check = await canUserApprove(
1154
+ params.nestId,
1155
+ params.nodeId,
1156
+ params.rejectedBy
1157
+ );
1158
+ if (!check.allowed) {
1159
+ throw new Error(check.reason);
1160
+ }
1161
+ await db.run(
1162
+ `UPDATE review_requests
1163
+ SET status = 'rejected', resolved_by = ?, resolved_at = ${nowExpr(db)}, resolution_note = ?
1164
+ WHERE id = ?`,
1165
+ [params.rejectedBy, params.note, pending.id]
1166
+ );
1167
+ await db.run(
1168
+ "UPDATE node_versions SET status = 'rejected' WHERE nest_id = ? AND node_id = ? AND version = ?",
1169
+ [params.nestId, params.nodeId, params.version]
1170
+ );
1171
+ const rejectCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
1172
+ void dispatchEvent({
1173
+ kind: "review_rejected",
1174
+ nestId: params.nestId,
1175
+ subjectId: params.nodeId,
1176
+ actor: params.rejectedBy,
1177
+ message: `*${rejectCtx.label}* v${params.version} rejected by ${params.rejectedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
1178
+ });
1179
+ void notifyNestEvent(
1180
+ params.nestId,
1181
+ `:x: *${rejectCtx.label}* v${params.version} rejected by ${params.rejectedBy} \u2014 "${params.note}"`,
1182
+ {
1183
+ status: "rejected",
1184
+ docTitle: rejectCtx.docTitle,
1185
+ path: rejectCtx.path,
1186
+ link: rejectCtx.link,
1187
+ actor: params.rejectedBy,
1188
+ version: params.version,
1189
+ note: params.note || null
1190
+ }
1191
+ );
1192
+ await notifyReviewResolved({
1193
+ nestId: params.nestId,
1194
+ nodeId: params.nodeId,
1195
+ status: "rejected",
1196
+ resolvedBy: params.rejectedBy,
1197
+ requestedBy: pending.requested_by,
1198
+ reviewId: pending.id
1199
+ });
1200
+ return await getReviewRequest(pending.id);
1201
+ }
1202
+ async function cancelReview(params) {
1203
+ const db = getDb();
1204
+ const pending = await db.get(
1205
+ "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1206
+ [params.nestId, params.nodeId]
1207
+ );
1208
+ if (!pending) return null;
1209
+ await db.run(
1210
+ `UPDATE review_requests
1211
+ SET status = 'cancelled', resolved_by = ?, resolved_at = ${nowExpr(db)}
1212
+ WHERE id = ?`,
1213
+ [params.cancelledBy, pending.id]
1214
+ );
1215
+ await db.run(
1216
+ "UPDATE node_versions SET status = 'draft' WHERE nest_id = ? AND node_id = ? AND version = ?",
1217
+ [params.nestId, params.nodeId, pending.version]
1218
+ );
1219
+ return await getReviewRequest(pending.id);
1220
+ }
1221
+ async function getReviewQueue(params) {
1222
+ const db = getDb();
1223
+ let whereClauses = [];
1224
+ const args = [];
1225
+ if (params.nestId) {
1226
+ whereClauses.push("nest_id = ?");
1227
+ args.push(params.nestId);
1228
+ }
1229
+ if (params.status) {
1230
+ const statuses = Array.isArray(params.status) ? params.status : [params.status];
1231
+ whereClauses.push(
1232
+ `status IN (${statuses.map(() => "?").join(",")})`
1233
+ );
1234
+ args.push(...statuses);
1235
+ }
1236
+ const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
1237
+ const total = (await db.get(
1238
+ `SELECT COUNT(*) as c FROM review_requests ${where}`,
1239
+ args
1240
+ )).c;
1241
+ const limit = params.limit || 50;
1242
+ const offset = params.offset || 0;
1243
+ const rows = await db.all(
1244
+ `SELECT * FROM review_requests ${where}
1245
+ ORDER BY
1246
+ CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,
1247
+ requested_at DESC
1248
+ LIMIT ? OFFSET ?`,
1249
+ [...args, limit, offset]
1250
+ );
1251
+ let requests = rows.map(rowToReviewRequest);
1252
+ if (params.stewardEmail) {
1253
+ const filtered = [];
1254
+ for (const r of requests) {
1255
+ const stewards = await resolveStewardsForNode(r.nestId, r.nodeId);
1256
+ if (stewards.some((s) => s.steward.userEmail === params.stewardEmail)) {
1257
+ filtered.push(r);
1258
+ }
1259
+ }
1260
+ requests = filtered;
1261
+ }
1262
+ const titleMapsByNest = /* @__PURE__ */ new Map();
1263
+ const nestIds = new Set(requests.map((r) => r.nestId));
1264
+ for (const nid of nestIds) {
1265
+ titleMapsByNest.set(nid, await buildTitleMap(nid));
1266
+ }
1267
+ requests = requests.map((r) => ({
1268
+ ...r,
1269
+ title: titleMapsByNest.get(r.nestId)?.get(r.nodeId)
1270
+ }));
1271
+ return { requests, total };
1272
+ }
1273
+ async function getReviewHistory(nestId, nodeId) {
1274
+ const db = getDb();
1275
+ const rows = await db.all(
1276
+ "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? ORDER BY requested_at DESC",
1277
+ [nestId, nodeId]
1278
+ );
1279
+ return rows.map(rowToReviewRequest);
1280
+ }
1281
+ async function getPendingReview(nestId, nodeId) {
1282
+ const db = getDb();
1283
+ const row = await db.get(
1284
+ "SELECT * FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1",
1285
+ [nestId, nodeId]
1286
+ );
1287
+ return row ? rowToReviewRequest(row) : null;
1288
+ }
1289
+ async function getReviewRequest(id) {
1290
+ const db = getDb();
1291
+ const row = await db.get(
1292
+ "SELECT * FROM review_requests WHERE id = ?",
1293
+ [id]
1294
+ );
1295
+ return row ? rowToReviewRequest(row) : null;
1296
+ }
1297
+ function rowToReviewRequest(row) {
1298
+ return {
1299
+ id: row.id,
1300
+ nestId: row.nest_id,
1301
+ nodeId: row.node_id,
1302
+ version: row.version,
1303
+ requestedBy: row.requested_by,
1304
+ requestedAt: row.requested_at,
1305
+ requestNote: row.request_note || void 0,
1306
+ status: row.status,
1307
+ resolvedBy: row.resolved_by || void 0,
1308
+ resolvedAt: row.resolved_at || void 0,
1309
+ resolutionNote: row.resolution_note || void 0,
1310
+ priority: row.priority
1311
+ };
1312
+ }
1313
+
1314
+ export {
1315
+ safePublishDocument,
1316
+ listWatchers,
1317
+ addWatcher,
1318
+ removeWatcher,
1319
+ listNotifications,
1320
+ markNotificationsRead,
1321
+ resolveCallerEmail,
1322
+ canReadNode,
1323
+ filterAccessible,
1324
+ safeJson,
1325
+ requireWorkflowPlane,
1326
+ seedDefaultEdgeTypes,
1327
+ parseConditionSchema,
1328
+ edgeTypeToResponse,
1329
+ edgeTypeRoutes,
1330
+ envRoutes,
1331
+ envValues,
1332
+ isSafeConnectorUrl,
1333
+ dispatchEvent,
1334
+ verifySmtp,
1335
+ notifyNestEvent,
1336
+ submitForReview,
1337
+ approve,
1338
+ reject,
1339
+ cancelReview,
1340
+ getReviewQueue,
1341
+ getReviewHistory,
1342
+ getPendingReview
1343
+ };