@promptowl/contextnest-community 1.15.0 → 1.16.1

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
@@ -73,6 +73,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
73
73
  | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
74
74
  | `TRACE_RETENTION_DAYS` | `14` | Activity-trace retention window in days (the `api_events` rows behind `GET /admin/trace` and `GET /nests/:id/trace`). Rows older than this are pruned opportunistically (every ~500 inserts). `0` = keep forever (pruning is skipped entirely). Capped at `3650`; invalid/negative values fall back to `14`. Also editable from Settings → Advanced. |
75
75
  | `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). |
76
+ | `FRAME_ANCESTORS` | `'self'` | Which origins may embed this server in an iframe, sent as CSP `frame-ancestors`. The default lets nothing but this origin frame the UI, which blocks clickjacking. Deployments that are meant to be embedded list the embedding origin — e.g. the PromptOwl Data Room iframes ContextNest, so that install sets `FRAME_ANCESTORS="https://app.promptowl.ai"`. Comma-separated; `'self'` is always included; `*` allows any site and disables the protection. Note the embedding page must be **same-site** (a sibling subdomain) for the session cookie to survive inside the frame — a genuinely cross-domain embed will render the login page no matter what this is set to. |
76
77
  | `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. The asset-upload route (`POST /nests/:id/assets`) is exempt up to the video cap below. |
77
78
  | `VIDEO_MAX_MB` | `30` | Max size (in MB) of a video uploaded into a doc. Default `30` keeps it under Cloud Run's ~32 MiB HTTP/1 request-body limit, so an oversized video is rejected with a clear message instead of a bare `413` from the platform. Raise only where the deployment can actually accept larger request bodies (not behind Cloud Run, or on HTTP/2 / direct-to-bucket upload). Images are fixed at 10 MB. |
