@promptowl/contextnest-community 1.15.0 → 1.17.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/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  addWatcher,
4
4
  approve,
5
5
  canReadNode,
6
+ canRequestDeletion,
6
7
  cancelReview,
7
8
  declineDeletion,
8
9
  deletionTargetTitle,
@@ -35,23 +36,21 @@ import {
35
36
  requireWorkflowPlane,
36
37
  resolveCallerEmail,
37
38
  safeJson,
38
- safePublishDocument,
39
39
  seedDefaultEdgeTypes,
40
40
  sendTestNotification,
41
- serializeNodeSafe,
42
- stripUndefinedDeep,
43
41
  submitForReview,
44
- withNodeWriteLock
45
- } from "./chunk-C4U75IUR.js";
42
+ withNodeWriteLock,
43
+ withdrawDeletionRequest
44
+ } from "./chunk-4YGJSUJC.js";
46
45
  import {
47
46
  createGrant,
48
47
  deleteGrant,
49
48
  grantCoversNode,
50
- hasAnyGrant,
49
+ grantRoleForNode,
51
50
  listGrants,
52
51
  listUserGrants,
53
52
  resolveNodeGrant
54
- } from "./chunk-5IAKUUZF.js";
53
+ } from "./chunk-HWDPCPYT.js";
55
54
  import {
56
55
  generateApiKey,
57
56
  getKeyPrefix,
@@ -64,13 +63,15 @@ import {
64
63
  checkConflict,
65
64
  createVersion,
66
65
  getApprovedVersion,
66
+ getApprovedVersions,
67
67
  getCurrentVersion,
68
68
  getDisplayStatus,
69
+ getTrackedNodeIds,
69
70
  getVersions,
70
71
  listMyDrafts,
71
72
  setApprovedVersion,
72
73
  upsertVersion
73
- } from "./chunk-JWVZ3T35.js";
74
+ } from "./chunk-UXJSO54R.js";
74
75
  import {
75
76
  canCreateInNest,
76
77
  canManageStewards,
@@ -90,7 +91,7 @@ import {
90
91
  resolveUserRoles,
91
92
  syncFromConfig,
92
93
  updateSteward
93
- } from "./chunk-GWDOLYFJ.js";
94
+ } from "./chunk-YF4OFT2V.js";
94
95
  import {
95
96
  addMember,
96
97
  buildTitleMap,
@@ -101,6 +102,7 @@ import {
101
102
  deleteTeam,
102
103
  disableStewardshipAndWipeGovernance,
103
104
  docLink,
105
+ engineApi,
104
106
  engineCache,
105
107
  findTeamByExternalId,
106
108
  folderForNode,
@@ -136,6 +138,7 @@ import {
136
138
  nestStorageRoot,
137
139
  normalizeTag,
138
140
  nowExpr,
141
+ opContext,
139
142
  permissionLevel,
140
143
  persistSetting,
141
144
  removeMember,
@@ -144,6 +147,7 @@ import {
144
147
  renameTeam,
145
148
  replaceTeamRoster,
146
149
  resolveMemberIdentity,
150
+ resolveNestAccess,
147
151
  resolveNestPath,
148
152
  resolveNestPermission,
149
153
  sendEmailToRecipient,
@@ -161,7 +165,7 @@ import {
161
165
  updateMemberRole,
162
166
  validateLicense,
163
167
  verifySmtp
164
- } from "./chunk-7BOG3S5H.js";
168
+ } from "./chunk-SNFGU5Q7.js";
165
169
  import {
166
170
  AppError,
167
171
  ConflictError,
@@ -177,7 +181,7 @@ import {
177
181
  initDb,
178
182
  isEmailListish,
179
183
  isEmailish
180
- } from "./chunk-BCFKLY4H.js";
184
+ } from "./chunk-JMJLNEXE.js";
181
185
  import {
182
186
  ANON_EMAIL,
183
187
  ANON_USER_ID
@@ -195,6 +199,29 @@ import { LinearRouter } from "hono/router/linear-router";
195
199
  import { createMiddleware as createMiddleware2 } from "hono/factory";
196
200
  import { cors } from "hono/cors";
197
201
 
202
+ // src/shared/artifact-sandbox.ts
203
+ var ARTIFACT_SANDBOX_CSP = [
204
+ "sandbox allow-scripts allow-popups allow-modals",
205
+ "default-src 'none'",
206
+ "script-src 'unsafe-inline'",
207
+ "style-src 'unsafe-inline'",
208
+ "img-src data: blob:",
209
+ "font-src data:",
210
+ "connect-src 'none'",
211
+ "base-uri 'none'",
212
+ "form-action 'none'"
213
+ ].join("; ");
214
+ function mergeFrameAncestors(existing, directive) {
215
+ if (!existing) return directive;
216
+ if (/(^|;)\s*frame-ancestors\b/i.test(existing)) return existing;
217
+ return `${existing}; ${directive}`;
218
+ }
219
+ function applyArtifactSandboxHeaders(c) {
220
+ c.header("Content-Security-Policy", ARTIFACT_SANDBOX_CSP);
221
+ c.header("X-Content-Type-Options", "nosniff");
222
+ c.header("Referrer-Policy", "no-referrer");
223
+ }
224
+
198
225
  // src/auth/routes.ts
199
226
  import { Hono as Hono2 } from "hono";
200
227
  import { v4 as uuid2 } from "uuid";
@@ -1580,17 +1607,42 @@ authRoutes.get("/teammates", async (c) => {
1580
1607
 
1581
1608
  // src/nests/routes.ts
1582
1609
  import { Hono as Hono3 } from "hono";
1583
- import { streamText } from "hono/streaming";
1584
1610
 
1585
1611
  // src/nodes/service.ts
1586
1612
  import {
1587
- serializeDocument,
1588
1613
  parseDocument as parseDocument2,
1589
1614
  normalizeStatus,
1615
+ isRejected,
1590
1616
  DocumentNotFoundError
1591
1617
  } from "@promptowl/contextnest-engine";
1592
- import { rename, mkdir, rm } from "fs/promises";
1593
- import { dirname, join as join2, basename } from "path";
1618
+ import { rename, mkdir } from "fs/promises";
1619
+ import { dirname, join as join2 } from "path";
1620
+
1621
+ // src/governance/safe-publish.ts
1622
+ import {
1623
+ publishDocument,
1624
+ serializeDocument
1625
+ } from "@promptowl/contextnest-engine";
1626
+ function serializeNodeSafe(node) {
1627
+ return serializeDocument({
1628
+ ...node,
1629
+ frontmatter: stripUndefinedDeep(node.frontmatter)
1630
+ });
1631
+ }
1632
+ function stripUndefinedDeep(value) {
1633
+ if (Array.isArray(value)) {
1634
+ return value.filter((v) => v !== void 0).map((v) => stripUndefinedDeep(v));
1635
+ }
1636
+ if (value && typeof value === "object") {
1637
+ const out = {};
1638
+ for (const [k, v] of Object.entries(value)) {
1639
+ if (v === void 0) continue;
1640
+ out[k] = stripUndefinedDeep(v);
1641
+ }
1642
+ return out;
1643
+ }
1644
+ return value;
1645
+ }
1594
1646
 
1595
1647
  // src/governance/prime-service.ts
1596
1648
  async function isPrimeDocument(nestId, tags, metadata) {
@@ -1615,6 +1667,7 @@ import { join } from "path";
1615
1667
  import {
1616
1668
  detectDrift,
1617
1669
  stageSuggestion,
1670
+ ChainEventLog,
1618
1671
  approveSuggestion,
1619
1672
  rejectSuggestion,
1620
1673
  listSuggestions,
@@ -1697,7 +1750,8 @@ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
1697
1750
  return { meta: result.meta, created: true };
1698
1751
  }
1699
1752
  async function scanNestForDrift(nestId, actor = "system:scanner") {
1700
- const { storage } = await engineCache.get(nestId);
1753
+ const { storage, invalidateDiscovery } = await engineCache.get(nestId);
1754
+ invalidateDiscovery();
1701
1755
  const docs = await storage.discoverDocuments();
1702
1756
  const results = await Promise.all(
1703
1757
  docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
@@ -1768,6 +1822,68 @@ async function getExternalEditDetail(nestId, documentId, suggestionId) {
1768
1822
  patch: found.patch
1769
1823
  };
1770
1824
  }
1825
+ async function recordVerdict(nestId, event) {
1826
+ try {
1827
+ const { storage } = await engineCache.get(nestId);
1828
+ await new ChainEventLog(storage).append(event);
1829
+ return true;
1830
+ } catch (err) {
1831
+ console.error(
1832
+ `[external-edit] failed to record verdict ${event.event_id} for nest ${nestId}:`,
1833
+ err
1834
+ );
1835
+ return false;
1836
+ }
1837
+ }
1838
+ async function listExternalEditVerdicts(nestId, documentId) {
1839
+ try {
1840
+ const { storage } = await engineCache.get(nestId);
1841
+ const events = await new ChainEventLog(storage).readByDocument(documentId);
1842
+ return events.flatMap((e) => {
1843
+ const meta = e.action_metadata || {};
1844
+ if (typeof meta.suggestion_id !== "string") return [];
1845
+ const reason = meta.rejection_reason;
1846
+ const comment = meta.approval_comment;
1847
+ return [
1848
+ {
1849
+ suggestion_id: meta.suggestion_id,
1850
+ status: typeof reason === "string" ? "rejected" : "approved",
1851
+ actor: e.actor,
1852
+ at: e.timestamp,
1853
+ note: typeof reason === "string" ? reason : typeof comment === "string" ? comment : void 0,
1854
+ source: typeof meta.source === "string" ? meta.source : void 0
1855
+ }
1856
+ ];
1857
+ });
1858
+ } catch (err) {
1859
+ console.error(
1860
+ `[external-edit] failed to read verdicts for ${nestId}/${documentId}:`,
1861
+ err
1862
+ );
1863
+ return [];
1864
+ }
1865
+ }
1866
+ async function mirrorVersion(input) {
1867
+ const { storage } = await engineCache.get(input.nestId);
1868
+ const node = await storage.readDocument(input.documentId);
1869
+ const { upsertVersion: upsertVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-EFZGGL3Y.js");
1870
+ await upsertVersion2({
1871
+ nestId: input.nestId,
1872
+ nodeId: input.documentId,
1873
+ version: input.version,
1874
+ content: node.body || "",
1875
+ author: input.actor,
1876
+ status: "published",
1877
+ tags: node.frontmatter.tags || [],
1878
+ changeNote: input.note
1879
+ });
1880
+ await setApprovedVersion2(
1881
+ input.nestId,
1882
+ input.documentId,
1883
+ input.version,
1884
+ input.actor
1885
+ );
1886
+ }
1771
1887
  async function approveExternalEdit(input) {
1772
1888
  const { storage } = await engineCache.get(input.nestId);
1773
1889
  let result;
@@ -1789,33 +1905,21 @@ async function approveExternalEdit(input) {
1789
1905
  throw err;
1790
1906
  }
1791
1907
  try {
1792
- const node = await storage.readDocument(input.documentId);
1793
- const versionNum = result.versionEntry.version;
1794
- const tags = node.frontmatter.tags || [];
1795
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-ECYCBVSE.js");
1796
- await createVersion2({
1908
+ await mirrorVersion({
1797
1909
  nestId: input.nestId,
1798
- nodeId: input.documentId,
1799
- version: versionNum,
1800
- content: node.body || "",
1801
- author: input.actor,
1802
- status: "published",
1803
- tags,
1804
- changeNote: input.comment || "External edit approved"
1910
+ documentId: input.documentId,
1911
+ version: result.versionEntry.version,
1912
+ actor: input.actor,
1913
+ note: input.comment ? `External edit approved: ${input.comment}` : "External edit approved"
1805
1914
  });
1806
- await setApprovedVersion2(
1807
- input.nestId,
1808
- input.documentId,
1809
- versionNum,
1810
- input.actor
1811
- );
1812
1915
  } catch (err) {
1813
1916
  console.error(
1814
1917
  `[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
1815
1918
  err
1816
1919
  );
1817
1920
  }
1818
- return result;
1921
+ const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
1922
+ return { ...result, historyRecorded };
1819
1923
  }
1820
1924
  async function rejectExternalEdit(input) {
1821
1925
  const { storage } = await engineCache.get(input.nestId);
@@ -1828,6 +1932,7 @@ async function rejectExternalEdit(input) {
1828
1932
  zone: "default",
1829
1933
  reason: input.reason
1830
1934
  });
1935
+ const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
1831
1936
  try {
1832
1937
  const approved = await loadChainHead(storage, input.documentId);
1833
1938
  if (approved) {
@@ -1839,7 +1944,7 @@ async function rejectExternalEdit(input) {
1839
1944
  err
1840
1945
  );
1841
1946
  }
1842
- return result;
1947
+ return { ...result, historyRecorded };
1843
1948
  }
1844
1949
  var scannerTimer = null;
1845
1950
  async function scanAllNests() {
@@ -1881,12 +1986,24 @@ function assertTitleLength(title) {
1881
1986
  );
1882
1987
  }
1883
1988
  }
1989
+ function assertTitleSluggable(title) {
1990
+ if (!slugify(title)) {
1991
+ throw new ValidationError(
1992
+ "the document id is built from the title using a-z and 0-9, so the title needs at least one of those"
1993
+ );
1994
+ }
1995
+ }
1884
1996
  var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1885
1997
  function isIdUnderFolder(id, folder) {
1886
1998
  return id.startsWith(`nodes/${folder}/`) || id.startsWith(`${folder}/`);
1887
1999
  }
1888
2000
  function folderToSegments(folder) {
1889
2001
  const segments = folder.split("/").map(slugify).filter(Boolean);
2002
+ if (folder.trim() && segments.length === 0) {
2003
+ throw new ValidationError(
2004
+ "the folder path is built from the name using a-z and 0-9, so the name needs at least one of those"
2005
+ );
2006
+ }
1890
2007
  if (segments[0] === "nodes") segments.shift();
1891
2008
  if (segments.length > 8) {
1892
2009
  throw new ValidationError("folder may nest at most 8 levels deep");
@@ -1898,7 +2015,6 @@ function folderToSegments(folder) {
1898
2015
  }
1899
2016
  return segments;
1900
2017
  }
1901
- var stripUndefined = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
1902
2018
  async function departmentTagFor(userEmail) {
1903
2019
  if (!config.OIDC_DEPARTMENT_TAGGING) return null;
1904
2020
  try {
@@ -1915,6 +2031,12 @@ async function departmentTagFor(userEmail) {
1915
2031
  }
1916
2032
  function toSafeError(err, context, message) {
1917
2033
  if (err instanceof AppError) return err;
2034
+ if (err?.code === "REJECTED_DOCUMENT") {
2035
+ console.error(`${context}:`, err);
2036
+ return new ConflictError(
2037
+ "This document is rejected. Change its status before saving."
2038
+ );
2039
+ }
1918
2040
  console.error(`${context}:`, err);
1919
2041
  return new AppError(500, message);
1920
2042
  }
@@ -2005,28 +2127,86 @@ async function resolveAuthorship(nestId, nodes) {
2005
2127
  }
2006
2128
  return result;
2007
2129
  }
2008
- async function listNodesForCaller(nestId, userId, filters = {}) {
2009
- const { storage, versions: versionManager } = await engineCache.get(nestId);
2010
- let documents = await storage.discoverDocuments();
2011
- if (filters.type) {
2012
- const wanted = new Set(
2013
- Array.isArray(filters.type) ? filters.type : [filters.type]
2130
+ async function getNodeForCaller(nestId, selector, userId, userEmail, opts = {}) {
2131
+ if (selector.id && !await canReadNode(nestId, selector.id, userId, userEmail)) {
2132
+ throw new ForbiddenError(
2133
+ "Access denied \u2014 no steward assignment for this node"
2014
2134
  );
2015
- documents = documents.filter((n) => wanted.has(n.frontmatter.type));
2016
2135
  }
2017
- if (filters.tag) {
2018
- const tag = normalizeTag2(filters.tag);
2019
- documents = documents.filter(
2020
- (n) => (n.frontmatter.tags || []).includes(tag)
2136
+ let got;
2137
+ try {
2138
+ got = await engineApi.run(
2139
+ "context_get",
2140
+ {
2141
+ ...selector,
2142
+ include_raw: true,
2143
+ // A retired node stays readable here: its stewards decide whether to
2144
+ // revive it, and they cannot do that if the read refuses.
2145
+ allow_rejected: true,
2146
+ ...opts.verifyChecksum ? { verify_checksum: true } : {}
2147
+ },
2148
+ await opContext(nestId, userEmail)
2021
2149
  );
2150
+ } catch (err) {
2151
+ if (err?.code !== "DOCUMENT_NOT_FOUND") {
2152
+ console.error(`getNodeForCaller failed (${nestId}/${selector.id ?? selector.title})`, err);
2153
+ }
2154
+ throw new NotFoundError(`Node not found: ${selector.id ?? selector.title}`);
2022
2155
  }
2023
- const userEmail = await resolveCallerEmail(userId);
2024
- let accessible = await filterAccessible(nestId, userId, userEmail, documents);
2025
- if (await resolveNestPermission(nestId, userId) === "none" && !await isStewardshipEnabled(nestId)) {
2026
- const grants = await listUserGrants(nestId, userId);
2027
- accessible = grants.length ? documents.filter((d) => grantCoversNode(grants, d.id)) : accessible;
2156
+ if (!selector.id && !await canReadNode(nestId, got.id, userId, userEmail)) {
2157
+ throw new ForbiddenError(
2158
+ "Access denied \u2014 no steward assignment for this node"
2159
+ );
2028
2160
  }
2161
+ return {
2162
+ id: got.id,
2163
+ filePath: "",
2164
+ rawContent: got.raw ?? "",
2165
+ frontmatter: got.frontmatter,
2166
+ body: got.body,
2167
+ ...got.pendingChange ? { pendingChange: got.pendingChange } : {}
2168
+ };
2169
+ }
2170
+ async function listNodesForCaller(nestId, userId, filters = {}) {
2171
+ const { versions: versionManager } = await engineCache.get(nestId);
2172
+ const userEmail = await resolveCallerEmail(userId);
2173
+ const { documents } = await engineApi.run(
2174
+ "context_list",
2175
+ {
2176
+ ...filters.type ? { type: filters.type } : {},
2177
+ ...filters.tag ? { tag: filters.tag } : {},
2178
+ ...filters.status ? { status: filters.status } : {},
2179
+ // Unlike the CLI and mcp-server, a retired node stays in this listing:
2180
+ // here it is an unpublished document its stewards may still need to act
2181
+ // on, not one removed from retrieval. An imported vault carrying
2182
+ // `status: rejected` registers exactly that way.
2183
+ include_retired: true,
2184
+ full: true
2185
+ },
2186
+ await opContext(nestId, userEmail)
2187
+ );
2188
+ const nodes = documents.map((d) => ({
2189
+ id: d.id,
2190
+ filePath: "",
2191
+ rawContent: "",
2192
+ frontmatter: d.frontmatter,
2193
+ body: d.body
2194
+ }));
2029
2195
  const publicReader = await isPublicReader(nestId, userId);
2196
+ const approvedOnly = publicReader || filters.approvedOnly === true;
2197
+ const approvedVersions = approvedOnly ? await getApprovedVersions(nestId) : null;
2198
+ let accessible = await filterAccessible(
2199
+ nestId,
2200
+ userId,
2201
+ userEmail,
2202
+ nodes,
2203
+ approvedVersions ?? void 0
2204
+ );
2205
+ const myGrants = await listUserGrants(nestId, userId);
2206
+ if (approvedVersions && !publicReader) {
2207
+ accessible = accessible.filter((d) => approvedVersions.has(d.id));
2208
+ }
2209
+ if (filters.limit) accessible = accessible.slice(0, filters.limit);
2030
2210
  const authorship = publicReader ? null : await resolveAuthorship(
2031
2211
  nestId,
2032
2212
  accessible.map((d) => ({
@@ -2042,8 +2222,11 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
2042
2222
  r.author = people.author;
2043
2223
  r.collaborators = people.collaborators;
2044
2224
  }
2045
- if (publicReader) {
2046
- const approved = await getApprovedVersion(nestId, doc.id);
2225
+ if (myGrants.length) {
2226
+ r.myGrant = grantRoleForNode(myGrants, doc.id);
2227
+ }
2228
+ if (approvedVersions) {
2229
+ const approved = approvedVersions.get(doc.id);
2047
2230
  if (approved != null) {
2048
2231
  try {
2049
2232
  const raw = await versionManager.reconstructVersion(
@@ -2080,7 +2263,7 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
2080
2263
  return r;
2081
2264
  })
2082
2265
  );
2083
- return filters.limit ? enriched.slice(0, filters.limit) : enriched;
2266
+ return enriched;
2084
2267
  }
2085
2268
  async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
2086
2269
  return listNodesForCaller(nestId, await userIdFromEmail(userEmail), filters);
@@ -2109,13 +2292,13 @@ async function autoPublishReason(nestId, userEmail, doc) {
2109
2292
  async function createNode(nestId, input, userEmail) {
2110
2293
  const { storage, versions: versionManager } = await engineCache.get(nestId);
2111
2294
  assertTitleLength(input.title);
2295
+ if (!input.id) assertTitleSluggable(input.title);
2112
2296
  const slug = slugify(input.title).slice(0, 100).replace(/-+$/, "");
2113
2297
  let id = input.id;
2114
2298
  if (!id) {
2115
2299
  const folderSegments = folderToSegments(input.folder ?? "");
2116
2300
  id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
2117
2301
  }
2118
- const now = (/* @__PURE__ */ new Date()).toISOString();
2119
2302
  const tags = (input.tags || []).map(normalizeTag2);
2120
2303
  const requestedType = input.type || "document";
2121
2304
  if (requestedType === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
@@ -2172,33 +2355,62 @@ async function createNode(nestId, input, userEmail) {
2172
2355
  tags,
2173
2356
  metadata
2174
2357
  });
2175
- const autoPublish = publishReason !== null;
2176
- const initialStatus = autoPublish ? "published" : "draft";
2177
- const initialVersion = autoPublish ? 0 : 1;
2178
- let node = {
2179
- id,
2180
- filePath: "",
2181
- frontmatter: {
2182
- title: input.title,
2183
- type: requestedType,
2184
- tags,
2185
- status: input.status || initialStatus,
2186
- version: initialVersion,
2187
- created_at: now,
2188
- updated_at: now,
2189
- metadata
2190
- },
2191
- body: input.content,
2192
- rawContent: ""
2193
- };
2194
- await storage.writeDocument(id, serializeDocument(node));
2358
+ const publishNote = publishReason === null ? void 0 : `Auto-published on create (${publishReason})`;
2359
+ let published = publishReason !== null;
2360
+ try {
2361
+ await engineApi.run(
2362
+ "context_create",
2363
+ {
2364
+ id,
2365
+ title: input.title,
2366
+ content: input.content,
2367
+ type: requestedType,
2368
+ // A skill node is invalid without a skill block, and this surface has
2369
+ // no field for one — default the trigger from the title, same as the
2370
+ // CLI and mcp-server do.
2371
+ ...requestedType === "skill" ? { trigger: `when asked to ${input.title.toLowerCase()}` } : {},
2372
+ ...tags.length ? { tags } : {},
2373
+ ...input.status ? { status: input.status } : {},
2374
+ metadata,
2375
+ publish: published,
2376
+ ...publishNote ? { note: publishNote } : {}
2377
+ },
2378
+ await opContext(nestId, userEmail)
2379
+ );
2380
+ } catch (err) {
2381
+ let onDisk = true;
2382
+ try {
2383
+ await storage.readDocument(id);
2384
+ } catch {
2385
+ onDisk = false;
2386
+ }
2387
+ if (!onDisk) throw err;
2388
+ console.error("publishDocument failed (node create auto-publish)", err);
2389
+ published = false;
2390
+ }
2391
+ const node = await storage.readDocument(id);
2195
2392
  await syncNodeTags(nestId, id, tags);
2196
2393
  let savedVersion = 1;
2197
- if (publishReason === null) {
2198
- try {
2199
- await versionManager.createVersion(node, userEmail);
2200
- } catch (err) {
2201
- console.error("VersionManager.createVersion failed (node create)", err);
2394
+ if (published) {
2395
+ savedVersion = node.frontmatter.version || 1;
2396
+ await createVersion({
2397
+ nestId,
2398
+ nodeId: id,
2399
+ version: savedVersion,
2400
+ content: node.body || "",
2401
+ author: userEmail,
2402
+ status: "published",
2403
+ tags,
2404
+ changeNote: publishNote
2405
+ });
2406
+ await setApprovedVersion(nestId, id, savedVersion, userEmail);
2407
+ } else {
2408
+ if (publishReason === null) {
2409
+ try {
2410
+ await versionManager.createVersion(node, userEmail);
2411
+ } catch (err) {
2412
+ console.error("VersionManager.createVersion failed (node create)", err);
2413
+ }
2202
2414
  }
2203
2415
  await createVersion({
2204
2416
  nestId,
@@ -2209,170 +2421,100 @@ async function createNode(nestId, input, userEmail) {
2209
2421
  status: "draft",
2210
2422
  tags
2211
2423
  });
2212
- } else {
2213
- const publishNote = `Auto-published on create (${publishReason})`;
2214
- try {
2215
- const result = await safePublishDocument(storage, id, {
2216
- editedBy: userEmail,
2217
- note: publishNote
2218
- });
2219
- savedVersion = result.node.frontmatter.version || 1;
2220
- await createVersion({
2221
- nestId,
2222
- nodeId: id,
2223
- version: savedVersion,
2224
- content: result.node.body || "",
2225
- author: userEmail,
2226
- status: "published",
2227
- tags,
2228
- changeNote: publishNote
2229
- });
2230
- await setApprovedVersion(nestId, id, savedVersion, userEmail);
2231
- node = result.node;
2232
- } catch (err) {
2233
- console.error("publishDocument failed (node create auto-publish)", err);
2234
- await createVersion({
2235
- nestId,
2236
- nodeId: id,
2237
- version: 1,
2238
- content: input.content,
2239
- author: userEmail,
2240
- status: "draft",
2241
- tags
2242
- });
2243
- }
2244
2424
  }
2245
2425
  await trackEvent("node.create", { nestId, nodeId: id });
2246
2426
  return { node, version: savedVersion };
2247
2427
  });
2248
2428
  }
2249
- function explicitFrontmatterStatus(raw) {
2250
- const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
2251
- if (!fm) return null;
2252
- const m = fm[1].match(/^status:[ \t]*["']?([A-Za-z_-]+)["']?[ \t]*$/m);
2253
- return m ? normalizeStatus(m[1]) : null;
2254
- }
2255
- async function resetImportedDocForPublish(storage, nestId, nodeId) {
2256
- const versionsDir = join2(
2257
- resolveNestPath(nestId),
2258
- dirname(nodeId),
2259
- ".versions",
2260
- basename(nodeId)
2261
- );
2262
- await rm(versionsDir, { recursive: true, force: true });
2263
- const node = await storage.readDocument(nodeId);
2264
- const fm = { ...node.frontmatter };
2265
- delete fm.version;
2266
- delete fm.checksum;
2267
- await storage.writeDocument(nodeId, serializeNodeSafe({ ...node, frontmatter: fm }));
2268
- }
2269
- async function registerImportedDocuments(nestId, userEmail, onProgress) {
2270
- const { storage } = await engineCache.get(nestId);
2271
- let docs;
2429
+ async function stageImportedFiles(nestId, files, userEmail) {
2430
+ const carried = files.filter(
2431
+ (f) => !String(f?.path || "").split(/[\\/]/).includes("_suggestions")
2432
+ );
2433
+ if (!carried.length) return { written: 0, rejected: [] };
2434
+ const result = await engineApi.run(
2435
+ "context_import",
2436
+ { files: carried, publish: false },
2437
+ await opContext(nestId, userEmail)
2438
+ );
2439
+ const rejected = (result.failed ?? []).map((f) => f.id ?? "(unnamed)");
2440
+ if (rejected.length) {
2441
+ console.warn(
2442
+ `[import] ${rejected.length} file(s) rejected:`,
2443
+ (result.failed ?? []).map((f) => `${f.id}: ${f.error}`).join(", ")
2444
+ );
2445
+ }
2446
+ return { written: result.written ?? 0, rejected };
2447
+ }
2448
+ async function alignOnDiskStatus(nestId, documentId, status) {
2272
2449
  try {
2273
- docs = await storage.discoverDocuments();
2450
+ const { storage } = await engineCache.get(nestId);
2451
+ const node = await storage.readDocument(documentId);
2452
+ if (node.frontmatter.status === status) return;
2453
+ await storage.writeDocument(
2454
+ documentId,
2455
+ serializeNodeSafe({
2456
+ ...node,
2457
+ frontmatter: { ...node.frontmatter, status }
2458
+ })
2459
+ );
2274
2460
  } catch (err) {
2275
- console.error("registerImportedDocuments: discovery failed", nestId, err);
2276
- return 0;
2461
+ console.warn(
2462
+ `[import] could not align on-disk status for ${nestId}/${documentId}:`,
2463
+ err
2464
+ );
2277
2465
  }
2466
+ }
2467
+ async function registerImportedDocuments(nestId, userEmail) {
2278
2468
  const startedAt = Date.now();
2279
- console.log(`[import] register: discovered=${docs.length} nest=${nestId}`);
2280
- await onProgress?.(0, docs.length);
2281
- let registered = 0;
2282
- let alreadyTracked = 0;
2283
- let publishFailed = 0;
2284
- let publishRecovered = 0;
2285
- let keptUnpublished = 0;
2286
- let processed = 0;
2287
- for (let doc of docs) {
2288
- const nodeId = doc.id;
2289
- await onProgress?.(++processed, docs.length);
2290
- if (await getCurrentVersion(nestId, nodeId) > 0) {
2291
- alreadyTracked++;
2292
- continue;
2293
- }
2294
- const title = doc.frontmatter?.title ?? (nodeId.split("/").pop() || nodeId);
2295
- doc = { ...doc, frontmatter: { ...doc.frontmatter, title, author: userEmail } };
2296
- try {
2297
- await storage.writeDocument(nodeId, serializeNodeSafe(doc));
2298
- } catch (err) {
2299
- console.error("import: failed to persist import metadata", nodeId, err);
2300
- }
2301
- const rawTags = Array.isArray(doc.frontmatter?.tags) ? doc.frontmatter.tags : [];
2302
- const tags = rawTags.map((t) => normalizeTag2(String(t)));
2303
- const fmVersion = Number(doc.frontmatter?.version);
2304
- let version = Number.isInteger(fmVersion) && fmVersion > 0 ? fmVersion : 1;
2305
- let content = doc.body || "";
2306
- const status = explicitFrontmatterStatus(doc.rawContent);
2307
- if (status && status !== "published" && status !== "approved") {
2308
- await createVersion({
2309
- nestId,
2310
- nodeId,
2311
- version,
2312
- content,
2313
- author: userEmail,
2314
- status: "draft",
2315
- changeNote: "Imported from existing folder (kept unpublished)",
2316
- tags
2317
- });
2318
- await syncNodeTags(nestId, nodeId, tags);
2319
- keptUnpublished++;
2320
- registered++;
2321
- continue;
2322
- }
2323
- let published = false;
2324
- try {
2325
- const result = await safePublishDocument(storage, nodeId, {
2326
- editedBy: userEmail,
2327
- note: "Imported from existing folder"
2328
- });
2329
- version = result.node.frontmatter.version || version;
2330
- content = result.node.body || content;
2331
- published = true;
2332
- } catch (err) {
2333
- console.warn(
2334
- `[import] publish retry needed for ${nodeId}: ${err?.message ?? err}`
2335
- );
2336
- try {
2337
- await resetImportedDocForPublish(storage, nestId, nodeId);
2338
- const result = await safePublishDocument(storage, nodeId, {
2339
- editedBy: userEmail,
2340
- note: "Imported from existing folder (reset chain)"
2341
- });
2342
- version = result.node.frontmatter.version || 1;
2343
- content = result.node.body || content;
2344
- published = true;
2345
- publishRecovered++;
2346
- } catch (err2) {
2347
- publishFailed++;
2348
- console.error(
2349
- "reset+republish failed (import register)",
2350
- nodeId,
2351
- err2
2352
- );
2353
- }
2354
- }
2469
+ const tracked = await getTrackedNodeIds(nestId);
2470
+ (await engineCache.get(nestId)).invalidateDiscovery();
2471
+ let result;
2472
+ try {
2473
+ result = await engineApi.run(
2474
+ "context_import",
2475
+ { discover: true, author: userEmail, exclude_ids: [...tracked] },
2476
+ await opContext(nestId, userEmail)
2477
+ );
2478
+ } catch (err) {
2479
+ console.error("[import] register failed", nestId, err);
2480
+ throw new Error(
2481
+ `Failed to import documents: ${err?.message ?? String(err)}`
2482
+ );
2483
+ }
2484
+ const docs = result.documents ?? [];
2485
+ const engineMs = Date.now() - startedAt;
2486
+ const dbStartedAt = Date.now();
2487
+ for (const d of docs) {
2488
+ const tags = d.tags.map((t) => normalizeTag2(String(t)));
2355
2489
  await createVersion({
2356
2490
  nestId,
2357
- nodeId,
2358
- version,
2359
- content,
2491
+ nodeId: d.id,
2492
+ version: d.version,
2493
+ content: d.content,
2360
2494
  author: userEmail,
2361
- status: published ? "published" : "draft",
2362
- changeNote: "Imported from existing folder",
2495
+ status: d.status,
2496
+ changeNote: d.status === "published" ? "Imported from existing folder" : "Imported from existing folder \u2014 could not be published, kept as a draft",
2363
2497
  tags
2364
2498
  });
2365
- if (published) {
2366
- await setApprovedVersion(nestId, nodeId, version, userEmail);
2499
+ if (d.status === "published") {
2500
+ await setApprovedVersion(nestId, d.id, d.version, userEmail);
2501
+ } else {
2502
+ await alignOnDiskStatus(nestId, d.id, d.status);
2367
2503
  }
2368
- await syncNodeTags(nestId, nodeId, tags);
2369
- registered++;
2504
+ await syncNodeTags(nestId, d.id, tags);
2370
2505
  }
2506
+ const published = docs.filter((d) => d.status === "published").length;
2371
2507
  console.log(
2372
- `[import] register done: registered=${registered} alreadyTracked=${alreadyTracked} keptUnpublished=${keptUnpublished} publishRecovered=${publishRecovered} publishFailed=${publishFailed} tookMs=${Date.now() - startedAt}`
2508
+ `[import] register done: registered=${docs.length} published=${published} kept=${docs.length - published} failed=${result.failed.length} alreadyTracked=${tracked.size} engineMs=${engineMs} dbMs=${Date.now() - dbStartedAt}`
2373
2509
  );
2374
- await trackEvent("nest.import.documents", { nestId, registered });
2375
- return registered;
2510
+ if (result.failed.length) {
2511
+ console.warn(
2512
+ "[import] documents that would not publish:",
2513
+ result.failed.map((f) => `${f.id}: ${f.error}`).join(", ")
2514
+ );
2515
+ }
2516
+ await trackEvent("nest.import.documents", { nestId, registered: docs.length });
2517
+ return docs.length;
2376
2518
  }
2377
2519
  async function updateNode(nestId, nodeId, patch, userEmail) {
2378
2520
  return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
@@ -2386,33 +2528,43 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2386
2528
  } catch {
2387
2529
  throw new NotFoundError(`Node not found: ${nodeId}`);
2388
2530
  }
2531
+ const opInput = {};
2389
2532
  if (patch.content !== void 0) {
2390
2533
  node = { ...node, body: patch.content };
2534
+ opInput.content = patch.content;
2391
2535
  }
2392
2536
  if (patch.append) {
2393
2537
  node = { ...node, body: (node.body || "") + "\n\n" + patch.append };
2538
+ opInput.append = "\n" + patch.append;
2394
2539
  }
2395
2540
  if (patch.tags) {
2396
- const newTags = patch.tags.map(normalizeTag2);
2397
- const merged = [
2398
- .../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...newTags])
2399
- ];
2400
- node = { ...node, frontmatter: { ...node.frontmatter, tags: merged } };
2541
+ const nextTags = [...new Set(patch.tags.map(normalizeTag2))];
2542
+ node = { ...node, frontmatter: { ...node.frontmatter, tags: nextTags } };
2543
+ opInput.tags = nextTags;
2401
2544
  }
2402
2545
  if (patch.status) {
2403
2546
  node = {
2404
2547
  ...node,
2405
2548
  frontmatter: { ...node.frontmatter, status: patch.status }
2406
2549
  };
2550
+ opInput.status = normalizeStatus(patch.status);
2551
+ } else if (isRejected(node)) {
2552
+ node = {
2553
+ ...node,
2554
+ frontmatter: { ...node.frontmatter, status: "draft" }
2555
+ };
2556
+ opInput.status = "draft";
2407
2557
  }
2408
2558
  if (patch.title) {
2409
2559
  if (patch.title !== node.frontmatter.title) {
2410
2560
  assertTitleLength(patch.title);
2561
+ assertTitleSluggable(patch.title);
2411
2562
  }
2412
2563
  node = {
2413
2564
  ...node,
2414
2565
  frontmatter: { ...node.frontmatter, title: patch.title }
2415
2566
  };
2567
+ opInput.title = patch.title;
2416
2568
  }
2417
2569
  if (patch.schedule !== void 0) {
2418
2570
  if (!isRunnableType(node.frontmatter.type)) {
@@ -2426,6 +2578,10 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2426
2578
  if (patch.schedule === "") delete metadata.schedule;
2427
2579
  else metadata.schedule = patch.schedule;
2428
2580
  node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
2581
+ opInput.metadata = {
2582
+ ...opInput.metadata,
2583
+ schedule: patch.schedule === "" ? null : patch.schedule
2584
+ };
2429
2585
  }
2430
2586
  if (patch.prime !== void 0) {
2431
2587
  await assertMayFlagPrime(nestId, userEmail);
@@ -2433,6 +2589,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2433
2589
  if (patch.prime === null) delete metadata.prime;
2434
2590
  else metadata.prime = patch.prime;
2435
2591
  node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
2592
+ opInput.metadata = { ...opInput.metadata, prime: patch.prime };
2436
2593
  }
2437
2594
  const hasStewards = await isStewardshipEnabled(nestId);
2438
2595
  const currentTags = node.frontmatter.tags || [];
@@ -2451,19 +2608,12 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2451
2608
  if (publishReason === null || isAuthorEditingOwnReview) {
2452
2609
  const currentVersion = await getCurrentVersion(nestId, nodeId);
2453
2610
  const newVersion = await versionManager.nextVersion(nodeId, currentVersion);
2454
- node = {
2455
- ...node,
2456
- frontmatter: {
2457
- ...node.frontmatter,
2458
- version: newVersion,
2459
- updated_at: (/* @__PURE__ */ new Date()).toISOString(),
2460
- // Drop stale published-state checksum so the next verified read
2461
- // doesn't flag this write as external drift.
2462
- checksum: void 0
2463
- }
2464
- };
2465
- node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
2466
- await storage.writeDocument(nodeId, serializeDocument(node));
2611
+ await engineApi.run(
2612
+ "context_update",
2613
+ { id: nodeId, ...opInput, publish: false, version: newVersion },
2614
+ await opContext(nestId, userEmail)
2615
+ );
2616
+ node = await storage.readDocument(nodeId);
2467
2617
  await syncNodeTags(nestId, nodeId, currentTags);
2468
2618
  try {
2469
2619
  await versionManager.createVersion(node, userEmail, {
@@ -2491,26 +2641,15 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2491
2641
  }
2492
2642
  responseVersion = newVersion;
2493
2643
  } else {
2494
- node = {
2495
- ...node,
2496
- frontmatter: {
2497
- ...node.frontmatter,
2498
- updated_at: (/* @__PURE__ */ new Date()).toISOString(),
2499
- checksum: void 0
2500
- }
2501
- };
2502
- node = { ...node, frontmatter: stripUndefined(node.frontmatter) };
2503
- await storage.writeDocument(nodeId, serializeDocument(node));
2504
- await syncNodeTags(nestId, nodeId, currentTags);
2505
- let publishedVersion = (node.frontmatter.version || 0) + 1;
2506
2644
  const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
2645
+ let publishedVersion;
2507
2646
  try {
2508
- const result = await safePublishDocument(storage, nodeId, {
2509
- editedBy: userEmail,
2510
- note: publishNote
2511
- });
2512
- publishedVersion = result.node.frontmatter.version || publishedVersion;
2513
- node = result.node;
2647
+ const result = await engineApi.run(
2648
+ "context_update",
2649
+ { id: nodeId, ...opInput, publish: true, note: publishNote },
2650
+ await opContext(nestId, userEmail)
2651
+ );
2652
+ publishedVersion = result.version;
2514
2653
  } catch (err) {
2515
2654
  console.error(
2516
2655
  "publishDocument failed (node patch auto-publish)",
@@ -2518,6 +2657,8 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2518
2657
  );
2519
2658
  throw err;
2520
2659
  }
2660
+ node = await storage.readDocument(nodeId);
2661
+ await syncNodeTags(nestId, nodeId, currentTags);
2521
2662
  await upsertVersion({
2522
2663
  nestId,
2523
2664
  nodeId,
@@ -2685,14 +2826,22 @@ async function deleteNode(nestId, nodeId, userEmail) {
2685
2826
  const { storage } = await engineCache.get(nestId);
2686
2827
  await assertNotReviewLocked(nestId, nodeId, userEmail, "deleting");
2687
2828
  try {
2688
- await storage.deleteDocument(nodeId);
2829
+ await engineApi.run(
2830
+ "context_delete",
2831
+ { id: nodeId },
2832
+ await opContext(nestId, userEmail)
2833
+ );
2689
2834
  } catch {
2690
2835
  throw new NotFoundError(`Node not found: ${nodeId}`);
2691
2836
  }
2692
2837
  await removeNodeFromTagIndex(nestId, nodeId);
2693
2838
  const derivedId = derivedIdOf(nodeId);
2694
2839
  try {
2695
- await storage.deleteDocument(derivedId);
2840
+ await engineApi.run(
2841
+ "context_delete",
2842
+ { id: derivedId },
2843
+ await opContext(nestId, userEmail)
2844
+ );
2696
2845
  await removeNodeFromTagIndex(nestId, derivedId);
2697
2846
  } catch {
2698
2847
  }
@@ -2738,8 +2887,9 @@ async function deleteFolder(nestId, folder, userEmail) {
2738
2887
  }
2739
2888
 
2740
2889
  // src/nests/unsynced-service.ts
2741
- import { readdirSync, readFileSync, rmSync, statSync } from "fs";
2890
+ import { readdirSync, readFileSync, statSync } from "fs";
2742
2891
  import { join as join3, relative, resolve } from "path";
2892
+ import { rm } from "fs/promises";
2743
2893
  var RESERVED = /* @__PURE__ */ new Set(["nests"]);
2744
2894
  function isNestStorageRoot(absPath) {
2745
2895
  return resolve(absPath) === resolve(nestStorageRoot());
@@ -2907,23 +3057,31 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
2907
3057
  `[unsynced] name "${baseName}" already in use, using "${nestName2}" instead`
2908
3058
  );
2909
3059
  }
2910
- const nest = await importNest(userId, nestName2, files);
3060
+ const nest = await importNest(userId, nestName2);
2911
3061
  console.log(
2912
3062
  `[unsynced] nest created id=${nest.id} name="${nest.name}" from folder="${folderName}"`
2913
3063
  );
2914
- const documents = await registerImportedDocuments(nest.id, callerEmail);
3064
+ let documents;
3065
+ try {
3066
+ await stageImportedFiles(nest.id, files, callerEmail);
3067
+ documents = await registerImportedDocuments(nest.id, callerEmail);
3068
+ } catch (err) {
3069
+ await deleteNest(nest.id).catch(() => {
3070
+ });
3071
+ throw err;
3072
+ }
2915
3073
  console.log(
2916
3074
  `[unsynced] registered ${documents} document(s) for nest ${nest.id}`
2917
3075
  );
2918
3076
  try {
2919
- rmSync(src, { recursive: true, force: true });
3077
+ await rm(src, { recursive: true, force: true });
2920
3078
  console.log(`[unsynced] removed source folder ${src}`);
2921
3079
  } catch (err) {
2922
3080
  console.error("[unsynced] failed to remove source folder", src, err);
2923
3081
  }
2924
3082
  return { nest, documents };
2925
3083
  }
2926
- function deleteUnsyncedFolder(folderName) {
3084
+ async function deleteUnsyncedFolder(folderName) {
2927
3085
  console.log(`[unsynced] delete requested folder="${folderName}"`);
2928
3086
  assertSafeFolderName(folderName);
2929
3087
  const src = join3(config.DATA_ROOT, folderName);
@@ -2939,21 +3097,27 @@ function deleteUnsyncedFolder(folderName) {
2939
3097
  if (!stat.isDirectory()) {
2940
3098
  throw new ValidationError(`Not a directory: ${folderName}`);
2941
3099
  }
2942
- rmSync(src, { recursive: true, force: true });
3100
+ await rm(src, { recursive: true, force: true });
2943
3101
  console.log(`[unsynced] deleted folder ${src}`);
2944
3102
  }
2945
3103
 
2946
3104
  // src/nests/routes.ts
2947
- async function effectivePermission(nestId, userId) {
2948
- if (config.AUTH_MODE === "open") {
2949
- const db = getDb();
2950
- const nest = await db.get(
2951
- "SELECT user_id FROM nests WHERE id = ?",
2952
- [nestId]
2953
- );
2954
- if (nest && nest.user_id === ANON_USER_ID) return "owner";
3105
+ async function effectiveAccess(nestId, userId) {
3106
+ const access = await resolveNestAccess(nestId, userId);
3107
+ if (config.AUTH_MODE === "open" && access.ownerId === ANON_USER_ID) {
3108
+ return { permission: "owner", access };
3109
+ }
3110
+ const permission = access.permission;
3111
+ if (permission === "none" && access.hasGrant) {
3112
+ return { permission: "read", access };
2955
3113
  }
2956
- return resolveNestPermission(nestId, userId);
3114
+ return { permission, access };
3115
+ }
3116
+ async function effectivePermission(nestId, userId) {
3117
+ return (await effectiveAccess(nestId, userId)).permission;
3118
+ }
3119
+ function isGrantOnly(access) {
3120
+ return access.permission === "none" && access.hasGrant;
2957
3121
  }
2958
3122
  var nestRoutes = new Hono3();
2959
3123
  nestRoutes.get("/", async (c) => {
@@ -2964,8 +3128,31 @@ nestRoutes.get("/", async (c) => {
2964
3128
  const db = getDb();
2965
3129
  const ownerEmailSql = "SELECT email FROM users WHERE id = ?";
2966
3130
  const callerEmail = await resolveCallerEmail(userId);
3131
+ const myGrantRows = await db.all(
3132
+ "SELECT nest_id, target FROM grants WHERE user_id = ?",
3133
+ [userId]
3134
+ );
3135
+ const grantTargetsByNest = /* @__PURE__ */ new Map();
3136
+ for (const r of myGrantRows) {
3137
+ const arr = grantTargetsByNest.get(r.nest_id) ?? [];
3138
+ arr.push({ target: r.target });
3139
+ grantTargetsByNest.set(r.nest_id, arr);
3140
+ }
2967
3141
  const annotate = async (n) => {
2968
- const permission = await effectivePermission(n.id, userId);
3142
+ const { permission, access } = await effectiveAccess(n.id, userId);
3143
+ if (permission !== "owner" && // open-mode commons promotion outranks a grant
3144
+ isGrantOnly(access)) {
3145
+ const owner_email2 = n.user_id === ANON_USER_ID ? null : (await db.get(ownerEmailSql, [n.user_id]))?.email ?? null;
3146
+ return {
3147
+ ...n,
3148
+ permission: "read",
3149
+ is_owner: false,
3150
+ owner_email: owner_email2,
3151
+ roles: ["viewer"],
3152
+ grant_only: true,
3153
+ stewardship_enabled: !!n.stewardship_enabled
3154
+ };
3155
+ }
2969
3156
  const is_owner = permission === "owner";
2970
3157
  const needOwnerEmail = !is_owner && n.user_id !== ANON_USER_ID;
2971
3158
  const [roles, owner_email] = await Promise.all([
@@ -2980,6 +3167,7 @@ nestRoutes.get("/", async (c) => {
2980
3167
  is_owner,
2981
3168
  owner_email,
2982
3169
  roles,
3170
+ grant_only: false,
2983
3171
  stewardship_enabled: !!n.stewardship_enabled
2984
3172
  };
2985
3173
  };
@@ -3023,14 +3211,38 @@ nestRoutes.get("/", async (c) => {
3023
3211
  if (arr.length < 8) arr.push(r.email);
3024
3212
  collabEmailsByNest.set(r.nest_id, arr);
3025
3213
  }
3026
- const withMeta = out.map((n) => ({
3027
- ...n,
3028
- document_count: docByNest.get(n.id) ?? 0,
3029
- collaborator_count: collabByNest.get(n.id) ?? 0,
3030
- steward_count: stewardByNest.get(n.id) ?? 0,
3031
- steward_emails: stewardEmailsByNest.get(n.id) ?? [],
3032
- collaborator_emails: collabEmailsByNest.get(n.id) ?? []
3033
- }));
3214
+ const grantOnlyIds = out.filter((n) => n.grant_only).map((n) => n.id);
3215
+ const grantedDocsByNest = /* @__PURE__ */ new Map();
3216
+ if (grantOnlyIds.length) {
3217
+ const rows = await db.all(
3218
+ `SELECT DISTINCT nest_id, node_id FROM node_versions
3219
+ WHERE nest_id IN (${grantOnlyIds.map(() => "?").join(",")})`,
3220
+ grantOnlyIds
3221
+ );
3222
+ for (const r of rows) {
3223
+ const targets = grantTargetsByNest.get(r.nest_id) ?? [];
3224
+ if (grantCoversNode(targets, r.node_id)) {
3225
+ grantedDocsByNest.set(r.nest_id, (grantedDocsByNest.get(r.nest_id) ?? 0) + 1);
3226
+ }
3227
+ }
3228
+ }
3229
+ const withMeta = out.map(
3230
+ (n) => n.grant_only ? {
3231
+ ...n,
3232
+ document_count: grantedDocsByNest.get(n.id) ?? 0,
3233
+ collaborator_count: 0,
3234
+ steward_count: 0,
3235
+ steward_emails: [],
3236
+ collaborator_emails: []
3237
+ } : {
3238
+ ...n,
3239
+ document_count: docByNest.get(n.id) ?? 0,
3240
+ collaborator_count: collabByNest.get(n.id) ?? 0,
3241
+ steward_count: stewardByNest.get(n.id) ?? 0,
3242
+ steward_emails: stewardEmailsByNest.get(n.id) ?? [],
3243
+ collaborator_emails: collabEmailsByNest.get(n.id) ?? []
3244
+ }
3245
+ );
3034
3246
  return c.json({ nests: withMeta });
3035
3247
  });
3036
3248
  nestRoutes.post("/", async (c) => {
@@ -3046,42 +3258,50 @@ nestRoutes.post("/:nestId", async (c) => {
3046
3258
  throw new NotFoundError("Not found");
3047
3259
  }
3048
3260
  const body = await c.req.json();
3261
+ const files = Array.isArray(body.files) ? body.files : [];
3262
+ const userId = c.get("userId");
3263
+ if (body.nestId) {
3264
+ const target = body.nestId;
3265
+ const nest2 = await getNest(target);
3266
+ if (!nest2 || !nest2.is_imported || await effectivePermission(target, userId) !== "owner") {
3267
+ throw new NotFoundError("Nest not found");
3268
+ }
3269
+ const email2 = await resolveCallerEmail(userId);
3270
+ if (body.finalize) {
3271
+ return c.json({ nest: nest2, documents: await registerImportedDocuments(target, email2) });
3272
+ }
3273
+ const registered = await getDb().get(
3274
+ "SELECT COUNT(*) AS c FROM node_versions WHERE nest_id = ?",
3275
+ [target]
3276
+ );
3277
+ if (Number(registered?.c ?? 0) > 0) {
3278
+ throw new ValidationError(
3279
+ "This nest has already been imported. Start a new import instead of adding files to a finished one."
3280
+ );
3281
+ }
3282
+ console.log(`[import] chunk: nest=${target} files=${files.length}`);
3283
+ return c.json(await stageImportedFiles(target, files, email2));
3284
+ }
3049
3285
  if (!body.name) {
3050
3286
  throw new ValidationError("name is required");
3051
3287
  }
3052
- const files = Array.isArray(body.files) ? body.files : [];
3053
- const userId = c.get("userId");
3054
3288
  console.log(
3055
- `[import] request: name=${JSON.stringify(body.name)} files=${files.length}`
3289
+ `[import] request: name=${JSON.stringify(body.name)} files=${files.length}` + (body.partial ? " (first chunk)" : "")
3056
3290
  );
3057
- if (c.req.query("stream") === "1") {
3058
- const name = body.name;
3059
- return streamText(c, async (stream) => {
3060
- try {
3061
- await stream.writeln(JSON.stringify({ phase: "write" }));
3062
- const nest2 = await importNest(userId, name, files);
3063
- const email = await resolveCallerEmail(userId);
3064
- const documents2 = await registerImportedDocuments(
3065
- nest2.id,
3066
- email,
3067
- async (done, total) => {
3068
- await stream.writeln(JSON.stringify({ phase: "register", done, total }));
3069
- }
3070
- );
3071
- await stream.writeln(JSON.stringify({ complete: true, nest: nest2, documents: documents2 }));
3072
- } catch (err) {
3073
- await stream.writeln(
3074
- JSON.stringify({ error: err?.message || "Import failed" })
3075
- );
3076
- }
3291
+ const email = await resolveCallerEmail(userId);
3292
+ const nest = await importNest(userId, body.name);
3293
+ try {
3294
+ const { rejected } = await stageImportedFiles(nest.id, files, email);
3295
+ if (body.partial) {
3296
+ return c.json({ nest, rejected }, 201);
3297
+ }
3298
+ const documents = await registerImportedDocuments(nest.id, email);
3299
+ return c.json({ nest, documents, rejected }, 201);
3300
+ } catch (err) {
3301
+ await deleteNest(nest.id).catch(() => {
3077
3302
  });
3303
+ throw err;
3078
3304
  }
3079
- const nest = await importNest(userId, body.name, files);
3080
- const documents = await registerImportedDocuments(
3081
- nest.id,
3082
- await resolveCallerEmail(userId)
3083
- );
3084
- return c.json({ nest, documents }, 201);
3085
3305
  });
3086
3306
  async function handleUnsyncedList(c) {
3087
3307
  const userId = c.get("userId");
@@ -3121,14 +3341,14 @@ nestRoutes.delete("/:nestId/sync", async (c) => {
3121
3341
  if (!body?.name) {
3122
3342
  throw new ValidationError("name is required");
3123
3343
  }
3124
- deleteUnsyncedFolder(body.name);
3344
+ await deleteUnsyncedFolder(body.name);
3125
3345
  return c.json({ ok: true });
3126
3346
  });
3127
3347
  nestRoutes.get("/:nestId", async (c) => {
3128
3348
  const nestId = c.req.param("nestId");
3129
3349
  if (nestId === "unsynced") return handleUnsyncedList(c);
3130
3350
  const userId = c.get("userId");
3131
- const permission = await effectivePermission(nestId, userId);
3351
+ const { permission, access } = await effectiveAccess(nestId, userId);
3132
3352
  if (permission === "none") {
3133
3353
  throw new NotFoundError("Nest not found");
3134
3354
  }
@@ -3138,8 +3358,15 @@ nestRoutes.get("/:nestId", async (c) => {
3138
3358
  roles = ["owner", ...roles];
3139
3359
  }
3140
3360
  const myStewards = await getStewardsForUser(nestId, email);
3361
+ const grant_only = roles.length === 0 && isGrantOnly(access);
3141
3362
  const nest = await getNest(nestId);
3142
- return c.json({ nest, permission, roles, myStewards });
3363
+ return c.json({
3364
+ nest,
3365
+ permission,
3366
+ roles: grant_only ? ["viewer"] : roles,
3367
+ myStewards,
3368
+ grant_only
3369
+ });
3143
3370
  });
3144
3371
  nestRoutes.patch("/:nestId", async (c) => {
3145
3372
  const nestId = c.req.param("nestId");
@@ -3838,27 +4065,62 @@ teamRoutes.delete("/:teamId/members/:userId", async (c) => {
3838
4065
  return c.json({ team });
3839
4066
  });
3840
4067
 
4068
+ // src/nodes/search.ts
4069
+ async function discoverForSearch(storage) {
4070
+ return storage.discoverDocuments({ includeRetired: true });
4071
+ }
4072
+ function titleScore(title, query) {
4073
+ const t = (title || "").toLowerCase();
4074
+ const q = query.trim().toLowerCase();
4075
+ if (!q) return 0;
4076
+ if (t === q) return 4;
4077
+ let midWord = false;
4078
+ for (let i = t.indexOf(q); i !== -1; i = t.indexOf(q, i + 1)) {
4079
+ if (i === 0) return 3;
4080
+ if (!/[a-z0-9]/.test(t[i - 1])) return 2;
4081
+ midWord = true;
4082
+ }
4083
+ if (midWord) return 1;
4084
+ const terms = q.split(/\s+/).filter(Boolean);
4085
+ if (terms.length > 1 && terms.every((term) => t.includes(term))) return 1;
4086
+ return 0;
4087
+ }
4088
+ function matchDocuments(documents, query) {
4089
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
4090
+ if (terms.length === 0) return [];
4091
+ return documents.filter((node) => {
4092
+ const haystack = [
4093
+ node.frontmatter.title,
4094
+ node.body || "",
4095
+ node.frontmatter.type || "",
4096
+ ...node.frontmatter.tags || []
4097
+ ].join(" ").toLowerCase();
4098
+ return terms.every((term) => haystack.includes(term));
4099
+ }).sort(
4100
+ (a, b) => titleScore(b.frontmatter.title, query) - titleScore(a.frontmatter.title, query)
4101
+ );
4102
+ }
4103
+ function bodySnippet(body, query) {
4104
+ const flat = body.replace(/\s+/g, " ").trim();
4105
+ const terms = (query || "").toLowerCase().split(/\s+/).filter(Boolean);
4106
+ const at = terms.map((t) => flat.toLowerCase().indexOf(t)).filter((i) => i >= 0).sort((a, b) => a - b)[0];
4107
+ if (at === void 0 || at < 80) return flat.slice(0, 200);
4108
+ const start = Math.max(0, at - 60);
4109
+ return `\u2026${flat.slice(start, start + 200)}`;
4110
+ }
4111
+ function toSearchHit(node, query) {
4112
+ return {
4113
+ id: node.id,
4114
+ title: node.frontmatter.title,
4115
+ type: node.frontmatter.type || "document",
4116
+ tags: node.frontmatter.tags || [],
4117
+ snippet: bodySnippet(node.body || "", query)
4118
+ };
4119
+ }
4120
+
3841
4121
  // src/nodes/routes.ts
3842
4122
  import { Hono as Hono8 } from "hono";
3843
4123
 
3844
- // src/shared/artifact-sandbox.ts
3845
- var ARTIFACT_SANDBOX_CSP = [
3846
- "sandbox allow-scripts allow-popups allow-modals",
3847
- "default-src 'none'",
3848
- "script-src 'unsafe-inline'",
3849
- "style-src 'unsafe-inline'",
3850
- "img-src data: blob:",
3851
- "font-src data:",
3852
- "connect-src 'none'",
3853
- "base-uri 'none'",
3854
- "form-action 'none'"
3855
- ].join("; ");
3856
- function applyArtifactSandboxHeaders(c) {
3857
- c.header("Content-Security-Policy", ARTIFACT_SANDBOX_CSP);
3858
- c.header("X-Content-Type-Options", "nosniff");
3859
- c.header("Referrer-Policy", "no-referrer");
3860
- }
3861
-
3862
4124
  // src/nodes/markdown-export.ts
3863
4125
  import { parseDocument as parseDocument3 } from "@promptowl/contextnest-engine";
3864
4126
  function nodeToMarkdown(node) {
@@ -3933,7 +4195,17 @@ function rawNodeBody(c, response, storedRaw) {
3933
4195
  nodeRoutes.get("/", async (c) => {
3934
4196
  const nestId = c.req.param("nestId");
3935
4197
  const userId = c.get("userId");
3936
- const nodes = await listNodesForCaller(nestId, userId);
4198
+ const q = c.req.query();
4199
+ const limit = q.limit ? parseInt(q.limit, 10) : void 0;
4200
+ const nodes = await listNodesForCaller(nestId, userId, {
4201
+ ...q.type ? { type: q.type } : {},
4202
+ ...q.tag ? { tag: q.tag } : {},
4203
+ ...q.status ? { status: q.status } : {},
4204
+ // Lets an owner preview what making the nest public would expose.
4205
+ ...q.approved_only === "1" || q.approved_only === "true" ? { approvedOnly: true } : {},
4206
+ // Ignore junk rather than 400 — a bad ?limit should not fail a listing.
4207
+ ...limit !== void 0 && Number.isFinite(limit) && limit > 0 ? { limit } : {}
4208
+ });
3937
4209
  return c.json({ count: nodes.length, nodes });
3938
4210
  });
3939
4211
  nodeRoutes.post("/", async (c) => {
@@ -3974,7 +4246,7 @@ nodeRoutes.post("/", async (c) => {
3974
4246
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
3975
4247
  const nestId = c.req.param("nestId");
3976
4248
  const nodeId = c.req.param("nodeId");
3977
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-FHJ2RBKN.js");
4249
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-OHTTZFND.js");
3978
4250
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
3979
4251
  nestId,
3980
4252
  nodeId
@@ -3995,7 +4267,7 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
3995
4267
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
3996
4268
  const nestId = c.req.param("nestId");
3997
4269
  const nodeId = c.req.param("nodeId");
3998
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-JUAFYB73.js");
4270
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-3CCKDO5N.js");
3999
4271
  const history = await getReviewHistory2(nestId, nodeId);
4000
4272
  return c.json({ reviews: history });
4001
4273
  });
@@ -4076,26 +4348,21 @@ nodeRoutes.delete("/:nodeId{.+}/watchers", async (c) => {
4076
4348
  if (!await removeWatcher(nestId, nodeId, email)) {
4077
4349
  throw new NotFoundError("Not watching");
4078
4350
  }
4079
- return c.json({ watchers: await listWatchers(nestId, nodeId) });
4080
- });
4081
- nodeRoutes.get("/:nodeId{.+}", async (c) => {
4082
- const nestId = c.req.param("nestId");
4083
- const nodeId = c.req.param("nodeId");
4084
- const { storage, versions: versionManager } = await engineCache.get(nestId);
4085
- const userId = c.get("userId");
4086
- const userEmail = await resolveCallerEmail(userId);
4087
- if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
4088
- return c.json(
4089
- { error: "Access denied \u2014 no steward assignment for this node" },
4090
- 403
4091
- );
4092
- }
4093
- let node;
4094
- try {
4095
- node = await storage.readDocument(nodeId, { verifyChecksum: true });
4096
- } catch {
4097
- throw new NotFoundError(`Node not found: ${nodeId}`);
4098
- }
4351
+ return c.json({ watchers: await listWatchers(nestId, nodeId) });
4352
+ });
4353
+ nodeRoutes.get("/:nodeId{.+}", async (c) => {
4354
+ const nestId = c.req.param("nestId");
4355
+ const nodeId = c.req.param("nodeId");
4356
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
4357
+ const userId = c.get("userId");
4358
+ const userEmail = await resolveCallerEmail(userId);
4359
+ let node = await getNodeForCaller(
4360
+ nestId,
4361
+ { id: nodeId },
4362
+ userId,
4363
+ userEmail,
4364
+ { verifyChecksum: true }
4365
+ );
4099
4366
  if (node.pendingChange) {
4100
4367
  try {
4101
4368
  await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
@@ -4124,6 +4391,7 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
4124
4391
  response.collaborators = people.collaborators;
4125
4392
  }
4126
4393
  }
4394
+ response.myGrant = await resolveNodeGrant(nestId, userId, nodeId);
4127
4395
  if (publicReader) {
4128
4396
  const approved = await getApprovedVersion(nestId, nodeId);
4129
4397
  if (approved != null) {
@@ -5611,30 +5879,14 @@ queryRoutes.get("/search", async (c) => {
5611
5879
  }
5612
5880
  const nestId = c.req.param("nestId");
5613
5881
  const { storage } = await engineCache.get(nestId);
5614
- const documents = await storage.discoverDocuments();
5615
- const terms = q.toLowerCase().split(/\s+/).filter(Boolean);
5616
- const matches = documents.filter((node) => {
5617
- const haystack = [
5618
- node.frontmatter.title,
5619
- node.body || "",
5620
- node.frontmatter.type || "",
5621
- ...node.frontmatter.tags || []
5622
- ].join(" ").toLowerCase();
5623
- return terms.every((term) => haystack.includes(term));
5624
- });
5882
+ const matches = matchDocuments(await discoverForSearch(storage), q);
5625
5883
  const userId = c.get("userId");
5626
5884
  const userEmail = await resolveCallerEmail(userId);
5627
5885
  const accessible = await filterAccessible(nestId, userId, userEmail, matches);
5628
5886
  return c.json({
5629
5887
  query: q,
5630
5888
  count: accessible.length,
5631
- nodes: accessible.map((n) => ({
5632
- id: n.id,
5633
- title: n.frontmatter.title,
5634
- type: n.frontmatter.type || "document",
5635
- tags: n.frontmatter.tags || [],
5636
- snippet: (n.body || "").slice(0, 200).replace(/\n/g, " ")
5637
- }))
5889
+ nodes: accessible.map((n) => toSearchHit(n, q))
5638
5890
  });
5639
5891
  });
5640
5892
  queryRoutes.get("/overview", async (c) => {
@@ -5674,10 +5926,6 @@ queryRoutes.get("/context", async (c) => {
5674
5926
  queryRoutes.get("/export", async (c) => {
5675
5927
  const nestId = c.req.param("nestId");
5676
5928
  if (c.req.query("format") === "bundle") {
5677
- const perm = await resolveNestPermission(nestId, c.get("userId"));
5678
- if (perm !== "owner" && perm !== "admin") {
5679
- throw new ForbiddenError("Only the nest owner or an admin can export a bundle");
5680
- }
5681
5929
  const draftsParam = c.req.query("includeDrafts");
5682
5930
  const includeDrafts = draftsParam === "1" || draftsParam === "true";
5683
5931
  const zip = await exportNestBundle(nestId, includeDrafts);
@@ -5793,6 +6041,7 @@ queryRoutes.post("/publish", async (c) => {
5793
6041
  await syncNodeTags(nestId, id, tags);
5794
6042
  created.push(id);
5795
6043
  }
6044
+ if (created.length > 0 || body.context_md) await storage.regenerateIndex();
5796
6045
  trackEvent("nest.publish", { nestId, count: created.length });
5797
6046
  return c.json(
5798
6047
  {
@@ -5809,6 +6058,30 @@ import { Hono as Hono12 } from "hono";
5809
6058
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5810
6059
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5811
6060
 
6061
+ // src/shared/node-id.ts
6062
+ function assertSafeNodeId(rawId) {
6063
+ let id = rawId;
6064
+ try {
6065
+ id = decodeURIComponent(rawId);
6066
+ } catch {
6067
+ }
6068
+ if (!id || id.length > 512) {
6069
+ throw new ValidationError("invalid node id");
6070
+ }
6071
+ if (id.includes("\0") || id.includes("\\")) {
6072
+ throw new ValidationError("invalid node id");
6073
+ }
6074
+ if (id.startsWith("/")) {
6075
+ throw new ValidationError("invalid node id (absolute path)");
6076
+ }
6077
+ for (const seg of id.split("/")) {
6078
+ if (seg === "" || seg === "." || seg === "..") {
6079
+ throw new ValidationError("invalid node id (path traversal)");
6080
+ }
6081
+ }
6082
+ return id;
6083
+ }
6084
+
5812
6085
  // src/telemetry/trace-log.ts
5813
6086
  var PRUNE_EVERY = 500;
5814
6087
  var insertsSincePrune = 0;
@@ -5887,30 +6160,6 @@ async function listTraceEvents(filters = {}) {
5887
6160
  // src/mcp/workflow-tools.ts
5888
6161
  import { v4 as uuid8 } from "uuid";
5889
6162
 
5890
- // src/shared/node-id.ts
5891
- function assertSafeNodeId(rawId) {
5892
- let id = rawId;
5893
- try {
5894
- id = decodeURIComponent(rawId);
5895
- } catch {
5896
- }
5897
- if (!id || id.length > 512) {
5898
- throw new ValidationError("invalid node id");
5899
- }
5900
- if (id.includes("\0") || id.includes("\\")) {
5901
- throw new ValidationError("invalid node id");
5902
- }
5903
- if (id.startsWith("/")) {
5904
- throw new ValidationError("invalid node id (absolute path)");
5905
- }
5906
- for (const seg of id.split("/")) {
5907
- if (seg === "" || seg === "." || seg === "..") {
5908
- throw new ValidationError("invalid node id (path traversal)");
5909
- }
5910
- }
5911
- return id;
5912
- }
5913
-
5914
6163
  // src/workflow/run-service.ts
5915
6164
  import { v4 as uuid6 } from "uuid";
5916
6165
  var RUNNABLE_TYPES = ["agent", "skill"];
@@ -6306,6 +6555,18 @@ async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
6306
6555
  }
6307
6556
  }
6308
6557
  }
6558
+ async function assertNoDuplicateEdge(db, nestId, fromNode, toNode, type, excludeId) {
6559
+ const row = await db.get(
6560
+ `SELECT id FROM edges
6561
+ WHERE nest_id = ? AND from_node = ? AND to_node = ? AND type_id = ?` + (excludeId ? " AND id != ?" : ""),
6562
+ excludeId ? [nestId, fromNode, toNode, type.id, excludeId] : [nestId, fromNode, toNode, type.id]
6563
+ );
6564
+ if (row) {
6565
+ throw new ConflictError(
6566
+ `a "${type.name}" connection from "${fromNode}" to "${toNode}" already exists`
6567
+ );
6568
+ }
6569
+ }
6309
6570
  var edgeRoutes = new Hono11();
6310
6571
  edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
6311
6572
  const nestId = c.req.param("nestId");
@@ -6376,6 +6637,7 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
6376
6637
  const now = (/* @__PURE__ */ new Date()).toISOString();
6377
6638
  const id = uuid7();
6378
6639
  await db.transaction(async (tx) => {
6640
+ await assertNoDuplicateEdge(tx, nestId, fromNode, toNode, type);
6379
6641
  if (type.is_flow) {
6380
6642
  await assertNoFlowCycle(tx, nestId, fromNode, toNode);
6381
6643
  }
@@ -6403,6 +6665,8 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
6403
6665
  "SELECT * FROM edge_types WHERE id = ?",
6404
6666
  [row.type_id]
6405
6667
  );
6668
+ const wasFlow = !!type.is_flow;
6669
+ let retype = null;
6406
6670
  if (typeof body.type === "string" && body.type.trim()) {
6407
6671
  const next = await db.get(
6408
6672
  "SELECT * FROM edge_types WHERE nest_id = ? AND (id = ? OR LOWER(name) = LOWER(?))",
@@ -6413,9 +6677,7 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
6413
6677
  `unknown edge type "${body.type}" \u2014 define it in the registry first`
6414
6678
  );
6415
6679
  }
6416
- if (next.is_flow && !type.is_flow) {
6417
- await assertNoFlowCycle(db, nestId, row.from_node, row.to_node);
6418
- }
6680
+ if (next.id !== type.id) retype = next;
6419
6681
  type = next;
6420
6682
  }
6421
6683
  let mode = row.condition_mode;
@@ -6429,11 +6691,19 @@ edgeRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
6429
6691
  mode = v.mode;
6430
6692
  stored = v.stored;
6431
6693
  }
6432
- await db.run(
6433
- `UPDATE edges SET type_id = ?, condition_mode = ?, condition = ?, updated_at = ?
6434
- WHERE id = ?`,
6435
- [type.id, mode, stored, (/* @__PURE__ */ new Date()).toISOString(), id]
6436
- );
6694
+ await db.transaction(async (tx) => {
6695
+ if (retype) {
6696
+ await assertNoDuplicateEdge(tx, nestId, row.from_node, row.to_node, retype, id);
6697
+ if (retype.is_flow && !wasFlow) {
6698
+ await assertNoFlowCycle(tx, nestId, row.from_node, row.to_node);
6699
+ }
6700
+ }
6701
+ await tx.run(
6702
+ `UPDATE edges SET type_id = ?, condition_mode = ?, condition = ?, updated_at = ?
6703
+ WHERE id = ?`,
6704
+ [type.id, mode, stored, (/* @__PURE__ */ new Date()).toISOString(), id]
6705
+ );
6706
+ });
6437
6707
  const updated = await db.get("SELECT * FROM edges WHERE id = ?", [id]);
6438
6708
  return c.json({ edge: toResponse(updated, type) });
6439
6709
  });
@@ -6768,6 +7038,7 @@ async function createEdge(ctx, args) {
6768
7038
  const now = (/* @__PURE__ */ new Date()).toISOString();
6769
7039
  const id = uuid8();
6770
7040
  await db.transaction(async (tx) => {
7041
+ await assertNoDuplicateEdge(tx, ctx.nestId, fromNode, toNode, type);
6771
7042
  if (type.is_flow) {
6772
7043
  await assertNoFlowCycle(tx, ctx.nestId, fromNode, toNode);
6773
7044
  }
@@ -6868,6 +7139,30 @@ function normalizeHops(raw) {
6868
7139
  if (!Number.isFinite(n)) return 2;
6869
7140
  return Math.max(0, Math.min(MAX_HOPS, Math.floor(n)));
6870
7141
  }
7142
+ function toolError(text, code) {
7143
+ return { text, isError: true, data: { code, message: text } };
7144
+ }
7145
+ function toCallToolResult(out) {
7146
+ if (typeof out === "string") {
7147
+ return { content: [{ type: "text", text: out }] };
7148
+ }
7149
+ return {
7150
+ content: [{ type: "text", text: out.text }],
7151
+ structuredContent: out.data,
7152
+ ...out.isError ? { isError: true } : {}
7153
+ };
7154
+ }
7155
+ function summarizeNode(node, body) {
7156
+ return {
7157
+ id: node.id,
7158
+ title: node.frontmatter.title,
7159
+ description: node.frontmatter.description,
7160
+ type: node.frontmatter.type || "document",
7161
+ status: node.frontmatter.status || "draft",
7162
+ tags: node.frontmatter.tags,
7163
+ body: body ?? void 0
7164
+ };
7165
+ }
6871
7166
  var TOOL_DEFINITIONS = [
6872
7167
  {
6873
7168
  name: "context_init",
@@ -6936,12 +7231,23 @@ var TOOL_DEFINITIONS = [
6936
7231
  },
6937
7232
  {
6938
7233
  name: "context_list",
6939
- description: "Browse vault contents with optional type, tag, or limit filters.",
7234
+ description: "Browse vault contents with optional type, tag, status, or limit filters.",
6940
7235
  inputSchema: {
6941
7236
  type: "object",
6942
7237
  properties: {
6943
7238
  type: { type: "string", description: "Filter by node type" },
6944
- tag: { type: "string", description: "Filter by tag" },
7239
+ tag: {
7240
+ type: "string",
7241
+ description: "Filter by tag (leading # optional, case-insensitive)"
7242
+ },
7243
+ status: {
7244
+ type: "string",
7245
+ description: "Filter by status: draft | pending_review | approved | published | rejected. Aliases normalized. Unlike the CLI and the OSS server, retired nodes stay in this listing whether or not you ask \u2014 here they are unpublished documents their stewards may still act on."
7246
+ },
7247
+ approved_only: {
7248
+ type: "boolean",
7249
+ description: "Return only nodes with an approved version, each served at that version rather than the live file \u2014 the view a public nest gives a stranger."
7250
+ },
6945
7251
  limit: { type: "number", description: "Max nodes to return" }
6946
7252
  }
6947
7253
  }
@@ -7023,13 +7329,20 @@ var TOOL_DEFINITIONS = [
7023
7329
  },
7024
7330
  {
7025
7331
  name: "context_update",
7026
- description: "Update an existing node \u2014 append, replace content, add tags.",
7332
+ description: "Update an existing node \u2014 replace or append content, set tags, move it through its lifecycle. Whether the edit publishes is decided by this nest's stewardship rules, not by the caller.",
7027
7333
  inputSchema: {
7028
7334
  type: "object",
7029
7335
  properties: {
7030
7336
  title: {
7337
+ // Selects the node; it does NOT rename it. Titles are unique here
7338
+ // precisely so MCP callers can address a document this way. Renaming
7339
+ // is not reachable from this tool.
7340
+ type: "string",
7341
+ description: "Title of node to update (selects it; does not rename)"
7342
+ },
7343
+ id: {
7031
7344
  type: "string",
7032
- description: "Title of node to update"
7345
+ description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
7033
7346
  },
7034
7347
  content: {
7035
7348
  type: "string",
@@ -7041,9 +7354,19 @@ var TOOL_DEFINITIONS = [
7041
7354
  items: { type: "string" },
7042
7355
  description: "Tags to add"
7043
7356
  },
7044
- scope: { type: "string", description: "New scope" }
7045
- },
7046
- required: ["title"]
7357
+ status: {
7358
+ type: "string",
7359
+ description: "New lifecycle status: draft | pending_review | approved | published | rejected. Aliases are normalized."
7360
+ },
7361
+ changeNote: {
7362
+ type: "string",
7363
+ description: "Why this changed \u2014 recorded in version history"
7364
+ }
7365
+ }
7366
+ // Neither is required alone — one of them is (the engine catalog's
7367
+ // `.refine(id || title)`, which JSON Schema can't express here).
7368
+ // `title` was required, which made this tool unreachable for `ctx`:
7369
+ // it addresses nodes by id and never sends a title.
7047
7370
  }
7048
7371
  },
7049
7372
  {
@@ -7110,7 +7433,7 @@ var TOOL_DEFINITIONS = [
7110
7433
  // repo's governance tables, which the engine doesn't own. Adopt an engine
7111
7434
  // descriptor here if the operation is ever promoted upstream.
7112
7435
  name: "context_request_deletion",
7113
- description: "Ask for something to be deleted when you can't delete it yourself. Flags a document, a folder (with everything in it), or the whole nest for a nest owner/admin to action. Nothing is removed by this call \u2014 it opens a request, and the person who resolves it either deletes the target or declines with a note. Use this instead of context_delete when that returns a permission error, or when the document belongs to someone else.",
7436
+ description: "Ask for something to be deleted when you can't delete it yourself. Flags a document, a folder (with everything in it), or the whole nest for a nest owner/admin to action. Nothing is removed by this call \u2014 it opens a request, and the person who resolves it either deletes the target or rejects it with a note. Use this instead of context_delete when that returns a permission error, or when the document belongs to someone else.",
7114
7437
  inputSchema: {
7115
7438
  type: "object",
7116
7439
  properties: {
@@ -7143,14 +7466,14 @@ var TOOL_DEFINITIONS = [
7143
7466
  properties: {
7144
7467
  status: {
7145
7468
  type: "string",
7146
- description: "pending (default) or declined \u2014 declined shows past verdicts and their notes."
7469
+ description: '"pending" (default) or "declined" \u2014 past rejections and their notes.'
7147
7470
  }
7148
7471
  }
7149
7472
  }
7150
7473
  },
7151
7474
  {
7152
7475
  name: "context_resolve_deletion",
7153
- description: "Action a deletion request: delete the flagged document/folder/nest, or decline it with a note explaining why it stays. Owner/admin only. Declining tells the requester the verdict; deleting is irreversible.",
7476
+ description: "Action a deletion request: delete the flagged document/folder/nest, or reject it with a note explaining why it stays. Owner/admin only. Rejecting tells the requester the verdict; deleting is irreversible.",
7154
7477
  inputSchema: {
7155
7478
  type: "object",
7156
7479
  properties: {
@@ -7160,11 +7483,11 @@ var TOOL_DEFINITIONS = [
7160
7483
  },
7161
7484
  action: {
7162
7485
  type: "string",
7163
- description: '"delete" to honour it, "decline" to keep the target'
7486
+ description: '"delete" to honour it, "decline" to reject it and keep the target'
7164
7487
  },
7165
7488
  note: {
7166
7489
  type: "string",
7167
- description: "Why you declined. Required when action is decline."
7490
+ description: 'Why you rejected it. Required when action is "decline".'
7168
7491
  }
7169
7492
  },
7170
7493
  required: ["request_id", "action"]
@@ -7234,9 +7557,13 @@ var TOOL_DEFINITIONS = [
7234
7557
  inputSchema: {
7235
7558
  type: "object",
7236
7559
  properties: {
7237
- title: { type: "string", description: "Title of the node" }
7238
- },
7239
- required: ["title"]
7560
+ title: { type: "string", description: "Title of the node" },
7561
+ id: {
7562
+ type: "string",
7563
+ description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
7564
+ }
7565
+ }
7566
+ // One of id or title — see context_update.
7240
7567
  }
7241
7568
  },
7242
7569
  {
@@ -7347,7 +7674,7 @@ async function resolveTargetNode(storage, args) {
7347
7674
  if (!args.id && !args.title) return "Provide a title or an id.";
7348
7675
  if (args.id) {
7349
7676
  try {
7350
- return await storage.readDocument(args.id);
7677
+ return await storage.readDocument(assertSafeNodeId(args.id));
7351
7678
  } catch {
7352
7679
  return `Node not found: ${args.id}`;
7353
7680
  }
@@ -7365,36 +7692,47 @@ async function runTool(toolName, args, ctx) {
7365
7692
  const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
7366
7693
  switch (toolName) {
7367
7694
  case "context_init": {
7368
- const content = await storage.readContextMd();
7369
- if (!content)
7695
+ const { context_md } = await engineApi.run(
7696
+ "context_init",
7697
+ {},
7698
+ await opContext(nestId, userEmail)
7699
+ );
7700
+ if (!context_md)
7370
7701
  return "No CONTEXT.md found. Use context_overview to see what's available.";
7371
7702
  return `# Vault Instructions (CONTEXT.md)
7372
7703
 
7373
- ${content}`;
7704
+ ${context_md}`;
7374
7705
  }
7375
7706
  case "context_overview": {
7376
- const docs = await storage.discoverDocuments();
7707
+ const { total } = await engineApi.run(
7708
+ "context_init",
7709
+ {},
7710
+ await opContext(nestId, userEmail)
7711
+ );
7712
+ const visible = await listNodesForCallerByEmail(nestId, userEmail, {});
7377
7713
  const types = {};
7378
7714
  const tags = {};
7379
- for (const n of docs) {
7380
- const t = n.frontmatter.type || "document";
7381
- types[t] = (types[t] || 0) + 1;
7382
- for (const tag of n.frontmatter.tags || []) tags[tag] = (tags[tag] || 0) + 1;
7383
- }
7384
- const typeList = Object.entries(types).sort((a, b) => b[1] - a[1]).map(([t, c]) => ` ${t}: ${c}`).join("\n");
7385
- const tagList = Object.entries(tags).sort((a, b) => b[1] - a[1]).map(([t, c]) => ` ${t}: ${c}`).join("\n");
7386
- const nodeList = docs.map(
7387
- (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).slice(0, 4).join(" ")}
7388
- ${(n.body || "").slice(0, 120).replace(/\n/g, " ")}`
7715
+ for (const n of visible) {
7716
+ types[n.type] = (types[n.type] || 0) + 1;
7717
+ for (const tag of n.tags || []) tags[tag] = (tags[tag] || 0) + 1;
7718
+ }
7719
+ const fmt = (counts) => Object.entries(counts).sort((a, b) => b[1] - a[1]).map(([k, c]) => ` ${k}: ${c}`).join("\n");
7720
+ const nodeList = visible.map(
7721
+ (n, i) => `${i + 1}. **${n.title}** [${n.type}] ${(n.tags || []).slice(0, 4).join(" ")}
7722
+ ${(n.content || "").slice(0, 120).replace(/\n/g, " ")}`
7389
7723
  ).join("\n\n");
7724
+ const withheld = total - visible.length;
7725
+ const note = withheld > 0 ? `
7726
+ _${withheld} node(s) not visible to you._
7727
+ ` : "";
7390
7728
  return `# Vault Overview
7391
- **Total nodes:** ${docs.length}
7392
-
7729
+ **Total nodes:** ${visible.length}
7730
+ ${note}
7393
7731
  ## Types
7394
- ${typeList}
7732
+ ${fmt(types)}
7395
7733
 
7396
7734
  ## Tags
7397
- ${tagList}
7735
+ ${fmt(tags)}
7398
7736
 
7399
7737
  ## All Nodes
7400
7738
 
@@ -7416,14 +7754,26 @@ ${nodeList}`;
7416
7754
  ].join(" ").toLowerCase();
7417
7755
  return terms.every((t) => hay.includes(t));
7418
7756
  });
7419
- if (!matches.length) return `No nodes matched search: "${args.query}"`;
7757
+ if (!matches.length)
7758
+ return {
7759
+ text: `No nodes matched search: "${args.query}"`,
7760
+ data: { results: [] }
7761
+ };
7420
7762
  const results = matches.map(
7421
7763
  ({ node: n, body }, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
7422
7764
  ${(body || "").slice(0, 200).replace(/\n/g, " ")}`
7423
7765
  ).join("\n\n");
7424
- return `Found ${matches.length} node(s) matching "${args.query}":
7425
-
7426
- ${results}`;
7766
+ return {
7767
+ text: `Found ${matches.length} node(s) matching "${args.query}":
7768
+
7769
+ ${results}`,
7770
+ // Same gated bodies the prose is built from — `resolveLlmBody`
7771
+ // already refused everything this caller may not read. No `score`:
7772
+ // this is a term-containment filter, not a ranked search.
7773
+ data: {
7774
+ results: matches.map(({ node: n, body }) => summarizeNode(n, body))
7775
+ }
7776
+ };
7427
7777
  }
7428
7778
  case "context_table_query": {
7429
7779
  const result = await queryTableNode(
@@ -7440,61 +7790,119 @@ ${results}`;
7440
7790
  ${lines.join("\n")}`;
7441
7791
  }
7442
7792
  case "context_query": {
7443
- const result = await queryEngine.query(args.query, {
7444
- hops: normalizeHops(args.hops)
7445
- });
7793
+ const { documents, traversal } = await engineApi.run(
7794
+ "context_query",
7795
+ { query: args.query, hops: normalizeHops(args.hops) },
7796
+ await opContext(nestId, userEmail)
7797
+ );
7446
7798
  const visibility = await Promise.all(
7447
- result.documents.map(async (n) => ({
7448
- node: n,
7449
- visible: await resolveLlmBody(ctx, n) !== null
7799
+ documents.map(async (d) => ({
7800
+ doc: d,
7801
+ body: await resolveLlmBody(ctx, {
7802
+ id: d.id,
7803
+ body: d.body ?? "",
7804
+ frontmatter: { title: d.title }
7805
+ })
7450
7806
  }))
7451
7807
  );
7452
- const nodes = visibility.filter((e) => e.visible).map((e) => e.node);
7453
- if (!nodes.length) return `No nodes matched: ${args.query}`;
7454
- const list = nodes.map(
7455
- (n, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}`
7456
- ).join("\n\n");
7457
- return `Found ${nodes.length} node(s) for \`${args.query}\`:
7458
-
7459
- ${list}`;
7808
+ const visible = visibility.filter((e) => e.body !== null);
7809
+ const nodes = visible.map((e) => e.doc);
7810
+ if (!nodes.length)
7811
+ return {
7812
+ text: `No nodes matched: ${args.query}`,
7813
+ data: { documents: [], traversal }
7814
+ };
7815
+ const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}] ${(n.tags || []).join(" ")}`).join("\n\n");
7816
+ return {
7817
+ text: `Found ${nodes.length} node(s) for \`${args.query}\`:
7818
+
7819
+ ${list}`,
7820
+ // The engine already hands back catalog `nodeSummary` shapes — only
7821
+ // `body` is swapped for the gated one resolveLlmBody returned.
7822
+ // `source_nodes` is deliberately absent: this handler never gates
7823
+ // them, so serving them would hand out content the prose refuses.
7824
+ data: {
7825
+ documents: visible.map((e) => ({ ...e.doc, body: e.body ?? void 0 })),
7826
+ traversal
7827
+ }
7828
+ };
7460
7829
  }
7461
7830
  case "context_get": {
7462
- const docs = await storage.discoverDocuments();
7463
7831
  let node;
7464
- if (args.title) {
7465
- node = docs.find(
7466
- (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7832
+ try {
7833
+ node = await getNodeForCaller(
7834
+ nestId,
7835
+ args.title ? { title: args.title } : { id: args.id },
7836
+ ctx.userId,
7837
+ userEmail
7838
+ );
7839
+ } catch {
7840
+ return toolError(
7841
+ `Node not found: ${args.title || args.id}`,
7842
+ "DOCUMENT_NOT_FOUND"
7467
7843
  );
7468
- } else if (args.id) {
7469
- node = docs.find((n) => n.id === args.id);
7470
7844
  }
7471
- if (!node) return `Node not found: ${args.title || args.id}`;
7472
7845
  const body = await resolveLlmBody(ctx, node);
7473
7846
  if (body === null) {
7474
- return `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`;
7847
+ return toolError(
7848
+ `Node "${node.frontmatter.title}" has no approved version yet \u2014 not available to AI.`,
7849
+ "UNAUTHORIZED_ACTION"
7850
+ );
7475
7851
  }
7476
7852
  const meta = [
7477
7853
  `**Title:** ${node.frontmatter.title}`,
7478
7854
  `**Type:** ${node.frontmatter.type || "document"}`,
7479
7855
  node.frontmatter.tags?.length ? `**Tags:** ${node.frontmatter.tags.join(" ")}` : null
7480
7856
  ].filter(Boolean).join("\n");
7481
- return `${meta}
7857
+ return {
7858
+ text: `${meta}
7482
7859
 
7483
7860
  ---
7484
7861
 
7485
- ${body || "(no content)"}`;
7862
+ ${body || "(no content)"}`,
7863
+ // `body` is the gated one, not node.body — under stewardship that is
7864
+ // the APPROVED snapshot, which can be older than the live draft this
7865
+ // frontmatter came from. So the payload carries only the fields the
7866
+ // prose above already reports, none of which claim to describe a
7867
+ // specific version. `version`, `status`, `checksum` and the
7868
+ // timestamps are deliberately absent: they describe the draft, and
7869
+ // pairing them with an approved body would misdescribe it (checksum
7870
+ // would also be a draft hash — see context_versions). `raw` is absent
7871
+ // too: no include_raw input here (Phase 2).
7872
+ data: {
7873
+ id: node.id,
7874
+ frontmatter: {
7875
+ title: node.frontmatter.title,
7876
+ description: node.frontmatter.description,
7877
+ type: node.frontmatter.type || "document",
7878
+ tags: node.frontmatter.tags
7879
+ },
7880
+ body
7881
+ }
7882
+ };
7486
7883
  }
7487
7884
  case "context_list": {
7488
7885
  const nodes = await listNodesForCallerByEmail(nestId, userEmail, {
7489
7886
  type: args.type,
7490
7887
  tag: args.tag,
7888
+ status: args.status,
7889
+ approvedOnly: args.approved_only === true,
7491
7890
  limit: args.limit || 50
7492
7891
  });
7493
- if (!nodes.length) return "No nodes found with the given filters.";
7892
+ const documents = nodes.map((n) => ({
7893
+ id: n.id,
7894
+ title: n.title,
7895
+ description: n.description,
7896
+ type: n.type || "document",
7897
+ status: n.status || "draft",
7898
+ tags: n.tags
7899
+ }));
7900
+ if (!nodes.length)
7901
+ return { text: "No nodes found with the given filters.", data: { documents } };
7494
7902
  const list = nodes.map((n, i) => `${i + 1}. **${n.title}** [${n.type}]`).join("\n");
7495
- return `${nodes.length} node(s):
7903
+ return { text: `${nodes.length} node(s):
7496
7904
 
7497
- ${list}`;
7905
+ ${list}`, data: { documents } };
7498
7906
  }
7499
7907
  case "context_resolve": {
7500
7908
  const result = await queryEngine.query(args.selector, {
@@ -7592,7 +8000,10 @@ ${sections}`;
7592
8000
  }
7593
8001
  case "context_create": {
7594
8002
  if (!await canCreateInNest(nestId, userEmail)) {
7595
- return "You don't have permission to create documents in this nest.";
8003
+ return toolError(
8004
+ "You don't have permission to create documents in this nest.",
8005
+ "UNAUTHORIZED_ACTION"
8006
+ );
7596
8007
  }
7597
8008
  const wanted = String(args.title ?? "").toLowerCase();
7598
8009
  return withNodeWriteLock(nodeWriteKey(nestId, "*create*"), async () => {
@@ -7601,9 +8012,19 @@ ${sections}`;
7601
8012
  (n) => String(n.frontmatter.title ?? "").toLowerCase() === wanted
7602
8013
  );
7603
8014
  if (twin) {
7604
- return `A node titled "${twin.frontmatter.title}" already exists (${twin.id}). Titles must be unique \u2014 MCP tools resolve documents by title, so a second one would be unreachable. Use context_update to change it, context_move to refile it, or create this one under a different title.`;
8015
+ const twinVersions = await getVersions(nestId, twin.id);
8016
+ if (twinVersions.length === 0) {
8017
+ return toolError(
8018
+ `A node titled "${twin.frontmatter.title}" already exists (${twin.id}) but has no version history, which is why it doesn't show up in a read or a draft list. It may be an interrupted create or a document whose import never finished. Write into it with context_update "${twin.id}" to keep whatever is there, or remove it with context_delete "${twin.id}" and create again \u2014 check its content first.`,
8019
+ "DOCUMENT_ALREADY_EXISTS"
8020
+ );
8021
+ }
8022
+ return toolError(
8023
+ `A node titled "${twin.frontmatter.title}" already exists (${twin.id}). Titles must be unique \u2014 MCP tools resolve documents by title, so a second one would be unreachable. Use context_update to change it, context_move to refile it, or create this one under a different title.`,
8024
+ "DOCUMENT_ALREADY_EXISTS"
8025
+ );
7605
8026
  }
7606
- const { node } = await createNode(
8027
+ const { node, version } = await createNode(
7607
8028
  nestId,
7608
8029
  {
7609
8030
  title: args.title,
@@ -7615,42 +8036,75 @@ ${sections}`;
7615
8036
  },
7616
8037
  userEmail
7617
8038
  );
7618
- return `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`;
8039
+ return {
8040
+ text: `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`,
8041
+ // checkpoint is null by construction: this server seals nothing —
8042
+ // a write becomes live through steward review, not a checkpoint.
8043
+ data: {
8044
+ id: node.id,
8045
+ version,
8046
+ status: node.frontmatter.status,
8047
+ checkpoint: null
8048
+ }
8049
+ };
7619
8050
  });
7620
8051
  }
7621
8052
  case "context_update": {
7622
- const docs = await storage.discoverDocuments();
7623
- const node = docs.find(
7624
- (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7625
- );
7626
- if (!node) return `Node not found: ${args.title}`;
8053
+ const node = await resolveTargetNode(storage, args);
8054
+ if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
8055
+ const label = args.id ? node.frontmatter.title : args.title;
7627
8056
  const editCheck = await canUserEdit(nestId, node.id, userEmail);
7628
8057
  if (!editCheck.allowed) {
7629
- return `You don't have permission to edit "${args.title}": ${editCheck.reason}`;
8058
+ return toolError(
8059
+ `You don't have permission to edit "${label}": ${editCheck.reason}`,
8060
+ "UNAUTHORIZED_ACTION"
8061
+ );
7630
8062
  }
7631
- const { node: updated } = await updateNode(
8063
+ const mergedTags = args.tags ? [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...args.tags])] : void 0;
8064
+ const { node: updated, version } = await updateNode(
7632
8065
  nestId,
7633
8066
  node.id,
7634
8067
  {
7635
8068
  content: args.content,
7636
8069
  append: args.append,
7637
- tags: args.tags
8070
+ tags: mergedTags,
8071
+ status: args.status,
8072
+ changeNote: args.changeNote
7638
8073
  },
7639
8074
  userEmail
7640
8075
  );
7641
- return `Updated node: **${updated.frontmatter.title}**`;
8076
+ return {
8077
+ text: `Updated node: **${updated.frontmatter.title}** \u2014 v${version}, status: ${updated.frontmatter.status}`,
8078
+ // checkpoint null — see context_create.
8079
+ data: {
8080
+ id: updated.id,
8081
+ version,
8082
+ status: updated.frontmatter.status,
8083
+ checkpoint: null
8084
+ }
8085
+ };
7642
8086
  }
7643
8087
  case "context_delete": {
7644
8088
  const node = await resolveTargetNode(storage, args);
7645
- if (typeof node === "string") return node;
8089
+ if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
7646
8090
  const deleteCheck = await canUserEdit(nestId, node.id, userEmail);
7647
8091
  if (!deleteCheck.allowed) {
7648
- return `You don't have permission to delete "${node.frontmatter.title}": ${deleteCheck.reason}
8092
+ return toolError(
8093
+ `You don't have permission to delete "${node.frontmatter.title}": ${deleteCheck.reason}
7649
8094
 
7650
- Use context_request_deletion to ask a nest owner/admin to delete it, with a reason.`;
8095
+ Use context_request_deletion to ask a nest owner/admin to delete it, with a reason.`,
8096
+ "UNAUTHORIZED_ACTION"
8097
+ );
7651
8098
  }
7652
8099
  await deleteNode(nestId, node.id, userEmail);
7653
- return `Deleted node: **${node.frontmatter.title}** (${node.id})`;
8100
+ return {
8101
+ text: `Deleted node: **${node.frontmatter.title}** (${node.id})`,
8102
+ data: {
8103
+ id: node.id,
8104
+ title: node.frontmatter.title,
8105
+ deleted: true
8106
+ }
8107
+ };
7654
8108
  }
7655
8109
  case "context_move": {
7656
8110
  if (typeof args.folder !== "string") {
@@ -7718,6 +8172,9 @@ ${sections}`;
7718
8172
  return "reason is required \u2014 say why it should be deleted; that is what the admin judges.";
7719
8173
  }
7720
8174
  if (args.nest === true) {
8175
+ if (!await canRequestDeletion({ nestId, userId, userEmail, nodeId: "" })) {
8176
+ return "Only an editor, a nest admin, or the nest owner can request the deletion of a nest.";
8177
+ }
7721
8178
  const request2 = await requestDeletion({
7722
8179
  nestId,
7723
8180
  nodeId: nestId,
@@ -7726,10 +8183,13 @@ ${sections}`;
7726
8183
  reason,
7727
8184
  baseUrl: ctx.baseUrl
7728
8185
  });
7729
- return `Requested deletion of the **entire nest**. A nest owner or admin will delete it or decline.
8186
+ return `Requested deletion of the **entire nest**. A nest owner or admin will delete it or reject the request.
7730
8187
  Request id: \`${request2.id}\``;
7731
8188
  }
7732
8189
  if (typeof args.folder === "string" && args.folder.trim()) {
8190
+ if (!await canRequestDeletion({ nestId, userId, userEmail, nodeId: "" })) {
8191
+ return "Only an editor, a nest admin, or the nest owner can request the deletion of a folder.";
8192
+ }
7733
8193
  const folder = args.folder.trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
7734
8194
  const docs = await storage.discoverDocuments();
7735
8195
  const inside = docs.filter((d) => isIdUnderFolder(d.id, folder));
@@ -7744,7 +8204,7 @@ Request id: \`${request2.id}\``;
7744
8204
  reason,
7745
8205
  baseUrl: ctx.baseUrl
7746
8206
  });
7747
- return `Requested deletion of folder **${folder}** (${inside.length} document(s) inside). A nest owner or admin will delete it or decline.
8207
+ return `Requested deletion of folder **${folder}** (${inside.length} document(s) inside). A nest owner or admin will delete it or reject the request.
7748
8208
  Request id: \`${request2.id}\``;
7749
8209
  }
7750
8210
  const node = await resolveTargetNode(storage, args);
@@ -7752,6 +8212,14 @@ Request id: \`${request2.id}\``;
7752
8212
  if (!await canReadNode(nestId, node.id, userId, userEmail)) {
7753
8213
  return `Node not found: ${node.id}`;
7754
8214
  }
8215
+ if (!await canRequestDeletion({
8216
+ nestId,
8217
+ userId,
8218
+ userEmail,
8219
+ nodeId: node.id
8220
+ })) {
8221
+ return "Only an editor, a nest admin, or the nest owner can request the deletion of a document.";
8222
+ }
7755
8223
  const request = await requestDeletion({
7756
8224
  nestId,
7757
8225
  nodeId: node.id,
@@ -7760,19 +8228,19 @@ Request id: \`${request2.id}\``;
7760
8228
  reason,
7761
8229
  baseUrl: ctx.baseUrl
7762
8230
  });
7763
- return `Requested deletion of **${node.frontmatter.title}**. A nest owner or admin will delete it or decline \u2014 nothing is removed yet.
8231
+ return `Requested deletion of **${node.frontmatter.title}**. A nest owner or admin will delete it or reject the request \u2014 nothing is removed yet.
7764
8232
  Request id: \`${request.id}\``;
7765
8233
  }
7766
8234
  case "context_deletion_queue": {
7767
8235
  const status = args.status === "declined" ? "declined" : "pending";
7768
8236
  const requests = await listDeletionRequests({ nestId, status });
7769
8237
  if (requests.length === 0) {
7770
- return status === "pending" ? "No deletion requests waiting. All caught up!" : "No declined deletion requests.";
8238
+ return status === "pending" ? "No deletion requests waiting. All caught up!" : "No rejected deletion requests.";
7771
8239
  }
7772
8240
  const list = requests.map((r, i) => {
7773
8241
  const kind = r.targetType === "document" ? "" : `${r.targetType}: `;
7774
8242
  const verdict = r.status === "declined" ? `
7775
- Declined by ${r.resolvedBy}: "${r.resolutionNote}"` : "";
8243
+ Rejected by ${r.resolvedBy}: "${r.resolutionNote}"` : "";
7776
8244
  return `${i + 1}. **${kind}${r.title || r.nodeId}**
7777
8245
  Asked by ${r.requestedBy}: "${r.reason}"
7778
8246
  Request id: \`${r.id}\`${verdict}`;
@@ -7800,7 +8268,7 @@ ${list}`;
7800
8268
  if (action === "decline") {
7801
8269
  const note = typeof args.note === "string" ? args.note.trim() : "";
7802
8270
  if (!note) {
7803
- return "note is required when declining \u2014 the requester is told why it stays.";
8271
+ return "note is required when rejecting \u2014 the requester is told why it stays.";
7804
8272
  }
7805
8273
  await declineDeletion({
7806
8274
  nestId,
@@ -7809,7 +8277,7 @@ ${list}`;
7809
8277
  note,
7810
8278
  baseUrl: ctx.baseUrl
7811
8279
  });
7812
- return `Declined the deletion request for **${label}**. It stays, and ${request.requestedBy} has been told why.`;
8280
+ return `Rejected the deletion request for **${label}**. It stays, and ${request.requestedBy} has been told why.`;
7813
8281
  }
7814
8282
  if (request.targetType === "nest") {
7815
8283
  await deleteNest(nestId);
@@ -7880,13 +8348,17 @@ Submit one with \`context_submit_review\`.`;
7880
8348
  const node = docs.find(
7881
8349
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7882
8350
  );
7883
- if (!node) return `Node not found: ${args.title}`;
8351
+ if (!node) return toolError(`Node not found: ${args.title}`, "DOCUMENT_NOT_FOUND");
7884
8352
  const submitCheck = await canUserEdit(ctx.nestId, node.id, userEmail);
7885
8353
  if (!submitCheck.allowed) {
7886
- return `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`;
8354
+ return toolError(
8355
+ `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`,
8356
+ "UNAUTHORIZED_ACTION"
8357
+ );
7887
8358
  }
7888
8359
  const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
7889
- if (currentVersion === 0) return `No versions found for "${args.title}"`;
8360
+ if (currentVersion === 0)
8361
+ return toolError(`No versions found for "${args.title}"`, "VERSION_NOT_FOUND");
7890
8362
  try {
7891
8363
  const request = await submitForReview({
7892
8364
  nestId: ctx.nestId,
@@ -7905,9 +8377,33 @@ Submit one with \`context_submit_review\`.`;
7905
8377
 
7906
8378
  **Stewards who can review:**
7907
8379
  ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "";
7908
- return `Submitted "${args.title}" v${currentVersion} for review (${request.priority} priority).${stewardList}`;
8380
+ return {
8381
+ text: `Submitted "${args.title}" v${currentVersion} for review (${request.priority} priority).${stewardList}`,
8382
+ // No catalog shape to conform to — `context_submit_review` is a
8383
+ // governance-namespace operation and that namespace is declared but
8384
+ // unimplemented in the engine (`api/index.ts`, NAMESPACES). So this
8385
+ // is the review state the handler already computed, and nothing
8386
+ // more: no `version`/`checkpoint` at the top level, because this
8387
+ // operation writes no new version and seals no checkpoint — it
8388
+ // points a review request at the version that already existed.
8389
+ // Built from the same gated values the prose above is built from
8390
+ // (`request`, `resolved`), never re-read past the permission check.
8391
+ data: {
8392
+ id: node.id,
8393
+ submitted: true,
8394
+ review: {
8395
+ id: request.id,
8396
+ version: request.version,
8397
+ status: request.status,
8398
+ priority: request.priority,
8399
+ requested_by: request.requestedBy,
8400
+ requested_at: request.requestedAt
8401
+ },
8402
+ reviewers: resolved.map((r) => r.steward.userEmail)
8403
+ }
8404
+ };
7909
8405
  } catch (err) {
7910
- return `Failed to submit: ${err.message}`;
8406
+ return toolError(`Failed to submit: ${err.message}`, "INTERNAL");
7911
8407
  }
7912
8408
  }
7913
8409
  case "context_approve": {
@@ -7951,23 +8447,46 @@ Reason: ${args.note}`;
7951
8447
  }
7952
8448
  }
7953
8449
  case "context_versions": {
7954
- const docs = await storage.discoverDocuments();
7955
- const node = docs.find(
7956
- (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7957
- );
7958
- if (!node) return `Node not found: ${args.title}`;
8450
+ const node = await resolveTargetNode(storage, args);
8451
+ if (typeof node === "string") return toolError(node, "DOCUMENT_NOT_FOUND");
8452
+ const label = args.id ? node.frontmatter.title : args.title;
7959
8453
  const allVersions = await getVersions(ctx.nestId, node.id);
7960
8454
  const approved = await getApprovedVersion(ctx.nestId, node.id);
7961
8455
  if (allVersions.length === 0) {
7962
- return `No version history for "${args.title}".`;
8456
+ return {
8457
+ text: `No version history for "${label}".`,
8458
+ data: { id: node.id, approved_version: approved ?? null, versions: [] }
8459
+ };
7963
8460
  }
7964
8461
  const list = allVersions.map(
7965
8462
  (v) => `- **v${v.version}** ${v.status}${v.version === approved ? " (AI-active)" : ""} \u2014 by ${v.editedBy} at ${v.editedAt}${v.changeNote ? ` "${v.changeNote}"` : ""}`
7966
8463
  ).join("\n");
7967
- return `# Version History: ${args.title}
8464
+ return {
8465
+ text: `# Version History: ${label}
7968
8466
  **Current:** v${allVersions[0]?.version || 0} | **AI-active:** ${approved ? `v${approved}` : "none"}
7969
8467
 
7970
- ${list}`;
8468
+ ${list}`,
8469
+ // Five catalog fields have no equivalent here and are omitted rather
8470
+ // than faked: `keyframe_interval` and `keyframe` (content lives on
8471
+ // disk — there is no keyframe+diff model), `chain_hash` (integrity is
8472
+ // server-side, not a per-version chain), `content_hash` (the column
8473
+ // exists but is conflict-detection state this tool has never served,
8474
+ // and it is ungated), and `published_at` (a version carries a
8475
+ // lifecycle status, not a publish timestamp). `status` and
8476
+ // `approved_version` are the community equivalent — the same two
8477
+ // facts the prose above reports.
8478
+ data: {
8479
+ id: node.id,
8480
+ approved_version: approved ?? null,
8481
+ versions: allVersions.map((v) => ({
8482
+ version: v.version,
8483
+ status: v.status,
8484
+ edited_by: v.editedBy,
8485
+ edited_at: v.editedAt,
8486
+ note: v.changeNote
8487
+ }))
8488
+ }
8489
+ };
7971
8490
  }
7972
8491
  case "context_assign_steward": {
7973
8492
  const scope = args.scope;
@@ -8025,8 +8544,8 @@ ${list}`;
8025
8544
  if (!target) return "target is required (a node id or folder prefix).";
8026
8545
  if (!["read", "write"].includes(role)) return "role must be read or write.";
8027
8546
  try {
8028
- const { createGrant: createGrant2 } = await import("./grants-service-O4AO326A.js");
8029
- const db = (await import("./client-NFUHPLLM.js")).getDb();
8547
+ const { createGrant: createGrant2 } = await import("./grants-service-4NXPBYWD.js");
8548
+ const db = (await import("./client-OBS5HOTA.js")).getDb();
8030
8549
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
8031
8550
  const e = normalizeEmail2(String(args.email || ""));
8032
8551
  if (!e) return "email is required.";
@@ -8119,7 +8638,7 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
8119
8638
  }
8120
8639
  server.tool(tool.name, tool.description, shape, async (args) => {
8121
8640
  const engine = await engineCache.get(nestId);
8122
- const text = await handleToolCall(tool.name, args, {
8641
+ const out = await handleToolCall(tool.name, args, {
8123
8642
  storage: engine.storage,
8124
8643
  queryEngine: engine.query,
8125
8644
  versionManager: engine.versions,
@@ -8128,7 +8647,7 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
8128
8647
  userEmail,
8129
8648
  baseUrl
8130
8649
  });
8131
- return { content: [{ type: "text", text }] };
8650
+ return toCallToolResult(out);
8132
8651
  });
8133
8652
  }
8134
8653
  return server;
@@ -9487,7 +10006,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
9487
10006
  shape[key] = field;
9488
10007
  }
9489
10008
  server.tool(tool.name, tool.description, shape, async (args) => {
9490
- const text = await handleServerToolCall(
10009
+ const out = await handleServerToolCall(
9491
10010
  tool.name,
9492
10011
  args,
9493
10012
  userId,
@@ -9495,7 +10014,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
9495
10014
  nestScope,
9496
10015
  baseUrl
9497
10016
  );
9498
- return { content: [{ type: "text", text }] };
10017
+ return toCallToolResult(out);
9499
10018
  });
9500
10019
  }
9501
10020
  return server;
@@ -9615,6 +10134,11 @@ Streamable HTTP, stateless. Two endpoints:
9615
10134
  (new document in a writable nest; a \`folder\` path creates the folder).
9616
10135
  - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
9617
10136
  governance (create/update, submit/approve/reject, stewards, sharing).
10137
+ Sharing a slice is one call: \`context_grant\` with
10138
+ \`{ "email", "target_type": "document" | "folder", "target", "role": "read" | "write" }\`
10139
+ \u2014 \`target\` is a node id (\`nodes/win-loss-log\`) or a folder prefix
10140
+ (\`nodes/gtm/deals\`). The recipient reaches exactly that document, or
10141
+ everything under that folder, and nothing else in the nest. Owner/admin only.
9618
10142
 
9619
10143
  Claude Code / Cursor / VS Code connect natively via HTTP with the
9620
10144
  \`Authorization: Bearer\` header. Claude Desktop needs the mcp-remote bridge:
@@ -9623,20 +10147,9 @@ Claude Code / Cursor / VS Code connect natively via HTTP with the
9623
10147
  npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
9624
10148
  \`\`\`
9625
10149
 
9626
- ## ctx CLI (npm: @promptowl/contextnest-cli)
9627
-
9628
- \`npm i -g @promptowl/contextnest-cli\`
9629
-
9630
- - \`ctx push --server ${B} --nest <nest-id> --key <api-key>\` \u2014 upload a local
9631
- vault into a nest (add \`--include-drafts\` for unpublished nodes).
9632
- - All other ctx commands (\`query\`, \`add\`, \`search\`, \`checkpoint\`) operate on
9633
- a LOCAL vault directory only. For remote reads use the API or MCP above \u2014
9634
- there is currently no remote \`ctx query\`.
9635
-
9636
10150
  ## npm packages
9637
10151
 
9638
10152
  - \`@promptowl/contextnest-community\` \u2014 this server
9639
- - \`@promptowl/contextnest-cli\` \u2014 the ctx CLI
9640
10153
  - \`@promptowl/contextnest-engine\` \u2014 vault storage + selector/graph engine
9641
10154
 
9642
10155
  ## Conventions
@@ -10215,6 +10728,25 @@ function denyUnlessNestManager(c, what) {
10215
10728
  403
10216
10729
  );
10217
10730
  }
10731
+ async function mayRequestDeletion(c, nestId, nodeId) {
10732
+ return canRequestDeletion({
10733
+ nestId,
10734
+ userId: c.get("userId"),
10735
+ userEmail: await getUserEmail4(c),
10736
+ nodeId,
10737
+ // The middleware promotes open-mode callers; resolving fresh would miss it.
10738
+ permission: c.get("nestPermission")
10739
+ });
10740
+ }
10741
+ function denyUnlessEditor(allowed, c, what) {
10742
+ if (allowed) return null;
10743
+ return c.json(
10744
+ {
10745
+ error: `Only an editor, a nest admin, or the nest owner can request the deletion of ${what}.`
10746
+ },
10747
+ 403
10748
+ );
10749
+ }
10218
10750
  governanceRoutes.post("/delete-folder", async (c) => {
10219
10751
  const nestId = c.req.param("nestId");
10220
10752
  const denied = denyUnlessNestManager(c, "a folder");
@@ -10232,6 +10764,12 @@ governanceRoutes.post("/delete-folder", async (c) => {
10232
10764
  });
10233
10765
  governanceRoutes.post("/request-deletion", async (c) => {
10234
10766
  const nestId = c.req.param("nestId");
10767
+ const denied = denyUnlessEditor(
10768
+ await mayRequestDeletion(c, nestId, ""),
10769
+ c,
10770
+ "a folder or a nest"
10771
+ );
10772
+ if (denied) return denied;
10235
10773
  const body = await c.req.json().catch(() => ({}));
10236
10774
  const reason = body.reason?.trim();
10237
10775
  if (!reason) {
@@ -10322,6 +10860,16 @@ governanceRoutes.post("/deletion-requests/:id/decline", async (c) => {
10322
10860
  if (!request) return c.json({ error: "Deletion request not found" }, 404);
10323
10861
  return c.json({ request });
10324
10862
  });
10863
+ governanceRoutes.post("/deletion-requests/:id/withdraw", async (c) => {
10864
+ const nestId = c.req.param("nestId");
10865
+ const request = await withdrawDeletionRequest({
10866
+ nestId,
10867
+ id: c.req.param("id"),
10868
+ withdrawnBy: await getUserEmail4(c)
10869
+ });
10870
+ if (!request) return c.json({ error: "Deletion request not found or not yours" }, 404);
10871
+ return c.json({ withdrawn: true, request });
10872
+ });
10325
10873
  governanceRoutes.get("/feed", async (c) => {
10326
10874
  const nestId = c.req.param("nestId");
10327
10875
  const filterRaw = c.req.query("filter");
@@ -10424,7 +10972,14 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
10424
10972
  return c.json({
10425
10973
  versions: enriched,
10426
10974
  approvedVersion: approved,
10427
- currentVersion: allVersions[0]?.version || 0
10975
+ currentVersion: allVersions[0]?.version || 0,
10976
+ // Verdicts on out-of-band edits. A rejection commits no content, so it has
10977
+ // no version row to hang off — it lives in the engine's chain-event log and
10978
+ // rides alongside the version list rather than inside it.
10979
+ externalEditVerdicts: await listExternalEditVerdicts(
10980
+ nestId,
10981
+ nodeId
10982
+ )
10428
10983
  });
10429
10984
  });
10430
10985
  governanceNodeRoutes.get("/:nodeId{.+}/versions/:version{[0-9]+}", async (c) => {
@@ -10572,6 +11127,12 @@ governanceNodeRoutes.post("/:nodeId{.+}/request-deletion", async (c) => {
10572
11127
  if (!await canReadNode(nestId, nodeId, c.get("userId"), userEmail)) {
10573
11128
  return c.json({ error: `Node not found: ${nodeId}` }, 404);
10574
11129
  }
11130
+ const denied = denyUnlessEditor(
11131
+ await mayRequestDeletion(c, nestId, nodeId),
11132
+ c,
11133
+ "a document"
11134
+ );
11135
+ if (denied) return denied;
10575
11136
  const { storage } = await engineCache.get(nestId);
10576
11137
  try {
10577
11138
  await storage.readDocument(nodeId);
@@ -10606,7 +11167,9 @@ governanceNodeRoutes.post(
10606
11167
  return c.json({
10607
11168
  approved: true,
10608
11169
  version: result.versionEntry.version,
10609
- chainEvent: result.chainEvent.event_id
11170
+ chainEvent: result.chainEvent.event_id,
11171
+ // As on reject: false means the verdict never reached the audit log.
11172
+ historyRecorded: result.historyRecorded
10610
11173
  });
10611
11174
  } catch (err) {
10612
11175
  console.error(
@@ -10641,7 +11204,10 @@ governanceNodeRoutes.post(
10641
11204
  });
10642
11205
  return c.json({
10643
11206
  rejected: true,
10644
- chainEvent: result.chainEvent.event_id
11207
+ chainEvent: result.chainEvent.event_id,
11208
+ // False means the edit was rejected but the audit entry did not land —
11209
+ // the caller is not shown a clean success (server log carries why).
11210
+ historyRecorded: result.historyRecorded
10645
11211
  });
10646
11212
  } catch (err) {
10647
11213
  return c.json({ error: err.message }, 400);
@@ -10886,6 +11452,13 @@ function createApp() {
10886
11452
  "*",
10887
11453
  corsOrigins === "*" ? cors({ origin: "*" }) : cors({ origin: corsOrigins, credentials: true })
10888
11454
  );
11455
+ const frameAncestors = `frame-ancestors ${config.FRAME_ANCESTORS}`;
11456
+ app.use("*", async (c, next) => {
11457
+ await next();
11458
+ const existing = c.res.headers.get("Content-Security-Policy");
11459
+ const merged = mergeFrameAncestors(existing, frameAncestors);
11460
+ if (merged !== existing) c.header("Content-Security-Policy", merged);
11461
+ });
10889
11462
  const ASSET_UPLOAD_PATH = /^\/nests\/[^/]+\/assets\/?$/;
10890
11463
  app.use("*", async (c, next) => {
10891
11464
  const lenStr = c.req.header("Content-Length");
@@ -11666,6 +12239,38 @@ function createApp() {
11666
12239
  users: usersRow.c
11667
12240
  });
11668
12241
  });
12242
+ app.use("/search", flexAuthMiddleware);
12243
+ app.get("/search", async (c) => {
12244
+ const q = (c.req.query("q") || "").trim();
12245
+ if (!q) throw new ValidationError("q query parameter is required");
12246
+ const limit = Math.min(Math.max(Number(c.req.query("limit")) || 20, 1), 50);
12247
+ const userId = c.get("userId");
12248
+ const userEmail = await resolveCallerEmail(userId);
12249
+ const perNest = await Promise.all(
12250
+ (await listVisibleNests(userId, { readable: true })).map(async (nest) => {
12251
+ try {
12252
+ const { storage } = await engineCache.get(nest.id);
12253
+ const matches = matchDocuments(await discoverForSearch(storage), q);
12254
+ if (matches.length === 0) return [];
12255
+ const accessible = await filterAccessible(nest.id, userId, userEmail, matches);
12256
+ return accessible.map((n) => ({
12257
+ ...toSearchHit(n, q),
12258
+ nest_id: nest.id,
12259
+ nest_name: nest.name ?? nest.id
12260
+ }));
12261
+ } catch (e) {
12262
+ console.error("[search] skipped nest", nest.id, e);
12263
+ return [];
12264
+ }
12265
+ })
12266
+ );
12267
+ const hits = perNest.flat().sort((a, b) => titleScore(b.title, q) - titleScore(a.title, q));
12268
+ return c.json({
12269
+ query: q,
12270
+ count: hits.length,
12271
+ nodes: hits.slice(0, limit)
12272
+ });
12273
+ });
11669
12274
  app.use("/runs/*", flexAuthMiddleware);
11670
12275
  app.route("/runs", runDetailRoutes);
11671
12276
  app.route("/hooks", hookFireRoutes);
@@ -11735,7 +12340,8 @@ function createApp() {
11735
12340
  c.set("nestPermission", "owner");
11736
12341
  return next();
11737
12342
  }
11738
- const permission = await resolveNestPermission(nestId, userId);
12343
+ const access = await resolveNestAccess(nestId, userId);
12344
+ const permission = access.permission;
11739
12345
  const isReadQuery = parts.length === 2 && ["context", "query", "search", "export"].includes(parts[1]);
11740
12346
  let grantRole = null;
11741
12347
  let grantNestVisible = false;
@@ -11748,11 +12354,13 @@ function createApp() {
11748
12354
  }
11749
12355
  grantRole = await resolveNodeGrant(nestId, userId, targetTail);
11750
12356
  } else if (parts.length === 1 && c.req.method === "GET") {
11751
- grantNestVisible = await hasAnyGrant(nestId, userId);
12357
+ grantNestVisible = access.hasGrant;
11752
12358
  } else if (parts[1] === "nodes" && parts.length === 2 && c.req.method === "GET") {
11753
- grantNestVisible = await hasAnyGrant(nestId, userId);
12359
+ grantNestVisible = access.hasGrant;
11754
12360
  } else if (isReadQuery) {
11755
- grantNestVisible = await hasAnyGrant(nestId, userId);
12361
+ grantNestVisible = access.hasGrant;
12362
+ } else if (parts[1] === "definitions" && parts.length === 2 && c.req.method === "GET") {
12363
+ grantNestVisible = access.hasGrant;
11756
12364
  }
11757
12365
  if (!grantRole && !grantNestVisible) {
11758
12366
  return deny(404, "Nest not found", {
@@ -11765,7 +12373,7 @@ function createApp() {
11765
12373
  const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
11766
12374
  const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
11767
12375
  const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
11768
- const isDeletionRequestAction = /\/request-deletion$/.test(path);
12376
+ const isDeletionRequestAction = /\/request-deletion$/.test(path) || /\/deletion-requests\/[^/]+\/withdraw$/.test(path);
11769
12377
  const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
11770
12378
  if (isStewardRoster && permission !== "owner" && permission !== "admin") {
11771
12379
  return c.json(
@@ -11877,6 +12485,7 @@ function createApp() {
11877
12485
  nestsApp.route("/:nestId/assets", assetRoutes);
11878
12486
  nestsApp.route("/:nestId/mcp", mcpRoutes);
11879
12487
  app.route("/nests", nestsApp);
12488
+ app.all("/.well-known/*", (c) => c.json({ error: "Not found" }, 404));
11880
12489
  app.use(
11881
12490
  "/assets/*",
11882
12491
  serveStatic({
@@ -11884,14 +12493,20 @@ function createApp() {
11884
12493
  onFound: (_p, c) => c.header("Cache-Control", "public, max-age=31536000, immutable")
11885
12494
  })
11886
12495
  );
12496
+ const isDocumentNavigation = (c) => c.req.header("Sec-Fetch-Mode") === "navigate" || (c.req.header("Accept") || "").includes("text/html");
11887
12497
  app.get(
11888
12498
  "*",
12499
+ async (c, next) => {
12500
+ if (isDocumentNavigation(c)) return next();
12501
+ return c.json({ error: "Not found" }, 404);
12502
+ },
11889
12503
  serveStatic({
11890
12504
  root: UI_DIR_REL,
11891
12505
  path: "index.html",
11892
12506
  onFound: (_p, c) => c.header("Cache-Control", "no-cache")
11893
12507
  })
11894
12508
  );
12509
+ app.notFound((c) => c.json({ error: "Not found" }, 404));
11895
12510
  app.onError((err, c) => {
11896
12511
  if (err instanceof AppError) {
11897
12512
  return c.json({ error: err.message }, err.statusCode);
@@ -12327,6 +12942,7 @@ async function main() {
12327
12942
  POST /nests/:id/query Selector query
12328
12943
  POST /nests/:id/context One-call context retrieval
12329
12944
  GET /nests/:id/search?q= Full-text search
12945
+ GET /search?q= Search every nest you can see
12330
12946
  POST /nests/:id/publish Bulk publish
12331
12947
  POST /nests/:id/mcp MCP endpoint
12332
12948
  GET /health Health check