@promptowl/contextnest-community 1.16.1 → 1.18.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.
Files changed (30) hide show
  1. package/CONFIGURATION.md +415 -415
  2. package/LICENSE.md +142 -142
  3. package/README.md +168 -169
  4. package/dist/{chunk-D6HICFVW.js → chunk-34UXRZXD.js} +59 -14
  5. package/dist/chunk-F66VCBLA.js +136 -0
  6. package/dist/{chunk-L7UCMGWZ.js → chunk-FUUJG6XP.js} +641 -285
  7. package/dist/{chunk-QJVRZT6M.js → chunk-HGN45I5P.js} +59 -21
  8. package/dist/{chunk-JMJLNEXE.js → chunk-HYSTFMFG.js} +43 -2
  9. package/dist/chunk-OOBZG6BW.js +379 -0
  10. package/dist/{chunk-OTGZQZKK.js → chunk-QUZXRLUA.js} +11 -10
  11. package/dist/{chunk-CLKZZZS2.js → chunk-TQACHVVA.js} +35 -4
  12. package/dist/{chunk-SLTQACJW.js → chunk-YB3LKF7U.js} +3 -1
  13. package/dist/{client-OBS5HOTA.js → client-SW5I6ZBK.js} +2 -2
  14. package/dist/engine-34E3YNX7.js +14 -0
  15. package/dist/external-edit-service-6Q6SHEOF.js +33 -0
  16. package/dist/{grants-service-I535QVTW.js → grants-service-ME7HIVHU.js} +5 -5
  17. package/dist/index.js +700 -526
  18. package/dist/{keys-73STFJJB.js → keys-CTOGAG3W.js} +5 -1
  19. package/dist/{migrations.postgres-CYKO5CFV.js → migrations.postgres-5VI4RDTS.js} +41 -4
  20. package/dist/{review-service-V2GR6VM3.js → review-service-H2M5ACPQ.js} +11 -9
  21. package/dist/{stewardship-service-LTREGVSJ.js → stewardship-service-U4CKL7Y5.js} +8 -4
  22. package/dist/{version-service-V6K5SZ3P.js → version-service-JWEN5UUW.js} +6 -4
  23. package/dist/web3/assets/{hootie-C2ocYkn4.svg → hootie-_U3ECslt.svg} +8 -8
  24. package/dist/web3/assets/index-DONv5_Ni.css +1 -0
  25. package/dist/web3/assets/index-jSGrnboh.js +1065 -0
  26. package/dist/web3/index.html +14 -14
  27. package/package.json +165 -165
  28. package/dist/chunk-XRK6SQSC.js +0 -58
  29. package/dist/web3/assets/index-DnQARUL3.css +0 -1
  30. package/dist/web3/assets/index-_m7WyozM.js +0 -1051
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,
@@ -38,25 +39,47 @@ import {
38
39
  seedDefaultEdgeTypes,
39
40
  sendTestNotification,
40
41
  submitForReview,
41
- withNodeWriteLock
42
- } from "./chunk-QJVRZT6M.js";
42
+ withNodeWriteLock,
43
+ withdrawDeletionRequest
44
+ } from "./chunk-HGN45I5P.js";
45
+ import {
46
+ canCreateInNest,
47
+ canManageStewards,
48
+ canUserAccess,
49
+ canUserApprove,
50
+ canUserEdit,
51
+ createStewardRecord,
52
+ getCollaboratorRole,
53
+ getStewardRolesForUser,
54
+ getStewardsForNest,
55
+ getStewardsForUser,
56
+ listStewards,
57
+ removeSteward,
58
+ resolveGrantRoles,
59
+ resolveStewardsForNode,
60
+ resolveStewardsWithFallback,
61
+ resolveUserRoles,
62
+ syncFromConfig,
63
+ updateSteward
64
+ } from "./chunk-TQACHVVA.js";
43
65
  import {
44
66
  createGrant,
45
67
  deleteGrant,
46
68
  grantCoversNode,
47
- hasAnyGrant,
69
+ grantRoleForNode,
48
70
  listGrants,
49
71
  listUserGrants,
50
72
  resolveNodeGrant
51
- } from "./chunk-OTGZQZKK.js";
73
+ } from "./chunk-QUZXRLUA.js";
52
74
  import {
53
75
  generateApiKey,
54
76
  getKeyPrefix,
55
77
  hashApiKey,
56
78
  hashPassword,
57
79
  parseBearerToken,
80
+ verifyKeyRequest,
58
81
  verifyPassword
59
- } from "./chunk-XRK6SQSC.js";
82
+ } from "./chunk-F66VCBLA.js";
60
83
  import {
61
84
  checkConflict,
62
85
  createVersion,
@@ -64,36 +87,32 @@ import {
64
87
  getApprovedVersions,
65
88
  getCurrentVersion,
66
89
  getDisplayStatus,
90
+ getDisplayStatuses,
67
91
  getTrackedNodeIds,
68
92
  getVersions,
69
93
  listMyDrafts,
70
94
  setApprovedVersion,
71
95
  upsertVersion
72
- } from "./chunk-D6HICFVW.js";
96
+ } from "./chunk-34UXRZXD.js";
73
97
  import {
74
- canCreateInNest,
75
- canManageStewards,
76
- canUserAccess,
77
- canUserApprove,
78
- canUserEdit,
79
- createStewardRecord,
80
- getCollaboratorRole,
81
- getStewardRolesForUser,
82
- getStewardsForNest,
83
- getStewardsForUser,
84
- listStewards,
85
- removeSteward,
86
- resolveGrantRoles,
87
- resolveStewardsForNode,
88
- resolveStewardsWithFallback,
89
- resolveUserRoles,
90
- syncFromConfig,
91
- updateSteward
92
- } from "./chunk-CLKZZZS2.js";
98
+ approveExternalEdit,
99
+ getExternalEditDetail,
100
+ getPendingChange,
101
+ listExternalEditVerdicts,
102
+ listNestExternalEdits,
103
+ loadLatestApprovedNode,
104
+ rejectExternalEdit,
105
+ scanDocumentForDrift,
106
+ scanNestForDrift,
107
+ startDriftScanner
108
+ } from "./chunk-OOBZG6BW.js";
93
109
  import {
94
110
  addMember,
111
+ assertSafeNodeId,
95
112
  buildTitleMap,
96
113
  canApproveWith,
114
+ canEditWith,
115
+ collabPermToRole,
97
116
  createNest,
98
117
  createTeam,
99
118
  deleteNest,
@@ -102,14 +121,17 @@ import {
102
121
  docLink,
103
122
  engineApi,
104
123
  engineCache,
124
+ ensureNodeIndex,
105
125
  findTeamByExternalId,
106
126
  folderForNode,
127
+ folderOfId,
107
128
  getAccessConfig,
108
129
  getCurrentLicense,
109
130
  getNest,
110
131
  getSuspensionReason,
111
132
  getTeam,
112
133
  importNest,
134
+ indexedFolderCounts,
113
135
  insertOrIgnore,
114
136
  installLicenseKey,
115
137
  isConfigSuperAdmin,
@@ -134,19 +156,25 @@ import {
134
156
  nestPrimeTags,
135
157
  nestReviewsPrimeOnly,
136
158
  nestStorageRoot,
159
+ nodesWithSuggestions,
137
160
  normalizeTag,
138
161
  nowExpr,
139
162
  opContext,
140
163
  permissionLevel,
141
164
  persistSetting,
165
+ primaryRole,
166
+ readIndexedNodes,
167
+ rebuildNodeIndex,
142
168
  removeMember,
143
169
  removeNodeFromTagIndex,
144
170
  renameNest,
145
171
  renameTeam,
146
172
  replaceTeamRoster,
147
173
  resolveMemberIdentity,
174
+ resolveNestAccess,
148
175
  resolveNestPath,
149
176
  resolveNestPermission,
177
+ resolveTeamRolesForUser,
150
178
  sendEmailToRecipient,
151
179
  setAllowSelfApprove,
152
180
  setPrimeOnlyReview,
@@ -162,7 +190,7 @@ import {
162
190
  updateMemberRole,
163
191
  validateLicense,
164
192
  verifySmtp
165
- } from "./chunk-L7UCMGWZ.js";
193
+ } from "./chunk-FUUJG6XP.js";
166
194
  import {
167
195
  AppError,
168
196
  ConflictError,
@@ -178,11 +206,12 @@ import {
178
206
  initDb,
179
207
  isEmailListish,
180
208
  isEmailish
181
- } from "./chunk-JMJLNEXE.js";
209
+ } from "./chunk-HYSTFMFG.js";
182
210
  import {
183
211
  ANON_EMAIL,
184
- ANON_USER_ID
185
- } from "./chunk-SLTQACJW.js";
212
+ ANON_USER_ID,
213
+ MAX_FOLDER_DEPTH
214
+ } from "./chunk-YB3LKF7U.js";
186
215
  import {
187
216
  normalizeEmail
188
217
  } from "./chunk-FRQJWGN3.js";
@@ -1076,19 +1105,11 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
1076
1105
  );
1077
1106
  const db = getDb();
1078
1107
  const userId = c.get("userId");
1079
- const existing = await db.get(
1080
- "SELECT key_prefix FROM api_keys WHERE user_id = ?",
1081
- [userId]
1082
- );
1083
- if (existing) {
1084
- return c.json(
1085
- {
1086
- error: "An API key already exists for this user. Rotate it instead.",
1087
- key_prefix: existing.key_prefix
1088
- },
1089
- 409
1090
- );
1091
- }
1108
+ const check = await verifyKeyRequest(db, userId, {
1109
+ action: "generate",
1110
+ label: body.label
1111
+ });
1112
+ if (!check.ok) return c.json({ error: check.error }, check.status);
1092
1113
  const apiKey = generateApiKey();
1093
1114
  const keyId = uuid2();
1094
1115
  await db.run(
@@ -1099,11 +1120,11 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
1099
1120
  hashApiKey(apiKey),
1100
1121
  getKeyPrefix(apiKey),
1101
1122
  body.nest_id || null,
1102
- body.label || null
1123
+ check.label
1103
1124
  ]
1104
1125
  );
1105
1126
  return c.json(
1106
- { api_key: apiKey, key_prefix: getKeyPrefix(apiKey) },
1127
+ { id: keyId, api_key: apiKey, key_prefix: getKeyPrefix(apiKey), label: check.label },
1107
1128
  201
1108
1129
  );
1109
1130
  });
@@ -1111,15 +1132,22 @@ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
1111
1132
  const body = await c.req.json().catch(() => ({}));
1112
1133
  const db = getDb();
1113
1134
  const userId = c.get("userId");
1135
+ const check = await verifyKeyRequest(db, userId, {
1136
+ action: "update",
1137
+ label: body.label,
1138
+ keyId: body.key_id
1139
+ });
1140
+ if (!check.ok) return c.json({ error: check.error }, check.status);
1141
+ const prior = check.key;
1114
1142
  const apiKey = generateApiKey();
1115
1143
  const keyId = uuid2();
1116
- const prior = await db.get(
1117
- "SELECT label, nest_id FROM api_keys WHERE user_id = ?",
1118
- [userId]
1119
- );
1120
- const nestScope = "nest_id" in body ? body.nest_id || null : prior?.nest_id ?? null;
1121
- await db.transaction(async (tx) => {
1122
- await tx.run("DELETE FROM api_keys WHERE user_id = ?", [userId]);
1144
+ const nestScope = "nest_id" in body ? body.nest_id || null : prior.nest_id;
1145
+ const replaced = await db.transaction(async (tx) => {
1146
+ const removed = await tx.run(
1147
+ "DELETE FROM api_keys WHERE id = ? AND user_id = ?",
1148
+ [prior.id, userId]
1149
+ );
1150
+ if (removed.changes === 0) return false;
1123
1151
  await tx.run(
1124
1152
  "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)",
1125
1153
  [
@@ -1128,29 +1156,47 @@ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
1128
1156
  hashApiKey(apiKey),
1129
1157
  getKeyPrefix(apiKey),
1130
1158
  nestScope,
1131
- body.label ?? prior?.label ?? null
1159
+ check.label ?? prior.label
1132
1160
  ]
1133
1161
  );
1162
+ return true;
1163
+ });
1164
+ if (!replaced) {
1165
+ return c.json(
1166
+ { error: "That key no longer exists \u2014 reload and try again." },
1167
+ 404
1168
+ );
1169
+ }
1170
+ return c.json({
1171
+ id: keyId,
1172
+ api_key: apiKey,
1173
+ key_prefix: getKeyPrefix(apiKey),
1174
+ label: check.label ?? prior.label
1134
1175
  });
1135
- return c.json({ api_key: apiKey, key_prefix: getKeyPrefix(apiKey) });
1136
1176
  });
