@promptowl/contextnest-community 1.13.0 → 1.15.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
@@ -4,31 +4,45 @@ import {
4
4
  approve,
5
5
  canReadNode,
6
6
  cancelReview,
7
+ declineDeletion,
8
+ deletionTargetTitle,
7
9
  dispatchEvent,
10
+ dropDeletionRequest,
8
11
  edgeTypeRoutes,
9
12
  edgeTypeToResponse,
10
13
  envRoutes,
11
14
  envValues,
12
15
  filterAccessible,
16
+ getDeletionRequest,
17
+ getLatestDeletionRequest,
13
18
  getPendingReview,
14
19
  getReviewHistory,
15
20
  getReviewQueue,
16
21
  isSafeConnectorUrl,
22
+ listDeletionRequests,
17
23
  listNotifications,
18
24
  listWatchers,
19
25
  markNotificationsRead,
26
+ nodeWriteKey,
27
+ notifyDeletionResolved,
20
28
  notifyNestEvent,
29
+ notifyNestInvite,
21
30
  parseConditionSchema,
22
31
  reject,
23
32
  removeWatcher,
33
+ repointPendingReview,
34
+ requestDeletion,
24
35
  requireWorkflowPlane,
25
36
  resolveCallerEmail,
26
37
  safeJson,
27
38
  safePublishDocument,
28
39
  seedDefaultEdgeTypes,
40
+ sendTestNotification,
41
+ serializeNodeSafe,
42
+ stripUndefinedDeep,
29
43
  submitForReview,
30
- verifySmtp
31
- } from "./chunk-PAIP5KHB.js";
44
+ withNodeWriteLock
45
+ } from "./chunk-C4U75IUR.js";
32
46
  import {
33
47
  createGrant,
34
48
  deleteGrant,
@@ -37,7 +51,7 @@ import {
37
51
  listGrants,
38
52
  listUserGrants,
39
53
  resolveNodeGrant
40
- } from "./chunk-ZQS5W45U.js";
54
+ } from "./chunk-5IAKUUZF.js";
41
55
  import {
42
56
  generateApiKey,
43
57
  getKeyPrefix,
@@ -53,19 +67,35 @@ import {
53
67
  getCurrentVersion,
54
68
  getDisplayStatus,
55
69
  getVersions,
70
+ listMyDrafts,
56
71
  setApprovedVersion,
57
72
  upsertVersion
58
- } from "./chunk-EG77SQHO.js";
73
+ } from "./chunk-JWVZ3T35.js";
59
74
  import {
60
- addMember,
61
- buildTitleMap,
62
75
  canCreateInNest,
63
76
  canManageStewards,
64
77
  canUserAccess,
65
78
  canUserApprove,
66
79
  canUserEdit,
67
- createNest,
68
80
  createStewardRecord,
81
+ getCollaboratorRole,
82
+ getStewardRolesForUser,
83
+ getStewardsForNest,
84
+ getStewardsForUser,
85
+ listStewards,
86
+ removeSteward,
87
+ resolveGrantRoles,
88
+ resolveStewardsForNode,
89
+ resolveStewardsWithFallback,
90
+ resolveUserRoles,
91
+ syncFromConfig,
92
+ updateSteward
93
+ } from "./chunk-GWDOLYFJ.js";
94
+ import {
95
+ addMember,
96
+ buildTitleMap,
97
+ canApproveWith,
98
+ createNest,
69
99
  createTeam,
70
100
  deleteNest,
71
101
  deleteTeam,
@@ -73,16 +103,14 @@ import {
73
103
  docLink,
74
104
  engineCache,
75
105
  findTeamByExternalId,
106
+ folderForNode,
76
107
  getAccessConfig,
77
- getCollaboratorRole,
78
108
  getCurrentLicense,
79
109
  getNest,
80
- getStewardRolesForUser,
81
- getStewardsForNest,
82
- getStewardsForUser,
83
110
  getSuspensionReason,
84
111
  getTeam,
85
112
  importNest,
113
+ insertOrIgnore,
86
114
  installLicenseKey,
87
115
  isConfigSuperAdmin,
88
116
  isLicenseAdminEmail,
@@ -96,7 +124,6 @@ import {
96
124
  listNests,
97
125
  listPublicNests,
98
126
  listSharedNests,
99
- listStewards,
100
127
  listTeamsForUser,
101
128
  listVisibleNests,
102
129
  loadAccessConfig,
@@ -104,34 +131,37 @@ import {
104
131
  loadServerSettings,
105
132
  nestAllowsSelfApprove,
106
133
  nestName,
134
+ nestPrimeTags,
135
+ nestReviewsPrimeOnly,
107
136
  nestStorageRoot,
137
+ normalizeTag,
138
+ nowExpr,
108
139
  permissionLevel,
109
140
  persistSetting,
110
141
  removeMember,
111
- removeSteward,
142
+ removeNodeFromTagIndex,
112
143
  renameNest,
113
144
  renameTeam,
114
145
  replaceTeamRoster,
115
- resolveGrantRoles,
116
146
  resolveMemberIdentity,
117
147
  resolveNestPath,
118
148
  resolveNestPermission,
119
- resolveStewardsForNode,
120
- resolveStewardsWithFallback,
121
- resolveUserRoles,
149
+ sendEmailToRecipient,
122
150
  setAllowSelfApprove,
151
+ setPrimeOnlyReview,
152
+ setPrimeTags,
123
153
  setStewardshipEnabled,
124
154
  shareTeamWithNest,
125
155
  startLicenseSafetyPoll,
126
156
  startTelemetryLoop,
127
- syncFromConfig,
157
+ syncNodeTags,
128
158
  trackEvent,
129
159
  uniqueNestName,
130
160
  unshareTeamFromNest,
131
161
  updateMemberRole,
132
- updateSteward,
133
- validateLicense
134
- } from "./chunk-Q5NRCDD4.js";
162
+ validateLicense,
163
+ verifySmtp
164
+ } from "./chunk-7BOG3S5H.js";
135
165
  import {
136
166
  AppError,
137
167
  ConflictError,
@@ -141,24 +171,20 @@ import {
141
171
  UnauthorizedError,
142
172
  ValidationError
143
173
  } from "./chunk-GUNJTORH.js";
144
- import {
145
- normalizeEmail
146
- } from "./chunk-FRQJWGN3.js";
147
- import {
148
- insertOrIgnore,
149
- nowExpr
150
- } from "./chunk-XQ46F76G.js";
151
174
  import {
152
175
  config,
153
176
  getDb,
154
177
  initDb,
155
178
  isEmailListish,
156
179
  isEmailish
157
- } from "./chunk-5QQ7WKAI.js";
180
+ } from "./chunk-BCFKLY4H.js";
158
181
  import {
159
182
  ANON_EMAIL,
160
183
  ANON_USER_ID
161
184
  } from "./chunk-SLTQACJW.js";
185
+ import {
186
+ normalizeEmail
187
+ } from "./chunk-FRQJWGN3.js";
162
188
 
163
189
  // src/index.ts
164
190
  import { serve } from "@hono/node-server";
@@ -386,6 +412,9 @@ function sweepStale(maxAgeMs = 10 * 6e4) {
386
412
 
387
413
  // src/auth/sso-token.ts
388
414
  import { createHmac, timingSafeEqual } from "crypto";
415
+ function isValidTicketSub(sub) {
416
+ return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(sub) && sub.length <= 254;
417
+ }
389
418
  function b64urlToBuffer(input) {
390
419
  const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - input.length % 4);
391
420
  return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
@@ -1208,6 +1237,7 @@ authRoutes.get("/sso", async (c) => {
1208
1237
  } catch {
1209
1238
  return ssoError("invalid_ticket");
1210
1239
  }
1240
+ if ((claims.scope || "") === "mcp") return ssoError("invalid_ticket");
1211
1241
  const expectedAud = config.PUBLIC_BASE_URL;
1212
1242
  if (!expectedAud && !warnedMissingSsoAudience) {
1213
1243
  warnedMissingSsoAudience = true;
@@ -1220,7 +1250,7 @@ authRoutes.get("/sso", async (c) => {
1220
1250
  if (aud !== expectedAud) return ssoError("bad_audience");
1221
1251
  }
1222
1252
  const sub = (claims.sub || "").trim();
1223
- if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(sub) || sub.length > 254) {
1253
+ if (!isValidTicketSub(sub)) {
1224
1254
  return ssoError("invalid_ticket");
1225
1255
  }
1226
1256
  if (!claims.jti) return ssoError("invalid_ticket");
@@ -1476,6 +1506,18 @@ authRoutes.post("/invite", async (c) => {
1476
1506
  );
1477
1507
  }
1478
1508
  trackEvent("admin.invite", { adminId: callerId, email });
1509
+ const inviter = await db.get("SELECT email FROM users WHERE id = ?", [
1510
+ callerId
1511
+ ]);
1512
+ void sendEmailToRecipient(email, {
1513
+ status: "invited",
1514
+ docTitle: "ContextNest",
1515
+ path: "",
1516
+ actor: email,
1517
+ by: inviter?.email || void 0,
1518
+ tempPassword,
1519
+ link: requestBaseUrl(c.req.url)
1520
+ });
1479
1521
  return c.json(
1480
1522
  {
1481
1523
  temporary_password: tempPassword,
@@ -1544,42 +1586,27 @@ import { streamText } from "hono/streaming";
1544
1586
  import {
1545
1587
  serializeDocument,
1546
1588
  parseDocument as parseDocument2,
1547
- normalizeStatus
1589
+ normalizeStatus,
1590
+ DocumentNotFoundError
1548
1591
  } from "@promptowl/contextnest-engine";
1549
1592
  import { rename, mkdir, rm } from "fs/promises";
1550
1593
  import { dirname, join as join2, basename } from "path";
1551
1594
 
1552
- // src/governance/tag-index-service.ts
1553
- function normalizeTag(raw) {
1554
- return raw.trim().replace(/^#+/, "").toLowerCase();
1555
- }
1556
- async function syncNodeTags(nestId, nodeId, tags) {
1557
- const db = getDb();
1558
- const normalized = Array.from(
1559
- new Set(
1560
- tags.filter((t) => typeof t === "string").map(normalizeTag).filter(Boolean)
1561
- )
1562
- );
1563
- const insertSql = insertOrIgnore(
1564
- db,
1565
- "INSERT INTO node_tag_index (nest_id, node_id, tag_name) VALUES (?, ?, ?)"
1566
- );
1567
- await db.transaction(async (tx) => {
1568
- await tx.run(
1569
- "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
1570
- [nestId, nodeId]
1571
- );
1572
- for (const tag of normalized) {
1573
- await tx.run(insertSql, [nestId, nodeId, tag]);
1574
- }
1575
- });
1576
- }
1577
- async function removeNodeFromTagIndex(nestId, nodeId) {
1578
- const db = getDb();
1579
- await db.run(
1580
- "DELETE FROM node_tag_index WHERE nest_id = ? AND node_id = ?",
1581
- [nestId, nodeId]
1582
- );
1595
+ // src/governance/prime-service.ts
1596
+ async function isPrimeDocument(nestId, tags, metadata) {
1597
+ const flag = documentPrimeFlag(metadata);
1598
+ if (flag !== void 0) return flag;
1599
+ const primeTags = await nestPrimeTags(nestId);
1600
+ if (!primeTags.length) return false;
1601
+ const wanted = new Set(primeTags);
1602
+ return (tags || []).some((t) => wanted.has(normalizeTag(String(t))));
1603
+ }
1604
+ function documentPrimeFlag(metadata) {
1605
+ const raw = metadata?.prime;
1606
+ if (typeof raw === "boolean") return raw;
1607
+ if (raw === "true") return true;
1608
+ if (raw === "false") return false;
1609
+ return void 0;
1583
1610
  }
1584
1611
 
1585
1612
  // src/governance/external-edit-service.ts
@@ -1765,7 +1792,7 @@ async function approveExternalEdit(input) {
1765
1792
  const node = await storage.readDocument(input.documentId);
1766
1793
  const versionNum = result.versionEntry.version;
1767
1794
  const tags = node.frontmatter.tags || [];
1768
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-L52F4WVZ.js");
1795
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-ECYCBVSE.js");
1769
1796
  await createVersion2({
1770
1797
  nestId: input.nestId,
1771
1798
  nodeId: input.documentId,
@@ -1846,9 +1873,21 @@ async function userIdFromEmail(email) {
1846
1873
  return row?.id ?? ANON_USER_ID;
1847
1874
  }
1848
1875
  var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
1876
+ var MAX_TITLE_LENGTH = 120;
1877
+ function assertTitleLength(title) {
1878
+ if (title.length > MAX_TITLE_LENGTH) {
1879
+ throw new ValidationError(
1880
+ `title must be at most ${MAX_TITLE_LENGTH} characters`
1881
+ );
1882
+ }
1883
+ }
1849
1884
  var slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1885
+ function isIdUnderFolder(id, folder) {
1886
+ return id.startsWith(`nodes/${folder}/`) || id.startsWith(`${folder}/`);
1887
+ }
1850
1888
  function folderToSegments(folder) {
1851
1889
  const segments = folder.split("/").map(slugify).filter(Boolean);
1890
+ if (segments[0] === "nodes") segments.shift();
1852
1891
  if (segments.length > 8) {
1853
1892
  throw new ValidationError("folder may nest at most 8 levels deep");
1854
1893
  }
@@ -1874,18 +1913,6 @@ async function departmentTagFor(userEmail) {
1874
1913
  return null;
1875
1914
  }
1876
1915
  }
1877
- var nodeWriteChains = /* @__PURE__ */ new Map();
1878
- function withNodeWriteLock(key, fn) {
1879
- const prev = (nodeWriteChains.get(key) ?? Promise.resolve()).catch(() => {
1880
- });
1881
- const run = prev.then(fn);
1882
- nodeWriteChains.set(key, run);
1883
- void run.catch(() => {
1884
- }).finally(() => {
1885
- if (nodeWriteChains.get(key) === run) nodeWriteChains.delete(key);
1886
- });
1887
- return run;
1888
- }
1889
1916
  function toSafeError(err, context, message) {
1890
1917
  if (err instanceof AppError) return err;
1891
1918
  console.error(`${context}:`, err);
@@ -1921,7 +1948,8 @@ function toNodeResponse(node) {
1921
1948
  pendingChange: node.pendingChange ?? void 0,
1922
1949
  // Same YAML-coercion rule as title above: `schedule: 123` parses as a
1923
1950
  // number — normalize to string at the API boundary.
1924
- schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0
1951
+ schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0,
1952
+ prime: documentPrimeFlag(fm.metadata)
1925
1953
  };
1926
1954
  }
1927
1955
  async function resolveAuthorship(nestId, nodes) {
@@ -2062,17 +2090,29 @@ async function actorAutoPublishes(nestId, userEmail) {
2062
2090
  const roles = await resolveUserRoles(nestId, userEmail);
2063
2091
  return roles.includes("owner") || roles.includes("admin");
2064
2092
  }
2093
+ async function assertMayFlagPrime(nestId, userEmail) {
2094
+ const roles = await resolveUserRoles(nestId, userEmail);
2095
+ if (!roles.includes("owner") && !roles.includes("admin")) {
2096
+ throw new ForbiddenError(
2097
+ "Only the nest owner or an admin can change a document's prime flag."
2098
+ );
2099
+ }
2100
+ }
2101
+ async function autoPublishReason(nestId, userEmail, doc) {
2102
+ if (!await isStewardshipEnabled(nestId)) return "no stewards configured";
2103
+ if (await isPrimeDocument(nestId, doc.tags, doc.metadata)) return null;
2104
+ if (await nestReviewsPrimeOnly(nestId)) {
2105
+ return "prime-only review, document is not prime";
2106
+ }
2107
+ return await actorAutoPublishes(nestId, userEmail) ? "owner/admin self-approve" : null;
2108
+ }
2065
2109
  async function createNode(nestId, input, userEmail) {
2066
2110
  const { storage, versions: versionManager } = await engineCache.get(nestId);
2067
- const slug = slugify(input.title);
2111
+ assertTitleLength(input.title);
2112
+ const slug = slugify(input.title).slice(0, 100).replace(/-+$/, "");
2068
2113
  let id = input.id;
2069
2114
  if (!id) {
2070
2115
  const folderSegments = folderToSegments(input.folder ?? "");
2071
- if ([...folderSegments, slug].some((seg) => seg.length > 100)) {
2072
- throw new ValidationError(
2073
- "each folder or title segment must be at most 100 characters"
2074
- );
2075
- }
2076
2116
  id = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/${slug}` : `nodes/${slug}`;
2077
2117
  }
2078
2118
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -2091,72 +2131,75 @@ async function createNode(nestId, input, userEmail) {
2091
2131
  )})`
2092
2132
  );
2093
2133
  }
2094
- const deptTag = await departmentTagFor(userEmail);
2095
- if (deptTag && !tags.some((t) => t.toLowerCase() === deptTag)) {
2096
- tags.push(deptTag);
2097
- }
2098
- const hasStewards = await isStewardshipEnabled(nestId);
2099
- const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
2100
- const initialStatus = autoPublish ? "published" : "draft";
2101
- const initialVersion = autoPublish ? 0 : 1;
2102
- let node = {
2103
- id,
2104
- filePath: "",
2105
- frontmatter: {
2106
- title: input.title,
2107
- type: requestedType,
2108
- tags,
2109
- status: input.status || initialStatus,
2110
- version: initialVersion,
2111
- created_at: now,
2112
- updated_at: now,
2113
- metadata: {
2114
- owners: ["*"],
2115
- scope: input.scope || "team",
2116
- ...input.schedule ? { schedule: input.schedule } : {}
2117
- }
2118
- },
2119
- body: input.content,
2120
- rawContent: ""
2121
- };
2122
- await storage.writeDocument(id, serializeDocument(node));
2123
- await syncNodeTags(nestId, id, tags);
2124
- let savedVersion = 1;
2125
- if (!autoPublish) {
2134
+ return withNodeWriteLock(nodeWriteKey(nestId, id), async () => {
2135
+ let occupied = false;
2126
2136
  try {
2127
- await versionManager.createVersion(node, userEmail);
2137
+ await storage.readDocument(id);
2138
+ occupied = true;
2128
2139
  } catch (err) {
2129
- console.error("VersionManager.createVersion failed (node create)", err);
2140
+ if (!(err instanceof DocumentNotFoundError)) throw err;
2130
2141
  }
2131
- await createVersion({
2132
- nestId,
2133
- nodeId: id,
2134
- version: 1,
2135
- content: input.content,
2136
- author: userEmail,
2137
- status: "draft",
2138
- tags
2139
- });
2140
- } else {
2141
- try {
2142
- const result = await safePublishDocument(storage, id, {
2143
- editedBy: userEmail,
2144
- note: hasStewards ? "Auto-published on create (owner/admin self-approve)" : "Auto-published on create (no stewards configured)"
2145
- });
2146
- savedVersion = result.node.frontmatter.version || 1;
2147
- await createVersion({
2148
- nestId,
2149
- nodeId: id,
2150
- version: savedVersion,
2151
- content: result.node.body || "",
2152
- author: userEmail,
2153
- status: "published",
2154
- tags
2142
+ if (occupied) {
2143
+ throw new ConflictError(
2144
+ `A document already exists at ${id}. Update it, or create this one under a different folder or title.`
2145
+ );
2146
+ }
2147
+ const db = getDb();
2148
+ const orphan = await db.get(
2149
+ "SELECT 1 AS present FROM node_versions WHERE nest_id = ? AND node_id = ? LIMIT 1",
2150
+ [nestId, id]
2151
+ );
2152
+ if (orphan) {
2153
+ console.warn(
2154
+ `[nodes] clearing orphaned rows for ${id} \u2014 version history exists but no document does`
2155
+ );
2156
+ await db.transaction(async (tx) => {
2157
+ await purgeNodeKeyedRows(tx, nestId, id);
2155
2158
  });
2156
- await setApprovedVersion(nestId, id, savedVersion, userEmail);
2157
- node = result.node;
2158
- } catch (err) {
2159
- console.error("publishDocument failed (node create auto-publish)", err);
2159
+ }
2160
+ const deptTag = await departmentTagFor(userEmail);
2161
+ if (deptTag && !tags.some((t) => t.toLowerCase() === deptTag)) {
2162
+ tags.push(deptTag);
2163
+ }
2164
+ if (input.prime !== void 0) await assertMayFlagPrime(nestId, userEmail);
2165
+ const metadata = {
2166
+ owners: ["*"],
2167
+ scope: input.scope || "team",
2168
+ ...input.schedule ? { schedule: input.schedule } : {},
2169
+ ...input.prime === void 0 ? {} : { prime: input.prime }
2170
+ };
2171
+ const publishReason = await autoPublishReason(nestId, userEmail, {
2172
+ tags,
2173
+ metadata
2174
+ });
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));
2195
+ await syncNodeTags(nestId, id, tags);
2196
+ 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);
2202
+ }
2160
2203
  await createVersion({
2161
2204
  nestId,
2162
2205
  nodeId: id,
@@ -2166,10 +2209,42 @@ async function createNode(nestId, input, userEmail) {
2166
2209
  status: "draft",
2167
2210
  tags
2168
2211
  });
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
+ }
2169
2244
  }
2170
- }
2171
- await trackEvent("node.create", { nestId, nodeId: id });
2172
- return { node, version: savedVersion };
2245
+ await trackEvent("node.create", { nestId, nodeId: id });
2246
+ return { node, version: savedVersion };
2247
+ });
2173
2248
  }
2174
2249
  function explicitFrontmatterStatus(raw) {
2175
2250
  const fm = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
@@ -2189,7 +2264,7 @@ async function resetImportedDocForPublish(storage, nestId, nodeId) {
2189
2264
  const fm = { ...node.frontmatter };
2190
2265
  delete fm.version;
2191
2266
  delete fm.checksum;
2192
- await storage.writeDocument(nodeId, serializeDocument({ ...node, frontmatter: fm }));
2267
+ await storage.writeDocument(nodeId, serializeNodeSafe({ ...node, frontmatter: fm }));
2193
2268
  }
2194
2269
  async function registerImportedDocuments(nestId, userEmail, onProgress) {
2195
2270
  const { storage } = await engineCache.get(nestId);
@@ -2219,7 +2294,7 @@ async function registerImportedDocuments(nestId, userEmail, onProgress) {
2219
2294
  const title = doc.frontmatter?.title ?? (nodeId.split("/").pop() || nodeId);
2220
2295
  doc = { ...doc, frontmatter: { ...doc.frontmatter, title, author: userEmail } };
2221
2296
  try {
2222
- await storage.writeDocument(nodeId, serializeDocument(doc));
2297
+ await storage.writeDocument(nodeId, serializeNodeSafe(doc));
2223
2298
  } catch (err) {
2224
2299
  console.error("import: failed to persist import metadata", nodeId, err);
2225
2300
  }
@@ -2300,7 +2375,7 @@ async function registerImportedDocuments(nestId, userEmail, onProgress) {
2300
2375
  return registered;
2301
2376
  }
2302
2377
  async function updateNode(nestId, nodeId, patch, userEmail) {
2303
- return withNodeWriteLock(`${nestId}::${nodeId}`, async () => {
2378
+ return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
2304
2379
  try {
2305
2380
  const { storage, versions: versionManager } = await engineCache.get(
2306
2381
  nestId
@@ -2331,6 +2406,9 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2331
2406
  };
2332
2407
  }
2333
2408
  if (patch.title) {
2409
+ if (patch.title !== node.frontmatter.title) {
2410
+ assertTitleLength(patch.title);
2411
+ }
2334
2412
  node = {
2335
2413
  ...node,
2336
2414
  frontmatter: { ...node.frontmatter, title: patch.title }
@@ -2349,18 +2427,30 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2349
2427
  else metadata.schedule = patch.schedule;
2350
2428
  node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
2351
2429
  }
2430
+ if (patch.prime !== void 0) {
2431
+ await assertMayFlagPrime(nestId, userEmail);
2432
+ const metadata = { ...node.frontmatter.metadata };
2433
+ if (patch.prime === null) delete metadata.prime;
2434
+ else metadata.prime = patch.prime;
2435
+ node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
2436
+ }
2352
2437
  const hasStewards = await isStewardshipEnabled(nestId);
2353
- const autoPublish = !hasStewards || await actorAutoPublishes(nestId, userEmail);
2354
2438
  const currentTags = node.frontmatter.tags || [];
2355
- if (hasStewards && await getPendingReview(nestId, nodeId)) {
2439
+ const publishReason = await autoPublishReason(nestId, userEmail, {
2440
+ tags: currentTags,
2441
+ metadata: node.frontmatter.metadata
2442
+ });
2443
+ const pendingReview = hasStewards ? await getPendingReview(nestId, nodeId) : null;
2444
+ const isAuthorEditingOwnReview = !!pendingReview && pendingReview.requestedBy.toLowerCase() === userEmail.toLowerCase();
2445
+ if (pendingReview && !isAuthorEditingOwnReview) {
2356
2446
  throw new LockedError(
2357
2447
  "This document is awaiting steward review and is locked. Approve or reject the pending review before editing."
2358
2448
  );
2359
2449
  }
2360
2450
  let responseVersion;
2361
- if (!autoPublish) {
2451
+ if (publishReason === null || isAuthorEditingOwnReview) {
2362
2452
  const currentVersion = await getCurrentVersion(nestId, nodeId);
2363
- const newVersion = currentVersion + 1;
2453
+ const newVersion = await versionManager.nextVersion(nodeId, currentVersion);
2364
2454
  node = {
2365
2455
  ...node,
2366
2456
  frontmatter: {
@@ -2396,6 +2486,9 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2396
2486
  tags: currentTags,
2397
2487
  changeNote: patch.changeNote
2398
2488
  });
2489
+ if (isAuthorEditingOwnReview) {
2490
+ await repointPendingReview({ nestId, nodeId, newVersion });
2491
+ }
2399
2492
  responseVersion = newVersion;
2400
2493
  } else {
2401
2494
  node = {
@@ -2410,10 +2503,11 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2410
2503
  await storage.writeDocument(nodeId, serializeDocument(node));
2411
2504
  await syncNodeTags(nestId, nodeId, currentTags);
2412
2505
  let publishedVersion = (node.frontmatter.version || 0) + 1;
2506
+ const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
2413
2507
  try {
2414
2508
  const result = await safePublishDocument(storage, nodeId, {
2415
2509
  editedBy: userEmail,
2416
- note: patch.changeNote || (hasStewards ? "Auto-published on edit (owner/admin self-approve)" : "Auto-published on edit (no stewards)")
2510
+ note: publishNote
2417
2511
  });
2418
2512
  publishedVersion = result.node.frontmatter.version || publishedVersion;
2419
2513
  node = result.node;
@@ -2432,7 +2526,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2432
2526
  author: userEmail,
2433
2527
  status: "published",
2434
2528
  tags: currentTags,
2435
- changeNote: patch.changeNote
2529
+ changeNote: publishNote
2436
2530
  });
2437
2531
  await setApprovedVersion(
2438
2532
  nestId,
@@ -2452,9 +2546,69 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2452
2546
  }
2453
2547
  });
2454
2548
  }
2549
+ async function assertNotReviewLocked(nestId, nodeId, userEmail, action) {
2550
+ if (!await isStewardshipEnabled(nestId)) return;
2551
+ if (!await getPendingReview(nestId, nodeId)) return;
2552
+ if (canApproveWith(await resolveUserRoles(nestId, userEmail, { nodeId }))) return;
2553
+ throw new LockedError(
2554
+ `This document is awaiting steward review and is locked. Approve or reject the pending review before ${action}, or ask a reviewer to.`
2555
+ );
2556
+ }
2455
2557
  function derivedIdOf(sourceNodeId) {
2456
2558
  return `nodes/${sourceNodeId.replace(/^nodes\//, "")}--annotations`;
2457
2559
  }
2560
+ var NODE_KEYED_TABLES = [
2561
+ ["node_versions", "node_id"],
2562
+ ["review_requests", "node_id"],
2563
+ ["deletion_requests", "node_id"],
2564
+ ["approved_versions", "node_id"],
2565
+ ["annotation_threads", "node_id"],
2566
+ ["comments", "node_id"],
2567
+ ["node_tag_index", "node_id"],
2568
+ ["watchers", "node_id"],
2569
+ ["schedules", "agent_node"],
2570
+ // A live capability token bound to one agent node — deleting the agent must
2571
+ // revoke it, or a later node at that id answers the old webhook.
2572
+ ["trigger_hooks", "agent_node"],
2573
+ ["edges", "from_node"],
2574
+ ["edges", "to_node"]
2575
+ ];
2576
+ async function purgeNodeKeyedRows(tx, nestId, nodeId) {
2577
+ for (const [table, column] of NODE_KEYED_TABLES) {
2578
+ await tx.run(`DELETE FROM ${table} WHERE nest_id = ? AND ${column} = ?`, [
2579
+ nestId,
2580
+ nodeId
2581
+ ]);
2582
+ }
2583
+ await tx.run(
2584
+ `DELETE FROM stewards
2585
+ WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
2586
+ [nestId, nodeId]
2587
+ );
2588
+ await tx.run(
2589
+ `DELETE FROM grants
2590
+ WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
2591
+ [nestId, nodeId]
2592
+ );
2593
+ }
2594
+ async function rewriteNodeKeyedRows(tx, nestId, oldId, newId) {
2595
+ for (const [table, column] of NODE_KEYED_TABLES) {
2596
+ await tx.run(
2597
+ `UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
2598
+ [newId, nestId, oldId]
2599
+ );
2600
+ }
2601
+ await tx.run(
2602
+ `UPDATE stewards SET node_pattern = ?
2603
+ WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
2604
+ [newId, nestId, oldId]
2605
+ );
2606
+ await tx.run(
2607
+ `UPDATE grants SET target = ?
2608
+ WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
2609
+ [newId, nestId, oldId]
2610
+ );
2611
+ }
2458
2612
  async function moveNode(nestId, nodeId, folder, userEmail) {
2459
2613
  const { storage } = await engineCache.get(nestId);
2460
2614
  let node;
@@ -2463,103 +2617,124 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
2463
2617
  } catch {
2464
2618
  throw new NotFoundError(`Node not found: ${nodeId}`);
2465
2619
  }
2466
- if (await isStewardshipEnabled(nestId) && await getPendingReview(nestId, nodeId)) {
2467
- throw new LockedError(
2468
- "This document is awaiting steward review and is locked. Approve or reject the pending review before moving."
2469
- );
2470
- }
2620
+ await assertNotReviewLocked(nestId, nodeId, userEmail, "moving");
2471
2621
  const leaf = nodeId.split("/").pop();
2472
2622
  const segments = folderToSegments(folder);
2473
2623
  const newId = segments.length > 0 ? `nodes/${segments.join("/")}/${leaf}` : `nodes/${leaf}`;
2474
2624
  if (newId === nodeId) return { node, previousId: nodeId };
2475
- try {
2476
- await storage.readDocument(newId);
2477
- throw new ConflictError(
2478
- `A document already exists at that location: ${newId}`
2479
- );
2480
- } catch (err) {
2481
- if (err instanceof ConflictError) throw err;
2482
- }
2483
- const moved = {
2484
- ...node,
2485
- id: newId,
2486
- frontmatter: {
2487
- ...node.frontmatter,
2488
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
2625
+ return withNodeWriteLock(nodeWriteKey(nestId, newId), async () => {
2626
+ try {
2627
+ await storage.readDocument(newId);
2628
+ throw new ConflictError(
2629
+ `A document already exists at that location: ${newId}`
2630
+ );
2631
+ } catch (err) {
2632
+ if (err instanceof ConflictError) throw err;
2633
+ if (!(err instanceof DocumentNotFoundError)) throw err;
2489
2634
  }
2490
- };
2491
- await storage.writeDocument(newId, serializeDocument(moved));
2492
- try {
2493
- const nestPath = resolveNestPath(nestId);
2494
- const oldChain = join2(nestPath, dirname(nodeId), ".versions", leaf);
2495
- const newChain = join2(nestPath, dirname(newId), ".versions", leaf);
2496
- if (oldChain !== newChain) {
2497
- await mkdir(dirname(newChain), { recursive: true });
2498
- await rename(oldChain, newChain);
2635
+ const moved = {
2636
+ ...node,
2637
+ id: newId,
2638
+ frontmatter: {
2639
+ ...node.frontmatter,
2640
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
2641
+ }
2642
+ };
2643
+ await storage.writeDocument(newId, serializeNodeSafe(moved));
2644
+ try {
2645
+ const nestPath = resolveNestPath(nestId);
2646
+ const oldChain = join2(nestPath, dirname(nodeId), ".versions", leaf);
2647
+ const newChain = join2(nestPath, dirname(newId), ".versions", leaf);
2648
+ if (oldChain !== newChain) {
2649
+ await mkdir(dirname(newChain), { recursive: true });
2650
+ await rename(oldChain, newChain);
2651
+ }
2652
+ } catch {
2499
2653
  }
2500
- } catch {
2501
- }
2502
- await storage.deleteDocument(nodeId);
2503
- const oldDerived = derivedIdOf(nodeId);
2504
- const newDerived = derivedIdOf(newId);
2505
- const db = getDb();
2506
- await db.transaction(async (tx) => {
2507
- for (const [table, column] of [
2508
- ["node_versions", "node_id"],
2509
- ["review_requests", "node_id"],
2510
- ["approved_versions", "node_id"],
2511
- ["annotation_threads", "node_id"],
2512
- ["comments", "node_id"],
2513
- ["node_tag_index", "node_id"]
2514
- ]) {
2654
+ await storage.deleteDocument(nodeId);
2655
+ const oldDerived = derivedIdOf(nodeId);
2656
+ const newDerived = derivedIdOf(newId);
2657
+ const db = getDb();
2658
+ await db.transaction(async (tx) => {
2659
+ await rewriteNodeKeyedRows(tx, nestId, nodeId, newId);
2660
+ await rewriteNodeKeyedRows(tx, nestId, oldDerived, newDerived);
2515
2661
  await tx.run(
2516
- `UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
2662
+ "UPDATE runs SET agent_node = ? WHERE nest_id = ? AND agent_node = ?",
2517
2663
  [newId, nestId, nodeId]
2518
2664
  );
2519
2665
  await tx.run(
2520
- `UPDATE ${table} SET ${column} = ? WHERE nest_id = ? AND ${column} = ?`,
2521
- [newDerived, nestId, oldDerived]
2666
+ `UPDATE run_steps SET node_id = ?
2667
+ WHERE node_id = ? AND run_id IN (SELECT id FROM runs WHERE nest_id = ?)`,
2668
+ [newId, nodeId, nestId]
2522
2669
  );
2670
+ });
2671
+ try {
2672
+ const derivedDoc = await storage.readDocument(oldDerived);
2673
+ await storage.writeDocument(
2674
+ newDerived,
2675
+ serializeNodeSafe({ ...derivedDoc, id: newDerived })
2676
+ );
2677
+ await storage.deleteDocument(oldDerived);
2678
+ } catch {
2523
2679
  }
2524
- await tx.run(
2525
- `UPDATE stewards SET node_pattern = ?
2526
- WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
2527
- [newId, nestId, nodeId]
2528
- );
2529
- await tx.run(
2530
- `UPDATE grants SET target = ?
2531
- WHERE nest_id = ? AND target_type = 'document' AND target = ?`,
2532
- [newId, nestId, nodeId]
2533
- );
2534
- await tx.run(
2535
- "UPDATE edges SET from_node = ? WHERE nest_id = ? AND from_node = ?",
2536
- [newId, nestId, nodeId]
2537
- );
2538
- await tx.run(
2539
- "UPDATE edges SET to_node = ? WHERE nest_id = ? AND to_node = ?",
2540
- [newId, nestId, nodeId]
2541
- );
2542
- await tx.run(
2543
- "UPDATE runs SET agent_node = ? WHERE nest_id = ? AND agent_node = ?",
2544
- [newId, nestId, nodeId]
2545
- );
2546
- await tx.run(
2547
- `UPDATE run_steps SET node_id = ?
2548
- WHERE node_id = ? AND run_id IN (SELECT id FROM runs WHERE nest_id = ?)`,
2549
- [newId, nodeId, nestId]
2550
- );
2680
+ await trackEvent("node.move", { nestId, nodeId, newId });
2681
+ return { node: moved, previousId: nodeId };
2551
2682
  });
2683
+ }
2684
+ async function deleteNode(nestId, nodeId, userEmail) {
2685
+ const { storage } = await engineCache.get(nestId);
2686
+ await assertNotReviewLocked(nestId, nodeId, userEmail, "deleting");
2552
2687
  try {
2553
- const derivedDoc = await storage.readDocument(oldDerived);
2554
- await storage.writeDocument(
2555
- newDerived,
2556
- serializeDocument({ ...derivedDoc, id: newDerived })
2557
- );
2558
- await storage.deleteDocument(oldDerived);
2688
+ await storage.deleteDocument(nodeId);
2689
+ } catch {
2690
+ throw new NotFoundError(`Node not found: ${nodeId}`);
2691
+ }
2692
+ await removeNodeFromTagIndex(nestId, nodeId);
2693
+ const derivedId = derivedIdOf(nodeId);
2694
+ try {
2695
+ await storage.deleteDocument(derivedId);
2696
+ await removeNodeFromTagIndex(nestId, derivedId);
2559
2697
  } catch {
2560
2698
  }
2561
- await trackEvent("node.move", { nestId, nodeId, newId });
2562
- return { node: moved, previousId: nodeId };
2699
+ const db = getDb();
2700
+ await db.transaction(async (tx) => {
2701
+ await purgeNodeKeyedRows(tx, nestId, nodeId);
2702
+ await purgeNodeKeyedRows(tx, nestId, derivedId);
2703
+ await tx.run(
2704
+ `INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
2705
+ VALUES (?, ?, ?, ?)
2706
+ ON CONFLICT(nest_id, node_id) DO UPDATE
2707
+ SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
2708
+ [nestId, nodeId, userEmail, (/* @__PURE__ */ new Date()).toISOString()]
2709
+ );
2710
+ });
2711
+ await trackEvent("node.delete", { nestId, nodeId });
2712
+ }
2713
+ async function deleteFolder(nestId, folder, userEmail) {
2714
+ const segments = folderToSegments(folder);
2715
+ if (segments.length === 0) {
2716
+ throw new ValidationError("folder is required");
2717
+ }
2718
+ const rel = segments.join("/");
2719
+ const { storage } = await engineCache.get(nestId);
2720
+ const docs = await storage.discoverDocuments();
2721
+ const targets = docs.map((d) => d.id).filter((id) => isIdUnderFolder(id, rel));
2722
+ if (targets.length === 0) {
2723
+ throw new NotFoundError(`No documents found under folder: ${folder}`);
2724
+ }
2725
+ const deleted = [];
2726
+ for (const id of targets) {
2727
+ try {
2728
+ await deleteNode(nestId, id, userEmail);
2729
+ deleted.push(id);
2730
+ } catch (err) {
2731
+ const detail = deleted.length > 0 ? ` (${deleted.length} of ${targets.length} already deleted)` : "";
2732
+ throw new Error(
2733
+ `Failed to delete "${id}"${detail}: ${err instanceof Error ? err.message : String(err)}`
2734
+ );
2735
+ }
2736
+ }
2737
+ return deleted;
2563
2738
  }
2564
2739
 
2565
2740
  // src/nests/unsynced-service.ts
@@ -3011,7 +3186,9 @@ nestRoutes.get("/:nestId/settings", async (c) => {
3011
3186
  }
3012
3187
  return c.json({
3013
3188
  stewardship_enabled: await isStewardshipEnabled(nestId),
3014
- allow_self_approve: await nestAllowsSelfApprove(nestId)
3189
+ allow_self_approve: await nestAllowsSelfApprove(nestId),
3190
+ prime_only_review: await nestReviewsPrimeOnly(nestId),
3191
+ prime_tags: await nestPrimeTags(nestId)
3015
3192
  });
3016
3193
  });
3017
3194
  nestRoutes.patch("/:nestId/settings", async (c) => {
@@ -3047,9 +3224,20 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
3047
3224
  if (typeof body.allow_self_approve === "boolean") {
3048
3225
  await setAllowSelfApprove(nestId, body.allow_self_approve);
3049
3226
  }
3227
+ if (typeof body.prime_only_review === "boolean") {
3228
+ await setPrimeOnlyReview(nestId, body.prime_only_review);
3229
+ }
3230
+ if (body.prime_tags !== void 0) {
3231
+ await setPrimeTags(
3232
+ nestId,
3233
+ Array.isArray(body.prime_tags) ? body.prime_tags : [body.prime_tags]
3234
+ );
3235
+ }
3050
3236
  return c.json({
3051
3237
  stewardship_enabled: await isStewardshipEnabled(nestId),
3052
3238
  allow_self_approve: await nestAllowsSelfApprove(nestId),
3239
+ prime_only_review: await nestReviewsPrimeOnly(nestId),
3240
+ prime_tags: await nestPrimeTags(nestId),
3053
3241
  wiped
3054
3242
  });
3055
3243
  });
@@ -3143,16 +3331,38 @@ async function addCollaborator(params) {
3143
3331
  actor: params.email || userId,
3144
3332
  permission: params.permission,
3145
3333
  by: params.grantedByEmail || void 0
3146
- }
3147
- );
3148
- return await db.get(
3149
- "SELECT * FROM nest_collaborators WHERE id = ?",
3150
- [collabId]
3151
- );
3152
- }
3153
-
3154
- // src/nests/sharing-routes.ts
3155
- var sharingRoutes = new Hono4();
3334
+ },
3335
+ // Invite email goes to the invitee directly (below), NOT the shared
3336
+ // NOTIFY_EMAIL_TO team inbox — so skip the team-channel email for this
3337
+ // event. Slack/Teams still fire.
3338
+ { skipEmail: true }
3339
+ );
3340
+ const inviteeEmail = params.email || (await db.get("SELECT email FROM users WHERE id = ?", [userId]))?.email;
3341
+ if (inviteeEmail) {
3342
+ void sendEmailToRecipient(inviteeEmail, {
3343
+ status: "shared",
3344
+ docTitle: shareNestName,
3345
+ path: shareNestName,
3346
+ link: docLink(nestId, void 0, params.baseUrl),
3347
+ actor: inviteeEmail,
3348
+ permission: params.permission,
3349
+ by: params.grantedByEmail || void 0
3350
+ });
3351
+ void notifyNestInvite({
3352
+ nestId,
3353
+ inviteeEmail,
3354
+ permission: params.permission,
3355
+ nestName: shareNestName
3356
+ });
3357
+ }
3358
+ return await db.get(
3359
+ "SELECT * FROM nest_collaborators WHERE id = ?",
3360
+ [collabId]
3361
+ );
3362
+ }
3363
+
3364
+ // src/nests/sharing-routes.ts
3365
+ var sharingRoutes = new Hono4();
3156
3366
  sharingRoutes.get("/collaborators", async (c) => {
3157
3367
  const db = getDb();
3158
3368
  const nestId = c.req.param("nestId");
@@ -3336,7 +3546,8 @@ nestTeamRoutes.post("/", async (c) => {
3336
3546
  nestId: c.req.param("nestId"),
3337
3547
  teamId: body.team_id,
3338
3548
  callerUserId: userId,
3339
- allowAdmin: await isLicenseAdminUserId(userId)
3549
+ allowAdmin: await isLicenseAdminUserId(userId),
3550
+ baseUrl: requestBaseUrl(c.req.url)
3340
3551
  });
3341
3552
  return c.json({ teams }, 201);
3342
3553
  });
@@ -3697,447 +3908,128 @@ ${m[1]}
3697
3908
  ` : "";
3698
3909
  }
3699
3910
 
3700
- // src/annotations/service.ts
3701
- import { v4 as uuid5 } from "uuid";
3702
-
3703
- // src/annotations/projection.ts
3704
- var MAX_CONTEXT_CHARS = 250;
3705
- var MAX_QUOTE_CHARS = 1e3;
3706
- function clampAnchor(raw) {
3707
- if (!raw) return null;
3708
- const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
3709
- if (!quote) return null;
3710
- const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
3711
- const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
3712
- const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
3713
- return { quote, before, after, ...line !== void 0 ? { line } : {} };
3714
- }
3715
- function oneLine(s) {
3716
- return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
3717
- }
3718
- function renderAnchor(anchor) {
3719
- if (!anchor) {
3720
- return "<!-- anchor: whole-artifact -->";
3721
- }
3722
- const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
3723
- const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
3724
- anchor.after
3725
- )}\u2026`;
3726
- return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
3911
+ // src/nodes/routes.ts
3912
+ var nodeRoutes = new Hono8();
3913
+ function nodeAsMarkdown(response, nodeId) {
3914
+ return nodeToMarkdown({
3915
+ id: nodeId,
3916
+ title: response.title,
3917
+ tags: response.tags,
3918
+ status: response.status,
3919
+ body: response.content
3920
+ });
3727
3921
  }
3728
- function statusHeading(thread) {
3729
- if (thread.status === "resolved") {
3730
- const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
3731
- const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
3732
- const meta = who || when ? ` (${who}${when})` : "";
3733
- return `\u2705 RESOLVED${meta}`;
3922
+ function rawNodeBody(c, response, storedRaw) {
3923
+ const body = response.content ?? "";
3924
+ const payload = c.req.query("frontmatter") === "1" ? frontmatterBlockOf(storedRaw) + body : body;
3925
+ c.header("X-Content-Type-Options", "nosniff");
3926
+ if ((response.type || "document") === "artifact") {
3927
+ applyArtifactSandboxHeaders(c);
3734
3928
  }
3735
- return "\u{1F7E0} OPEN";
3736
- }
3737
- function snapshotLabel(v) {
3738
- return v == null ? "Unpinned" : `Snapshot v${v}`;
3739
- }
3740
- function orderThreads(threads) {
3741
- return [...threads].sort((a, b) => {
3742
- if (a.status !== b.status) return a.status === "open" ? -1 : 1;
3743
- return a.createdAt.localeCompare(b.createdAt);
3929
+ return c.body(payload, 200, {
3930
+ "Content-Type": rawContentType(response.type)
3744
3931
  });
3745
3932
  }
3746
- function projectThreadsToMarkdown(artifactTitle, threads) {
3747
- const lines = [];
3748
- lines.push(`# ${artifactTitle} \u2014 Annotations`);
3749
- lines.push("");
3750
- lines.push(
3751
- "> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
3752
- );
3753
- lines.push("");
3754
- if (threads.length === 0) {
3755
- lines.push("_No annotations yet._");
3756
- lines.push("");
3757
- return lines.join("\n");
3933
+ nodeRoutes.get("/", async (c) => {
3934
+ const nestId = c.req.param("nestId");
3935
+ const userId = c.get("userId");
3936
+ const nodes = await listNodesForCaller(nestId, userId);
3937
+ return c.json({ count: nodes.length, nodes });
3938
+ });
3939
+ nodeRoutes.post("/", async (c) => {
3940
+ const body = await c.req.json();
3941
+ if (!body.title || !body.content) {
3942
+ throw new ValidationError("title and content are required");
3758
3943
  }
3759
- const buckets2 = /* @__PURE__ */ new Map();
3760
- for (const t of threads) {
3761
- const key = t.snapshotVersion ?? null;
3762
- (buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
3944
+ if (body.folder !== void 0 && typeof body.folder !== "string") {
3945
+ throw new ValidationError('folder must be a string path like "gtm/deals"');
3763
3946
  }
3764
- const keys = [...buckets2.keys()].sort((a, b) => {
3765
- if (a == null) return 1;
3766
- if (b == null) return -1;
3767
- return b - a;
3947
+ const nestId = c.req.param("nestId");
3948
+ const authorEmail = await getUserEmail(c);
3949
+ const { node } = await createNode(
3950
+ nestId,
3951
+ {
3952
+ title: body.title,
3953
+ content: body.content,
3954
+ type: body.type,
3955
+ tags: body.tags,
3956
+ scope: body.scope,
3957
+ status: body.status,
3958
+ schedule: body.schedule,
3959
+ folder: body.folder,
3960
+ prime: body.prime
3961
+ },
3962
+ authorEmail
3963
+ );
3964
+ const resolved = await resolveStewardsForNode(nestId, node.id);
3965
+ return c.json({
3966
+ node: toNodeResponse(node),
3967
+ stewards: resolved.length > 0 ? resolved.map((r) => ({
3968
+ email: r.steward.userEmail,
3969
+ role: r.steward.role,
3970
+ source: r.source
3971
+ })) : void 0
3972
+ }, 201);
3973
+ });
3974
+ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
3975
+ const nestId = c.req.param("nestId");
3976
+ const nodeId = c.req.param("nodeId");
3977
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-FHJ2RBKN.js");
3978
+ const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
3979
+ nestId,
3980
+ nodeId
3981
+ );
3982
+ return c.json({
3983
+ nodeId,
3984
+ stewards: stewards.map((r) => ({
3985
+ email: r.steward.userEmail,
3986
+ role: r.steward.role,
3987
+ scope: r.steward.scope,
3988
+ source: r.source,
3989
+ priority: r.priority
3990
+ })),
3991
+ fallbackToOwner,
3992
+ ownerEmail
3768
3993
  });
3769
- for (const key of keys) {
3770
- lines.push(`## ${snapshotLabel(key)}`);
3771
- lines.push("");
3772
- for (const thread of orderThreads(buckets2.get(key))) {
3773
- lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
3774
- lines.push(renderAnchor(thread.anchor));
3775
- for (const comment of thread.comments) {
3776
- lines.push(
3777
- `- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
3778
- );
3779
- }
3780
- lines.push("");
3781
- }
3994
+ });
3995
+ nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
3996
+ const nestId = c.req.param("nestId");
3997
+ const nodeId = c.req.param("nodeId");
3998
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-JUAFYB73.js");
3999
+ const history = await getReviewHistory2(nestId, nodeId);
4000
+ return c.json({ reviews: history });
4001
+ });
4002
+ nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
4003
+ const nestId = c.req.param("nestId");
4004
+ const nodeId = c.req.param("nodeId");
4005
+ const { versions: versionManager } = await engineCache.get(nestId);
4006
+ const userId = c.get("userId");
4007
+ const userEmail = await resolveCallerEmail(userId);
4008
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
4009
+ return c.json(
4010
+ { error: "Access denied \u2014 no steward assignment for this node" },
4011
+ 403
4012
+ );
3782
4013
  }
3783
- return lines.join("\n");
3784
- }
3785
-
3786
- // src/annotations/service.ts
3787
- async function loadComments(threadId) {
3788
- const db = getDb();
3789
- const rows = await db.all(
3790
- "SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
3791
- [threadId]
3792
- );
3793
- return rows.map((r) => ({
3794
- id: r.id,
3795
- author: r.author,
3796
- body: r.body,
3797
- createdAt: r.created_at
3798
- }));
3799
- }
3800
- async function rowToThread(row) {
3801
- let anchor = null;
3802
- if (row.anchor_json) {
3803
- try {
3804
- anchor = JSON.parse(row.anchor_json);
3805
- } catch {
3806
- anchor = null;
3807
- }
4014
+ const body = await c.req.json().catch(() => ({}));
4015
+ const targetVersion = Number(body.targetVersion);
4016
+ if (!Number.isInteger(targetVersion) || targetVersion < 1) {
4017
+ throw new ValidationError("targetVersion (a positive integer) is required");
3808
4018
  }
3809
- return {
3810
- id: row.id,
3811
- nestId: row.nest_id,
3812
- nodeId: row.node_id,
3813
- snapshotVersion: row.snapshot_version,
3814
- anchor,
3815
- status: row.status,
3816
- createdBy: row.created_by,
3817
- createdAt: row.created_at,
3818
- resolvedBy: row.resolved_by,
3819
- resolvedAt: row.resolved_at,
3820
- comments: await loadComments(row.id)
3821
- };
3822
- }
3823
- async function getThreadRow(threadId) {
3824
- return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
3825
- }
3826
- async function countThreadsByNode(nestId) {
3827
- const rows = await getDb().all(
3828
- `SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
3829
- FROM annotation_threads
3830
- WHERE nest_id = ?
3831
- GROUP BY node_id`,
3832
- [nestId]
3833
- );
3834
- const out = {};
3835
- for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
3836
- return out;
3837
- }
3838
- async function listThreads(nestId, nodeId) {
3839
- const rows = await getDb().all(
3840
- "SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
3841
- [nestId, nodeId]
3842
- );
3843
- return Promise.all(rows.map(rowToThread));
3844
- }
3845
- async function createThread(nestId, nodeId, input, authorEmail) {
3846
- const body = (input.body ?? "").trim();
3847
- if (!body) {
3848
- throw new Error("comment body is required");
3849
- }
3850
- const db = getDb();
3851
- const id = uuid5();
3852
- const anchor = clampAnchor(input.anchor);
3853
- const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
3854
- await db.transaction(async (tx) => {
3855
- await tx.run(
3856
- `INSERT INTO annotation_threads
3857
- (id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
3858
- VALUES (?, ?, ?, ?, ?, 'open', ?)`,
3859
- [
3860
- id,
3861
- nestId,
3862
- nodeId,
3863
- snapshot,
3864
- anchor ? JSON.stringify(anchor) : null,
3865
- authorEmail
3866
- ]
3867
- );
3868
- await tx.run(
3869
- "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
3870
- [uuid5(), id, authorEmail, body]
3871
- );
3872
- });
3873
- return rowToThread(await getThreadRow(id));
3874
- }
3875
- async function getScopedThreadRow(threadId, nestId, nodeId) {
3876
- const row = await getThreadRow(threadId);
3877
- if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
3878
- throw new NotFoundError(`Thread not found: ${threadId}`);
3879
- }
3880
- return row;
3881
- }
3882
- async function addComment(nestId, nodeId, threadId, authorEmail, body) {
3883
- const trimmed = (body ?? "").trim();
3884
- if (!trimmed) {
3885
- throw new Error("comment body is required");
3886
- }
3887
- await getScopedThreadRow(threadId, nestId, nodeId);
3888
- await getDb().run(
3889
- "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
3890
- [uuid5(), threadId, authorEmail, trimmed]
3891
- );
3892
- return rowToThread(await getThreadRow(threadId));
3893
- }
3894
- async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
3895
- await getScopedThreadRow(threadId, nestId, nodeId);
3896
- const db = getDb();
3897
- if (status === "resolved") {
3898
- await db.run(
3899
- `UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
3900
- [byEmail, threadId]
3901
- );
3902
- } else {
3903
- await db.run(
3904
- "UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
3905
- [threadId]
3906
- );
3907
- }
3908
- return rowToThread(await getThreadRow(threadId));
3909
- }
3910
- async function readArtifact(nestId, nodeId, version) {
3911
- const { storage, versions: versionManager } = await engineCache.get(nestId);
3912
- const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
3913
- let title = nodeId;
3914
- let type = null;
3915
- let liveHtml = "";
3916
- let liveExists = true;
3917
- try {
3918
- const node = await storage.readDocument(nodeId);
3919
- title = node.frontmatter?.title || nodeId;
3920
- type = node.frontmatter?.type || null;
3921
- liveHtml = node.body || "";
3922
- } catch {
3923
- liveExists = false;
3924
- }
3925
- if (target != null) {
3926
- try {
3927
- const raw = await versionManager.reconstructVersion(nodeId, target);
3928
- return { title, html: bodyOnly(nodeId, raw), version: target, type };
3929
- } catch (err) {
3930
- console.warn(
3931
- `[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
3932
- err
3933
- );
3934
- }
3935
- }
3936
- if (!liveExists) {
3937
- throw new NotFoundError(`Artifact not found: ${nodeId}`);
3938
- }
3939
- return { title, html: liveHtml, version: target, type };
3940
- }
3941
- function derivedAnnotationsId(sourceNodeId) {
3942
- const base = sourceNodeId.replace(/^nodes\//, "");
3943
- return `nodes/${base}--annotations`;
3944
- }
3945
- async function syncAnnotationsNode(nestId, nodeId, userEmail) {
3946
- try {
3947
- let title = nodeId;
3948
- let type = null;
3949
- try {
3950
- const { storage } = await engineCache.get(nestId);
3951
- const node = await storage.readDocument(nodeId);
3952
- title = node.frontmatter?.title || nodeId;
3953
- type = node.frontmatter?.type || "document";
3954
- } catch {
3955
- }
3956
- if (type !== null && type !== "artifact") return;
3957
- const threads = await listThreads(nestId, nodeId);
3958
- const derivedTitle = `${title} \u2014 Annotations`;
3959
- const markdown = projectThreadsToMarkdown(title, threads);
3960
- const derivedId = derivedAnnotationsId(nodeId);
3961
- try {
3962
- await updateNode(nestId, derivedId, { content: markdown }, userEmail);
3963
- } catch (err) {
3964
- if (err instanceof NotFoundError) {
3965
- await createNode(
3966
- nestId,
3967
- {
3968
- id: derivedId,
3969
- title: derivedTitle,
3970
- content: markdown,
3971
- type: "document",
3972
- tags: ["annotations"]
3973
- },
3974
- userEmail
3975
- );
3976
- } else {
3977
- throw err;
3978
- }
3979
- }
3980
- } catch (err) {
3981
- console.warn(
3982
- `[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
3983
- err
3984
- );
3985
- }
3986
- }
3987
-
3988
- // src/nodes/routes.ts
3989
- var nodeRoutes = new Hono8();
3990
- function nodeAsMarkdown(response, nodeId) {
3991
- return nodeToMarkdown({
3992
- id: nodeId,
3993
- title: response.title,
3994
- tags: response.tags,
3995
- status: response.status,
3996
- body: response.content
3997
- });
3998
- }
3999
- function rawNodeBody(c, response, storedRaw) {
4000
- const body = response.content ?? "";
4001
- const payload = c.req.query("frontmatter") === "1" ? frontmatterBlockOf(storedRaw) + body : body;
4002
- c.header("X-Content-Type-Options", "nosniff");
4003
- if ((response.type || "document") === "artifact") {
4004
- applyArtifactSandboxHeaders(c);
4005
- }
4006
- return c.body(payload, 200, {
4007
- "Content-Type": rawContentType(response.type)
4008
- });
4009
- }
4010
- nodeRoutes.get("/", async (c) => {
4011
- const nestId = c.req.param("nestId");
4012
- const userId = c.get("userId");
4013
- const nodes = await listNodesForCaller(nestId, userId);
4014
- return c.json({ count: nodes.length, nodes });
4015
- });
4016
- nodeRoutes.post("/", async (c) => {
4017
- const body = await c.req.json();
4018
- if (!body.title || !body.content) {
4019
- throw new ValidationError("title and content are required");
4020
- }
4021
- if (body.folder !== void 0 && typeof body.folder !== "string") {
4022
- throw new ValidationError('folder must be a string path like "gtm/deals"');
4023
- }
4024
- const nestId = c.req.param("nestId");
4025
- const authorEmail = await getUserEmail(c);
4026
- const { node } = await createNode(
4027
- nestId,
4028
- {
4029
- title: body.title,
4030
- content: body.content,
4031
- type: body.type,
4032
- tags: body.tags,
4033
- scope: body.scope,
4034
- status: body.status,
4035
- schedule: body.schedule,
4036
- folder: body.folder
4037
- },
4038
- authorEmail
4039
- );
4040
- const resolved = await resolveStewardsForNode(nestId, node.id);
4041
- return c.json({
4042
- node: toNodeResponse(node),
4043
- stewards: resolved.length > 0 ? resolved.map((r) => ({
4044
- email: r.steward.userEmail,
4045
- role: r.steward.role,
4046
- source: r.source
4047
- })) : void 0
4048
- }, 201);
4049
- });
4050
- nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4051
- const nestId = c.req.param("nestId");
4052
- const nodeId = c.req.param("nodeId");
4053
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-B6L7UACX.js");
4054
- const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
4055
- nestId,
4056
- nodeId
4057
- );
4058
- return c.json({
4059
- nodeId,
4060
- stewards: stewards.map((r) => ({
4061
- email: r.steward.userEmail,
4062
- role: r.steward.role,
4063
- scope: r.steward.scope,
4064
- source: r.source,
4065
- priority: r.priority
4066
- })),
4067
- fallbackToOwner,
4068
- ownerEmail
4069
- });
4070
- });
4071
- nodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
4072
- const nestId = c.req.param("nestId");
4073
- const nodeId = c.req.param("nodeId");
4074
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-L52F4WVZ.js");
4075
- const allVersions = await getVersions2(nestId, nodeId);
4076
- const approved = await getApprovedVersion2(nestId, nodeId);
4077
- const db = getDb();
4078
- const resolutions = await db.all(
4079
- `SELECT version, status, resolved_by, resolved_at
4080
- FROM review_requests
4081
- WHERE nest_id = ? AND node_id = ?
4082
- AND status IN ('approved', 'rejected')
4083
- AND resolved_by IS NOT NULL
4084
- ORDER BY resolved_at DESC`,
4085
- [nestId, nodeId]
4086
- );
4087
- const byVersion = /* @__PURE__ */ new Map();
4088
- for (const r of resolutions) {
4089
- if (!byVersion.has(r.version)) {
4090
- byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
4091
- }
4092
- }
4093
- const enriched = allVersions.map((v) => {
4094
- const r = byVersion.get(v.version);
4095
- return r ? { ...v, resolvedBy: r.resolvedBy, resolutionStatus: r.status } : v;
4096
- });
4097
- return c.json({
4098
- versions: enriched,
4099
- approvedVersion: approved,
4100
- currentVersion: allVersions[0]?.version || 0
4101
- });
4102
- });
4103
- nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
4104
- const nestId = c.req.param("nestId");
4105
- const nodeId = c.req.param("nodeId");
4106
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-Q3NDK5TX.js");
4107
- const history = await getReviewHistory2(nestId, nodeId);
4108
- return c.json({ reviews: history });
4109
- });
4110
- nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
4111
- const nestId = c.req.param("nestId");
4112
- const nodeId = c.req.param("nodeId");
4113
- const { versions: versionManager } = await engineCache.get(nestId);
4114
- const userId = c.get("userId");
4115
- const userEmail = await resolveCallerEmail(userId);
4116
- if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
4117
- return c.json(
4118
- { error: "Access denied \u2014 no steward assignment for this node" },
4119
- 403
4120
- );
4121
- }
4122
- const body = await c.req.json().catch(() => ({}));
4123
- const targetVersion = Number(body.targetVersion);
4124
- if (!Number.isInteger(targetVersion) || targetVersion < 1) {
4125
- throw new ValidationError("targetVersion (a positive integer) is required");
4126
- }
4127
- let raw;
4128
- try {
4129
- raw = await versionManager.reconstructVersion(nodeId, targetVersion);
4130
- } catch {
4131
- throw new NotFoundError(
4132
- `Version ${targetVersion} not found for ${nodeId}`
4133
- );
4134
- }
4135
- const content = bodyOnly(nodeId, raw);
4136
- const { node, version } = await updateNode(
4137
- nestId,
4138
- nodeId,
4139
- { content, changeNote: `Restored from version ${targetVersion}` },
4140
- userEmail
4019
+ let raw;
4020
+ try {
4021
+ raw = await versionManager.reconstructVersion(nodeId, targetVersion);
4022
+ } catch {
4023
+ throw new NotFoundError(
4024
+ `Version ${targetVersion} not found for ${nodeId}`
4025
+ );
4026
+ }
4027
+ const content = bodyOnly(nodeId, raw);
4028
+ const { node, version } = await updateNode(
4029
+ nestId,
4030
+ nodeId,
4031
+ { content, changeNote: `Restored from version ${targetVersion}` },
4032
+ userEmail
4141
4033
  );
4142
4034
  await trackEvent("node.revert", { nestId, nodeId, targetVersion });
4143
4035
  return c.json({ ok: true, version, node: toNodeResponse(node) });
@@ -4269,6 +4161,10 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
4269
4161
  const pending = await getPendingReview(nestId, nodeId);
4270
4162
  response.pendingReviewBy = pending?.requestedBy ?? null;
4271
4163
  }
4164
+ response.deletionRequest = await getLatestDeletionRequest(
4165
+ nestId,
4166
+ nodeId
4167
+ );
4272
4168
  if (isRawFormat(c)) {
4273
4169
  return rawNodeBody(c, response, node.rawContent);
4274
4170
  }
@@ -4312,7 +4208,8 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
4312
4208
  title: body.title,
4313
4209
  status: body.status,
4314
4210
  changeNote: body.changeNote,
4315
- schedule: body.schedule
4211
+ schedule: body.schedule,
4212
+ prime: body.prime
4316
4213
  },
4317
4214
  authorEmail
4318
4215
  );
@@ -4321,84 +4218,307 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
4321
4218
  nodeRoutes.delete("/:nodeId{.+}", async (c) => {
4322
4219
  const nestId = c.req.param("nestId");
4323
4220
  const nodeId = c.req.param("nodeId");
4324
- const { storage } = await engineCache.get(nestId);
4325
- if (await isStewardshipEnabled(nestId) && await getPendingReview(nestId, nodeId)) {
4326
- throw new LockedError(
4327
- "This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
4221
+ await deleteNode(nestId, nodeId, await getUserEmail(c));
4222
+ return c.json({ deleted: true });
4223
+ });
4224
+ async function getUserEmail(c) {
4225
+ const userId = c.get("userId");
4226
+ const db = getDb();
4227
+ const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
4228
+ return user?.email || "anonymous@localhost";
4229
+ }
4230
+
4231
+ // src/annotations/routes.ts
4232
+ import { Hono as Hono9 } from "hono";
4233
+
4234
+ // src/annotations/service.ts
4235
+ import { v4 as uuid5 } from "uuid";
4236
+
4237
+ // src/annotations/projection.ts
4238
+ var MAX_CONTEXT_CHARS = 250;
4239
+ var MAX_QUOTE_CHARS = 1e3;
4240
+ function clampAnchor(raw) {
4241
+ if (!raw) return null;
4242
+ const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
4243
+ if (!quote) return null;
4244
+ const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
4245
+ const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
4246
+ const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
4247
+ return { quote, before, after, ...line !== void 0 ? { line } : {} };
4248
+ }
4249
+ function oneLine(s) {
4250
+ return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
4251
+ }
4252
+ function renderAnchor(anchor) {
4253
+ if (!anchor) {
4254
+ return "<!-- anchor: whole-artifact -->";
4255
+ }
4256
+ const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
4257
+ const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
4258
+ anchor.after
4259
+ )}\u2026`;
4260
+ return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
4261
+ }
4262
+ function statusHeading(thread) {
4263
+ if (thread.status === "resolved") {
4264
+ const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
4265
+ const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
4266
+ const meta = who || when ? ` (${who}${when})` : "";
4267
+ return `\u2705 RESOLVED${meta}`;
4268
+ }
4269
+ return "\u{1F7E0} OPEN";
4270
+ }
4271
+ function snapshotLabel(v) {
4272
+ return v == null ? "Unpinned" : `Snapshot v${v}`;
4273
+ }
4274
+ function orderThreads(threads) {
4275
+ return [...threads].sort((a, b) => {
4276
+ if (a.status !== b.status) return a.status === "open" ? -1 : 1;
4277
+ return a.createdAt.localeCompare(b.createdAt);
4278
+ });
4279
+ }
4280
+ function projectThreadsToMarkdown(artifactTitle, threads) {
4281
+ const lines = [];
4282
+ lines.push(`# ${artifactTitle} \u2014 Annotations`);
4283
+ lines.push("");
4284
+ lines.push(
4285
+ "> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
4286
+ );
4287
+ lines.push("");
4288
+ if (threads.length === 0) {
4289
+ lines.push("_No annotations yet._");
4290
+ lines.push("");
4291
+ return lines.join("\n");
4292
+ }
4293
+ const buckets2 = /* @__PURE__ */ new Map();
4294
+ for (const t of threads) {
4295
+ const key = t.snapshotVersion ?? null;
4296
+ (buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
4297
+ }
4298
+ const keys = [...buckets2.keys()].sort((a, b) => {
4299
+ if (a == null) return 1;
4300
+ if (b == null) return -1;
4301
+ return b - a;
4302
+ });
4303
+ for (const key of keys) {
4304
+ lines.push(`## ${snapshotLabel(key)}`);
4305
+ lines.push("");
4306
+ for (const thread of orderThreads(buckets2.get(key))) {
4307
+ lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
4308
+ lines.push(renderAnchor(thread.anchor));
4309
+ for (const comment of thread.comments) {
4310
+ lines.push(
4311
+ `- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
4312
+ );
4313
+ }
4314
+ lines.push("");
4315
+ }
4316
+ }
4317
+ return lines.join("\n");
4318
+ }
4319
+
4320
+ // src/annotations/service.ts
4321
+ async function loadComments(threadId) {
4322
+ const db = getDb();
4323
+ const rows = await db.all(
4324
+ "SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
4325
+ [threadId]
4326
+ );
4327
+ return rows.map((r) => ({
4328
+ id: r.id,
4329
+ author: r.author,
4330
+ body: r.body,
4331
+ createdAt: r.created_at
4332
+ }));
4333
+ }
4334
+ async function rowToThread(row) {
4335
+ let anchor = null;
4336
+ if (row.anchor_json) {
4337
+ try {
4338
+ anchor = JSON.parse(row.anchor_json);
4339
+ } catch {
4340
+ anchor = null;
4341
+ }
4342
+ }
4343
+ return {
4344
+ id: row.id,
4345
+ nestId: row.nest_id,
4346
+ nodeId: row.node_id,
4347
+ snapshotVersion: row.snapshot_version,
4348
+ anchor,
4349
+ status: row.status,
4350
+ createdBy: row.created_by,
4351
+ createdAt: row.created_at,
4352
+ resolvedBy: row.resolved_by,
4353
+ resolvedAt: row.resolved_at,
4354
+ comments: await loadComments(row.id)
4355
+ };
4356
+ }
4357
+ async function getThreadRow(threadId) {
4358
+ return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
4359
+ }
4360
+ async function countThreadsByNode(nestId) {
4361
+ const rows = await getDb().all(
4362
+ `SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
4363
+ FROM annotation_threads
4364
+ WHERE nest_id = ?
4365
+ GROUP BY node_id`,
4366
+ [nestId]
4367
+ );
4368
+ const out = {};
4369
+ for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
4370
+ return out;
4371
+ }
4372
+ async function listThreads(nestId, nodeId) {
4373
+ const rows = await getDb().all(
4374
+ "SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
4375
+ [nestId, nodeId]
4376
+ );
4377
+ return Promise.all(rows.map(rowToThread));
4378
+ }
4379
+ async function createThread(nestId, nodeId, input, authorEmail) {
4380
+ const body = (input.body ?? "").trim();
4381
+ if (!body) {
4382
+ throw new Error("comment body is required");
4383
+ }
4384
+ const db = getDb();
4385
+ const id = uuid5();
4386
+ const anchor = clampAnchor(input.anchor);
4387
+ const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
4388
+ await db.transaction(async (tx) => {
4389
+ await tx.run(
4390
+ `INSERT INTO annotation_threads
4391
+ (id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
4392
+ VALUES (?, ?, ?, ?, ?, 'open', ?)`,
4393
+ [
4394
+ id,
4395
+ nestId,
4396
+ nodeId,
4397
+ snapshot,
4398
+ anchor ? JSON.stringify(anchor) : null,
4399
+ authorEmail
4400
+ ]
4401
+ );
4402
+ await tx.run(
4403
+ "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4404
+ [uuid5(), id, authorEmail, body]
4405
+ );
4406
+ });
4407
+ return rowToThread(await getThreadRow(id));
4408
+ }
4409
+ async function getScopedThreadRow(threadId, nestId, nodeId) {
4410
+ const row = await getThreadRow(threadId);
4411
+ if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
4412
+ throw new NotFoundError(`Thread not found: ${threadId}`);
4413
+ }
4414
+ return row;
4415
+ }
4416
+ async function addComment(nestId, nodeId, threadId, authorEmail, body) {
4417
+ const trimmed = (body ?? "").trim();
4418
+ if (!trimmed) {
4419
+ throw new Error("comment body is required");
4420
+ }
4421
+ await getScopedThreadRow(threadId, nestId, nodeId);
4422
+ await getDb().run(
4423
+ "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4424
+ [uuid5(), threadId, authorEmail, trimmed]
4425
+ );
4426
+ return rowToThread(await getThreadRow(threadId));
4427
+ }
4428
+ async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
4429
+ await getScopedThreadRow(threadId, nestId, nodeId);
4430
+ const db = getDb();
4431
+ if (status === "resolved") {
4432
+ await db.run(
4433
+ `UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
4434
+ [byEmail, threadId]
4435
+ );
4436
+ } else {
4437
+ await db.run(
4438
+ "UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
4439
+ [threadId]
4328
4440
  );
4329
4441
  }
4330
- try {
4331
- await storage.deleteDocument(nodeId);
4332
- } catch {
4333
- throw new NotFoundError(`Node not found: ${nodeId}`);
4442
+ return rowToThread(await getThreadRow(threadId));
4443
+ }
4444
+ async function readArtifact(nestId, nodeId, version) {
4445
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
4446
+ const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
4447
+ let title = nodeId;
4448
+ let type = null;
4449
+ let liveHtml = "";
4450
+ let liveExists = true;
4451
+ try {
4452
+ const node = await storage.readDocument(nodeId);
4453
+ title = node.frontmatter?.title || nodeId;
4454
+ type = node.frontmatter?.type || null;
4455
+ liveHtml = node.body || "";
4456
+ } catch {
4457
+ liveExists = false;
4458
+ }
4459
+ if (target != null) {
4460
+ try {
4461
+ const raw = await versionManager.reconstructVersion(nodeId, target);
4462
+ return { title, html: bodyOnly(nodeId, raw), version: target, type };
4463
+ } catch (err) {
4464
+ console.warn(
4465
+ `[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
4466
+ err
4467
+ );
4468
+ }
4469
+ }
4470
+ if (!liveExists) {
4471
+ throw new NotFoundError(`Artifact not found: ${nodeId}`);
4334
4472
  }
4335
- await removeNodeFromTagIndex(nestId, nodeId);
4336
- const derivedId = derivedAnnotationsId(nodeId);
4473
+ return { title, html: liveHtml, version: target, type };
4474
+ }
4475
+ function derivedAnnotationsId(sourceNodeId) {
4476
+ const base = sourceNodeId.replace(/^nodes\//, "");
4477
+ return `nodes/${base}--annotations`;
4478
+ }
4479
+ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
4337
4480
  try {
4338
- await storage.deleteDocument(derivedId);
4339
- await removeNodeFromTagIndex(nestId, derivedId);
4340
- } catch {
4341
- }
4342
- const db = getDb();
4343
- await db.transaction(async (tx) => {
4344
- await tx.run(
4345
- "DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
4346
- [nestId, nodeId]
4347
- );
4348
- await tx.run(
4349
- "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
4350
- [nestId, nodeId]
4351
- );
4352
- await tx.run(
4353
- "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
4354
- [nestId, nodeId]
4355
- );
4356
- await tx.run(
4357
- `DELETE FROM stewards
4358
- WHERE nest_id = ? AND scope = 'document' AND node_pattern = ?`,
4359
- [nestId, nodeId]
4360
- );
4361
- await tx.run(
4362
- "DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
4363
- [nestId, nodeId]
4364
- );
4365
- await tx.run(
4366
- "DELETE FROM node_versions WHERE nest_id = ? AND node_id = ?",
4367
- [nestId, derivedId]
4368
- );
4369
- await tx.run(
4370
- "DELETE FROM review_requests WHERE nest_id = ? AND node_id = ?",
4371
- [nestId, derivedId]
4372
- );
4373
- await tx.run(
4374
- "DELETE FROM approved_versions WHERE nest_id = ? AND node_id = ?",
4375
- [nestId, derivedId]
4376
- );
4377
- await tx.run(
4378
- "DELETE FROM annotation_threads WHERE nest_id = ? AND node_id = ?",
4379
- [nestId, derivedId]
4380
- );
4381
- await tx.run(
4382
- `INSERT INTO node_deletions (nest_id, node_id, deleted_by, deleted_at)
4383
- VALUES (?, ?, ?, ?)
4384
- ON CONFLICT(nest_id, node_id) DO UPDATE
4385
- SET deleted_by = excluded.deleted_by, deleted_at = excluded.deleted_at`,
4386
- [nestId, nodeId, await getUserEmail(c), (/* @__PURE__ */ new Date()).toISOString()]
4481
+ let title = nodeId;
4482
+ let type = null;
4483
+ try {
4484
+ const { storage } = await engineCache.get(nestId);
4485
+ const node = await storage.readDocument(nodeId);
4486
+ title = node.frontmatter?.title || nodeId;
4487
+ type = node.frontmatter?.type || "document";
4488
+ } catch {
4489
+ }
4490
+ if (type !== null && type !== "artifact") return;
4491
+ const threads = await listThreads(nestId, nodeId);
4492
+ const derivedTitle = `${title.slice(0, 100)} \u2014 Annotations`;
4493
+ const markdown = projectThreadsToMarkdown(title, threads);
4494
+ const derivedId = derivedAnnotationsId(nodeId);
4495
+ try {
4496
+ await updateNode(nestId, derivedId, { content: markdown }, userEmail);
4497
+ } catch (err) {
4498
+ if (err instanceof NotFoundError) {
4499
+ await createNode(
4500
+ nestId,
4501
+ {
4502
+ id: derivedId,
4503
+ title: derivedTitle,
4504
+ content: markdown,
4505
+ type: "document",
4506
+ tags: ["annotations"]
4507
+ },
4508
+ userEmail
4509
+ );
4510
+ } else {
4511
+ throw err;
4512
+ }
4513
+ }
4514
+ } catch (err) {
4515
+ console.warn(
4516
+ `[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
4517
+ err
4387
4518
  );
4388
- });
4389
- await trackEvent("node.delete", { nestId, nodeId });
4390
- return c.json({ deleted: true });
4391
- });
4392
- async function getUserEmail(c) {
4393
- const userId = c.get("userId");
4394
- const db = getDb();
4395
- const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
4396
- return user?.email || "anonymous@localhost";
4519
+ }
4397
4520
  }
4398
4521
 
4399
- // src/annotations/routes.ts
4400
- import { Hono as Hono9 } from "hono";
4401
-
4402
4522
  // src/annotations/types.ts
4403
4523
  var ARTIFACT_NODE_TYPE = "artifact";
4404
4524
 
@@ -4518,7 +4638,7 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
4518
4638
 
4519
4639
  // src/nodes/query-routes.ts
4520
4640
  import { Hono as Hono10 } from "hono";
4521
- import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
4641
+ import { serializeDocument as serializeDocument3 } from "@promptowl/contextnest-engine";
4522
4642
 
4523
4643
  // src/tables/service.ts
4524
4644
  function splitCsvLine(line) {
@@ -4937,6 +5057,68 @@ async function resolveExportBody(nestId, nodeId, workingBody) {
4937
5057
  }
4938
5058
  }
4939
5059
 
5060
+ // src/nests/bundle-service.ts
5061
+ import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
5062
+ import { join as join4, sep } from "path";
5063
+ import JSZip from "jszip";
5064
+ import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
5065
+ function walkFiles(root) {
5066
+ const out = [];
5067
+ let entries;
5068
+ try {
5069
+ entries = readdirSync2(root, { recursive: true });
5070
+ } catch {
5071
+ return out;
5072
+ }
5073
+ for (const rel of entries) {
5074
+ try {
5075
+ if (statSync2(join4(root, rel)).isFile()) out.push(rel.split(sep).join("/"));
5076
+ } catch {
5077
+ }
5078
+ }
5079
+ return out;
5080
+ }
5081
+ async function exportNestBundle(nestId, includeDrafts) {
5082
+ const db = getDb();
5083
+ const approvedRows = await db.all(
5084
+ "SELECT node_id, approved_version FROM approved_versions WHERE nest_id = ?",
5085
+ [nestId]
5086
+ );
5087
+ const approvedByNode = new Map(
5088
+ approvedRows.map((r) => [r.node_id, r.approved_version])
5089
+ );
5090
+ const { storage } = await engineCache.get(nestId);
5091
+ const docs = await storage.discoverDocuments();
5092
+ const zip = new JSZip();
5093
+ let count = 0;
5094
+ for (const doc of docs) {
5095
+ const approved = approvedByNode.get(doc.id);
5096
+ const frontmatter = stripUndefinedDeep({ ...doc.frontmatter });
5097
+ let body = doc.body || "";
5098
+ if (approved != null) {
5099
+ const approvedBody = await resolveExportBody(nestId, doc.id, body);
5100
+ if (approvedBody == null) continue;
5101
+ body = approvedBody;
5102
+ frontmatter.status = "published";
5103
+ frontmatter.version = approved;
5104
+ } else {
5105
+ if (!includeDrafts) continue;
5106
+ frontmatter.status = "draft";
5107
+ }
5108
+ zip.file(`${doc.id}.md`, serializeDocument2({ ...doc, frontmatter, body }));
5109
+ count++;
5110
+ }
5111
+ const nestPath = resolveNestPath(nestId);
5112
+ for (const rel of walkFiles(nestPath)) {
5113
+ const segs = rel.split("/");
5114
+ const keep = segs.includes(".versions") || rel === ".context/config.yaml" || rel === "CONTEXT.md";
5115
+ if (!keep) continue;
5116
+ zip.file(rel, readFileSync2(join4(nestPath, rel)));
5117
+ }
5118
+ trackEvent("nest.export.bundle", { nestId, documents: count, includeDrafts });
5119
+ return zip.generateAsync({ type: "arraybuffer" });
5120
+ }
5121
+
4940
5122
  // src/nodes/graph-service.ts
4941
5123
  var MAX_GRAPH_NODES = 150;
4942
5124
  var WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
@@ -5490,10 +5672,24 @@ queryRoutes.get("/context", async (c) => {
5490
5672
  return c.json({ content: content || "" });
5491
5673
  });
5492
5674
  queryRoutes.get("/export", async (c) => {
5675
+ const nestId = c.req.param("nestId");
5676
+ 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
+ const draftsParam = c.req.query("includeDrafts");
5682
+ const includeDrafts = draftsParam === "1" || draftsParam === "true";
5683
+ const zip = await exportNestBundle(nestId, includeDrafts);
5684
+ const filename = `nest-${nestId}.zip`;
5685
+ return c.body(zip, 200, {
5686
+ "Content-Type": "application/zip",
5687
+ "Content-Disposition": `attachment; filename="${filename}"`
5688
+ });
5689
+ }
5493
5690
  if (!isMarkdownFormat(c)) {
5494
- throw new ValidationError("format=markdown is required");
5691
+ throw new ValidationError("format=markdown or format=bundle is required");
5495
5692
  }
5496
- const nestId = c.req.param("nestId");
5497
5693
  const { storage, query: queryEngine } = await engineCache.get(nestId);
5498
5694
  const selector = c.req.query("selector")?.trim() || null;
5499
5695
  let documents;
@@ -5592,7 +5788,7 @@ queryRoutes.post("/publish", async (c) => {
5592
5788
  body: doc.content,
5593
5789
  rawContent: ""
5594
5790
  };
5595
- const serialized = serializeDocument2(node);
5791
+ const serialized = serializeDocument3(node);
5596
5792
  await storage.writeDocument(id, serialized);
5597
5793
  await syncNodeTags(nestId, id, tags);
5598
5794
  created.push(id);
@@ -6798,7 +6994,7 @@ var TOOL_DEFINITIONS = [
6798
6994
  },
6799
6995
  {
6800
6996
  name: "context_create",
6801
- description: "Create a new knowledge node in the vault.",
6997
+ description: "Create a new knowledge node in the vault. The title must not already be in use \u2014 these tools resolve documents by title, so a duplicate is refused rather than filed as an unreachable twin. To change an existing document use context_update; to refile it use context_move.",
6802
6998
  inputSchema: {
6803
6999
  type: "object",
6804
7000
  properties: {
@@ -6819,7 +7015,7 @@ var TOOL_DEFINITIONS = [
6819
7015
  scope: { type: "string", description: "Visibility scope" },
6820
7016
  folder: {
6821
7017
  type: "string",
6822
- description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
7018
+ description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified. A leading "nodes/" is accepted and not doubled, so "nodes/gtm/deals" and "gtm/deals" mean the same folder.'
6823
7019
  }
6824
7020
  },
6825
7021
  required: ["title", "content"]
@@ -6850,6 +7046,49 @@ var TOOL_DEFINITIONS = [
6850
7046
  required: ["title"]
6851
7047
  }
6852
7048
  },
7049
+ {
7050
+ name: "context_delete",
7051
+ // Mirrors the engine's canonical `context_delete` operation (core
7052
+ // namespace, alias delete_document): same name, same {id, title} input,
7053
+ // same "deleted" semantics. The engine's `uri` field is omitted — this
7054
+ // repo has no contextnest:// resolver on any surface, so accepting one
7055
+ // would promise a resolution it can't do.
7056
+ description: "Delete a node from the vault by title or id. Removes everything keyed to it: version history, review state, annotations, comments, watchers, schedules, trigger hooks, workflow edges, document grants, and any steward assignments scoped to it. Irreversible \u2014 the node must be recreated to come back, and it comes back clean: no history, no stewards, no shares. A node under open review is frozen unless you can approve it (owner, admin, or its reviewer).",
7057
+ inputSchema: {
7058
+ type: "object",
7059
+ properties: {
7060
+ title: { type: "string", description: "Title of node to delete" },
7061
+ id: {
7062
+ type: "string",
7063
+ 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.'
7064
+ }
7065
+ }
7066
+ }
7067
+ },
7068
+ {
7069
+ name: "context_move",
7070
+ // NOT in the engine's operation catalog — `move` is a community-edition
7071
+ // concern (the id rewrite spans governance tables the engine doesn't own).
7072
+ // This mirrors the existing REST POST /nodes/:id/move rather than adding a
7073
+ // new concept, so the surfaces agree. If move is ever promoted to a core
7074
+ // operation upstream, adopt that descriptor and drop this schema.
7075
+ description: "Move a node to a different folder. The id changes; version history, review state, annotations, comments, watchers, schedules, links, and document grants all follow. The title and its [[wiki-link]] stay valid. A node under open review is frozen unless you can approve it (owner, admin, or its reviewer).",
7076
+ inputSchema: {
7077
+ type: "object",
7078
+ properties: {
7079
+ title: { type: "string", description: "Title of node to move" },
7080
+ id: {
7081
+ type: "string",
7082
+ 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.'
7083
+ },
7084
+ folder: {
7085
+ type: "string",
7086
+ description: 'Destination folder under nodes/ (e.g. "gtm/deals"); segments are slugified. Use "" to move to the nest root.'
7087
+ }
7088
+ },
7089
+ required: ["folder"]
7090
+ }
7091
+ },
6853
7092
  // ─── Governance Tools ───────────────────────────────────────────────
6854
7093
  {
6855
7094
  name: "context_stewards",
@@ -6864,6 +7103,73 @@ var TOOL_DEFINITIONS = [
6864
7103
  }
6865
7104
  }
6866
7105
  },
7106
+ {
7107
+ // Deletion requests, mirroring the REST + UI flow: anyone who can READ a
7108
+ // thing may ask for it to be deleted; only an owner/admin resolves it.
7109
+ // Not in the engine catalog (1.2.1) — deletion requests live in this
7110
+ // repo's governance tables, which the engine doesn't own. Adopt an engine
7111
+ // descriptor here if the operation is ever promoted upstream.
7112
+ 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.",
7114
+ inputSchema: {
7115
+ type: "object",
7116
+ properties: {
7117
+ title: { type: "string", description: "Title of the document to flag" },
7118
+ id: {
7119
+ type: "string",
7120
+ description: 'Document id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two documents share a title.'
7121
+ },
7122
+ folder: {
7123
+ type: "string",
7124
+ description: 'Flag a whole folder instead of a document \u2014 the path, e.g. "gtm/deals". Every document under it goes if the request is honoured.'
7125
+ },
7126
+ nest: {
7127
+ type: "boolean",
7128
+ description: "Flag the ENTIRE nest for deletion. Only an owner/admin can honour it."
7129
+ },
7130
+ reason: {
7131
+ type: "string",
7132
+ description: "Why it should be deleted. Required \u2014 this is what the admin judges."
7133
+ }
7134
+ },
7135
+ required: ["reason"]
7136
+ }
7137
+ },
7138
+ {
7139
+ name: "context_deletion_queue",
7140
+ description: "Show deletion requests waiting on a nest owner/admin: what was flagged, who asked, and why. Use the request id with context_resolve_deletion to action one.",
7141
+ inputSchema: {
7142
+ type: "object",
7143
+ properties: {
7144
+ status: {
7145
+ type: "string",
7146
+ description: "pending (default) or declined \u2014 declined shows past verdicts and their notes."
7147
+ }
7148
+ }
7149
+ }
7150
+ },
7151
+ {
7152
+ 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.",
7154
+ inputSchema: {
7155
+ type: "object",
7156
+ properties: {
7157
+ request_id: {
7158
+ type: "string",
7159
+ description: "Request id from context_deletion_queue"
7160
+ },
7161
+ action: {
7162
+ type: "string",
7163
+ description: '"delete" to honour it, "decline" to keep the target'
7164
+ },
7165
+ note: {
7166
+ type: "string",
7167
+ description: "Why you declined. Required when action is decline."
7168
+ }
7169
+ },
7170
+ required: ["request_id", "action"]
7171
+ }
7172
+ },
6867
7173
  {
6868
7174
  name: "context_review_queue",
6869
7175
  description: "Show documents pending review. These need a steward to approve or reject them before they become available to AI.",
@@ -6877,6 +7183,14 @@ var TOOL_DEFINITIONS = [
6877
7183
  }
6878
7184
  }
6879
7185
  },
7186
+ {
7187
+ name: "context_my_drafts",
7188
+ description: "List YOUR unsubmitted drafts in this nest \u2014 documents you wrote that were never submitted for review (plus any that came back rejected). These are invisible to the review queue and unreadable by AI queries until submitted and approved. Use context_submit_review to hand one to a steward.",
7189
+ inputSchema: {
7190
+ type: "object",
7191
+ properties: {}
7192
+ }
7193
+ },
6880
7194
  {
6881
7195
  name: "context_submit_review",
6882
7196
  description: "Submit a node for steward review. The node will be marked as pending review and stewards will be notified.",
@@ -7029,6 +7343,24 @@ async function handleToolCall(toolName, args, ctx) {
7029
7343
  });
7030
7344
  }
7031
7345
  }
7346
+ async function resolveTargetNode(storage, args) {
7347
+ if (!args.id && !args.title) return "Provide a title or an id.";
7348
+ if (args.id) {
7349
+ try {
7350
+ return await storage.readDocument(args.id);
7351
+ } catch {
7352
+ return `Node not found: ${args.id}`;
7353
+ }
7354
+ }
7355
+ const docs = await storage.discoverDocuments();
7356
+ const matches = docs.filter(
7357
+ (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7358
+ );
7359
+ if (matches.length > 1) {
7360
+ return `${matches.length} nodes share the title "${args.title}". Re-run with one of these ids instead: ${matches.map((n) => n.id).join(", ")}`;
7361
+ }
7362
+ return matches[0] ?? `Node not found: ${args.title}`;
7363
+ }
7032
7364
  async function runTool(toolName, args, ctx) {
7033
7365
  const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
7034
7366
  switch (toolName) {
@@ -7262,19 +7594,29 @@ ${sections}`;
7262
7594
  if (!await canCreateInNest(nestId, userEmail)) {
7263
7595
  return "You don't have permission to create documents in this nest.";
7264
7596
  }
7265
- const { node } = await createNode(
7266
- nestId,
7267
- {
7268
- title: args.title,
7269
- content: args.content,
7270
- type: args.type,
7271
- tags: args.tags,
7272
- scope: args.scope,
7273
- folder: args.folder
7274
- },
7275
- userEmail
7276
- );
7277
- return `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`;
7597
+ const wanted = String(args.title ?? "").toLowerCase();
7598
+ return withNodeWriteLock(nodeWriteKey(nestId, "*create*"), async () => {
7599
+ const existingDocs = await storage.discoverDocuments();
7600
+ const twin = existingDocs.find(
7601
+ (n) => String(n.frontmatter.title ?? "").toLowerCase() === wanted
7602
+ );
7603
+ 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.`;
7605
+ }
7606
+ const { node } = await createNode(
7607
+ nestId,
7608
+ {
7609
+ title: args.title,
7610
+ content: args.content,
7611
+ type: args.type,
7612
+ tags: args.tags,
7613
+ scope: args.scope,
7614
+ folder: args.folder
7615
+ },
7616
+ userEmail
7617
+ );
7618
+ return `Created node: **${args.title}** (${node.id}) \u2014 status: ${node.frontmatter.status}`;
7619
+ });
7278
7620
  }
7279
7621
  case "context_update": {
7280
7622
  const docs = await storage.discoverDocuments();
@@ -7298,6 +7640,39 @@ ${sections}`;
7298
7640
  );
7299
7641
  return `Updated node: **${updated.frontmatter.title}**`;
7300
7642
  }
7643
+ case "context_delete": {
7644
+ const node = await resolveTargetNode(storage, args);
7645
+ if (typeof node === "string") return node;
7646
+ const deleteCheck = await canUserEdit(nestId, node.id, userEmail);
7647
+ if (!deleteCheck.allowed) {
7648
+ return `You don't have permission to delete "${node.frontmatter.title}": ${deleteCheck.reason}
7649
+
7650
+ Use context_request_deletion to ask a nest owner/admin to delete it, with a reason.`;
7651
+ }
7652
+ await deleteNode(nestId, node.id, userEmail);
7653
+ return `Deleted node: **${node.frontmatter.title}** (${node.id})`;
7654
+ }
7655
+ case "context_move": {
7656
+ if (typeof args.folder !== "string") {
7657
+ return 'folder is required \u2014 a path like "gtm/deals", or "" for the nest root.';
7658
+ }
7659
+ const node = await resolveTargetNode(storage, args);
7660
+ if (typeof node === "string") return node;
7661
+ const moveCheck = await canUserEdit(nestId, node.id, userEmail);
7662
+ if (!moveCheck.allowed) {
7663
+ return `You don't have permission to move "${node.frontmatter.title}": ${moveCheck.reason}`;
7664
+ }
7665
+ const { node: moved, previousId } = await moveNode(
7666
+ nestId,
7667
+ node.id,
7668
+ args.folder,
7669
+ userEmail
7670
+ );
7671
+ if (previousId === moved.id) {
7672
+ return `**${moved.frontmatter.title}** is already at \`${moved.id}\` \u2014 nothing to move.`;
7673
+ }
7674
+ return `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}\``;
7675
+ }
7301
7676
  // ─── Governance Tool Handlers ──────────────────────────────────────
7302
7677
  case "context_stewards": {
7303
7678
  if (args.title) {
@@ -7337,6 +7712,126 @@ ${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ?
7337
7712
 
7338
7713
  ${sections}`;
7339
7714
  }
7715
+ case "context_request_deletion": {
7716
+ const reason = typeof args.reason === "string" ? args.reason.trim() : "";
7717
+ if (!reason) {
7718
+ return "reason is required \u2014 say why it should be deleted; that is what the admin judges.";
7719
+ }
7720
+ if (args.nest === true) {
7721
+ const request2 = await requestDeletion({
7722
+ nestId,
7723
+ nodeId: nestId,
7724
+ targetType: "nest",
7725
+ requestedBy: userEmail,
7726
+ reason,
7727
+ baseUrl: ctx.baseUrl
7728
+ });
7729
+ return `Requested deletion of the **entire nest**. A nest owner or admin will delete it or decline.
7730
+ Request id: \`${request2.id}\``;
7731
+ }
7732
+ if (typeof args.folder === "string" && args.folder.trim()) {
7733
+ const folder = args.folder.trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
7734
+ const docs = await storage.discoverDocuments();
7735
+ const inside = docs.filter((d) => isIdUnderFolder(d.id, folder));
7736
+ if (inside.length === 0) {
7737
+ return `No folder "${folder}" \u2014 nothing is filed under it. Use context_list to see what exists.`;
7738
+ }
7739
+ const request2 = await requestDeletion({
7740
+ nestId,
7741
+ nodeId: folder,
7742
+ targetType: "folder",
7743
+ requestedBy: userEmail,
7744
+ reason,
7745
+ baseUrl: ctx.baseUrl
7746
+ });
7747
+ return `Requested deletion of folder **${folder}** (${inside.length} document(s) inside). A nest owner or admin will delete it or decline.
7748
+ Request id: \`${request2.id}\``;
7749
+ }
7750
+ const node = await resolveTargetNode(storage, args);
7751
+ if (typeof node === "string") return node;
7752
+ if (!await canReadNode(nestId, node.id, userId, userEmail)) {
7753
+ return `Node not found: ${node.id}`;
7754
+ }
7755
+ const request = await requestDeletion({
7756
+ nestId,
7757
+ nodeId: node.id,
7758
+ targetType: "document",
7759
+ requestedBy: userEmail,
7760
+ reason,
7761
+ baseUrl: ctx.baseUrl
7762
+ });
7763
+ return `Requested deletion of **${node.frontmatter.title}**. A nest owner or admin will delete it or decline \u2014 nothing is removed yet.
7764
+ Request id: \`${request.id}\``;
7765
+ }
7766
+ case "context_deletion_queue": {
7767
+ const status = args.status === "declined" ? "declined" : "pending";
7768
+ const requests = await listDeletionRequests({ nestId, status });
7769
+ if (requests.length === 0) {
7770
+ return status === "pending" ? "No deletion requests waiting. All caught up!" : "No declined deletion requests.";
7771
+ }
7772
+ const list = requests.map((r, i) => {
7773
+ const kind = r.targetType === "document" ? "" : `${r.targetType}: `;
7774
+ const verdict = r.status === "declined" ? `
7775
+ Declined by ${r.resolvedBy}: "${r.resolutionNote}"` : "";
7776
+ return `${i + 1}. **${kind}${r.title || r.nodeId}**
7777
+ Asked by ${r.requestedBy}: "${r.reason}"
7778
+ Request id: \`${r.id}\`${verdict}`;
7779
+ }).join("\n\n");
7780
+ return `# Deletion requests (${status})
7781
+ **${requests.length} item(s)**
7782
+
7783
+ ${list}`;
7784
+ }
7785
+ case "context_resolve_deletion": {
7786
+ const requestId = typeof args.request_id === "string" ? args.request_id : "";
7787
+ const action = args.action === "delete" ? "delete" : args.action === "decline" ? "decline" : null;
7788
+ if (!requestId || !action) {
7789
+ return 'request_id and action are required \u2014 action is "delete" or "decline". Use context_deletion_queue to list request ids.';
7790
+ }
7791
+ const request = await getDeletionRequest(requestId);
7792
+ if (!request || request.nestId !== nestId || request.status !== "pending") {
7793
+ return `No pending deletion request with id ${requestId}.`;
7794
+ }
7795
+ const roles = await resolveUserRoles(nestId, userEmail);
7796
+ if (!roles.includes("owner") && !roles.includes("admin")) {
7797
+ return `You don't have permission to resolve deletion requests in this nest \u2014 only a nest owner or admin can. You can add to the discussion, but the verdict is theirs.`;
7798
+ }
7799
+ const label = await deletionTargetTitle(request);
7800
+ if (action === "decline") {
7801
+ const note = typeof args.note === "string" ? args.note.trim() : "";
7802
+ if (!note) {
7803
+ return "note is required when declining \u2014 the requester is told why it stays.";
7804
+ }
7805
+ await declineDeletion({
7806
+ nestId,
7807
+ id: requestId,
7808
+ declinedBy: userEmail,
7809
+ note,
7810
+ baseUrl: ctx.baseUrl
7811
+ });
7812
+ return `Declined the deletion request for **${label}**. It stays, and ${request.requestedBy} has been told why.`;
7813
+ }
7814
+ if (request.targetType === "nest") {
7815
+ await deleteNest(nestId);
7816
+ } else if (request.targetType === "folder") {
7817
+ await deleteFolder(nestId, request.nodeId, userEmail);
7818
+ await dropDeletionRequest(request.id);
7819
+ } else {
7820
+ await deleteNode(nestId, request.nodeId, userEmail);
7821
+ }
7822
+ await notifyDeletionResolved({
7823
+ nestId,
7824
+ nodeId: request.nodeId,
7825
+ docTitle: label,
7826
+ outcome: "deleted",
7827
+ requestedBy: request.requestedBy,
7828
+ resolvedBy: userEmail,
7829
+ requestId: request.id,
7830
+ targetType: request.targetType,
7831
+ baseUrl: ctx.baseUrl
7832
+ });
7833
+ return `Deleted **${label}** \u2014 ${request.requestedBy}'s request was honoured. Irreversible.`;
7834
+ }
7340
7835
  case "context_review_queue": {
7341
7836
  const status = args.status || "pending";
7342
7837
  const result = await getReviewQueue({
@@ -7355,6 +7850,30 @@ ${sections}`;
7355
7850
  **${result.total} item(s)**
7356
7851
 
7357
7852
  ${list}`;
7853
+ }
7854
+ case "context_my_drafts": {
7855
+ const { drafts, total } = await listMyDrafts({
7856
+ authorEmail: userEmail,
7857
+ nestIds: [ctx.nestId],
7858
+ limit: 100
7859
+ });
7860
+ if (total === 0) {
7861
+ return "No unsubmitted drafts \u2014 everything you've written here is in review or approved.";
7862
+ }
7863
+ const list = drafts.map(
7864
+ (d, i) => `${i + 1}. **${d.title}** v${d.version} \u2014 ${d.status}
7865
+ Path: ${d.nodeId}
7866
+ Last edited: ${d.updatedAt}`
7867
+ ).join("\n\n");
7868
+ const more = total > drafts.length ? `
7869
+
7870
+ _Showing the ${drafts.length} most recent of ${total}._` : "";
7871
+ return `# My Drafts
7872
+ **${total} unsubmitted document(s)**
7873
+
7874
+ ${list}${more}
7875
+
7876
+ Submit one with \`context_submit_review\`.`;
7358
7877
  }
7359
7878
  case "context_submit_review": {
7360
7879
  const docs = await storage.discoverDocuments();
@@ -7397,17 +7916,15 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
7397
7916
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7398
7917
  );
7399
7918
  if (!node) return `Node not found: ${args.title}`;
7400
- const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
7401
7919
  try {
7402
7920
  const request = await approve({
7403
7921
  nestId: ctx.nestId,
7404
7922
  nodeId: node.id,
7405
- version: currentVersion,
7406
7923
  approvedBy: ctx.userEmail,
7407
7924
  note: args.note,
7408
7925
  baseUrl: ctx.baseUrl
7409
7926
  });
7410
- return `Approved "${args.title}" v${currentVersion}. This version is now live for AI queries.${args.note ? `
7927
+ return `Approved "${args.title}" v${request.version}. This version is now live for AI queries.${args.note ? `
7411
7928
  Note: ${args.note}` : ""}`;
7412
7929
  } catch (err) {
7413
7930
  return `Cannot approve: ${err.message}`;
@@ -7419,17 +7936,15 @@ Note: ${args.note}` : ""}`;
7419
7936
  (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
7420
7937
  );
7421
7938
  if (!node) return `Node not found: ${args.title}`;
7422
- const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
7423
7939
  try {
7424
7940
  const request = await reject({
7425
7941
  nestId: ctx.nestId,
7426
7942
  nodeId: node.id,
7427
- version: currentVersion,
7428
7943
  rejectedBy: ctx.userEmail,
7429
7944
  note: args.note,
7430
7945
  baseUrl: ctx.baseUrl
7431
7946
  });
7432
- return `Rejected "${args.title}" v${currentVersion}.
7947
+ return `Rejected "${args.title}" v${request.version}.
7433
7948
  Reason: ${args.note}`;
7434
7949
  } catch (err) {
7435
7950
  return `Cannot reject: ${err.message}`;
@@ -7510,8 +8025,8 @@ ${list}`;
7510
8025
  if (!target) return "target is required (a node id or folder prefix).";
7511
8026
  if (!["read", "write"].includes(role)) return "role must be read or write.";
7512
8027
  try {
7513
- const { createGrant: createGrant2 } = await import("./grants-service-FZD6QM6V.js");
7514
- const db = (await import("./client-AAMF22YJ.js")).getDb();
8028
+ const { createGrant: createGrant2 } = await import("./grants-service-O4AO326A.js");
8029
+ const db = (await import("./client-NFUHPLLM.js")).getDb();
7515
8030
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
7516
8031
  const e = normalizeEmail2(String(args.email || ""));
7517
8032
  if (!e) return "email is required.";
@@ -8598,7 +9113,7 @@ definitionRoutes.delete("/:id", async (c) => {
8598
9113
  import { Hono as Hono18 } from "hono";
8599
9114
  import { v4 as uuid13 } from "uuid";
8600
9115
  import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
8601
- import { join as join4 } from "path";
9116
+ import { join as join5 } from "path";
8602
9117
  var assetRoutes = new Hono18();
8603
9118
  var CONTENT_TYPES = {
8604
9119
  png: "image/png",
@@ -8646,9 +9161,9 @@ assetRoutes.post("/", async (c) => {
8646
9161
  );
8647
9162
  }
8648
9163
  const name = `${uuid13()}.${ext === "jpeg" ? "jpg" : ext}`;
8649
- const dir = join4(resolveNestPath(nestId), "assets");
9164
+ const dir = join5(resolveNestPath(nestId), "assets");
8650
9165
  await mkdir2(dir, { recursive: true });
8651
- await writeFile(join4(dir, name), Buffer.from(await file.arrayBuffer()));
9166
+ await writeFile(join5(dir, name), Buffer.from(await file.arrayBuffer()));
8652
9167
  const url = `/nests/${nestId}/assets/${name}`;
8653
9168
  return c.json(
8654
9169
  {
@@ -8670,7 +9185,7 @@ assetRoutes.get("/:file", async (c) => {
8670
9185
  }
8671
9186
  let bytes;
8672
9187
  try {
8673
- bytes = await readFile2(join4(resolveNestPath(nestId), "assets", name));
9188
+ bytes = await readFile2(join5(resolveNestPath(nestId), "assets", name));
8674
9189
  } catch {
8675
9190
  throw new NotFoundError("Asset not found");
8676
9191
  }
@@ -8690,6 +9205,7 @@ import { Hono as Hono19 } from "hono";
8690
9205
  import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
8691
9206
  import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
8692
9207
  import { z as z2 } from "zod";
9208
+ var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set(["nest_create", "context_create"]);
8693
9209
  async function getUserEmail3(userId) {
8694
9210
  const db = getDb();
8695
9211
  const user = await db.get("SELECT email FROM users WHERE id = ?", [
@@ -8871,6 +9387,38 @@ var SERVER_TOOL_DEFINITIONS = [
8871
9387
  },
8872
9388
  required: ["nest", "selector"]
8873
9389
  }
9390
+ },
9391
+ {
9392
+ name: "nest_create",
9393
+ description: "Create a new nest owned by the caller. Returns the new nest's id \u2014 target it with the other tools afterwards.",
9394
+ inputSchema: {
9395
+ type: "object",
9396
+ properties: {
9397
+ name: { type: "string", description: "Nest name" },
9398
+ description: { type: "string", description: "Optional description" }
9399
+ },
9400
+ required: ["name"]
9401
+ }
9402
+ },
9403
+ {
9404
+ name: "context_create",
9405
+ description: "Create a new knowledge node (document) in a nest. Needs write permission on that nest. A `folder` path creates the folder implicitly \u2014 folders exist through the nodes placed in them.",
9406
+ inputSchema: {
9407
+ type: "object",
9408
+ properties: {
9409
+ ...NEST_ARG,
9410
+ title: { type: "string", description: "Descriptive title" },
9411
+ content: { type: "string", description: "Markdown content body" },
9412
+ type: { type: "string", description: "Node type (default: document)" },
9413
+ tags: { type: "array", items: { type: "string" }, description: "Tags" },
9414
+ scope: { type: "string", description: "Visibility scope" },
9415
+ folder: {
9416
+ type: "string",
9417
+ description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
9418
+ }
9419
+ },
9420
+ required: ["nest", "title", "content"]
9421
+ }
8874
9422
  }
8875
9423
  ];
8876
9424
  async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
@@ -8879,6 +9427,31 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
8879
9427
  if (!nests.length) return "No nests accessible with these credentials.";
8880
9428
  return renderNestIndexMarkdown(nests, baseUrl);
8881
9429
  }
9430
+ if (SERVER_WRITE_TOOLS.has(toolName)) {
9431
+ if (isSuspended()) {
9432
+ const reason = getSuspensionReason();
9433
+ return `This server is suspended by PromptOwl; writes are blocked${reason ? ` \u2014 ${reason}` : ""}. Contact support@promptowl.ai.`;
9434
+ }
9435
+ if (!getCurrentLicense()?.valid) {
9436
+ return "This server has no valid PromptOwl license installed, so writes are disabled. Ask the server admin to install one (POST /license/install).";
9437
+ }
9438
+ }
9439
+ if (toolName === "nest_create") {
9440
+ if (nestScope) {
9441
+ return "This credential is scoped to a single nest and cannot create new ones.";
9442
+ }
9443
+ try {
9444
+ const nest2 = await createNest(
9445
+ userId,
9446
+ String(args.name ?? ""),
9447
+ args.description ? String(args.description) : void 0
9448
+ );
9449
+ return `Created nest "${nest2.name}" (id: ${nest2.id}). Add documents with context_create, retrieve with the other tools.`;
9450
+ } catch (err) {
9451
+ if (err instanceof ValidationError) return err.message;
9452
+ throw err;
9453
+ }
9454
+ }
8882
9455
  const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
8883
9456
  const nest = await resolveNestRef(String(args.nest ?? ""), userId);
8884
9457
  if (!nest) return inaccessible;
@@ -9037,7 +9610,9 @@ Streamable HTTP, stateless. Two endpoints:
9037
9610
  Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
9038
9611
  \`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
9039
9612
  each takes a \`nest\` argument (id or exact name) plus the same
9040
- selector/hops/max_tokens parameters as the REST API. Read-only.
9613
+ selector/hops/max_tokens parameters as the REST API. Two bootstrap writes:
9614
+ \`nest_create\` (new nest owned by the caller) and \`context_create\`
9615
+ (new document in a writable nest; a \`folder\` path creates the folder).
9041
9616
  - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
9042
9617
  governance (create/update, submit/approve/reject, stewards, sharing).
9043
9618
 
@@ -9425,8 +10000,8 @@ function rowToComment(row) {
9425
10000
  }
9426
10001
 
9427
10002
  // src/governance/stewards-parser.ts
9428
- import { readFileSync as readFileSync2, existsSync } from "fs";
9429
- import { join as join5 } from "path";
10003
+ import { readFileSync as readFileSync3, existsSync } from "fs";
10004
+ import { join as join6 } from "path";
9430
10005
  function parseStewardsYaml(content) {
9431
10006
  const result = { version: 1 };
9432
10007
  const lines = content.split("\n");
@@ -9492,13 +10067,13 @@ function parseEntry(str) {
9492
10067
  function loadStewardsConfig(nestId) {
9493
10068
  const nestPath = resolveNestPath(nestId);
9494
10069
  const candidates = [
9495
- join5(nestPath, "stewards.yaml"),
9496
- join5(nestPath, "stewards.yml"),
9497
- join5(nestPath, ".context", "stewards.yaml")
10070
+ join6(nestPath, "stewards.yaml"),
10071
+ join6(nestPath, "stewards.yml"),
10072
+ join6(nestPath, ".context", "stewards.yaml")
9498
10073
  ];
9499
10074
  for (const candidatePath of candidates) {
9500
10075
  if (existsSync(candidatePath)) {
9501
- const content = readFileSync2(candidatePath, "utf-8");
10076
+ const content = readFileSync3(candidatePath, "utf-8");
9502
10077
  return parseStewardsYaml(content);
9503
10078
  }
9504
10079
  }
@@ -9554,7 +10129,8 @@ governanceRoutes.post("/stewards", async (c) => {
9554
10129
  documentId: body.documentId,
9555
10130
  tagName: body.tagName,
9556
10131
  users: body.users,
9557
- assignedBy
10132
+ assignedBy,
10133
+ baseUrl: requestBaseUrl(c.req.url)
9558
10134
  });
9559
10135
  return c.json({ stewards: created2 }, 201);
9560
10136
  }
@@ -9572,7 +10148,8 @@ governanceRoutes.post("/stewards", async (c) => {
9572
10148
  role: body.role
9573
10149
  }
9574
10150
  ],
9575
- assignedBy
10151
+ assignedBy,
10152
+ baseUrl: requestBaseUrl(c.req.url)
9576
10153
  });
9577
10154
  return c.json({ steward: created[0] }, 201);
9578
10155
  });
@@ -9628,6 +10205,123 @@ governanceRoutes.get("/review-queue", async (c) => {
9628
10205
  }
9629
10206
  return c.json({ ...result, requests });
9630
10207
  });
10208
+ function denyUnlessNestManager(c, what) {
10209
+ const permission = c.get("nestPermission");
10210
+ if (permission === "owner" || permission === "admin") return null;
10211
+ return c.json(
10212
+ {
10213
+ error: `Only the nest owner or an admin can delete ${what}. Request its deletion instead, or ask an admin to action it.`
10214
+ },
10215
+ 403
10216
+ );
10217
+ }
10218
+ governanceRoutes.post("/delete-folder", async (c) => {
10219
+ const nestId = c.req.param("nestId");
10220
+ const denied = denyUnlessNestManager(c, "a folder");
10221
+ if (denied) return denied;
10222
+ const body = await c.req.json().catch(() => ({}));
10223
+ if (!body.folder?.trim()) {
10224
+ throw new ValidationError("folder is required");
10225
+ }
10226
+ const deleted = await deleteFolder(
10227
+ nestId,
10228
+ body.folder.trim(),
10229
+ await getUserEmail4(c)
10230
+ );
10231
+ return c.json({ deleted: deleted.length, nodeIds: deleted });
10232
+ });
10233
+ governanceRoutes.post("/request-deletion", async (c) => {
10234
+ const nestId = c.req.param("nestId");
10235
+ const body = await c.req.json().catch(() => ({}));
10236
+ const reason = body.reason?.trim();
10237
+ if (!reason) {
10238
+ throw new ValidationError("A reason for the deletion request is required");
10239
+ }
10240
+ if (body.targetType !== "folder" && body.targetType !== "nest") {
10241
+ throw new ValidationError(
10242
+ 'targetType must be "folder" or "nest" \u2014 flag a single document via POST /nests/:nestId/nodes/:nodeId/request-deletion'
10243
+ );
10244
+ }
10245
+ const target = body.targetType === "nest" ? nestId : (body.target || "").trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
10246
+ if (body.targetType === "folder" && !target) {
10247
+ throw new ValidationError("target (the folder path) is required");
10248
+ }
10249
+ if (body.targetType === "folder") {
10250
+ const { storage } = await engineCache.get(nestId);
10251
+ const docs = await storage.discoverDocuments();
10252
+ if (!docs.some((d) => isIdUnderFolder(d.id, target))) {
10253
+ return c.json({ error: `Folder not found: ${target}` }, 404);
10254
+ }
10255
+ }
10256
+ const request = await requestDeletion({
10257
+ nestId,
10258
+ nodeId: target,
10259
+ targetType: body.targetType,
10260
+ requestedBy: await getUserEmail4(c),
10261
+ reason,
10262
+ baseUrl: requestBaseUrl(c.req.url)
10263
+ });
10264
+ return c.json({ request }, 201);
10265
+ });
10266
+ governanceRoutes.get("/deletion-requests", async (c) => {
10267
+ const nestId = c.req.param("nestId");
10268
+ const raw = c.req.query("status");
10269
+ const status = raw === "declined" || raw === "all" ? raw : "pending";
10270
+ const requests = await listDeletionRequests({ nestId, status });
10271
+ return c.json({ requests, total: requests.length });
10272
+ });
10273
+ governanceRoutes.post("/deletion-requests/:id/delete", async (c) => {
10274
+ const nestId = c.req.param("nestId");
10275
+ const request = await getDeletionRequest(c.req.param("id"));
10276
+ if (!request || request.nestId !== nestId || request.status !== "pending") {
10277
+ return c.json({ error: "Deletion request not found" }, 404);
10278
+ }
10279
+ const actor = await getUserEmail4(c);
10280
+ const docTitle = await deletionTargetTitle(request);
10281
+ if (request.targetType === "nest" || request.targetType === "folder") {
10282
+ const denied = denyUnlessNestManager(
10283
+ c,
10284
+ request.targetType === "nest" ? "a nest" : "a folder"
10285
+ );
10286
+ if (denied) return denied;
10287
+ }
10288
+ if (request.targetType === "nest") {
10289
+ await deleteNest(nestId);
10290
+ } else if (request.targetType === "folder") {
10291
+ await deleteFolder(nestId, request.nodeId, actor);
10292
+ await dropDeletionRequest(request.id);
10293
+ } else {
10294
+ await deleteNode(nestId, request.nodeId, actor);
10295
+ }
10296
+ await notifyDeletionResolved({
10297
+ nestId,
10298
+ nodeId: request.nodeId,
10299
+ docTitle,
10300
+ outcome: "deleted",
10301
+ requestedBy: request.requestedBy,
10302
+ resolvedBy: actor,
10303
+ requestId: request.id,
10304
+ targetType: request.targetType,
10305
+ baseUrl: requestBaseUrl(c.req.url)
10306
+ });
10307
+ return c.json({ deleted: true, nodeId: request.nodeId });
10308
+ });
10309
+ governanceRoutes.post("/deletion-requests/:id/decline", async (c) => {
10310
+ const nestId = c.req.param("nestId");
10311
+ const body = await c.req.json().catch(() => ({}));
10312
+ if (!body.note?.trim()) {
10313
+ throw new ValidationError("A note is required when declining");
10314
+ }
10315
+ const request = await declineDeletion({
10316
+ nestId,
10317
+ id: c.req.param("id"),
10318
+ declinedBy: await getUserEmail4(c),
10319
+ note: body.note.trim(),
10320
+ baseUrl: requestBaseUrl(c.req.url)
10321
+ });
10322
+ if (!request) return c.json({ error: "Deletion request not found" }, 404);
10323
+ return c.json({ request });
10324
+ });
9631
10325
  governanceRoutes.get("/feed", async (c) => {
9632
10326
  const nestId = c.req.param("nestId");
9633
10327
  const filterRaw = c.req.query("filter");
@@ -9689,25 +10383,71 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
9689
10383
  governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
9690
10384
  const nestId = c.req.param("nestId");
9691
10385
  const nodeId = c.req.param("nodeId");
10386
+ const userId = c.get("userId");
10387
+ const userEmail = await resolveCallerEmail(userId);
10388
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
10389
+ return c.json(
10390
+ { error: "Access denied \u2014 no steward assignment for this node" },
10391
+ 403
10392
+ );
10393
+ }
9692
10394
  const allVersions = await getVersions(nestId, nodeId);
9693
10395
  const approved = await getApprovedVersion(nestId, nodeId);
9694
10396
  const { versions: versionManager } = await engineCache.get(nestId);
9695
- const withContent = await Promise.all(
10397
+ const resolutions = await getDb().all(
10398
+ `SELECT version, status, resolved_by
10399
+ FROM review_requests
10400
+ WHERE nest_id = ? AND node_id = ?
10401
+ AND status IN ('approved', 'rejected')
10402
+ AND resolved_by IS NOT NULL
10403
+ ORDER BY resolved_at DESC`,
10404
+ [nestId, nodeId]
10405
+ );
10406
+ const byVersion = /* @__PURE__ */ new Map();
10407
+ for (const r of resolutions) {
10408
+ if (!byVersion.has(r.version)) {
10409
+ byVersion.set(r.version, { status: r.status, resolvedBy: r.resolved_by });
10410
+ }
10411
+ }
10412
+ const enriched = await Promise.all(
9696
10413
  allVersions.map(async (v) => {
10414
+ const r = byVersion.get(v.version);
10415
+ const resolution = r ? { resolvedBy: r.resolvedBy, resolutionStatus: r.status } : {};
9697
10416
  try {
9698
- const raw = await versionManager.reconstructVersion(nodeId, v.version);
9699
- return { ...v, content: bodyOnly(nodeId, raw) };
10417
+ const diff = await versionManager.getDiff(nodeId, v.version);
10418
+ return { ...v, ...resolution, diff: diff ?? void 0 };
9700
10419
  } catch {
9701
- return v;
10420
+ return { ...v, ...resolution };
9702
10421
  }
9703
10422
  })
9704
10423
  );
9705
10424
  return c.json({
9706
- versions: withContent,
10425
+ versions: enriched,
9707
10426
  approvedVersion: approved,
9708
10427
  currentVersion: allVersions[0]?.version || 0
9709
10428
  });
9710
10429
  });
10430
+ governanceNodeRoutes.get("/:nodeId{.+}/versions/:version{[0-9]+}", async (c) => {
10431
+ const nestId = c.req.param("nestId");
10432
+ const nodeId = c.req.param("nodeId");
10433
+ const version = Number(c.req.param("version"));
10434
+ const userId = c.get("userId");
10435
+ const userEmail = await resolveCallerEmail(userId);
10436
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
10437
+ return c.json(
10438
+ { error: "Access denied \u2014 no steward assignment for this node" },
10439
+ 403
10440
+ );
10441
+ }
10442
+ const { versions: versionManager } = await engineCache.get(nestId);
10443
+ try {
10444
+ const raw = await versionManager.reconstructVersion(nodeId, version);
10445
+ return c.json({ version, content: bodyOnly(nodeId, raw) });
10446
+ } catch (err) {
10447
+ console.error("reconstructVersion failed", nestId, nodeId, version, err);
10448
+ return c.json({ version, content: null }, 200);
10449
+ }
10450
+ });
9711
10451
  governanceNodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
9712
10452
  const nestId = c.req.param("nestId");
9713
10453
  const nodeId = c.req.param("nodeId");
@@ -9820,6 +10560,33 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
9820
10560
  201
9821
10561
  );
9822
10562
  });
10563
+ governanceNodeRoutes.post("/:nodeId{.+}/request-deletion", async (c) => {
10564
+ const nestId = c.req.param("nestId");
10565
+ const nodeId = c.req.param("nodeId");
10566
+ const body = await c.req.json().catch(() => ({}));
10567
+ const reason = body.reason?.trim();
10568
+ if (!reason) {
10569
+ throw new ValidationError("A reason for the deletion request is required");
10570
+ }
10571
+ const userEmail = await getUserEmail4(c);
10572
+ if (!await canReadNode(nestId, nodeId, c.get("userId"), userEmail)) {
10573
+ return c.json({ error: `Node not found: ${nodeId}` }, 404);
10574
+ }
10575
+ const { storage } = await engineCache.get(nestId);
10576
+ try {
10577
+ await storage.readDocument(nodeId);
10578
+ } catch {
10579
+ return c.json({ error: `Node not found: ${nodeId}` }, 404);
10580
+ }
10581
+ const request = await requestDeletion({
10582
+ nestId,
10583
+ nodeId,
10584
+ requestedBy: userEmail,
10585
+ reason,
10586
+ baseUrl: requestBaseUrl(c.req.url)
10587
+ });
10588
+ return c.json({ request }, 201);
10589
+ });
9823
10590
  governanceNodeRoutes.post(
9824
10591
  "/:nodeId{.+}/external-edits/:suggestionId/approve",
9825
10592
  async (c) => {
@@ -9891,7 +10658,6 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
9891
10658
  const request = await approve({
9892
10659
  nestId,
9893
10660
  nodeId,
9894
- version: await getCurrentVersion(nestId, nodeId),
9895
10661
  approvedBy: userEmail,
9896
10662
  note: body.note,
9897
10663
  override: body.override && isAdmin,
@@ -9914,7 +10680,6 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
9914
10680
  const request = await reject({
9915
10681
  nestId,
9916
10682
  nodeId,
9917
- version: await getCurrentVersion(nestId, nodeId),
9918
10683
  rejectedBy: userEmail,
9919
10684
  note: body.note,
9920
10685
  baseUrl: requestBaseUrl(c.req.url)
@@ -10007,19 +10772,19 @@ async function ensureAnonymousUser() {
10007
10772
  // src/app.ts
10008
10773
  import { serveStatic } from "@hono/node-server/serve-static";
10009
10774
  import { fileURLToPath } from "url";
10010
- import { dirname as dirname2, join as join6, relative as relative2 } from "path";
10011
- import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
10775
+ import { dirname as dirname2, join as join7, relative as relative2 } from "path";
10776
+ import { existsSync as existsSync2, readFileSync as readFileSync4 } from "fs";
10012
10777
  var HERE = dirname2(fileURLToPath(import.meta.url));
10013
10778
  var SERVICE_VERSION = (() => {
10014
10779
  try {
10015
- return JSON.parse(readFileSync3(join6(HERE, "..", "package.json"), "utf8")).version;
10780
+ return JSON.parse(readFileSync4(join7(HERE, "..", "package.json"), "utf8")).version;
10016
10781
  } catch {
10017
10782
  return "unknown";
10018
10783
  }
10019
10784
  })();
10020
10785
  var UI_DIR_CANDIDATES = [
10021
- join6(HERE, "web3"),
10022
- join6(process.cwd(), "dist", "web3")
10786
+ join7(HERE, "web3"),
10787
+ join7(process.cwd(), "dist", "web3")
10023
10788
  ];
10024
10789
  var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
10025
10790
  var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
@@ -10055,6 +10820,49 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
10055
10820
  }
10056
10821
  return c.json({ error: "Missing or invalid credentials" }, 401);
10057
10822
  });
10823
+ function nestPathParts(path) {
10824
+ return path.replace(/^\/nests\//, "").split("/").filter(Boolean);
10825
+ }
10826
+ var flexAuthWithPromptowlTicket = createMiddleware2(
10827
+ async (c, next) => {
10828
+ const header = c.req.header("Authorization") || "";
10829
+ const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
10830
+ const secret = config.OFFICIAL_COMMUNITY_SSO_SECRET;
10831
+ const looksLikeJwt = !!token && !token.startsWith("cnst_") && token.split(".").length === 3;
10832
+ if (!looksLikeJwt || !secret) return flexAuthMiddleware(c, next);
10833
+ let claims;
10834
+ try {
10835
+ claims = verifySsoTicket(token, secret);
10836
+ } catch {
10837
+ return c.json({ error: "Invalid service ticket" }, 401);
10838
+ }
10839
+ if (claims.scope !== "mcp" || claims.jti) {
10840
+ return c.json({ error: "Invalid service ticket" }, 401);
10841
+ }
10842
+ const expectedAud = config.PUBLIC_BASE_URL;
10843
+ if (expectedAud && (claims.aud || "").replace(/\/$/, "") !== expectedAud) {
10844
+ return c.json({ error: "Invalid service ticket" }, 401);
10845
+ }
10846
+ const sub = claims.sub.trim();
10847
+ if (!isValidTicketSub(sub)) {
10848
+ return c.json({ error: "Invalid service ticket" }, 401);
10849
+ }
10850
+ const db = getDb();
10851
+ const user = await db.get(
10852
+ "SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
10853
+ [sub]
10854
+ );
10855
+ if (!user) {
10856
+ return c.json(
10857
+ { error: "No ContextNest account exists for this PromptOwl user" },
10858
+ 404
10859
+ );
10860
+ }
10861
+ c.set("userId", user.id);
10862
+ c.set("nestScope", null);
10863
+ return next();
10864
+ }
10865
+ );
10058
10866
  var APPROVE_CACHE_TTL_MS = 3e4;
10059
10867
  var approveCache = /* @__PURE__ */ new Map();
10060
10868
  async function canApproveInNest(nestId, email) {
@@ -10240,6 +11048,7 @@ function createApp() {
10240
11048
  });
10241
11049
  const serverAdminAllowed = async (c) => config.AUTH_MODE === "open" || await isServerAdminUserId(c.get("userId"));
10242
11050
  app.use("/admin/settings", flexAuthMiddleware);
11051
+ app.use("/admin/settings/*", flexAuthMiddleware);
10243
11052
  const currentServerSettings = () => ({
10244
11053
  promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
10245
11054
  manual_sign_in: config.MANUAL_SIGN_IN,
@@ -10272,6 +11081,7 @@ function createApp() {
10272
11081
  // Server-wide runner default. NEVER the value — masked tail only.
10273
11082
  anthropic_api_key_masked: process.env.ANTHROPIC_API_KEY ? `\u2022\u2022\u2022\u2022${process.env.ANTHROPIC_API_KEY.slice(-4)}` : null,
10274
11083
  slack_webhook_url: config.SLACK_WEBHOOK_URL ?? "",
11084
+ msteams_webhook_url: config.MSTEAMS_WEBHOOK_URL ?? "",
10275
11085
  smtp_url: config.SMTP_URL ?? "",
10276
11086
  notify_email_from: config.NOTIFY_EMAIL_FROM ?? "",
10277
11087
  notify_email_to: config.NOTIFY_EMAIL_TO ?? ""
@@ -10281,6 +11091,20 @@ function createApp() {
10281
11091
  return c.json({ error: "Only the server admin can view this." }, 403);
10282
11092
  return c.json(currentServerSettings());
10283
11093
  });
11094
+ app.post("/admin/settings/test-notification", async (c) => {
11095
+ if (!await serverAdminAllowed(c))
11096
+ return c.json({ error: "Only the server admin can do this." }, 403);
11097
+ let body;
11098
+ try {
11099
+ body = await c.req.json();
11100
+ } catch {
11101
+ return c.json({ error: "Invalid JSON body" }, 400);
11102
+ }
11103
+ if (body.channel !== "slack" && body.channel !== "teams")
11104
+ return c.json({ error: "channel must be 'slack' or 'teams'" }, 400);
11105
+ const result = await sendTestNotification(body.channel);
11106
+ return result.ok ? c.json({ ok: true }) : c.json({ error: result.error }, 502);
11107
+ });
10284
11108
  app.patch("/admin/settings", async (c) => {
10285
11109
  if (!await serverAdminAllowed(c))
10286
11110
  return c.json({ error: "Only the server admin can change this." }, 403);
@@ -10473,6 +11297,12 @@ function createApp() {
10473
11297
  addError("slack_webhook_url", "Slack webhook must be an https:// URL (or leave it empty to turn Slack notifications off).");
10474
11298
  else pending.push({ name: "SLACK_WEBHOOK_URL", value: v || null });
10475
11299
  }
11300
+ if ("msteams_webhook_url" in body) {
11301
+ const v = String(body.msteams_webhook_url ?? "").trim();
11302
+ if (v && !/^https:\/\//i.test(v))
11303
+ addError("msteams_webhook_url", "Microsoft Teams webhook must be an https:// URL (or leave it empty to turn Teams notifications off).");
11304
+ else pending.push({ name: "MSTEAMS_WEBHOOK_URL", value: v || null });
11305
+ }
10476
11306
  if ("smtp_url" in body) {
10477
11307
  const v = String(body.smtp_url ?? "").trim();
10478
11308
  if (v && !/^smtps?:\/\//i.test(v))
@@ -10708,6 +11538,25 @@ function createApp() {
10708
11538
  requested_at: r.requestedAt,
10709
11539
  priority: r.priority
10710
11540
  });
11541
+ const deletions = await listDeletionRequests({
11542
+ nestIds: [...nestName2.keys()],
11543
+ status: "pending"
11544
+ });
11545
+ const toDeletionItem = (r) => ({
11546
+ type: "deletion",
11547
+ nest_id: r.nestId,
11548
+ nest_name: nestName2.get(r.nestId),
11549
+ node_id: r.nodeId,
11550
+ title: r.title || r.nodeId,
11551
+ requested_by: r.requestedBy,
11552
+ requested_at: r.createdAt,
11553
+ priority: "normal",
11554
+ reason: r.reason,
11555
+ request_id: r.id,
11556
+ // node_id holds a folder path / nest id for those targets, so a client
11557
+ // must not treat it as a document to open.
11558
+ target_type: r.targetType
11559
+ });
10711
11560
  const byNewest = (a, b) => a.requested_at < b.requested_at ? 1 : -1;
10712
11561
  const others = requests.filter((r) => r.requestedBy !== me);
10713
11562
  const nestApprove = new Map(
@@ -10720,8 +11569,27 @@ function createApp() {
10720
11569
  )
10721
11570
  )
10722
11571
  );
10723
- const items = others.filter((r) => nestApprove.get(r.nestId)).map(toItem).sort(byNewest);
10724
- const waiting = requests.filter((r) => r.requestedBy === me).map(toItem).sort(byNewest);
11572
+ const items = others.filter((r) => nestApprove.get(r.nestId)).map(toItem);
11573
+ const waiting = requests.filter((r) => r.requestedBy === me).map(toItem);
11574
+ const othersDeletions = deletions.filter((r) => r.requestedBy !== me);
11575
+ const nestManage = new Map(
11576
+ await Promise.all(
11577
+ [...new Set(othersDeletions.map((r) => r.nestId))].map(
11578
+ async (nestId) => {
11579
+ const roles = await resolveUserRoles(nestId, me);
11580
+ return [nestId, roles.includes("owner") || roles.includes("admin")];
11581
+ }
11582
+ )
11583
+ )
11584
+ );
11585
+ items.push(
11586
+ ...othersDeletions.filter((r) => nestManage.get(r.nestId)).map(toDeletionItem)
11587
+ );
11588
+ items.sort(byNewest);
11589
+ waiting.push(
11590
+ ...deletions.filter((r) => r.requestedBy === me).map(toDeletionItem)
11591
+ );
11592
+ waiting.sort(byNewest);
10725
11593
  const notifications = await listNotifications(me, { unreadOnly: true, limit: 50 });
10726
11594
  return c.json({
10727
11595
  items,
@@ -10732,6 +11600,37 @@ function createApp() {
10732
11600
  pagination: { total, limit, offset, has_more: offset + requests.length < total }
10733
11601
  });
10734
11602
  });
11603
+ app.get("/me/drafts", async (c) => {
11604
+ const userId = c.get("userId");
11605
+ const me = await resolveCallerEmail(userId);
11606
+ const nestName2 = /* @__PURE__ */ new Map();
11607
+ for (const n of await listVisibleNests(userId))
11608
+ nestName2.set(n.id, n.name ?? n.id);
11609
+ const limit = Math.min(Math.max(Number(c.req.query("limit")) || 25, 1), 100);
11610
+ const offset = Math.max(Number(c.req.query("offset")) || 0, 0);
11611
+ const { drafts, total } = await listMyDrafts({
11612
+ authorEmail: me,
11613
+ nestIds: [...nestName2.keys()],
11614
+ limit,
11615
+ offset
11616
+ });
11617
+ return c.json({
11618
+ drafts: drafts.map((d) => ({
11619
+ nest_id: d.nestId,
11620
+ nest_name: nestName2.get(d.nestId),
11621
+ node_id: d.nodeId,
11622
+ title: d.title,
11623
+ // Where it actually lives — the thing that was impossible to find.
11624
+ folder: folderForNode(d.nodeId),
11625
+ status: d.status,
11626
+ version: d.version,
11627
+ updated_at: d.updatedAt
11628
+ })),
11629
+ // has_more off the REQUESTED page size, not the returned row count: the
11630
+ // existence filter can shorten a page, and that must not read as "last".
11631
+ pagination: { total, limit, offset, has_more: offset + limit < total }
11632
+ });
11633
+ });
10735
11634
  app.get("/me/notifications", async (c) => {
10736
11635
  const me = await resolveCallerEmail(c.get("userId"));
10737
11636
  const unreadOnly = c.req.query("unread") === "true";
@@ -10771,15 +11670,18 @@ function createApp() {
10771
11670
  app.route("/runs", runDetailRoutes);
10772
11671
  app.route("/hooks", hookFireRoutes);
10773
11672
  app.route("/llms.txt", llmsTxtRoutes);
10774
- app.use("/index", flexAuthMiddleware);
11673
+ app.use("/index", flexAuthWithPromptowlTicket);
10775
11674
  app.route("/index", nestIndexRoutes);
10776
- app.use("/mcp", flexAuthMiddleware);
11675
+ app.use("/mcp", flexAuthWithPromptowlTicket);
10777
11676
  app.route("/mcp", serverMcpRoutes);
10778
11677
  const nestsApp = new Hono21();
10779
- nestsApp.use("*", flexAuthMiddleware);
10780
11678
  nestsApp.use("*", async (c, next) => {
10781
- const localPath = c.req.path.replace(/^\/nests\//, "");
10782
- const parts = localPath.split("/").filter(Boolean);
11679
+ const parts = nestPathParts(c.req.path);
11680
+ const ticketEligible = parts.length === 2 && (c.req.method === "POST" && parts[1] === "context" || c.req.method === "GET" && parts[1] === "nodes" || parts[1] === "mcp");
11681
+ return ticketEligible ? flexAuthWithPromptowlTicket(c, next) : flexAuthMiddleware(c, next);
11682
+ });
11683
+ nestsApp.use("*", async (c, next) => {
11684
+ const parts = nestPathParts(c.req.path);
10783
11685
  if (parts.length < 2) return next();
10784
11686
  if (parts[0] === "unsynced") return next();
10785
11687
  const nestId = parts[0];
@@ -10816,7 +11718,8 @@ function createApp() {
10816
11718
  const isCommentPath = /\/comments$/.test(path2) || /\/comments\/[^/]+\/resolve$/.test(path2);
10817
11719
  const isActivityPath = /\/activity$/.test(path2);
10818
11720
  const isFeedPath = parts.length === 2 && parts[1] === "feed";
10819
- const isGovernance = path2.includes("/stewards") || path2.includes("/review-queue") || path2.includes("/versions") || path2.includes("/reviews") || path2.includes("/submit-review") || path2.includes("/cancel-review") || path2.includes("/approve") || path2.includes("/reject") || path2.includes("/can-access") || path2.includes("/can-approve") || path2.includes("/can-edit") || isCommentPath || isActivityPath || isFeedPath;
11721
+ const isDeletionQueuePath = parts.length === 2 && parts[1] === "deletion-requests";
11722
+ const isGovernance = path2.includes("/stewards") || path2.includes("/review-queue") || path2.includes("/versions") || path2.includes("/reviews") || path2.includes("/submit-review") || path2.includes("/cancel-review") || path2.includes("/approve") || path2.includes("/reject") || path2.includes("/can-access") || path2.includes("/can-approve") || path2.includes("/can-edit") || isCommentPath || isActivityPath || isFeedPath || isDeletionQueuePath;
10820
11723
  const needsLicense = c.req.method !== "GET" || isGovernance;
10821
11724
  if (needsLicense) {
10822
11725
  const lic = getCurrentLicense();
@@ -10862,6 +11765,7 @@ function createApp() {
10862
11765
  const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
10863
11766
  const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
10864
11767
  const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
11768
+ const isDeletionRequestAction = /\/request-deletion$/.test(path);
10865
11769
  const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
10866
11770
  if (isStewardRoster && permission !== "owner" && permission !== "admin") {
10867
11771
  return c.json(
@@ -10880,7 +11784,7 @@ function createApp() {
10880
11784
  required = c.req.method === "GET" ? "write" : "admin";
10881
11785
  } else if (parts[1] === "hooks") {
10882
11786
  required = "write";
10883
- } else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
11787
+ } else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isDeletionRequestAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
10884
11788
  required = "write";
10885
11789
  }
10886
11790
  const isNodeAction = c.req.method === "POST" && parts.length >= 4 && (parts[parts.length - 1] === "revert" || parts[parts.length - 1] === "move");
@@ -11160,6 +12064,111 @@ async function backfillNodeVersionsFromHistory(db) {
11160
12064
  `[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
11161
12065
  );
11162
12066
  }
12067
+ var NOTIFICATION_TITLES_MIGRATION_ID = "006_backfill_notification_titles";
12068
+ async function backfillNotificationTitles(db) {
12069
+ const already = await db.get(
12070
+ "SELECT id FROM schema_migrations WHERE id = ?",
12071
+ [NOTIFICATION_TITLES_MIGRATION_ID]
12072
+ );
12073
+ if (already) return;
12074
+ const rows = await db.all(
12075
+ "SELECT id, nest_id, message FROM notifications WHERE message LIKE '%nodes/%'"
12076
+ );
12077
+ let rewritten = 0;
12078
+ if (rows.length > 0) {
12079
+ const titlesByNest = /* @__PURE__ */ new Map();
12080
+ for (const nestId of new Set(rows.map((r) => r.nest_id))) {
12081
+ titlesByNest.set(nestId, await buildTitleMap(nestId));
12082
+ }
12083
+ for (const row of rows) {
12084
+ const titles = titlesByNest.get(row.nest_id) ?? /* @__PURE__ */ new Map();
12085
+ const next = row.message.replace(
12086
+ /"([^"]*nodes\/[^"]*)"/g,
12087
+ (whole, id) => {
12088
+ const label = describeNodeWith(titles, id);
12089
+ return `"${label.replace(/"/g, "")}"`;
12090
+ }
12091
+ );
12092
+ if (next === row.message) continue;
12093
+ await db.run("UPDATE notifications SET message = ? WHERE id = ?", [
12094
+ next,
12095
+ row.id
12096
+ ]);
12097
+ rewritten++;
12098
+ }
12099
+ }
12100
+ await db.run(
12101
+ insertOrIgnore(db, "INSERT INTO schema_migrations (id) VALUES (?)"),
12102
+ [NOTIFICATION_TITLES_MIGRATION_ID]
12103
+ );
12104
+ console.log(
12105
+ `[backfill] notifications: rewrote ${rewritten} of ${rows.length} message(s) carrying a node id`
12106
+ );
12107
+ }
12108
+ function describeNodeWith(titles, nodeId) {
12109
+ const at = nodeId.indexOf("nodes/");
12110
+ const normalized = at > 0 ? nodeId.slice(at) : nodeId;
12111
+ const title = titles.get(nodeId) || titles.get(normalized) || normalized.split("/").pop() || nodeId;
12112
+ const folder = folderForNode(normalized);
12113
+ return folder ? `${title} (in ${folder})` : title;
12114
+ }
12115
+
12116
+ // src/db/repair-version-chains.ts
12117
+ var MIGRATION_ID2 = "010_repair_grafted_version_chains";
12118
+ async function repairGraftedVersionChains(db) {
12119
+ const already = await db.get("SELECT id FROM schema_migrations WHERE id = ?", [
12120
+ MIGRATION_ID2
12121
+ ]);
12122
+ if (already) return;
12123
+ const nests = await db.all("SELECT id FROM nests");
12124
+ let scanned = 0;
12125
+ let repaired = 0;
12126
+ let externalized = 0;
12127
+ for (const { id: nestId } of nests) {
12128
+ let storage;
12129
+ let versions;
12130
+ try {
12131
+ ({ storage, versions } = await engineCache.get(nestId));
12132
+ } catch (err) {
12133
+ console.warn(
12134
+ `[repair] engine unavailable for nest ${nestId}:`,
12135
+ err.message
12136
+ );
12137
+ continue;
12138
+ }
12139
+ let docs;
12140
+ try {
12141
+ docs = await storage.discoverDocuments();
12142
+ } catch (err) {
12143
+ console.warn(
12144
+ `[repair] discoverDocuments failed for nest ${nestId}:`,
12145
+ err.message
12146
+ );
12147
+ continue;
12148
+ }
12149
+ for (const doc of docs) {
12150
+ scanned += 1;
12151
+ try {
12152
+ if (await versions.repairLatestVersion(doc.id)) {
12153
+ repaired += 1;
12154
+ console.log(`[repair] re-anchored version chain: ${nestId}/${doc.id}`);
12155
+ }
12156
+ externalized += await versions.externalizeDiffs(doc.id);
12157
+ } catch (err) {
12158
+ console.warn(
12159
+ `[repair] could not repair ${nestId}/${doc.id}:`,
12160
+ err.message
12161
+ );
12162
+ }
12163
+ }
12164
+ }
12165
+ await db.run(insertOrIgnore(db, "INSERT INTO schema_migrations (id) VALUES (?)"), [
12166
+ MIGRATION_ID2
12167
+ ]);
12168
+ console.log(
12169
+ `[repair] version chains: scanned ${scanned} docs across ${nests.length} nests, re-anchored ${repaired}, externalized ${externalized} inline change log(s)`
12170
+ );
12171
+ }
11163
12172
 
11164
12173
  // src/workflow/scheduler.ts
11165
12174
  import { v4 as uuid15 } from "uuid";
@@ -11237,6 +12246,16 @@ async function main() {
11237
12246
  } catch (err) {
11238
12247
  console.error("[backfill] node_versions backfill failed:", err);
11239
12248
  }
12249
+ try {
12250
+ await repairGraftedVersionChains(db);
12251
+ } catch (err) {
12252
+ console.error("[repair] version chain repair failed:", err);
12253
+ }
12254
+ try {
12255
+ await backfillNotificationTitles(db);
12256
+ } catch (err) {
12257
+ console.error("[backfill] notification title backfill failed:", err);
12258
+ }
11240
12259
  const accessCfg = loadAccessConfig();
11241
12260
  if (accessCfg) {
11242
12261
  console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);