78
79
  | `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. |
@@ -11,7 +11,7 @@ import {
11
11
  resolveNestPermission,
12
12
  resolveTeamRolesForUser,
13
13
  sendEmailToRecipient
14
- } from "./chunk-7BOG3S5H.js";
14
+ } from "./chunk-L7UCMGWZ.js";
15
15
  import {
16
16
  ConflictError,
17
17
  ValidationError
@@ -20,7 +20,7 @@ import {
20
20
  config,
21
21
  getDb,
22
22
  isEmailish
23
- } from "./chunk-BCFKLY4H.js";
23
+ } from "./chunk-JMJLNEXE.js";
24
24
 
25
25
  // src/governance/stewardship-service.ts
26
26
  import { v4 as uuid } from "uuid";
@@ -2,10 +2,10 @@ import {
2
2
  buildTitleMap,
3
3
  insertOrReplace,
4
4
  nowExpr
5
- } from "./chunk-7BOG3S5H.js";
5
+ } from "./chunk-L7UCMGWZ.js";
6
6
  import {
7
7
  getDb
8
- } from "./chunk-BCFKLY4H.js";
8
+ } from "./chunk-JMJLNEXE.js";
9
9
 
10
10
  // src/governance/version-service.ts
11
11
  import { createHash } from "crypto";
@@ -100,6 +100,15 @@ async function getCurrentVersion(nestId, nodeId) {
100
100
  );
101
101
  return row?.v || 0;
102
102
  }
103
+ async function getTrackedNodeIds(nestId) {
104
+ const db = getDb();
105
+ const rows = await db.all(
106
+ `SELECT DISTINCT node_id FROM node_versions
107
+ WHERE nest_id = ? AND author NOT LIKE ?`,
108
+ [nestId, `${SYSTEM_AUTHOR_PREFIX}%`]
109
+ );
110
+ return new Set(rows.map((r) => r.node_id));
111
+ }
103
112
  async function getApprovedVersion(nestId, nodeId) {
104
113
  const db = getDb();
105
114
  const row = await db.get(
@@ -108,6 +117,14 @@ async function getApprovedVersion(nestId, nodeId) {
108
117
  );
109
118
  return row?.approved_version ?? null;
110
119
  }
120
+ async function getApprovedVersions(nestId) {
121
+ const db = getDb();
122
+ const rows = await db.all(
123
+ "SELECT node_id, approved_version FROM approved_versions WHERE nest_id = ?",
124
+ [nestId]
125
+ );
126
+ return new Map(rows.map((r) => [r.node_id, r.approved_version]));
127
+ }
111
128
  async function setApprovedVersion(nestId, nodeId, version, approvedBy) {
112
129
  const db = getDb();
113
130
  const sql = insertOrReplace(
@@ -262,7 +279,9 @@ export {
262
279
  getVersions,
263
280
  getVersion,
264
281
  getCurrentVersion,
282
+ getTrackedNodeIds,
265
283
  getApprovedVersion,
284
+ getApprovedVersions,
266
285
  setApprovedVersion,
267
286
  checkConflict,
268
287
  getNodeTags,
@@ -512,6 +512,30 @@ var config = {
512
512
  if ((process.env.AUTH_MODE || "key") === "open") return "*";
513
513
  return ["http://localhost:5173", "http://localhost:3838"];
514
514
  },
515
+ /**
516
+ * Who may frame this server, as a CSP `frame-ancestors` source list.
517
+ *
518
+ * Defaults to `'self'`: no other site can embed the UI, which is what stops
519
+ * a hostile page from framing it invisibly and stealing clicks from a
520
+ * signed-in operator. Deployments that are *meant* to be embedded name the
521
+ * embedding origin — e.g. the PromptOwl Data Room iframes this server, so
522
+ * that install sets `FRAME_ANCESTORS="https://app.promptowl.ai"`.
523
+ *
524
+ * Comma-separated; `'self'` is always included. `*` allows any site to frame
525
+ * this server and disables the protection.
526
+ *
527
+ * Note we deliberately do NOT also send `X-Frame-Options` — it only
528
+ * understands "same origin", so it would block a sibling subdomain that
529
+ * `frame-ancestors` is configured to allow. Every browser that matters
530
+ * honours `frame-ancestors`, and it wins where both are present.
531
+ */
532
+ get FRAME_ANCESTORS() {
533
+ const raw = process.env.FRAME_ANCESTORS?.trim();
534
+ if (!raw) return "'self'";
535
+ if (raw === "*") return "*";
536
+ const sources = raw.split(",").map((s) => s.trim()).filter(Boolean);
537
+ return [.../* @__PURE__ */ new Set(["'self'", ...sources])].join(" ");
538
+ },
515
539
  /**
516
540
  * Max JSON body size in bytes. Prevents DoS via giant payloads.
517
541
  * Default: 10 MB. Override via MAX_BODY_BYTES env.
@@ -8,7 +8,7 @@ import {
8
8
  config,
9
9
  getDb,
10
10
  isEmailish
11
- } from "./chunk-BCFKLY4H.js";
11
+ } from "./chunk-JMJLNEXE.js";
12
12
  import {
13
13
  ANON_USER_ID
14
14
  } from "./chunk-SLTQACJW.js";
@@ -600,7 +600,7 @@ var STATUS_META = {
600
600
  },
601
601
  deletion_declined: {
602
602
  subjectWord: "kept",
603
- label: "Deletion declined",
603
+ label: "Deletion rejected",
604
604
  color: "#d97706"
605
605
  },
606
606
  deletion_deleted: {
@@ -683,16 +683,16 @@ var TEMPLATES = {
683
683
  heading: (x) => `${x.title} is ${x.meta.subjectWord}`,
684
684
  detailsHeader: "Document details",
685
685
  linkLabel: "Review the request",
686
- lineT: (x) => `${x.title} has been flagged for deletion${x.byT}${x.noteT}. Delete it or decline the request from the nest.`,
687
- lineH: (x) => `${x.doc} has been flagged for deletion${x.byH}${x.noteH}. Delete it or decline the request from the nest.`,
686
+ lineT: (x) => `${x.title} has been flagged for deletion${x.byT}${x.noteT}. Delete it or reject the request from the nest.`,
687
+ lineH: (x) => `${x.doc} has been flagged for deletion${x.byH}${x.noteH}. Delete it or reject the request from the nest.`,
688
688
  rows: governanceRows
689
689
  },
690
690
  deletion_declined: {
691
- heading: (x) => `Your deletion request for ${x.title} was declined`,
691
+ heading: (x) => `Your deletion request for ${x.title} was rejected`,
692
692
  detailsHeader: "Document details",
693
693
  linkLabel: "Open document",
694
- lineT: (x) => `Your request to delete ${x.title} was declined${x.byT}${x.noteT}. The document stays.`,
695
- lineH: (x) => `Your request to delete ${x.doc} was declined${x.byH}${x.noteH}. The document stays.`,
694
+ lineT: (x) => `Your request to delete ${x.title} was rejected${x.byT}${x.noteT}. The document stays.`,
695
+ lineH: (x) => `Your request to delete ${x.doc} was rejected${x.byH}${x.noteH}. The document stays.`,
696
696
  rows: governanceRows
697
697
  },
698
698
  deletion_deleted: {
@@ -973,74 +973,17 @@ async function sendEmailToRecipient(to, details) {
973
973
  }
974
974
 
975
975
  // src/nests/service.ts
976
- import { rmSync as rmSync2, mkdirSync } from "fs";
976
+ import { mkdirSync } from "fs";
977
977
  import { v4 as uuid2 } from "uuid";
978
978
  import { NestStorage as NestStorage3 } from "@promptowl/contextnest-engine";
979
979
 
980
- // src/fs/vault-import.ts
981
- import { rmSync } from "fs";
982
- import { writeFile, mkdir } from "fs/promises";
983
- import { join as join2, dirname, isAbsolute } from "path";
984
- function isSkippedSegment(segment) {
985
- return segment.startsWith(".") || segment === "node_modules" || segment === "_suggestions";
986
- }
987
- var ALLOWED_DOT_DIRS = /* @__PURE__ */ new Set([".versions", ".context"]);
988
- function safeSegments(rel) {
989
- if (!rel || isAbsolute(rel)) return null;
990
- const segs = rel.split(/[\\/]/).filter(Boolean);
991
- if (!segs.length) return null;
992
- for (const s of segs) {
993
- if (s === "..") return null;
994
- if (!ALLOWED_DOT_DIRS.has(s) && isSkippedSegment(s)) return null;
995
- }
996
- return segs;
997
- }
998
- var WRITE_CONCURRENCY = 32;
999
- async function writeImportedFiles(dest, files) {
1000
- const targets = [];
1001
- const skipped = [];
1002
- for (const f of files) {
1003
- const segs = safeSegments(f?.path || "");
1004
- if (!segs) {
1005
- skipped.push(f?.path || "(empty)");
1006
- continue;
1007
- }
1008
- targets.push({ full: join2(dest, ...segs), content: f.content ?? "" });
1009
- }
1010
- const dirs = new Set(targets.map((t) => dirname(t.full)));
1011
- await Promise.all([...dirs].map((d) => mkdir(d, { recursive: true })));
1012
- let written = 0;
1013
- for (let i = 0; i < targets.length; i += WRITE_CONCURRENCY) {
1014
- const batch = targets.slice(i, i + WRITE_CONCURRENCY);
1015
- await Promise.all(
1016
- batch.map(async (t) => {
1017
- await writeFile(t.full, t.content, "utf-8");
1018
- written++;
1019
- })
1020
- );
1021
- }
1022
- console.log(
1023
- `[import] writeImportedFiles: received=${files.length} written=${written} skipped=${skipped.length}`
1024
- );
1025
- if (skipped.length) {
1026
- console.log("[import] skipped (unsafe/hidden path):", skipped.join(", "));
1027
- }
1028
- return written;
1029
- }
1030
- function discardImportDir(dest) {
1031
- try {
1032
- rmSync(dest, { recursive: true, force: true });
1033
- } catch {
1034
- }
1035
- }
1036
-
1037
980
  // src/shared/paths.ts
1038
- import { join as join3 } from "path";
981
+ import { join as join2 } from "path";
1039
982
  function nestStorageRoot() {
1040
983
  return config.NEST_STORAGE_ROOT;
1041
984
  }
1042
985
  function resolveNestPath(nestId) {
1043
- return join3(nestStorageRoot(), nestId);
986
+ return join2(nestStorageRoot(), nestId);
1044
987
  }
1045
988
 
1046
989
  // src/governance/teams-service.ts
@@ -1052,11 +995,29 @@ import {
1052
995
  GraphQueryEngine,
1053
996
  VersionManager
1054
997
  } from "@promptowl/contextnest-engine";
998
+ import { createEngineApi } from "@promptowl/contextnest-engine/api";
1055
999
 
1056
1000
  // src/nodes/flat-storage.ts
1057
- import { readdirSync } from "fs";
1058
- import { join as join4, relative, sep } from "path";
1001
+ import { readdir } from "fs/promises";
1002
+ import { join as join3, relative, sep } from "path";
1059
1003
  import { NestStorage } from "@promptowl/contextnest-engine";
1004
+
1005
+ // src/fs/vault-import.ts
1006
+ function isSkippedSegment(segment) {
1007
+ return segment.startsWith(".") || segment === "node_modules" || segment === "_suggestions";
1008
+ }
1009
+
1010
+ // src/shared/batch.ts
1011
+ var IO_CONCURRENCY = 32;
1012
+ async function mapBatched(items, fn, concurrency = IO_CONCURRENCY) {
1013
+ const out = [];
1014
+ for (let i = 0; i < items.length; i += concurrency) {
1015
+ out.push(...await Promise.all(items.slice(i, i + concurrency).map(fn)));
1016
+ }
1017
+ return out;
1018
+ }
1019
+
1020
+ // src/nodes/flat-storage.ts
1060
1021
  var META_FILES = /* @__PURE__ */ new Set([
1061
1022
  "INDEX.md",
1062
1023
  "CONTEXT.md",
@@ -1070,7 +1031,7 @@ var FlatNestStorage = class extends NestStorage {
1070
1031
  const root = this.root;
1071
1032
  let entries;
1072
1033
  try {
1073
- entries = readdirSync(root, { recursive: true, withFileTypes: true });
1034
+ entries = await readdir(root, { recursive: true, withFileTypes: true });
1074
1035
  } catch (err) {
1075
1036
  console.error("[FlatNestStorage] cannot read folder", root, err);
1076
1037
  return [];
@@ -1080,33 +1041,72 @@ var FlatNestStorage = class extends NestStorage {
1080
1041
  if (!e.isFile() || !e.name.endsWith(".md")) continue;
1081
1042
  if (META_FILES.has(e.name)) continue;
1082
1043
  const dir = e.parentPath ?? e.path ?? root;
1083
- const rel = relative(root, join4(dir, e.name)).split(sep).join("/");
1044
+ const rel = relative(root, join3(dir, e.name)).split(sep).join("/");
1084
1045
  if (rel.split("/").some(isSkippedSegment)) continue;
1085
1046
  ids.push(rel.replace(/\.md$/, ""));
1086
1047
  }
1087
- const nodes = [];
1088
- for (const id of ids.sort()) {
1048
+ ids.sort();
1049
+ const read = await mapBatched(ids, async (id) => {
1089
1050
  try {
1090
- nodes.push(await this.readDocument(id));
1051
+ return await this.readDocument(id);
1091
1052
  } catch (err) {
1092
1053
  console.error("[FlatNestStorage] skipped unreadable doc", id, err);
1054
+ return null;
1093
1055
  }
1094
- }
1095
- return nodes;
1056
+ });
1057
+ return read.filter((n) => n !== null);
1096
1058
  }
1097
1059
  };
1098
1060
 
1099
1061
  // src/nodes/engine.ts
1062
+ var DISCOVERY_TTL_MS = 3e4;
1063
+ var DOCUMENT_WRITERS = [
1064
+ "writeDocument",
1065
+ "deleteDocument",
1066
+ "writeVaultFile",
1067
+ "init"
1068
+ ];
1069
+ function withDiscoveryCache(storage) {
1070
+ const entries = /* @__PURE__ */ new Map();
1071
+ const discover = storage.discoverDocuments.bind(storage);
1072
+ let generation = 0;
1073
+ storage.discoverDocuments = async (options = {}) => {
1074
+ const key = options.includeRetired || options.includeSuperseded ? "all" : "live";
1075
+ const hit = entries.get(key);
1076
+ if (hit && Date.now() - hit.ts < DISCOVERY_TTL_MS) return hit.docs;
1077
+ const startedAt = generation;
1078
+ const docs = await discover(options);
1079
+ if (generation === startedAt) entries.set(key, { docs, ts: Date.now() });
1080
+ return docs;
1081
+ };
1082
+ const invalidate = () => {
1083
+ generation++;
1084
+ entries.clear();
1085
+ };
1086
+ for (const name of DOCUMENT_WRITERS) {
1087
+ const write = storage[name].bind(storage);
1088
+ storage[name] = async (...args) => {
1089
+ try {
1090
+ return await write(...args);
1091
+ } finally {
1092
+ invalidate();
1093
+ }
1094
+ };
1095
+ }
1096
+ return { storage, invalidate };
1097
+ }
1100
1098
  var NestEngineCache = class {
1101
1099
  cache = /* @__PURE__ */ new Map();
1102
1100
  async get(nestId) {
1103
1101
  let engine = this.cache.get(nestId);
1104
1102
  if (!engine) {
1105
1103
  const nestPath = resolveNestPath(nestId);
1106
- const storage = await isImportedNest(nestId) ? new FlatNestStorage(nestPath) : new NestStorage2(nestPath);
1104
+ const { storage, invalidate } = withDiscoveryCache(
1105
+ await isImportedNest(nestId) ? new FlatNestStorage(nestPath) : new NestStorage2(nestPath)
1106
+ );
1107
1107
  const query = new GraphQueryEngine(storage);
1108
1108
  const versions = new VersionManager(storage);
1109
- engine = { storage, query, versions };
1109
+ engine = { storage, query, versions, invalidateDiscovery: invalidate };
1110
1110
  this.cache.set(nestId, engine);
1111
1111
  }
1112
1112
  return engine;
@@ -1116,6 +1116,11 @@ var NestEngineCache = class {
1116
1116
  }
1117
1117
  };
1118
1118
  var engineCache = new NestEngineCache();
1119
+ var engineApi = createEngineApi();
1120
+ async function opContext(nestId, actor, onProgress) {
1121
+ const { storage, query, versions } = await engineCache.get(nestId);
1122
+ return { storage, query, versions, actor, onProgress };
1123
+ }
1119
1124
 
1120
1125
  // src/governance/title-resolver.ts
1121
1126
  async function buildTitleMap(nestId) {
@@ -1775,6 +1780,7 @@ async function removeNodeFromTagIndex(nestId, nodeId) {
1775
1780
  }
1776
1781
 
1777
1782
  // src/nests/service.ts
1783
+ import { rm } from "fs/promises";
1778
1784
  async function isImportedNest(nestId) {
1779
1785
  const row = await getDb().get(
1780
1786
  "SELECT is_imported FROM nests WHERE id = ?",
@@ -1954,27 +1960,16 @@ async function createNest(userId, name, description) {
1954
1960
  trackEvent("nest.create", { nestId: id, userId });
1955
1961
  return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
1956
1962
  }
1957
- async function importNest(userId, name, files) {
1963
+ async function importNest(userId, name) {
1958
1964
  const nm = (name || "").trim();
1959
1965
  if (!nm) {
1960
1966
  throw new ValidationError("name is required");
1961
1967
  }
1962
- if (!Array.isArray(files)) {
1963
- throw new ValidationError("files are required");
1964
- }
1965
1968
  const db = getDb();
1966
1969
  const id = uuid2();
1967
1970
  const slug = toSlug(nm);
1968
1971
  const visibility = userId === ANON_USER_ID ? "public" : "private";
1969
- const dest = resolveNestPath(id);
1970
- mkdirSync(dest, { recursive: true });
1971
- try {
1972
- await writeImportedFiles(dest, files);
1973
- } catch (err) {
1974
- console.error("[nests] import write failed", dest, err);
1975
- discardImportDir(dest);
1976
- throw new ValidationError("Failed to import folder");
1977
- }
1972
+ mkdirSync(resolveNestPath(id), { recursive: true });
1978
1973
  await db.run(
1979
1974
  "INSERT INTO nests (id, user_id, name, slug, description, visibility, is_imported) VALUES (?, ?, ?, ?, ?, ?, 1)",
1980
1975
  [id, userId, nm, slug, null, visibility]
@@ -2138,7 +2133,7 @@ async function deleteNest(nestId) {
2138
2133
  });
2139
2134
  const path = resolveNestPath(nestId);
2140
2135
  try {
2141
- rmSync2(path, { recursive: true, force: true });
2136
+ await rm(path, { recursive: true, force: true });
2142
2137
  } catch (err) {
2143
2138
  console.warn(`[nests] failed to remove nest directory ${path}:`, err);
2144
2139
  }
@@ -2194,6 +2189,8 @@ export {
2194
2189
  getNest,
2195
2190
  deleteNest,
2196
2191
  engineCache,
2192
+ engineApi,
2193
+ opContext,
2197
2194
  buildTitleMap,
2198
2195
  folderForNode,
2199
2196
  describeNode,
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-GUNJTORH.js";
4
4
  import {
5
5
  getDb
6
- } from "./chunk-BCFKLY4H.js";
6
+ } from "./chunk-JMJLNEXE.js";
7
7
 
8
8
  // src/governance/grants-service.ts
9
9
  import { v4 as uuid } from "uuid";
@@ -2,22 +2,24 @@ import {
2
2
  grantCoversNode,
3
3
  listUserGrants,
4
4
  resolveNodeGrant
5
- } from "./chunk-5IAKUUZF.js";
5
+ } from "./chunk-OTGZQZKK.js";
6
6
  import {
7
7
  createVersion,
8
8
  getApprovedVersion,
9
+ getApprovedVersions,
9
10
  getCurrentVersion,
10
11
  setApprovedVersion
11
- } from "./chunk-JWVZ3T35.js";
12
+ } from "./chunk-D6HICFVW.js";
12
13
  import {
13
14
  canUserAccess,
14
15
  canUserApprove,
15
16
  resolveStewardsForNode
16
- } from "./chunk-GWDOLYFJ.js";
17
+ } from "./chunk-CLKZZZS2.js";
17
18
  import {
18
19
  buildDocContext,
19
20
  buildTitleMap,
20
21
  describeNode,
22
+ engineApi,
21
23
  engineCache,
22
24
  insertOrReplace,
23
25
  isPublicReader,
@@ -25,10 +27,11 @@ import {
25
27
  nestName,
26
28
  notifyEmailForNest,
27
29
  nowExpr,
30
+ opContext,
28
31
  prettyFolderPath,
29
32
  sendEmailToRecipient,
30
33
  titleForNode
31
- } from "./chunk-7BOG3S5H.js";
34
+ } from "./chunk-L7UCMGWZ.js";
32
35
  import {
33
36
  ConflictError,
34
37
  NotFoundError,
@@ -37,7 +40,7 @@ import {
37
40
  import {
38
41
  config,
39
42
  getDb
40
- } from "./chunk-BCFKLY4H.js";
43
+ } from "./chunk-JMJLNEXE.js";
41
44
 
42
45
  // src/governance/review-service.ts
43
46
  import { v4 as uuid3 } from "uuid";
@@ -222,15 +225,10 @@ async function canReadNode(nestId, nodeId, userId, userEmail) {
222
225
  if ((await canUserAccess(nestId, nodeId, userEmail)).allowed) return true;
223
226
  return await resolveNodeGrant(nestId, userId, nodeId) !== null;
224
227
  }
225
- async function filterAccessible(nestId, userId, userEmail, nodes) {
228
+ async function filterAccessible(nestId, userId, userEmail, nodes, approvedVersions) {
226
229
  if (await isPublicReader(nestId, userId)) {
227
- const filtered = [];
228
- for (const n of nodes) {
229
- if (await getApprovedVersion(nestId, n.id) !== null) {
230
- filtered.push(n);
231
- }
232
- }
233
- return filtered;
230
+ const approved = approvedVersions ?? await getApprovedVersions(nestId);
231
+ return nodes.filter((n) => approved.has(n.id));
234
232
  }
235
233
  if (!await isStewardshipEnabled(nestId)) return nodes;
236
234
  const grants = await listUserGrants(nestId, userId);
@@ -834,39 +832,6 @@ function withNodeWriteLock(key, fn) {
834
832
  return run;
835
833
  }
836
834
 
837
- // src/governance/safe-publish.ts
838
- import {
839
- publishDocument,
840
- serializeDocument
841
- } from "@promptowl/contextnest-engine";
842
- async function safePublishDocument(storage, docId, options) {
843
- const node = await storage.readDocument(docId);
844
- const cleanedFrontmatter = stripUndefinedDeep(node.frontmatter);
845
- const cleanedNode = { ...node, frontmatter: cleanedFrontmatter };
846
- await storage.writeDocument(docId, serializeDocument(cleanedNode));
847
- return publishDocument(storage, docId, options);
848
- }
849
- function serializeNodeSafe(node) {
850
- return serializeDocument({
851
- ...node,
852
- frontmatter: stripUndefinedDeep(node.frontmatter)
853
- });
854
- }
855
- function stripUndefinedDeep(value) {
856
- if (Array.isArray(value)) {
857
- return value.filter((v) => v !== void 0).map((v) => stripUndefinedDeep(v));
858
- }
859
- if (value && typeof value === "object") {
860
- const out = {};
861
- for (const [k, v] of Object.entries(value)) {
862
- if (v === void 0) continue;
863
- out[k] = stripUndefinedDeep(v);
864
- }
865
- return out;
866
- }
867
- return value;
868
- }
869
-
870
835
  // src/governance/review-service.ts
871
836
  async function submitForReview(params) {
872
837
  const db = getDb();
@@ -1032,13 +997,14 @@ async function approve(params) {
1032
997
  );
1033
998
  try {
1034
999
  const { storage } = await engineCache.get(params.nestId);
1035
- const result = await safePublishDocument(storage, params.nodeId, {
1036
- editedBy: params.approvedBy,
1037
- note: params.note || `Approved review request ${pending.id}`
1038
- });
1039
- const engineVersion = result.versionEntry.version;
1000
+ const note = params.note || `Approved review request ${pending.id}`;
1001
+ const { version: engineVersion } = await engineApi.run(
1002
+ "context_publish",
1003
+ { id: params.nodeId, note },
1004
+ await opContext(params.nestId, params.approvedBy)
1005
+ );
1040
1006
  if (engineVersion !== version) {
1041
- const node = result.node;
1007
+ const node = await storage.readDocument(params.nodeId);
1042
1008
  const tags = node.frontmatter.tags || [];
1043
1009
  await createVersion({
1044
1010
  nestId: params.nestId,
@@ -1048,7 +1014,7 @@ async function approve(params) {
1048
1014
  author: params.approvedBy,
1049
1015
  status: "published",
1050
1016
  tags,
1051
- changeNote: params.note || `Approved review request ${pending.id}`
1017
+ changeNote: note
1052
1018
  });
1053
1019
  await setApprovedVersion(
1054
1020
  params.nestId,
@@ -1369,7 +1335,7 @@ async function notifyDeletionRequested(request, baseUrl) {
1369
1335
  async function notifyDeletionResolved(params) {
1370
1336
  try {
1371
1337
  const { outcome, resolvedBy, note } = params;
1372
- const verb = outcome === "deleted" ? "deleted" : "declined the deletion of";
1338
+ const verb = outcome === "deleted" ? "deleted" : "rejected the deletion of";
1373
1339
  const line = `${resolvedBy} ${verb} *${params.docTitle}*${note ? ` \u2014 "${note}"` : ""}`;
1374
1340
  const recipient = params.requestedBy.toLowerCase();
1375
1341
  const canInbox = !(params.targetType === "nest" && outcome === "deleted");
@@ -1379,7 +1345,7 @@ async function notifyDeletionResolved(params) {
1379
1345
  [recipient],
1380
1346
  `deletion_${outcome}`,
1381
1347
  params.requestId,
1382
- outcome === "deleted" ? `"${params.docTitle}" was deleted by ${resolvedBy} \u2014 your request was accepted` : `Your deletion request for "${params.docTitle}" was declined by ${resolvedBy}${note ? ` \u2014 "${note}"` : ""}`
1348
+ outcome === "deleted" ? `"${params.docTitle}" was deleted by ${resolvedBy} \u2014 your request was accepted` : `Your deletion request for "${params.docTitle}" was rejected by ${resolvedBy}${note ? ` \u2014 "${note}"` : ""}`
1383
1349
  );
1384
1350
  }
1385
1351
  void dispatchEvent({
@@ -1582,9 +1548,6 @@ function rowToReviewRequest(row) {
1582
1548
  }
1583
1549
 
1584
1550
  export {
1585
- safePublishDocument,
1586
- serializeNodeSafe,
1587
- stripUndefinedDeep,
1588
1551
  listWatchers,
1589
1552
  addWatcher,
1590
1553
  removeWatcher,
@@ -2,7 +2,7 @@ import {
2
2
  getDb,
3
3
  initDb,
4
4
  resetDb
5
- } from "./chunk-BCFKLY4H.js";
5
+ } from "./chunk-JMJLNEXE.js";
6
6
  import "./chunk-SLTQACJW.js";
7
7
  export {
8
8
  getDb,
@@ -6,9 +6,9 @@ import {
6
6
  listGrants,
7
7
  listUserGrants,
8
8
  resolveNodeGrant
9
- } from "./chunk-5IAKUUZF.js";
9
+ } from "./chunk-OTGZQZKK.js";
10
10
  import "./chunk-GUNJTORH.js";
11
- import "./chunk-BCFKLY4H.js";
11
+ import "./chunk-JMJLNEXE.js";
12
12
  import "./chunk-SLTQACJW.js";
13
13
  export {
14
14
  createGrant,