1137
1177
  authRoutes.get("/keys", authMiddleware, async (c) => {
1138
1178
  const db = getDb();
1179
+ const userId = c.get("userId");
1180
+ const check = await verifyKeyRequest(db, userId, { action: "fetch" });
1181
+ if (!check.ok) return c.json({ error: check.error }, check.status);
1139
1182
  const keys = await db.all(
1140
- "SELECT id, key_prefix, nest_id, label, created_at, last_used_at FROM api_keys WHERE user_id = ?",
1141
- [c.get("userId")]
1183
+ "SELECT id, key_prefix, nest_id, label, created_at, last_used_at FROM api_keys WHERE user_id = ? ORDER BY created_at ASC, id ASC",
1184
+ [userId]
1142
1185
  );
1143
1186
  return c.json({ keys });
1144
1187
  });
1145
1188
  authRoutes.delete("/keys/:keyId", authMiddleware, async (c) => {
1146
1189
  const db = getDb();
1147
- const result = await db.run(
1148
- "DELETE FROM api_keys WHERE id = ? AND user_id = ?",
1149
- [c.req.param("keyId"), c.get("userId")]
1150
- );
1151
- if (result.changes === 0) {
1152
- return c.json({ error: "Key not found" }, 404);
1153
- }
1190
+ const userId = c.get("userId");
1191
+ const check = await verifyKeyRequest(db, userId, {
1192
+ action: "update",
1193
+ keyId: c.req.param("keyId")
1194
+ });
1195
+ if (!check.ok) return c.json({ error: check.error }, check.status);
1196
+ await db.run("DELETE FROM api_keys WHERE id = ? AND user_id = ?", [
1197
+ check.key.id,
1198
+ userId
1199
+ ]);
1154
1200
  return c.json({ deleted: true });
1155
1201
  });
1156
1202
  authRoutes.post("/device", async (c) => {
@@ -1607,13 +1653,14 @@ import { Hono as Hono3 } from "hono";
1607
1653
 
1608
1654
  // src/nodes/service.ts
1609
1655
  import {
1610
- parseDocument as parseDocument2,
1656
+ parseDocument,
1611
1657
  normalizeStatus,
1612
1658
  isRejected,
1659
+ filterDocuments,
1613
1660
  DocumentNotFoundError
1614
1661
  } from "@promptowl/contextnest-engine";
1615
1662
  import { rename, mkdir } from "fs/promises";
1616
- import { dirname, join as join2 } from "path";
1663
+ import { dirname, join } from "path";
1617
1664
 
1618
1665
  // src/governance/safe-publish.ts
1619
1666
  import {
@@ -1641,6 +1688,9 @@ function stripUndefinedDeep(value) {
1641
1688
  return value;
1642
1689
  }
1643
1690
 
1691
+ // src/nodes/service.ts
1692
+ import { inputJsonSchema } from "@promptowl/contextnest-engine/api";
1693
+
1644
1694
  // src/governance/prime-service.ts
1645
1695
  async function isPrimeDocument(nestId, tags, metadata) {
1646
1696
  const flag = documentPrimeFlag(metadata);
@@ -1658,313 +1708,6 @@ function documentPrimeFlag(metadata) {
1658
1708
  return void 0;
1659
1709
  }
1660
1710
 
1661
- // src/governance/external-edit-service.ts
1662
- import { readFile } from "fs/promises";
1663
- import { join } from "path";
1664
- import {
1665
- detectDrift,
1666
- stageSuggestion,
1667
- ChainEventLog,
1668
- approveSuggestion,
1669
- rejectSuggestion,
1670
- listSuggestions,
1671
- readSuggestion,
1672
- parseDocument,
1673
- VersionManager,
1674
- computeContentHash,
1675
- getChecksumContent
1676
- } from "@promptowl/contextnest-engine";
1677
- var communityRbac = {
1678
- isCzar: () => false,
1679
- canIngest: () => true,
1680
- isDocOwner: () => true
1681
- };
1682
- function docPath(nestId, documentId) {
1683
- return join(resolveNestPath(nestId), `${documentId}.md`);
1684
- }
1685
- async function readRaw(nestId, documentId) {
1686
- try {
1687
- return await readFile(docPath(nestId, documentId), "utf-8");
1688
- } catch {
1689
- return null;
1690
- }
1691
- }
1692
- async function loadChainHead(storage, documentId) {
1693
- const history = await storage.readHistory(documentId);
1694
- if (!history || history.versions.length === 0) return null;
1695
- const latest = history.versions[history.versions.length - 1];
1696
- try {
1697
- const content = await new VersionManager(storage).reconstructVersion(
1698
- documentId,
1699
- latest.version
1700
- );
1701
- return { version: latest.version, content };
1702
- } catch {
1703
- return null;
1704
- }
1705
- }
1706
- async function loadLatestApprovedNode(nestId, documentId) {
1707
- const { storage } = await engineCache.get(nestId);
1708
- const head = await loadChainHead(storage, documentId);
1709
- if (!head) return null;
1710
- return parseDocument(docPath(nestId, documentId), head.content, documentId);
1711
- }
1712
- async function bodyMatchesLatestVersion(storage, documentId, liveBodyHash) {
1713
- const head = await loadChainHead(storage, documentId);
1714
- if (!head) return false;
1715
- const headBodyHash = computeContentHash(getChecksumContent(head.content));
1716
- return headBodyHash === liveBodyHash;
1717
- }
1718
- async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
1719
- const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
1720
- return res?.meta ?? null;
1721
- }
1722
- async function scanDocumentForDriftInternal(nestId, documentId, actor) {
1723
- const { storage } = await engineCache.get(nestId);
1724
- const node = await storage.readDocument(documentId).catch(() => null);
1725
- if (!node) return null;
1726
- const raw = await readRaw(nestId, documentId);
1727
- if (raw == null) return null;
1728
- const drift = detectDrift(raw, node.frontmatter.checksum);
1729
- if (!drift.drifted) return null;
1730
- if (await bodyMatchesLatestVersion(storage, documentId, drift.actualHash)) {
1731
- return null;
1732
- }
1733
- const approved = await loadChainHead(storage, documentId);
1734
- if (!approved) return null;
1735
- const existing = await listSuggestions(storage, documentId);
1736
- const dup = existing.find((s) => s.proposed_hash === drift.actualHash);
1737
- if (dup) return { meta: dup, created: false };
1738
- const result = await stageSuggestion({
1739
- storage,
1740
- documentId,
1741
- approvedRawContent: approved.content,
1742
- proposedRawContent: raw,
1743
- source: "out-of-band-edit",
1744
- actor,
1745
- docTier: "standard"
1746
- });
1747
- return { meta: result.meta, created: true };
1748
- }
1749
- async function scanNestForDrift(nestId, actor = "system:scanner") {
1750
- const { storage, invalidateDiscovery } = await engineCache.get(nestId);
1751
- invalidateDiscovery();
1752
- const docs = await storage.discoverDocuments();
1753
- const results = await Promise.all(
1754
- docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
1755
- );
1756
- const staged = results.filter((r) => r?.created).length;
1757
- return { scanned: docs.length, staged };
1758
- }
1759
- async function getPendingChange(nestId, documentId) {
1760
- const { storage } = await engineCache.get(nestId);
1761
- const list = await listSuggestions(storage, documentId);
1762
- for (let i = list.length - 1; i >= 0; i--) {
1763
- const meta = list[i];
1764
- if (await bodyMatchesLatestVersion(storage, documentId, meta.proposed_hash)) {
1765
- continue;
1766
- }
1767
- return {
1768
- suggestion_id: meta.suggestion_id,
1769
- detected_at: meta.detected_at,
1770
- source: meta.source,
1771
- proposed_hash: meta.proposed_hash
1772
- };
1773
- }
1774
- return null;
1775
- }
1776
- async function listNestExternalEdits(nestId) {
1777
- const { storage } = await engineCache.get(nestId);
1778
- const docs = await storage.discoverDocuments();
1779
- const lists = await Promise.all(
1780
- docs.map(async (doc) => {
1781
- const metas = await listSuggestions(storage, doc.id);
1782
- const fresh = [];
1783
- for (const meta of metas) {
1784
- if (await bodyMatchesLatestVersion(storage, doc.id, meta.proposed_hash)) {
1785
- continue;
1786
- }
1787
- fresh.push(meta);
1788
- }
1789
- return fresh;
1790
- })
1791
- );
1792
- const entries = lists.flat().map((meta) => ({
1793
- suggestion_id: meta.suggestion_id,
1794
- nest_id: nestId,
1795
- document_id: meta.document_id,
1796
- source: meta.source,
1797
- detected_at: meta.detected_at,
1798
- actor: meta.actor,
1799
- target_hash: meta.target_hash,
1800
- proposed_hash: meta.proposed_hash,
1801
- note: meta.note
1802
- }));
1803
- return entries.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
1804
- }
1805
- async function getExternalEditDetail(nestId, documentId, suggestionId) {
1806
- const { storage } = await engineCache.get(nestId);
1807
- const found = await readSuggestion(storage, documentId, suggestionId);
1808
- if (!found) return null;
1809
- return {
1810
- suggestion_id: found.meta.suggestion_id,
1811
- nest_id: nestId,
1812
- document_id: found.meta.document_id,
1813
- source: found.meta.source,
1814
- detected_at: found.meta.detected_at,
1815
- actor: found.meta.actor,
1816
- target_hash: found.meta.target_hash,
1817
- proposed_hash: found.meta.proposed_hash,
1818
- note: found.meta.note,
1819
- patch: found.patch
1820
- };
1821
- }
1822
- async function recordVerdict(nestId, event) {
1823
- try {
1824
- const { storage } = await engineCache.get(nestId);
1825
- await new ChainEventLog(storage).append(event);
1826
- return true;
1827
- } catch (err) {
1828
- console.error(
1829
- `[external-edit] failed to record verdict ${event.event_id} for nest ${nestId}:`,
1830
- err
1831
- );
1832
- return false;
1833
- }
1834
- }
1835
- async function listExternalEditVerdicts(nestId, documentId) {
1836
- try {
1837
- const { storage } = await engineCache.get(nestId);
1838
- const events = await new ChainEventLog(storage).readByDocument(documentId);
1839
- return events.flatMap((e) => {
1840
- const meta = e.action_metadata || {};
1841
- if (typeof meta.suggestion_id !== "string") return [];
1842
- const reason = meta.rejection_reason;
1843
- const comment = meta.approval_comment;
1844
- return [
1845
- {
1846
- suggestion_id: meta.suggestion_id,
1847
- status: typeof reason === "string" ? "rejected" : "approved",
1848
- actor: e.actor,
1849
- at: e.timestamp,
1850
- note: typeof reason === "string" ? reason : typeof comment === "string" ? comment : void 0,
1851
- source: typeof meta.source === "string" ? meta.source : void 0
1852
- }
1853
- ];
1854
- });
1855
- } catch (err) {
1856
- console.error(
1857
- `[external-edit] failed to read verdicts for ${nestId}/${documentId}:`,
1858
- err
1859
- );
1860
- return [];
1861
- }
1862
- }
1863
- async function mirrorVersion(input) {
1864
- const { storage } = await engineCache.get(input.nestId);
1865
- const node = await storage.readDocument(input.documentId);
1866
- const { upsertVersion: upsertVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-V6K5SZ3P.js");
1867
- await upsertVersion2({
1868
- nestId: input.nestId,
1869
- nodeId: input.documentId,
1870
- version: input.version,
1871
- content: node.body || "",
1872
- author: input.actor,
1873
- status: "published",
1874
- tags: node.frontmatter.tags || [],
1875
- changeNote: input.note
1876
- });
1877
- await setApprovedVersion2(
1878
- input.nestId,
1879
- input.documentId,
1880
- input.version,
1881
- input.actor
1882
- );
1883
- }
1884
- async function approveExternalEdit(input) {
1885
- const { storage } = await engineCache.get(input.nestId);
1886
- let result;
1887
- try {
1888
- result = await approveSuggestion({
1889
- storage,
1890
- rbac: communityRbac,
1891
- documentId: input.documentId,
1892
- suggestionId: input.suggestionId,
1893
- actor: input.actor,
1894
- zone: "default",
1895
- comment: input.comment
1896
- });
1897
- } catch (err) {
1898
- console.error(
1899
- `[external-edit] approveSuggestion failed for ${input.nestId}/${input.documentId} suggestion=${input.suggestionId}:`,
1900
- err
1901
- );
1902
- throw err;
1903
- }
1904
- try {
1905
- await mirrorVersion({
1906
- nestId: input.nestId,
1907
- documentId: input.documentId,
1908
- version: result.versionEntry.version,
1909
- actor: input.actor,
1910
- note: input.comment ? `External edit approved: ${input.comment}` : "External edit approved"
1911
- });
1912
- } catch (err) {
1913
- console.error(
1914
- `[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
1915
- err
1916
- );
1917
- }
1918
- const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
1919
- return { ...result, historyRecorded };
1920
- }
1921
- async function rejectExternalEdit(input) {
1922
- const { storage } = await engineCache.get(input.nestId);
1923
- const result = await rejectSuggestion({
1924
- storage,
1925
- rbac: communityRbac,
1926
- documentId: input.documentId,
1927
- suggestionId: input.suggestionId,
1928
- actor: input.actor,
1929
- zone: "default",
1930
- reason: input.reason
1931
- });
1932
- const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
1933
- try {
1934
- const approved = await loadChainHead(storage, input.documentId);
1935
- if (approved) {
1936
- await storage.writeDocument(input.documentId, approved.content);
1937
- }
1938
- } catch (err) {
1939
- console.error(
1940
- `[external-edit] revert-on-reject failed for ${input.nestId}/${input.documentId}:`,
1941
- err
1942
- );
1943
- }
1944
- return { ...result, historyRecorded };
1945
- }
1946
- var scannerTimer = null;
1947
- async function scanAllNests() {
1948
- const db = getDb();
1949
- const rows = await db.all("SELECT id FROM nests");
1950
- await Promise.all(
1951
- rows.map(
1952
- ({ id }) => scanNestForDrift(id).catch(
1953
- (err) => console.error(`[external-edit] scan failed for nest ${id}:`, err)
1954
- )
1955
- )
1956
- );
1957
- }
1958
- function startDriftScanner(intervalMs = 3e4) {
1959
- if (scannerTimer) return;
1960
- scannerTimer = setInterval(() => {
1961
- scanAllNests().catch(
1962
- (err) => console.error("[external-edit] scanner tick failed:", err)
1963
- );
1964
- }, intervalMs);
1965
- scannerTimer.unref?.();
1966
- }
1967
-
1968
1711
  // src/nodes/service.ts
1969
1712
  async function userIdFromEmail(email) {
1970
1713
  const db = getDb();
@@ -1983,17 +1726,42 @@ function assertTitleLength(title) {
1983
1726
  );
1984
1727
  }
1985
1728
  }
1986
- function assertTitleSluggable(title) {
1987
- if (!slugify(title)) {
1729
+ function assertTitleReadable(title) {
1730
+ if (!/[\p{L}\p{N}]/u.test(title)) {
1988
1731
  throw new ValidationError(
1989
- "the document id is built from the title using a-z and 0-9, so the title needs at least one of those"
1732
+ "the title needs at least one letter or number \u2014 it is what search and wiki links read back"
1990
1733
  );
1991
1734
  }
1992
1735
  }
1993
- var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1736
+ var slugify = (s) => s.normalize("NFKD").replace(/\p{M}+/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1737
+ async function generatedSlug(storage, prefix) {
1738
+ for (let n = 1; n <= 1e3; n++) {
1739
+ const slug = `document-${n}`;
1740
+ try {
1741
+ await storage.readDocument(`${prefix}${slug}`);
1742
+ } catch (err) {
1743
+ if (err instanceof DocumentNotFoundError) return slug;
1744
+ throw err;
1745
+ }
1746
+ }
1747
+ throw new ValidationError(
1748
+ "too many generated document ids in this folder \u2014 give the document a title with Latin letters or digits, or file it elsewhere"
1749
+ );
1750
+ }
1994
1751
  function isIdUnderFolder(id, folder) {
1995
1752
  return id.startsWith(`nodes/${folder}/`) || id.startsWith(`${folder}/`);
1996
1753
  }
1754
+ function assertSafeFolder(folder) {
1755
+ if (folder.split(/[/\\]/).some((seg) => seg === "..")) {
1756
+ throw new ValidationError(
1757
+ `Invalid folder "${folder}": path traversal ("..") is not allowed.`
1758
+ );
1759
+ }
1760
+ }
1761
+ async function storagePrefix(nestId) {
1762
+ const { storage } = await engineCache.get(nestId);
1763
+ return await storage.detectLayout() === "structured" ? "nodes" : "";
1764
+ }
1997
1765
  function folderToSegments(folder) {
1998
1766
  const segments = folder.split("/").map(slugify).filter(Boolean);
1999
1767
  if (folder.trim() && segments.length === 0) {
@@ -2002,8 +1770,10 @@ function folderToSegments(folder) {
2002
1770
  );
2003
1771
  }
2004
1772
  if (segments[0] === "nodes") segments.shift();
2005
- if (segments.length > 8) {
2006
- throw new ValidationError("folder may nest at most 8 levels deep");
1773
+ if (segments.length > MAX_FOLDER_DEPTH) {
1774
+ throw new ValidationError(
1775
+ `folder may nest at most ${MAX_FOLDER_DEPTH} levels deep`
1776
+ );
2007
1777
  }
2008
1778
  if (segments.some((seg) => seg.length > 100)) {
2009
1779
  throw new ValidationError(
@@ -2039,7 +1809,7 @@ function toSafeError(err, context, message) {
2039
1809
  }
2040
1810
  function bodyOnly(nodeId, raw) {
2041
1811
  try {
2042
- return parseDocument2(`${nodeId}.md`, raw, nodeId).body ?? "";
1812
+ return parseDocument(`${nodeId}.md`, raw, nodeId).body ?? "";
2043
1813
  } catch {
2044
1814
  return raw;
2045
1815
  }
@@ -2124,6 +1894,15 @@ async function resolveAuthorship(nestId, nodes) {
2124
1894
  }
2125
1895
  return result;
2126
1896
  }
1897
+ var folderScopeSupported = null;
1898
+ function engineScopesFolders() {
1899
+ if (folderScopeSupported === null) {
1900
+ const op = engineApi.getOperation("context_list");
1901
+ const properties = op ? inputJsonSchema(op).properties ?? {} : {};
1902
+ folderScopeSupported = "folder" in properties;
1903
+ }
1904
+ return folderScopeSupported;
1905
+ }
2127
1906
  async function getNodeForCaller(nestId, selector, userId, userEmail, opts = {}) {
2128
1907
  if (selector.id && !await canReadNode(nestId, selector.id, userId, userEmail)) {
2129
1908
  throw new ForbiddenError(
@@ -2165,29 +1944,49 @@ async function getNodeForCaller(nestId, selector, userId, userEmail, opts = {})
2165
1944
  };
2166
1945
  }
2167
1946
  async function listNodesForCaller(nestId, userId, filters = {}) {
1947
+ if (filters.folder !== void 0) assertSafeFolder(filters.folder);
2168
1948
  const { versions: versionManager } = await engineCache.get(nestId);
2169
1949
  const userEmail = await resolveCallerEmail(userId);
2170
- const { documents } = await engineApi.run(
2171
- "context_list",
2172
- {
2173
- ...filters.type ? { type: filters.type } : {},
2174
- ...filters.tag ? { tag: filters.tag } : {},
2175
- ...filters.status ? { status: filters.status } : {},
2176
- // Unlike the CLI and mcp-server, a retired node stays in this listing:
2177
- // here it is an unpublished document its stewards may still need to act
2178
- // on, not one removed from retrieval. An imported vault carrying
2179
- // `status: rejected` registers exactly that way.
2180
- include_retired: true,
2181
- full: true
2182
- },
2183
- await opContext(nestId, userEmail)
2184
- );
2185
- const nodes = documents.map((d) => ({
1950
+ const frontmatterFilters = {
1951
+ ...filters.type ? { type: filters.type } : {},
1952
+ ...filters.tag ? { tag: filters.tag } : {},
1953
+ ...filters.status ? { status: filters.status } : {},
1954
+ includeRetired: true
1955
+ };
1956
+ let documents;
1957
+ if (filters.withContent) {
1958
+ const scopeInEngine = filters.folder !== void 0 && engineScopesFolders();
1959
+ const scopePrefix = scopeInEngine ? await storagePrefix(nestId) : "";
1960
+ ({ documents } = await engineApi.run(
1961
+ "context_list",
1962
+ {
1963
+ ...frontmatterFilters,
1964
+ include_retired: true,
1965
+ ...scopeInEngine ? {
1966
+ folder: [scopePrefix, filters.folder].filter(Boolean).join("/"),
1967
+ recursive: false
1968
+ } : {},
1969
+ full: true
1970
+ },
1971
+ await opContext(nestId, userEmail)
1972
+ ));
1973
+ } else {
1974
+ await ensureNodeIndex(nestId);
1975
+ const indexed = await readIndexedNodes(
1976
+ nestId,
1977
+ filters.folder !== void 0 ? { folder: filters.folder } : {}
1978
+ );
1979
+ documents = filterDocuments(indexed, frontmatterFilters);
1980
+ }
1981
+ const scoped = filters.folder === void 0 ? documents : documents.filter((d) => folderOfId(d.id) === filters.folder);
1982
+ const nodes = scoped.map((d) => ({
2186
1983
  id: d.id,
2187
1984
  filePath: "",
2188
1985
  rawContent: "",
2189
1986
  frontmatter: d.frontmatter,
2190
- body: d.body
1987
+ // Empty on the indexed path: no listing renders a body, and the
1988
+ // approved-only branch below serves its own from the version store.
1989
+ body: d.body ?? ""
2191
1990
  }));
2192
1991
  const publicReader = await isPublicReader(nestId, userId);
2193
1992
  const approvedOnly = publicReader || filters.approvedOnly === true;
@@ -2199,14 +1998,18 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
2199
1998
  nodes,
2200
1999
  approvedVersions ?? void 0
2201
2000
  );
2202
- if (await resolveNestPermission(nestId, userId) === "none" && !await isStewardshipEnabled(nestId)) {
2203
- const grants = await listUserGrants(nestId, userId);
2204
- accessible = grants.length ? nodes.filter((d) => grantCoversNode(grants, d.id)) : accessible;
2205
- }
2001
+ const myGrants = await listUserGrants(nestId, userId);
2206
2002
  if (approvedVersions && !publicReader) {
2207
2003
  accessible = accessible.filter((d) => approvedVersions.has(d.id));
2208
2004
  }
2209
2005
  if (filters.limit) accessible = accessible.slice(0, filters.limit);
2006
+ const [displayStatuses, driftedDocs] = approvedVersions ? [null, null] : await Promise.all([
2007
+ getDisplayStatuses(nestId),
2008
+ // From the DB, not a vault walk. The disk truth behind it is
2009
+ // reconciled by the index rebuild and kept current by the stage /
2010
+ // approve / reject paths.
2011
+ nodesWithSuggestions(nestId)
2012
+ ]);
2210
2013
  const authorship = publicReader ? null : await resolveAuthorship(
2211
2014
  nestId,
2212
2015
  accessible.map((d) => ({
@@ -2222,6 +2025,9 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
2222
2025
  r.author = people.author;
2223
2026
  r.collaborators = people.collaborators;
2224
2027
  }
2028
+ if (myGrants.length) {
2029
+ r.myGrant = grantRoleForNode(myGrants, doc.id);
2030
+ }
2225
2031
  if (approvedVersions) {
2226
2032
  const approved = approvedVersions.get(doc.id);
2227
2033
  if (approved != null) {
@@ -2246,22 +2052,125 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
2246
2052
  return r;
2247
2053
  }
2248
2054
  let pending = null;
2249
- try {
2250
- pending = await getPendingChange(nestId, doc.id);
2251
- } catch {
2252
- pending = null;
2055
+ if (driftedDocs?.has(doc.id)) {
2056
+ try {
2057
+ pending = await getPendingChange(nestId, doc.id);
2058
+ } catch {
2059
+ pending = null;
2060
+ }
2253
2061
  }
2254
2062
  if (pending) {
2255
2063
  r.pendingChange = pending;
2256
2064
  r.status = "external_edit_pending";
2257
2065
  } else {
2258
- r.status = await getDisplayStatus(nestId, r.id);
2066
+ r.status = displayStatuses?.get(r.id) ?? "draft";
2259
2067
  }
2260
2068
  return r;
2261
2069
  })
2262
2070
  );
2263
2071
  return enriched;
2264
2072
  }
2073
+ function withSubtreeTotals(direct) {
2074
+ return direct.map(({ path, count }) => ({
2075
+ path,
2076
+ count,
2077
+ total: direct.reduce(
2078
+ (sum, f) => f.path === path || f.path.startsWith(`${path}/`) ? sum + f.count : sum,
2079
+ 0
2080
+ )
2081
+ })).sort((a, b) => a.path.localeCompare(b.path));
2082
+ }
2083
+ async function listNestFolders(nestId, opts = {}) {
2084
+ if (opts.folder !== void 0) assertSafeFolder(opts.folder);
2085
+ await ensureNodeIndex(nestId);
2086
+ const counts = /* @__PURE__ */ new Map();
2087
+ for (const { path, count } of await indexedFolderCounts(nestId)) {
2088
+ counts.set(path, count);
2089
+ let parent = path;
2090
+ while (parent.includes("/")) {
2091
+ parent = parent.slice(0, parent.lastIndexOf("/"));
2092
+ if (!counts.has(parent)) counts.set(parent, 0);
2093
+ }
2094
+ }
2095
+ const direct = [...counts].map(([path, count]) => ({ path, count }));
2096
+ const scope = opts.folder ?? "";
2097
+ const inScope = direct.filter((f) => {
2098
+ if (scope && !f.path.startsWith(`${scope}/`)) return false;
2099
+ if (opts.recursive === false) {
2100
+ const rel = scope ? f.path.slice(scope.length + 1) : f.path;
2101
+ return !rel.includes("/");
2102
+ }
2103
+ return true;
2104
+ });
2105
+ return withSubtreeTotals(inScope);
2106
+ }
2107
+ var RECENT_LIMIT = 3;
2108
+ async function nestCensus(nestId, userId) {
2109
+ const userEmail = await resolveCallerEmail(userId);
2110
+ await ensureNodeIndex(nestId);
2111
+ const documents = await readIndexedNodes(nestId);
2112
+ const visible = await filterAccessible(nestId, userId, userEmail, documents);
2113
+ const tags = {};
2114
+ const types = {};
2115
+ const direct = /* @__PURE__ */ new Map();
2116
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1e3;
2117
+ let editedLast7d = 0;
2118
+ for (const d of visible) {
2119
+ const fm = d.frontmatter;
2120
+ types[fm.type || "document"] = (types[fm.type || "document"] || 0) + 1;
2121
+ for (const t of fm.tags || []) tags[String(t)] = (tags[String(t)] || 0) + 1;
2122
+ const folder = folderOfId(d.id);
2123
+ if (folder) direct.set(folder, (direct.get(folder) || 0) + 1);
2124
+ const edited = Date.parse(String(fm.updated_at || ""));
2125
+ if (!Number.isNaN(edited) && edited >= cutoff) editedLast7d++;
2126
+ }
2127
+ const paths = /* @__PURE__ */ new Set();
2128
+ for (const folder of direct.keys()) {
2129
+ const segs = folder.split("/");
2130
+ for (let i = 1; i <= segs.length; i++) paths.add(segs.slice(0, i).join("/"));
2131
+ }
2132
+ const folders = withSubtreeTotals(
2133
+ [...paths].map((path) => ({ path, count: direct.get(path) ?? 0 }))
2134
+ );
2135
+ const statusMap = await getDisplayStatuses(nestId);
2136
+ const statuses = {};
2137
+ for (const d of visible) {
2138
+ const st = statusMap.get(d.id) ?? "draft";
2139
+ statuses[st] = (statuses[st] || 0) + 1;
2140
+ }
2141
+ const authorship = await resolveAuthorship(
2142
+ nestId,
2143
+ visible.map((d) => ({
2144
+ id: d.id,
2145
+ frontmatterAuthor: d.frontmatter.author ?? null
2146
+ }))
2147
+ );
2148
+ const authors = [
2149
+ ...new Set(
2150
+ [...authorship.values()].map((a) => a.author).filter((a) => !!a)
2151
+ )
2152
+ ].sort((a, b) => a.localeCompare(b));
2153
+ const recent = visible.filter((d) => d.frontmatter.updated_at).sort(
2154
+ (a, b) => String(b.frontmatter.updated_at).localeCompare(
2155
+ String(a.frontmatter.updated_at)
2156
+ )
2157
+ ).slice(0, RECENT_LIMIT).map((d) => ({
2158
+ id: d.id,
2159
+ title: String(d.frontmatter.title ?? ""),
2160
+ type: d.frontmatter.type || "document",
2161
+ updated_at: d.frontmatter.updated_at
2162
+ }));
2163
+ return {
2164
+ total: visible.length,
2165
+ folders,
2166
+ tags,
2167
+ types,
2168
+ statuses,
2169
+ authors,
2170
+ editedLast7d,
2171
+ recent
2172
+ };
2173
+ }
2265
2174
  async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
2266
2175
  return listNodesForCaller(nestId, await userIdFromEmail(userEmail), filters);
2267
2176
  }
@@ -2287,14 +2196,27 @@ async function autoPublishReason(nestId, userEmail, doc) {
2287
2196
  return await actorAutoPublishes(nestId, userEmail) ? "owner/admin self-approve" : null;
2288
2197
  }
2289
2198
  async function createNode(nestId, input, userEmail) {
2199
+ const generatedId = !input.id && !slugify(input.title);
2200
+ for (let attempt = 0; ; attempt++) {
2201
+ try {
2202
+ return await createNodeOnce(nestId, input, userEmail);
2203
+ } catch (err) {
2204
+ if (!generatedId || attempt >= 3 || !(err instanceof ConflictError)) {
2205
+ throw err;
2206
+ }
2207
+ }
2208
+ }
2209
+ }
2210
+ async function createNodeOnce(nestId, input, userEmail) {
2290
2211
  const { storage, versions: versionManager } = await engineCache.get(nestId);
2291
2212
  assertTitleLength(input.title);
2292
- if (!input.id) assertTitleSluggable(input.title);
2213
+ assertTitleReadable(input.title);
2293
2214
  const slug = slugify(input.title).slice(0, 100).replace(/-+$/, "");
2294
2215
  let id = input.id;
2295
2216
  if (!id) {
2296
2217
  const folderSegments = folderToSegments(input.folder ?? "");
2297
- id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
2218
+ const prefix = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/` : "nodes/";
2219
+ id = `${prefix}${slug || await generatedSlug(storage, prefix)}`;
2298
2220
  }
2299
2221
  const tags = (input.tags || []).map(normalizeTag2);
2300
2222
  const requestedType = input.type || "document";
@@ -2555,7 +2477,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2555
2477
  if (patch.title) {
2556
2478
  if (patch.title !== node.frontmatter.title) {
2557
2479
  assertTitleLength(patch.title);
2558
- assertTitleSluggable(patch.title);
2480
+ assertTitleReadable(patch.title);
2559
2481
  }
2560
2482
  node = {
2561
2483
  ...node,
@@ -2703,6 +2625,8 @@ var NODE_KEYED_TABLES = [
2703
2625
  ["annotation_threads", "node_id"],
2704
2626
  ["comments", "node_id"],
2705
2627
  ["node_tag_index", "node_id"],
2628
+ ["node_index", "node_id"],
2629
+ ["node_suggestions", "node_id"],
2706
2630
  ["watchers", "node_id"],
2707
2631
  ["schedules", "agent_node"],
2708
2632
  // A live capability token bound to one agent node — deleting the agent must
@@ -2781,8 +2705,8 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
2781
2705
  await storage.writeDocument(newId, serializeNodeSafe(moved));
2782
2706
  try {
2783
2707
  const nestPath = resolveNestPath(nestId);
2784
- const oldChain = join2(nestPath, dirname(nodeId), ".versions", leaf);
2785
- const newChain = join2(nestPath, dirname(newId), ".versions", leaf);
2708
+ const oldChain = join(nestPath, dirname(nodeId), ".versions", leaf);
2709
+ const newChain = join(nestPath, dirname(newId), ".versions", leaf);
2786
2710
  if (oldChain !== newChain) {
2787
2711
  await mkdir(dirname(newChain), { recursive: true });
2788
2712
  await rename(oldChain, newChain);
@@ -2815,6 +2739,11 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
2815
2739
  await storage.deleteDocument(oldDerived);
2816
2740
  } catch {
2817
2741
  }
2742
+ try {
2743
+ await storage.regenerateIndex();
2744
+ } catch (err) {
2745
+ console.error("[move] regenerateIndex failed", nestId, nodeId, newId, err);
2746
+ }
2818
2747
  await trackEvent("node.move", { nestId, nodeId, newId });
2819
2748
  return { node: moved, previousId: nodeId };
2820
2749
  });
@@ -2885,7 +2814,7 @@ async function deleteFolder(nestId, folder, userEmail) {
2885
2814
 
2886
2815
  // src/nests/unsynced-service.ts
2887
2816
  import { readdirSync, readFileSync, statSync } from "fs";
2888
- import { join as join3, relative, resolve } from "path";
2817
+ import { join as join2, relative, resolve } from "path";
2889
2818
  import { rm } from "fs/promises";
2890
2819
  var RESERVED = /* @__PURE__ */ new Set(["nests"]);
2891
2820
  function isNestStorageRoot(absPath) {
@@ -2902,7 +2831,7 @@ function scanMarkdown(dir) {
2902
2831
  }
2903
2832
  for (const e of entries) {
2904
2833
  if (e.name.startsWith(".") || e.name === "node_modules") continue;
2905
- const full = join3(dir, e.name);
2834
+ const full = join2(dir, e.name);
2906
2835
  if (e.isDirectory()) {
2907
2836
  const sub = scanMarkdown(full);
2908
2837
  count += sub.count;
@@ -2952,7 +2881,7 @@ function collectLeafFolders(rel, abs) {
2952
2881
  if (!e.isDirectory()) continue;
2953
2882
  if (e.name.startsWith(".") || e.name === "node_modules") continue;
2954
2883
  out.push(
2955
- ...collectLeafFolders(`${rel}/${e.name}`, join3(abs, e.name))
2884
+ ...collectLeafFolders(`${rel}/${e.name}`, join2(abs, e.name))
2956
2885
  );
2957
2886
  }
2958
2887
  return out;
@@ -2970,7 +2899,7 @@ function listUnsyncedFolders() {
2970
2899
  if (!e.isDirectory()) continue;
2971
2900
  if (e.name.startsWith(".")) continue;
2972
2901
  if (RESERVED.has(e.name)) continue;
2973
- const abs = join3(root, e.name);
2902
+ const abs = join2(root, e.name);
2974
2903
  if (isNestStorageRoot(abs)) continue;
2975
2904
  out.push(...collectLeafFolders(e.name, abs));
2976
2905
  }
@@ -3007,7 +2936,7 @@ function collectMarkdownFiles(dir, root) {
3007
2936
  }
3008
2937
  for (const e of entries) {
3009
2938
  if (e.name.startsWith(".") || e.name === "node_modules") continue;
3010
- const full = join3(dir, e.name);
2939
+ const full = join2(dir, e.name);
3011
2940
  if (e.isDirectory()) {
3012
2941
  out.push(...collectMarkdownFiles(full, root));
3013
2942
  } else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
@@ -3025,7 +2954,7 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
3025
2954
  `[unsynced] sync requested folder="${folderName}" user="${callerEmail}"`
3026
2955
  );
3027
2956
  assertSafeFolderName(folderName);
3028
- const src = join3(config.DATA_ROOT, folderName);
2957
+ const src = join2(config.DATA_ROOT, folderName);
3029
2958
  if (isNestStorageRoot(src)) {
3030
2959
  throw new ValidationError("Folder is not eligible for sync");
3031
2960
  }
@@ -3081,7 +3010,7 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
3081
3010
  async function deleteUnsyncedFolder(folderName) {
3082
3011
  console.log(`[unsynced] delete requested folder="${folderName}"`);
3083
3012
  assertSafeFolderName(folderName);
3084
- const src = join3(config.DATA_ROOT, folderName);
3013
+ const src = join2(config.DATA_ROOT, folderName);
3085
3014
  if (isNestStorageRoot(src)) {
3086
3015
  throw new ValidationError("Folder is not eligible for delete");
3087
3016
  }
@@ -3099,16 +3028,22 @@ async function deleteUnsyncedFolder(folderName) {
3099
3028
  }
3100
3029
 
3101
3030
  // src/nests/routes.ts
3102
- async function effectivePermission(nestId, userId) {
3103
- if (config.AUTH_MODE === "open") {
3104
- const db = getDb();
3105
- const nest = await db.get(
3106
- "SELECT user_id FROM nests WHERE id = ?",
3107
- [nestId]
3108
- );
3109
- if (nest && nest.user_id === ANON_USER_ID) return "owner";
3031
+ async function effectiveAccess(nestId, userId) {
3032
+ const access = await resolveNestAccess(nestId, userId);
3033
+ if (config.AUTH_MODE === "open" && access.ownerId === ANON_USER_ID) {
3034
+ return { permission: "owner", access };
3035
+ }
3036
+ const permission = access.permission;
3037
+ if (permission === "none" && access.hasGrant) {
3038
+ return { permission: "read", access };
3110
3039
  }
3111
- return resolveNestPermission(nestId, userId);
3040
+ return { permission, access };
3041
+ }
3042
+ async function effectivePermission(nestId, userId) {
3043
+ return (await effectiveAccess(nestId, userId)).permission;
3044
+ }
3045
+ function isGrantOnly(access) {
3046
+ return access.permission === "none" && access.hasGrant;
3112
3047
  }
3113
3048
  var nestRoutes = new Hono3();
3114
3049
  nestRoutes.get("/", async (c) => {
@@ -3119,8 +3054,31 @@ nestRoutes.get("/", async (c) => {
3119
3054
  const db = getDb();
3120
3055
  const ownerEmailSql = "SELECT email FROM users WHERE id = ?";
3121
3056
  const callerEmail = await resolveCallerEmail(userId);
3057
+ const myGrantRows = await db.all(
3058
+ "SELECT nest_id, target FROM grants WHERE user_id = ?",
3059
+ [userId]
3060
+ );
3061
+ const grantTargetsByNest = /* @__PURE__ */ new Map();
3062
+ for (const r of myGrantRows) {
3063
+ const arr = grantTargetsByNest.get(r.nest_id) ?? [];
3064
+ arr.push({ target: r.target });
3065
+ grantTargetsByNest.set(r.nest_id, arr);
3066
+ }
3122
3067
  const annotate = async (n) => {
3123
- const permission = await effectivePermission(n.id, userId);
3068
+ const { permission, access } = await effectiveAccess(n.id, userId);
3069
+ if (permission !== "owner" && // open-mode commons promotion outranks a grant
3070
+ isGrantOnly(access)) {
3071
+ const owner_email2 = n.user_id === ANON_USER_ID ? null : (await db.get(ownerEmailSql, [n.user_id]))?.email ?? null;
3072
+ return {
3073
+ ...n,
3074
+ permission: "read",
3075
+ is_owner: false,
3076
+ owner_email: owner_email2,
3077
+ roles: ["viewer"],
3078
+ grant_only: true,
3079
+ stewardship_enabled: !!n.stewardship_enabled
3080
+ };
3081
+ }
3124
3082
  const is_owner = permission === "owner";
3125
3083
  const needOwnerEmail = !is_owner && n.user_id !== ANON_USER_ID;
3126
3084
  const [roles, owner_email] = await Promise.all([
@@ -3135,6 +3093,7 @@ nestRoutes.get("/", async (c) => {
3135
3093
  is_owner,
3136
3094
  owner_email,
3137
3095
  roles,
3096
+ grant_only: false,
3138
3097
  stewardship_enabled: !!n.stewardship_enabled
3139
3098
  };
3140
3099
  };
@@ -3147,9 +3106,11 @@ nestRoutes.get("/", async (c) => {
3147
3106
  }
3148
3107
  const out = await Promise.all(deduped.map(annotate));
3149
3108
  const docRows = await db.all(
3150
- "SELECT nest_id, COUNT(DISTINCT node_id) AS c FROM node_versions GROUP BY nest_id"
3109
+ `SELECT nest_id, COUNT(DISTINCT node_id) AS c, MAX(created_at) AS last_activity
3110
+ FROM node_versions GROUP BY nest_id`
3151
3111
  );
3152
3112
  const docByNest = new Map(docRows.map((r) => [r.nest_id, Number(r.c)]));
3113
+ const activityByNest = new Map(docRows.map((r) => [r.nest_id, r.last_activity]));
3153
3114
  const collabRows = await db.all(
3154
3115
  "SELECT nest_id, COUNT(*) AS c FROM nest_collaborators GROUP BY nest_id"
3155
3116
  );
@@ -3178,14 +3139,39 @@ nestRoutes.get("/", async (c) => {
3178
3139
  if (arr.length < 8) arr.push(r.email);
3179
3140
  collabEmailsByNest.set(r.nest_id, arr);
3180
3141
  }
3181
- const withMeta = out.map((n) => ({
3182
- ...n,
3183
- document_count: docByNest.get(n.id) ?? 0,
3184
- collaborator_count: collabByNest.get(n.id) ?? 0,
3185
- steward_count: stewardByNest.get(n.id) ?? 0,
3186
- steward_emails: stewardEmailsByNest.get(n.id) ?? [],
3187
- collaborator_emails: collabEmailsByNest.get(n.id) ?? []
3188
- }));
3142
+ const grantOnlyIds = out.filter((n) => n.grant_only).map((n) => n.id);
3143
+ const grantedDocsByNest = /* @__PURE__ */ new Map();
3144
+ if (grantOnlyIds.length) {
3145
+ const rows = await db.all(
3146
+ `SELECT DISTINCT nest_id, node_id FROM node_versions
3147
+ WHERE nest_id IN (${grantOnlyIds.map(() => "?").join(",")})`,
3148
+ grantOnlyIds
3149
+ );
3150
+ for (const r of rows) {
3151
+ const targets = grantTargetsByNest.get(r.nest_id) ?? [];
3152
+ if (grantCoversNode(targets, r.node_id)) {
3153
+ grantedDocsByNest.set(r.nest_id, (grantedDocsByNest.get(r.nest_id) ?? 0) + 1);
3154
+ }
3155
+ }
3156
+ }
3157
+ const withMeta = out.map(
3158
+ (n) => n.grant_only ? {
3159
+ ...n,
3160
+ document_count: grantedDocsByNest.get(n.id) ?? 0,
3161
+ collaborator_count: 0,
3162
+ steward_count: 0,
3163
+ steward_emails: [],
3164
+ collaborator_emails: []
3165
+ } : {
3166
+ ...n,
3167
+ last_activity: activityByNest.get(n.id) ?? null,
3168
+ document_count: docByNest.get(n.id) ?? 0,
3169
+ collaborator_count: collabByNest.get(n.id) ?? 0,
3170
+ steward_count: stewardByNest.get(n.id) ?? 0,
3171
+ steward_emails: stewardEmailsByNest.get(n.id) ?? [],
3172
+ collaborator_emails: collabEmailsByNest.get(n.id) ?? []
3173
+ }
3174
+ );
3189
3175
  return c.json({ nests: withMeta });
3190
3176
  });
3191
3177
  nestRoutes.post("/", async (c) => {
@@ -3291,7 +3277,7 @@ nestRoutes.get("/:nestId", async (c) => {
3291
3277
  const nestId = c.req.param("nestId");
3292
3278
  if (nestId === "unsynced") return handleUnsyncedList(c);
3293
3279
  const userId = c.get("userId");
3294
- const permission = await effectivePermission(nestId, userId);
3280
+ const { permission, access } = await effectiveAccess(nestId, userId);
3295
3281
  if (permission === "none") {
3296
3282
  throw new NotFoundError("Nest not found");
3297
3283
  }
@@ -3301,8 +3287,15 @@ nestRoutes.get("/:nestId", async (c) => {
3301
3287
  roles = ["owner", ...roles];
3302
3288
  }
3303
3289
  const myStewards = await getStewardsForUser(nestId, email);
3290
+ const grant_only = roles.length === 0 && isGrantOnly(access);
3304
3291
  const nest = await getNest(nestId);
3305
- return c.json({ nest, permission, roles, myStewards });
3292
+ return c.json({
3293
+ nest,
3294
+ permission,
3295
+ roles: grant_only ? ["viewer"] : roles,
3296
+ myStewards,
3297
+ grant_only
3298
+ });
3306
3299
  });
3307
3300
  nestRoutes.patch("/:nestId", async (c) => {
3308
3301
  const nestId = c.req.param("nestId");
@@ -3435,7 +3428,7 @@ async function addCollaborator(params) {
3435
3428
  if (existing) {
3436
3429
  userId = existing.id;
3437
3430
  } else {
3438
- const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
3431
+ const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
3439
3432
  userId = uuid3();
3440
3433
  await db.run(
3441
3434
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
@@ -3627,7 +3620,7 @@ async function resolveGrantee(email) {
3627
3620
  const e = normalizeEmail(email);
3628
3621
  const existing = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
3629
3622
  if (existing) return existing.id;
3630
- const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
3623
+ const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
3631
3624
  const id = uuid4();
3632
3625
  await db.run(
3633
3626
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
@@ -3635,6 +3628,41 @@ async function resolveGrantee(email) {
3635
3628
  );
3636
3629
  return id;
3637
3630
  }
3631
+ async function assertGrantAddsAccess(params) {
3632
+ const { nestId, email, userId, targetType, target, role } = params;
3633
+ const roles = [];
3634
+ if (userId) {
3635
+ const access = await resolveNestAccess(nestId, userId);
3636
+ if (access.ownerId === userId) roles.push("owner");
3637
+ const collab = collabPermToRole(access.collaboratorPermission);
3638
+ if (collab) roles.push(collab);
3639
+ for (const teamRole of await resolveTeamRolesForUser(nestId, userId)) {
3640
+ roles.push(teamRole);
3641
+ }
3642
+ }
3643
+ if (targetType === "document") {
3644
+ const stewards = await resolveStewardsForNode(nestId, target);
3645
+ for (const s of stewards) {
3646
+ if (s.steward.userEmail.toLowerCase() === email.toLowerCase()) {
3647
+ roles.push(s.steward.role);
3648
+ }
3649
+ }
3650
+ }
3651
+ const covered = role === "write" ? canEditWith(roles) : roles.length > 0;
3652
+ if (covered) {
3653
+ const held = primaryRole(roles) ?? "existing";
3654
+ throw new ValidationError(
3655
+ `${email} already has ${held} access here, so this ${targetType} is already ${role === "write" ? "editable by" : "visible to"} them.`
3656
+ );
3657
+ }
3658
+ if (!userId) return;
3659
+ const existing = await resolveNodeGrant(nestId, userId, target);
3660
+ if (existing && (role === "read" || existing === "write")) {
3661
+ throw new ValidationError(
3662
+ `${email} has already been shared this ${targetType} (${existing === "write" ? "can edit" : "can view"}).`
3663
+ );
3664
+ }
3665
+ }
3638
3666
  grantRoutes.post("/", async (c) => {
3639
3667
  const nestId = c.req.param("nestId");
3640
3668
  await requireManage(c, nestId);
@@ -3650,7 +3678,19 @@ grantRoutes.post("/", async (c) => {
3650
3678
  if (!["read", "write"].includes(role)) {
3651
3679
  throw new ValidationError("role must be read | write");
3652
3680
  }
3653
- const userId = await resolveGrantee(body.email);
3681
+ const email = normalizeEmail(body.email);
3682
+ const known = await getDb().get("SELECT id FROM users WHERE LOWER(email) = ?", [
3683
+ email
3684
+ ]);
3685
+ await assertGrantAddsAccess({
3686
+ nestId,
3687
+ email,
3688
+ userId: known?.id ?? null,
3689
+ targetType,
3690
+ target,
3691
+ role
3692
+ });
3693
+ const userId = known?.id ?? await resolveGrantee(body.email);
3654
3694
  const grant = await createGrant({
3655
3695
  nestId,
3656
3696
  targetType,
@@ -4002,6 +4042,25 @@ teamRoutes.delete("/:teamId/members/:userId", async (c) => {
4002
4042
  });
4003
4043
 
4004
4044
  // src/nodes/search.ts
4045
+ async function discoverForSearch(storage) {
4046
+ return storage.discoverDocuments({ includeRetired: true });
4047
+ }
4048
+ function titleScore(title, query) {
4049
+ const t = (title || "").toLowerCase();
4050
+ const q = query.trim().toLowerCase();
4051
+ if (!q) return 0;
4052
+ if (t === q) return 4;
4053
+ let midWord = false;
4054
+ for (let i = t.indexOf(q); i !== -1; i = t.indexOf(q, i + 1)) {
4055
+ if (i === 0) return 3;
4056
+ if (!/[a-z0-9]/.test(t[i - 1])) return 2;
4057
+ midWord = true;
4058
+ }
4059
+ if (midWord) return 1;
4060
+ const terms = q.split(/\s+/).filter(Boolean);
4061
+ if (terms.length > 1 && terms.every((term) => t.includes(term))) return 1;
4062
+ return 0;
4063
+ }
4005
4064
  function matchDocuments(documents, query) {
4006
4065
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
4007
4066
  if (terms.length === 0) return [];
@@ -4013,15 +4072,25 @@ function matchDocuments(documents, query) {
4013
4072
  ...node.frontmatter.tags || []
4014
4073
  ].join(" ").toLowerCase();
4015
4074
  return terms.every((term) => haystack.includes(term));
4016
- });
4075
+ }).sort(
4076
+ (a, b) => titleScore(b.frontmatter.title, query) - titleScore(a.frontmatter.title, query)
4077
+ );
4017
4078
  }
4018
- function toSearchHit(node) {
4079
+ function bodySnippet(body, query) {
4080
+ const flat = body.replace(/\s+/g, " ").trim();
4081
+ const terms = (query || "").toLowerCase().split(/\s+/).filter(Boolean);
4082
+ const at = terms.map((t) => flat.toLowerCase().indexOf(t)).filter((i) => i >= 0).sort((a, b) => a - b)[0];
4083
+ if (at === void 0 || at < 80) return flat.slice(0, 200);
4084
+ const start = Math.max(0, at - 60);
4085
+ return `\u2026${flat.slice(start, start + 200)}`;
4086
+ }
4087
+ function toSearchHit(node, query) {
4019
4088
  return {
4020
4089
  id: node.id,
4021
4090
  title: node.frontmatter.title,
4022
4091
  type: node.frontmatter.type || "document",
4023
4092
  tags: node.frontmatter.tags || [],
4024
- snippet: (node.body || "").slice(0, 200).replace(/\n/g, " ")
4093
+ snippet: bodySnippet(node.body || "", query)
4025
4094
  };
4026
4095
  }
4027
4096
 
@@ -4029,7 +4098,7 @@ function toSearchHit(node) {
4029
4098
  import { Hono as Hono8 } from "hono";
4030
4099
 
4031
4100
  // src/nodes/markdown-export.ts
4032
- import { parseDocument as parseDocument3 } from "@promptowl/contextnest-engine";
4101
+ import { parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
4033
4102
  function nodeToMarkdown(node) {
4034
4103
  const tags = node.tags ?? [];
4035
4104
  return [
@@ -4065,7 +4134,7 @@ function rawContentType(nodeType) {
4065
4134
  function frontmatterBlockOf(raw) {
4066
4135
  if (!raw || !raw.startsWith("---")) return "";
4067
4136
  try {
4068
- const body = parseDocument3("frontmatter-split.md", raw, "frontmatter-split").body ?? "";
4137
+ const body = parseDocument2("frontmatter-split.md", raw, "frontmatter-split").body ?? "";
4069
4138
  if (raw.endsWith(body)) return raw.slice(0, raw.length - body.length);
4070
4139
  } catch {
4071
4140
  }
@@ -4111,7 +4180,14 @@ nodeRoutes.get("/", async (c) => {
4111
4180
  // Lets an owner preview what making the nest public would expose.
4112
4181
  ...q.approved_only === "1" || q.approved_only === "true" ? { approvedOnly: true } : {},
4113
4182
  // Ignore junk rather than 400 — a bad ?limit should not fail a listing.
4114
- ...limit !== void 0 && Number.isFinite(limit) && limit > 0 ? { limit } : {}
4183
+ ...limit !== void 0 && Number.isFinite(limit) && limit > 0 ? { limit } : {},
4184
+ // Present (even as "") = one folder level only, for the lazily-expanded
4185
+ // document tree. Absent = the whole nest, as every existing caller expects.
4186
+ ...q.folder !== void 0 ? { folder: q.folder } : {},
4187
+ // Bodies are off by default — the list is served from the DB index, which
4188
+ // holds metadata only. Opt back in and the listing crawls the vault for
4189
+ // them; kept for any client that was reading `content` off this route.
4190
+ ...q.content === "1" || q.content === "true" ? { withContent: true } : {}
4115
4191
  });
4116
4192
  return c.json({ count: nodes.length, nodes });
4117
4193
  });
@@ -4153,7 +4229,7 @@ nodeRoutes.post("/", async (c) => {
4153
4229
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4154
4230
  const nestId = c.req.param("nestId");
4155
4231
  const nodeId = c.req.param("nodeId");
4156
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-LTREGVSJ.js");
4232
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-U4CKL7Y5.js");
4157
4233
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
4158
4234
  nestId,
4159
4235
  nodeId
@@ -4174,7 +4250,7 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4174
4250
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
4175
4251
  const nestId = c.req.param("nestId");
4176
4252
  const nodeId = c.req.param("nodeId");
4177
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-V2GR6VM3.js");
4253
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-H2M5ACPQ.js");
4178
4254
  const history = await getReviewHistory2(nestId, nodeId);
4179
4255
  return c.json({ reviews: history });
4180
4256
  });
@@ -4298,6 +4374,7 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
4298
4374
  response.collaborators = people.collaborators;
4299
4375
  }
4300
4376
  }
4377
+ response.myGrant = await resolveNodeGrant(nestId, userId, nodeId);
4301
4378
  if (publicReader) {
4302
4379
  const approved = await getApprovedVersion(nestId, nodeId);
4303
4380
  if (approved != null) {
@@ -5233,7 +5310,7 @@ async function resolveExportBody(nestId, nodeId, workingBody) {
5233
5310
 
5234
5311
  // src/nests/bundle-service.ts
5235
5312
  import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
5236
- import { join as join4, sep } from "path";
5313
+ import { join as join3, sep } from "path";
5237
5314
  import JSZip from "jszip";
5238
5315
  import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
5239
5316
  function walkFiles(root) {
@@ -5246,7 +5323,7 @@ function walkFiles(root) {
5246
5323
  }
5247
5324
  for (const rel of entries) {
5248
5325
  try {
5249
- if (statSync2(join4(root, rel)).isFile()) out.push(rel.split(sep).join("/"));
5326
+ if (statSync2(join3(root, rel)).isFile()) out.push(rel.split(sep).join("/"));
5250
5327
  } catch {
5251
5328
  }
5252
5329
  }
@@ -5287,7 +5364,7 @@ async function exportNestBundle(nestId, includeDrafts) {
5287
5364
  const segs = rel.split("/");
5288
5365
  const keep = segs.includes(".versions") || rel === ".context/config.yaml" || rel === "CONTEXT.md";
5289
5366
  if (!keep) continue;
5290
- zip.file(rel, readFileSync2(join4(nestPath, rel)));
5367
+ zip.file(rel, readFileSync2(join3(nestPath, rel)));
5291
5368
  }
5292
5369
  trackEvent("nest.export.bundle", { nestId, documents: count, includeDrafts });
5293
5370
  return zip.generateAsync({ type: "arraybuffer" });
@@ -5458,7 +5535,9 @@ queryRoutes.get("/changes", async (c) => {
5458
5535
  const includeContent = ["1", "true"].includes(
5459
5536
  c.req.query("include_content") || ""
5460
5537
  );
5461
- const all = await listNodesForCaller(nestId, userId);
5538
+ const all = await listNodesForCaller(nestId, userId, {
5539
+ ...includeContent ? { withContent: true } : {}
5540
+ });
5462
5541
  const db = getDb();
5463
5542
  const publicReader = await isPublicReader(nestId, userId);
5464
5543
  const approvedRows = await db.all(
@@ -5525,7 +5604,10 @@ queryRoutes.get("/runnables", async (c) => {
5525
5604
  const nestId = c.req.param("nestId");
5526
5605
  const userId = c.get("userId");
5527
5606
  const all = await listNodesForCaller(nestId, userId, {
5528
- type: RUNNABLE_NODE_TYPES
5607
+ type: RUNNABLE_NODE_TYPES,
5608
+ // The prompt/skill body IS the payload here — an orchestrator pulls it to
5609
+ // run the thing, so this surface pays for the file reads.
5610
+ withContent: true
5529
5611
  });
5530
5612
  const runnables = all.map((n) => ({
5531
5613
  id: n.id,
@@ -5785,15 +5867,14 @@ queryRoutes.get("/search", async (c) => {
5785
5867
  }
5786
5868
  const nestId = c.req.param("nestId");
5787
5869
  const { storage } = await engineCache.get(nestId);
5788
- const documents = await storage.discoverDocuments();
5789
- const matches = matchDocuments(documents, q);
5870
+ const matches = matchDocuments(await discoverForSearch(storage), q);
5790
5871
  const userId = c.get("userId");
5791
5872
  const userEmail = await resolveCallerEmail(userId);
5792
5873
  const accessible = await filterAccessible(nestId, userId, userEmail, matches);
5793
5874
  return c.json({
5794
5875
  query: q,
5795
5876
  count: accessible.length,
5796
- nodes: accessible.map(toSearchHit)
5877
+ nodes: accessible.map((n) => toSearchHit(n, q))
5797
5878
  });
5798
5879
  });
5799
5880
  queryRoutes.get("/overview", async (c) => {
@@ -5821,6 +5902,25 @@ queryRoutes.get("/overview", async (c) => {
5821
5902
  }))
5822
5903
  });
5823
5904
  });
5905
+ queryRoutes.get("/census", async (c) => {
5906
+ const census = await nestCensus(c.req.param("nestId"), c.get("userId"));
5907
+ return c.json({ census });
5908
+ });
5909
+ queryRoutes.get("/folders", async (c) => {
5910
+ const q = c.req.query();
5911
+ const folders = await listNestFolders(c.req.param("nestId"), {
5912
+ ...q.folder !== void 0 ? { folder: q.folder } : {},
5913
+ ...q.recursive === "0" || q.recursive === "false" ? { recursive: false } : {}
5914
+ });
5915
+ return c.json({ folders });
5916
+ });
5917
+ queryRoutes.post("/reindex", async (c) => {
5918
+ if (permissionLevel(c.get("nestPermission")) < permissionLevel("write")) {
5919
+ throw new ForbiddenError("Reindexing a nest requires write access");
5920
+ }
5921
+ const indexed = await rebuildNodeIndex(c.req.param("nestId"));
5922
+ return c.json({ indexed });
5923
+ });
5824
5924
  queryRoutes.get("/comment-counts", async (c) => {
5825
5925
  const counts = await countThreadsByNode(c.req.param("nestId"));
5826
5926
  return c.json({ counts });
@@ -5833,10 +5933,6 @@ queryRoutes.get("/context", async (c) => {
5833
5933
  queryRoutes.get("/export", async (c) => {
5834
5934
  const nestId = c.req.param("nestId");
5835
5935
  if (c.req.query("format") === "bundle") {
5836
- const perm = await resolveNestPermission(nestId, c.get("userId"));
5837
- if (perm !== "owner" && perm !== "admin") {
5838
- throw new ForbiddenError("Only the nest owner or an admin can export a bundle");
5839
- }
5840
5936
  const draftsParam = c.req.query("includeDrafts");
5841
5937
  const includeDrafts = draftsParam === "1" || draftsParam === "true";
5842
5938
  const zip = await exportNestBundle(nestId, includeDrafts);
@@ -5969,30 +6065,6 @@ import { Hono as Hono12 } from "hono";
5969
6065
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5970
6066
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5971
6067
 
5972
- // src/shared/node-id.ts
5973
- function assertSafeNodeId(rawId) {
5974
- let id = rawId;
5975
- try {
5976
- id = decodeURIComponent(rawId);
5977
- } catch {
5978
- }
5979
- if (!id || id.length > 512) {
5980
- throw new ValidationError("invalid node id");
5981
- }
5982
- if (id.includes("\0") || id.includes("\\")) {
5983
- throw new ValidationError("invalid node id");
5984
- }
5985
- if (id.startsWith("/")) {
5986
- throw new ValidationError("invalid node id (absolute path)");
5987
- }
5988
- for (const seg of id.split("/")) {
5989
- if (seg === "" || seg === "." || seg === "..") {
5990
- throw new ValidationError("invalid node id (path traversal)");
5991
- }
5992
- }
5993
- return id;
5994
- }
5995
-
5996
6068
  // src/telemetry/trace-log.ts
5997
6069
  var PRUNE_EVERY = 500;
5998
6070
  var insertsSincePrune = 0;
@@ -6296,6 +6368,30 @@ async function appendRunStep(runId, input) {
6296
6368
  return { seq };
6297
6369
  }
6298
6370
  var RUN_STATUSES = ["running", "succeeded", "failed", "cancelled"];
6371
+ async function cancelDescendants(runId) {
6372
+ const db = getDb();
6373
+ const at = (/* @__PURE__ */ new Date()).toISOString();
6374
+ const trace = JSON.stringify({ error: "Parent run cancelled" });
6375
+ let frontier = [runId];
6376
+ let cancelled = 0;
6377
+ for (let depth = 0; frontier.length && depth < 64; depth++) {
6378
+ const ph = frontier.map(() => "?").join(",");
6379
+ const kids = await db.all(
6380
+ `SELECT id FROM runs WHERE parent_run_id IN (${ph}) AND status = 'running'`,
6381
+ frontier
6382
+ );
6383
+ if (!kids.length) break;
6384
+ frontier = kids.map((k) => k.id);
6385
+ const kph = frontier.map(() => "?").join(",");
6386
+ await db.run(
6387
+ `UPDATE runs SET status = 'cancelled', trace = COALESCE(trace, ?), finished_at = ?
6388
+ WHERE id IN (${kph}) AND status = 'running'`,
6389
+ [trace, at, ...frontier]
6390
+ );
6391
+ cancelled += frontier.length;
6392
+ }
6393
+ return cancelled;
6394
+ }
6299
6395
  async function closeRun(runId, input) {
6300
6396
  const status = input.status;
6301
6397
  if (typeof status !== "string" || !RUN_STATUSES.includes(status)) {
@@ -6317,6 +6413,7 @@ async function closeRun(runId, input) {
6317
6413
  if (!run) throw new NotFoundError("Run not found");
6318
6414
  throw new ConflictError("Run already terminal \u2014 status can't be changed");
6319
6415
  }
6416
+ if (status === "cancelled") await cancelDescendants(runId);
6320
6417
  return { status };
6321
6418
  }
6322
6419
  async function getRunTrace(run, opts) {
@@ -7620,7 +7717,9 @@ ${context_md}`;
7620
7717
  {},
7621
7718
  await opContext(nestId, userEmail)
7622
7719
  );
7623
- const visible = await listNodesForCallerByEmail(nestId, userEmail, {});
7720
+ const visible = await listNodesForCallerByEmail(nestId, userEmail, {
7721
+ withContent: true
7722
+ });
7624
7723
  const types = {};
7625
7724
  const tags = {};
7626
7725
  for (const n of visible) {
@@ -7923,6 +8022,13 @@ ${sections}`;
7923
8022
  (n) => String(n.frontmatter.title ?? "").toLowerCase() === wanted
7924
8023
  );
7925
8024
  if (twin) {
8025
+ const twinVersions = await getVersions(nestId, twin.id);
8026
+ if (twinVersions.length === 0) {
8027
+ return toolError(
8028
+ `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.`,
8029
+ "DOCUMENT_ALREADY_EXISTS"
8030
+ );
8031
+ }
7926
8032
  return toolError(
7927
8033
  `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.`,
7928
8034
  "DOCUMENT_ALREADY_EXISTS"
@@ -8076,6 +8182,9 @@ ${sections}`;
8076
8182
  return "reason is required \u2014 say why it should be deleted; that is what the admin judges.";
8077
8183
  }
8078
8184
  if (args.nest === true) {
8185
+ if (!await canRequestDeletion({ nestId, userId, userEmail, nodeId: "" })) {
8186
+ return "Only an editor, a nest admin, or the nest owner can request the deletion of a nest.";
8187
+ }
8079
8188
  const request2 = await requestDeletion({
8080
8189
  nestId,
8081
8190
  nodeId: nestId,
@@ -8088,6 +8197,9 @@ ${sections}`;
8088
8197
  Request id: \`${request2.id}\``;
8089
8198
  }
8090
8199
  if (typeof args.folder === "string" && args.folder.trim()) {
8200
+ if (!await canRequestDeletion({ nestId, userId, userEmail, nodeId: "" })) {
8201
+ return "Only an editor, a nest admin, or the nest owner can request the deletion of a folder.";
8202
+ }
8091
8203
  const folder = args.folder.trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
8092
8204
  const docs = await storage.discoverDocuments();
8093
8205
  const inside = docs.filter((d) => isIdUnderFolder(d.id, folder));
@@ -8110,6 +8222,14 @@ Request id: \`${request2.id}\``;
8110
8222
  if (!await canReadNode(nestId, node.id, userId, userEmail)) {
8111
8223
  return `Node not found: ${node.id}`;
8112
8224
  }
8225
+ if (!await canRequestDeletion({
8226
+ nestId,
8227
+ userId,
8228
+ userEmail,
8229
+ nodeId: node.id
8230
+ })) {
8231
+ return "Only an editor, a nest admin, or the nest owner can request the deletion of a document.";
8232
+ }
8113
8233
  const request = await requestDeletion({
8114
8234
  nestId,
8115
8235
  nodeId: node.id,
@@ -8434,14 +8554,14 @@ ${list}`,
8434
8554
  if (!target) return "target is required (a node id or folder prefix).";
8435
8555
  if (!["read", "write"].includes(role)) return "role must be read or write.";
8436
8556
  try {
8437
- const { createGrant: createGrant2 } = await import("./grants-service-I535QVTW.js");
8438
- const db = (await import("./client-OBS5HOTA.js")).getDb();
8557
+ const { createGrant: createGrant2 } = await import("./grants-service-ME7HIVHU.js");
8558
+ const db = (await import("./client-SW5I6ZBK.js")).getDb();
8439
8559
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
8440
8560
  const e = normalizeEmail2(String(args.email || ""));
8441
8561
  if (!e) return "email is required.";
8442
8562
  let row = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
8443
8563
  if (!row) {
8444
- const { hashPassword: hashPassword2 } = await import("./keys-73STFJJB.js");
8564
+ const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
8445
8565
  const { v4: uuid16 } = await import("uuid");
8446
8566
  const id = uuid16();
8447
8567
  await db.run(
@@ -8904,8 +9024,9 @@ runDetailRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
8904
9024
  throw new ValidationError("status must be running | succeeded | failed | cancelled");
8905
9025
  }
8906
9026
  const terminal = status !== "running";
8907
- await getDb().run(
8908
- `UPDATE runs SET status = ?, trace = COALESCE(?, trace), finished_at = ? WHERE id = ?`,
9027
+ const res = await getDb().run(
9028
+ `UPDATE runs SET status = ?, trace = COALESCE(?, trace), finished_at = ?
9029
+ WHERE id = ? AND status = 'running'`,
8909
9030
  [
8910
9031
  status,
8911
9032
  body.trace === void 0 ? null : JSON.stringify(body.trace),
@@ -8913,6 +9034,13 @@ runDetailRoutes.patch("/:id", requireWorkflowPlane, async (c) => {
8913
9034
  run.id
8914
9035
  ]
8915
9036
  );
9037
+ if (!res.changes) {
9038
+ const now = await getDb().get("SELECT status FROM runs WHERE id = ?", [
9039
+ run.id
9040
+ ]);
9041
+ throw new ConflictError(`Run already terminal (${now?.status ?? "closed"})`);
9042
+ }
9043
+ if (status === "cancelled") await cancelDescendants(run.id);
8916
9044
  if (status === "failed") {
8917
9045
  const err = body.trace && typeof body.trace === "object" ? String(body.trace.error ?? "").slice(0, 200) : "";
8918
9046
  void dispatchEvent({
@@ -9521,8 +9649,8 @@ definitionRoutes.delete("/:id", async (c) => {
9521
9649
  // src/nests/asset-routes.ts
9522
9650
  import { Hono as Hono18 } from "hono";
9523
9651
  import { v4 as uuid13 } from "uuid";
9524
- import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
9525
- import { join as join5 } from "path";
9652
+ import { mkdir as mkdir2, readFile, writeFile } from "fs/promises";
9653
+ import { join as join4 } from "path";
9526
9654
  var assetRoutes = new Hono18();
9527
9655
  var CONTENT_TYPES = {
9528
9656
  png: "image/png",
@@ -9570,9 +9698,9 @@ assetRoutes.post("/", async (c) => {
9570
9698
  );
9571
9699
  }
9572
9700
  const name = `${uuid13()}.${ext === "jpeg" ? "jpg" : ext}`;
9573
- const dir = join5(resolveNestPath(nestId), "assets");
9701
+ const dir = join4(resolveNestPath(nestId), "assets");
9574
9702
  await mkdir2(dir, { recursive: true });
9575
- await writeFile(join5(dir, name), Buffer.from(await file.arrayBuffer()));
9703
+ await writeFile(join4(dir, name), Buffer.from(await file.arrayBuffer()));
9576
9704
  const url = `/nests/${nestId}/assets/${name}`;
9577
9705
  return c.json(
9578
9706
  {
@@ -9594,7 +9722,7 @@ assetRoutes.get("/:file", async (c) => {
9594
9722
  }
9595
9723
  let bytes;
9596
9724
  try {
9597
- bytes = await readFile2(join5(resolveNestPath(nestId), "assets", name));
9725
+ bytes = await readFile(join4(resolveNestPath(nestId), "assets", name));
9598
9726
  } catch {
9599
9727
  throw new NotFoundError("Asset not found");
9600
9728
  }
@@ -10024,6 +10152,11 @@ Streamable HTTP, stateless. Two endpoints:
10024
10152
  (new document in a writable nest; a \`folder\` path creates the folder).
10025
10153
  - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
10026
10154
  governance (create/update, submit/approve/reject, stewards, sharing).
10155
+ Sharing a slice is one call: \`context_grant\` with
10156
+ \`{ "email", "target_type": "document" | "folder", "target", "role": "read" | "write" }\`
10157
+ \u2014 \`target\` is a node id (\`nodes/win-loss-log\`) or a folder prefix
10158
+ (\`nodes/gtm/deals\`). The recipient reaches exactly that document, or
10159
+ everything under that folder, and nothing else in the nest. Owner/admin only.
10027
10160
 
10028
10161
  Claude Code / Cursor / VS Code connect natively via HTTP with the
10029
10162
  \`Authorization: Bearer\` header. Claude Desktop needs the mcp-remote bridge:
@@ -10032,20 +10165,9 @@ Claude Code / Cursor / VS Code connect natively via HTTP with the
10032
10165
  npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
10033
10166
  \`\`\`
10034
10167
 
10035
- ## ctx CLI (npm: @promptowl/contextnest-cli)
10036
-
10037
- \`npm i -g @promptowl/contextnest-cli\`
10038
-
10039
- - \`ctx push --server ${B} --nest <nest-id> --key <api-key>\` \u2014 upload a local
10040
- vault into a nest (add \`--include-drafts\` for unpublished nodes).
10041
- - All other ctx commands (\`query\`, \`add\`, \`search\`, \`checkpoint\`) operate on
10042
- a LOCAL vault directory only. For remote reads use the API or MCP above \u2014
10043
- there is currently no remote \`ctx query\`.
10044
-
10045
10168
  ## npm packages
10046
10169
 
10047
10170
  - \`@promptowl/contextnest-community\` \u2014 this server
10048
- - \`@promptowl/contextnest-cli\` \u2014 the ctx CLI
10049
10171
  - \`@promptowl/contextnest-engine\` \u2014 vault storage + selector/graph engine
10050
10172
 
10051
10173
  ## Conventions
@@ -10410,7 +10532,7 @@ function rowToComment(row) {
10410
10532
 
10411
10533
  // src/governance/stewards-parser.ts
10412
10534
  import { readFileSync as readFileSync3, existsSync } from "fs";
10413
- import { join as join6 } from "path";
10535
+ import { join as join5 } from "path";
10414
10536
  function parseStewardsYaml(content) {
10415
10537
  const result = { version: 1 };
10416
10538
  const lines = content.split("\n");
@@ -10476,9 +10598,9 @@ function parseEntry(str) {
10476
10598
  function loadStewardsConfig(nestId) {
10477
10599
  const nestPath = resolveNestPath(nestId);
10478
10600
  const candidates = [
10479
- join6(nestPath, "stewards.yaml"),
10480
- join6(nestPath, "stewards.yml"),
10481
- join6(nestPath, ".context", "stewards.yaml")
10601
+ join5(nestPath, "stewards.yaml"),
10602
+ join5(nestPath, "stewards.yml"),
10603
+ join5(nestPath, ".context", "stewards.yaml")
10482
10604
  ];
10483
10605
  for (const candidatePath of candidates) {
10484
10606
  if (existsSync(candidatePath)) {
@@ -10624,6 +10746,25 @@ function denyUnlessNestManager(c, what) {
10624
10746
  403
10625
10747
  );
10626
10748
  }
10749
+ async function mayRequestDeletion(c, nestId, nodeId) {
10750
+ return canRequestDeletion({
10751
+ nestId,
10752
+ userId: c.get("userId"),
10753
+ userEmail: await getUserEmail4(c),
10754
+ nodeId,
10755
+ // The middleware promotes open-mode callers; resolving fresh would miss it.
10756
+ permission: c.get("nestPermission")
10757
+ });
10758
+ }
10759
+ function denyUnlessEditor(allowed, c, what) {
10760
+ if (allowed) return null;
10761
+ return c.json(
10762
+ {
10763
+ error: `Only an editor, a nest admin, or the nest owner can request the deletion of ${what}.`
10764
+ },
10765
+ 403
10766
+ );
10767
+ }
10627
10768
  governanceRoutes.post("/delete-folder", async (c) => {
10628
10769
  const nestId = c.req.param("nestId");
10629
10770
  const denied = denyUnlessNestManager(c, "a folder");
@@ -10641,6 +10782,12 @@ governanceRoutes.post("/delete-folder", async (c) => {
10641
10782
  });
10642
10783
  governanceRoutes.post("/request-deletion", async (c) => {
10643
10784
  const nestId = c.req.param("nestId");
10785
+ const denied = denyUnlessEditor(
10786
+ await mayRequestDeletion(c, nestId, ""),
10787
+ c,
10788
+ "a folder or a nest"
10789
+ );
10790
+ if (denied) return denied;
10644
10791
  const body = await c.req.json().catch(() => ({}));
10645
10792
  const reason = body.reason?.trim();
10646
10793
  if (!reason) {
@@ -10731,6 +10878,16 @@ governanceRoutes.post("/deletion-requests/:id/decline", async (c) => {
10731
10878
  if (!request) return c.json({ error: "Deletion request not found" }, 404);
10732
10879
  return c.json({ request });
10733
10880
  });
10881
+ governanceRoutes.post("/deletion-requests/:id/withdraw", async (c) => {
10882
+ const nestId = c.req.param("nestId");
10883
+ const request = await withdrawDeletionRequest({
10884
+ nestId,
10885
+ id: c.req.param("id"),
10886
+ withdrawnBy: await getUserEmail4(c)
10887
+ });
10888
+ if (!request) return c.json({ error: "Deletion request not found or not yours" }, 404);
10889
+ return c.json({ withdrawn: true, request });
10890
+ });
10734
10891
  governanceRoutes.get("/feed", async (c) => {
10735
10892
  const nestId = c.req.param("nestId");
10736
10893
  const filterRaw = c.req.query("filter");
@@ -10804,7 +10961,7 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
10804
10961
  const approved = await getApprovedVersion(nestId, nodeId);
10805
10962
  const { versions: versionManager } = await engineCache.get(nestId);
10806
10963
  const resolutions = await getDb().all(
10807
- `SELECT version, status, resolved_by
10964
+ `SELECT version, status, resolved_by, resolution_note
10808
10965
  FROM review_requests
10809
10966
  WHERE nest_id = ? AND node_id = ?
10810
10967
  AND status IN ('approved', 'rejected')
@@ -10815,13 +10972,21 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
10815
10972
  const byVersion = /* @__PURE__ */ new Map();
10816
10973
  for (const r of resolutions) {
10817
10974
  if (!byVersion.has(r.version)) {
10818
- byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
10975
+ byVersion.set(r.version, {
10976
+ status: r.status,
10977
+ resolvedBy: r.resolved_by,
10978
+ resolutionNote: r.resolution_note?.trim() || void 0
10979
+ });
10819
10980
  }
10820
10981
  }
10821
10982
  const enriched = await Promise.all(
10822
10983
  allVersions.map(async (v) => {
10823
10984
  const r = byVersion.get(v.version);
10824
- const resolution = r ? { resolvedBy: r.resolvedBy, resolutionStatus: r.status } : {};
10985
+ const resolution = r ? {
10986
+ resolvedBy: r.resolvedBy,
10987
+ resolutionStatus: r.status,
10988
+ resolutionNote: r.resolutionNote
10989
+ } : {};
10825
10990
  try {
10826
10991
  const diff = await versionManager.getDiff(nodeId, v.version);
10827
10992
  return { ...v, ...resolution, diff: diff ?? void 0 };
@@ -10988,6 +11153,12 @@ governanceNodeRoutes.post("/:nodeId{.+}/request-deletion", async (c) => {
10988
11153
  if (!await canReadNode(nestId, nodeId, c.get("userId"), userEmail)) {
10989
11154
  return c.json({ error: `Node not found: ${nodeId}` }, 404);
10990
11155
  }
11156
+ const denied = denyUnlessEditor(
11157
+ await mayRequestDeletion(c, nestId, nodeId),
11158
+ c,
11159
+ "a document"
11160
+ );
11161
+ if (denied) return denied;
10991
11162
  const { storage } = await engineCache.get(nestId);
10992
11163
  try {
10993
11164
  await storage.readDocument(nodeId);
@@ -11193,19 +11364,19 @@ async function ensureAnonymousUser() {
11193
11364
  // src/app.ts
11194
11365
  import { serveStatic } from "@hono/node-server/serve-static";
11195
11366
  import { fileURLToPath } from "url";
11196
- import { dirname as dirname2, join as join7, relative as relative2 } from "path";
11367
+ import { dirname as dirname2, join as join6, relative as relative2 } from "path";
11197
11368
  import { existsSync as existsSync2, readFileSync as readFileSync4 } from "fs";
11198
11369
  var HERE = dirname2(fileURLToPath(import.meta.url));
11199
11370
  var SERVICE_VERSION = (() => {
11200
11371
  try {
11201
- return JSON.parse(readFileSync4(join7(HERE, "..", "package.json"), "utf8")).version;
11372
+ return JSON.parse(readFileSync4(join6(HERE, "..", "package.json"), "utf8")).version;
11202
11373
  } catch {
11203
11374
  return "unknown";
11204
11375
  }
11205
11376
  })();
11206
11377
  var UI_DIR_CANDIDATES = [
11207
- join7(HERE, "web3"),
11208
- join7(process.cwd(), "dist", "web3")
11378
+ join6(HERE, "web3"),
11379
+ join6(process.cwd(), "dist", "web3")
11209
11380
  ];
11210
11381
  var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
11211
11382
  var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
@@ -12102,14 +12273,14 @@ function createApp() {
12102
12273
  const userId = c.get("userId");
12103
12274
  const userEmail = await resolveCallerEmail(userId);
12104
12275
  const perNest = await Promise.all(
12105
- (await listVisibleNests(userId)).map(async (nest) => {
12276
+ (await listVisibleNests(userId, { readable: true })).map(async (nest) => {
12106
12277
  try {
12107
12278
  const { storage } = await engineCache.get(nest.id);
12108
- const matches = matchDocuments(await storage.discoverDocuments(), q);
12279
+ const matches = matchDocuments(await discoverForSearch(storage), q);
12109
12280
  if (matches.length === 0) return [];
12110
12281
  const accessible = await filterAccessible(nest.id, userId, userEmail, matches);
12111
12282
  return accessible.map((n) => ({
12112
- ...toSearchHit(n),
12283
+ ...toSearchHit(n, q),
12113
12284
  nest_id: nest.id,
12114
12285
  nest_name: nest.name ?? nest.id
12115
12286
  }));
@@ -12119,7 +12290,7 @@ function createApp() {
12119
12290
  }
12120
12291
  })
12121
12292
  );
12122
- const hits = perNest.flat();
12293
+ const hits = perNest.flat().sort((a, b) => titleScore(b.title, q) - titleScore(a.title, q));
12123
12294
  return c.json({
12124
12295
  query: q,
12125
12296
  count: hits.length,
@@ -12195,7 +12366,8 @@ function createApp() {
12195
12366
  c.set("nestPermission", "owner");
12196
12367
  return next();
12197
12368
  }
12198
- const permission = await resolveNestPermission(nestId, userId);
12369
+ const access = await resolveNestAccess(nestId, userId);
12370
+ const permission = access.permission;
12199
12371
  const isReadQuery = parts.length === 2 && ["context", "query", "search", "export"].includes(parts[1]);
12200
12372
  let grantRole = null;
12201
12373
  let grantNestVisible = false;
@@ -12208,11 +12380,13 @@ function createApp() {
12208
12380
  }
12209
12381
  grantRole = await resolveNodeGrant(nestId, userId, targetTail);
12210
12382
  } else if (parts.length === 1 && c.req.method === "GET") {
12211
- grantNestVisible = await hasAnyGrant(nestId, userId);
12383
+ grantNestVisible = access.hasGrant;
12212
12384
  } else if (parts[1] === "nodes" && parts.length === 2 && c.req.method === "GET") {
12213
- grantNestVisible = await hasAnyGrant(nestId, userId);
12385
+ grantNestVisible = access.hasGrant;
12214
12386
  } else if (isReadQuery) {
12215
- grantNestVisible = await hasAnyGrant(nestId, userId);
12387
+ grantNestVisible = access.hasGrant;
12388
+ } else if (parts[1] === "definitions" && parts.length === 2 && c.req.method === "GET") {
12389
+ grantNestVisible = access.hasGrant;
12216
12390
  }
12217
12391
  if (!grantRole && !grantNestVisible) {
12218
12392
  return deny(404, "Nest not found", {
@@ -12225,7 +12399,7 @@ function createApp() {
12225
12399
  const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
12226
12400
  const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
12227
12401
  const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
12228
- const isDeletionRequestAction = /\/request-deletion$/.test(path);
12402
+ const isDeletionRequestAction = /\/request-deletion$/.test(path) || /\/deletion-requests\/[^/]+\/withdraw$/.test(path);
12229
12403
  const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
12230
12404
  if (isStewardRoster && permission !== "owner" && permission !== "admin") {
12231
12405
  return c.json(