@promptowl/contextnest-community 1.20.0 → 1.21.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
@@ -18,8 +18,10 @@ import {
18
18
  getPendingReview,
19
19
  getReviewHistory,
20
20
  getReviewQueue,
21
+ isNestTarget,
21
22
  isSafeConnectorUrl,
22
23
  listDeletionRequests,
24
+ nodeTrail,
23
25
  nodeWriteKey,
24
26
  notifyDeletionResolved,
25
27
  notifyNestEvent,
@@ -35,7 +37,7 @@ import {
35
37
  submitForReview,
36
38
  withNodeWriteLock,
37
39
  withdrawDeletionRequest
38
- } from "./chunk-GVUBZTI3.js";
40
+ } from "./chunk-4KJGQHWB.js";
39
41
  import {
40
42
  canCreateInNest,
41
43
  canManageStewards,
@@ -55,7 +57,7 @@ import {
55
57
  resolveUserRoles,
56
58
  syncFromConfig,
57
59
  updateSteward
58
- } from "./chunk-O5DYL7ZG.js";
60
+ } from "./chunk-N2DTJFCD.js";
59
61
  import {
60
62
  createGrant,
61
63
  deleteGrant,
@@ -64,7 +66,7 @@ import {
64
66
  listGrants,
65
67
  listUserGrants,
66
68
  resolveNodeGrant
67
- } from "./chunk-3LUUJUWS.js";
69
+ } from "./chunk-XWGEXQU3.js";
68
70
  import {
69
71
  generateApiKey,
70
72
  getKeyPrefix,
@@ -77,17 +79,20 @@ import {
77
79
  import {
78
80
  checkConflict,
79
81
  createVersion,
82
+ deleteSupersededDrafts,
83
+ deleteUnsealedVersions,
80
84
  getApprovedVersion,
81
85
  getApprovedVersions,
82
86
  getCurrentVersion,
83
87
  getDisplayStatus,
84
88
  getDisplayStatuses,
89
+ getSealedVersion,
85
90
  getTrackedNodeIds,
86
91
  getVersions,
87
92
  listMyDrafts,
88
93
  setApprovedVersion,
89
94
  upsertVersion
90
- } from "./chunk-KHMSDTJJ.js";
95
+ } from "./chunk-IGPJ74O4.js";
91
96
  import {
92
97
  approveExternalEdit,
93
98
  getExternalEditDetail,
@@ -99,19 +104,22 @@ import {
99
104
  scanDocumentForDrift,
100
105
  scanNestForDrift,
101
106
  startDriftScanner
102
- } from "./chunk-EY7WOKJR.js";
107
+ } from "./chunk-6PJQSKKV.js";
103
108
  import {
104
109
  addMember,
105
110
  addWatcher,
111
+ archiveNest,
106
112
  assertSafeNodeId,
107
113
  buildTitleMap,
108
114
  canApproveWith,
109
115
  canEditWith,
116
+ capForArchive,
110
117
  collabPermToRole,
111
118
  createNest,
112
119
  createTeam,
113
120
  deleteNest,
114
121
  deleteTeam,
122
+ describeNode,
115
123
  disableStewardshipAndWipeGovernance,
116
124
  docLink,
117
125
  engineApi,
@@ -133,6 +141,7 @@ import {
133
141
  isEmailConfigured,
134
142
  isLicenseAdminEmail,
135
143
  isLicenseAdminUserId,
144
+ isNestArchived,
136
145
  isPublicReader,
137
146
  isServerAdminUserId,
138
147
  isStewardshipEnabled,
@@ -143,6 +152,7 @@ import {
143
152
  listNotifications,
144
153
  listPublicNests,
145
154
  listSharedNests,
155
+ listSharedTeamMembers,
146
156
  listTeamsForUser,
147
157
  listVisibleNests,
148
158
  listWatchers,
@@ -160,8 +170,11 @@ import {
160
170
  notifyNestInvite,
161
171
  nowExpr,
162
172
  opContext,
173
+ parseSharedTeams,
163
174
  permissionLevel,
164
175
  persistSetting,
176
+ pinNest,
177
+ pinnedNestIds,
165
178
  primaryRole,
166
179
  readIndexedNodes,
167
180
  rebuildNodeIndex,
@@ -177,6 +190,7 @@ import {
177
190
  resolveNestPermission,
178
191
  resolveNodeUrl,
179
192
  resolveTeamRolesForUser,
193
+ restoreNest,
180
194
  sendEmailToRecipient,
181
195
  setAllowSelfApprove,
182
196
  setPrimeOnlyReview,
@@ -189,11 +203,12 @@ import {
189
203
  syncNodeTags,
190
204
  trackEvent,
191
205
  uniqueNestName,
206
+ unpinNest,
192
207
  unshareTeamFromNest,
193
208
  updateMemberRole,
194
209
  validateLicense,
195
210
  verifySmtp
196
- } from "./chunk-7CPP6FIU.js";
211
+ } from "./chunk-6JGQX4GA.js";
197
212
  import {
198
213
  AppError,
199
214
  ConflictError,
@@ -211,7 +226,7 @@ import {
211
226
  initDb,
212
227
  isEmailListish,
213
228
  isEmailish
214
- } from "./chunk-XCFKS65Y.js";
229
+ } from "./chunk-LPPKPEYI.js";
215
230
  import {
216
231
  ANON_EMAIL,
217
232
  ANON_USER_ID,
@@ -1727,6 +1742,10 @@ authRoutes.delete("/users/:userId", async (c) => {
1727
1742
  target.id
1728
1743
  ]);
1729
1744
  await tx.run("DELETE FROM api_keys WHERE user_id = ?", [target.id]);
1745
+ try {
1746
+ await tx.run("DELETE FROM nest_pins WHERE user_id = ?", [target.id]);
1747
+ } catch {
1748
+ }
1730
1749
  await tx.run("DELETE FROM users WHERE id = ?", [target.id]);
1731
1750
  });
1732
1751
  await deleteAllSessionsForUser(target.id);
@@ -1870,6 +1889,7 @@ import {
1870
1889
  filterDocuments,
1871
1890
  DocumentNotFoundError,
1872
1891
  NODE_TYPES,
1892
+ TAG_PATTERN,
1873
1893
  generateIndexMd
1874
1894
  } from "@promptowl/contextnest-engine";
1875
1895
  import { rename, mkdir, rm, stat } from "fs/promises";
@@ -1918,6 +1938,158 @@ function documentPrimeFlag(metadata) {
1918
1938
  return void 0;
1919
1939
  }
1920
1940
 
1941
+ // src/governance/mention-notify.ts
1942
+ import { v4 as uuid4 } from "uuid";
1943
+
1944
+ // src/nests/people-service.ts
1945
+ var PERSON_SOURCES = [
1946
+ "owner",
1947
+ // 0
1948
+ "collaborator",
1949
+ // 1
1950
+ "team",
1951
+ // 2 — not in the union; team rosters are JSON on the team row
1952
+ "steward",
1953
+ // 3
1954
+ "grant"
1955
+ // 4
1956
+ ];
1957
+ async function listNestPeople(nestId) {
1958
+ const db = getDb();
1959
+ const [nest, rows] = await Promise.all([
1960
+ db.get("SELECT shared_teams FROM nests WHERE id = ?", [nestId]),
1961
+ db.all(
1962
+ `SELECT 0 AS rank, u.id AS user_id, u.email AS email, u.name AS name
1963
+ FROM nests n JOIN users u ON u.id = n.user_id
1964
+ WHERE n.id = ?
1965
+ UNION ALL
1966
+ SELECT 1, u.id, u.email, u.name
1967
+ FROM nest_collaborators nc JOIN users u ON u.id = nc.user_id
1968
+ WHERE nc.nest_id = ?
1969
+ UNION ALL
1970
+ SELECT 3, u.id, s.user_email, u.name
1971
+ FROM stewards s
1972
+ LEFT JOIN users u ON LOWER(u.email) = LOWER(s.user_email)
1973
+ WHERE s.nest_id = ? AND s.is_active = 1
1974
+ UNION ALL
1975
+ SELECT 4, u.id, u.email, u.name
1976
+ FROM grants g JOIN users u ON u.id = g.user_id
1977
+ WHERE g.nest_id = ?
1978
+ ORDER BY rank`,
1979
+ [nestId, nestId, nestId, nestId]
1980
+ )
1981
+ ]);
1982
+ if (!nest) return [];
1983
+ const teamRefs = parseSharedTeams(nest.shared_teams);
1984
+ const teamRows = teamRefs.length ? (await listSharedTeamMembers(teamRefs)).map((m) => ({
1985
+ rank: 2,
1986
+ user_id: m.userId,
1987
+ email: m.email,
1988
+ name: m.name
1989
+ })) : [];
1990
+ const people = [];
1991
+ const seen = /* @__PURE__ */ new Set();
1992
+ for (const row of [...rows, ...teamRows].sort((a, b) => a.rank - b.rank)) {
1993
+ const email = (row.email || "").trim();
1994
+ const key = email.toLowerCase();
1995
+ if (!email || seen.has(key)) continue;
1996
+ seen.add(key);
1997
+ people.push({
1998
+ email,
1999
+ name: row.name ?? null,
2000
+ userId: row.user_id ?? null,
2001
+ source: PERSON_SOURCES[row.rank]
2002
+ });
2003
+ }
2004
+ return people;
2005
+ }
2006
+
2007
+ // src/shared/mentions.ts
2008
+ var MENTION = /(?:^|\s)@(?:\(([^)]+)\)|([a-zA-Z0-9._@-]+))/g;
2009
+ function stripCode(markdown2) {
2010
+ return markdown2.replace(/```[\s\S]*?```/g, " ").replace(/`[^`\n]*`/g, " ");
2011
+ }
2012
+ function extractMentions(markdown2) {
2013
+ const names = [];
2014
+ const seen = /* @__PURE__ */ new Set();
2015
+ const text = stripCode(markdown2 || "");
2016
+ let match;
2017
+ MENTION.lastIndex = 0;
2018
+ while ((match = MENTION.exec(text)) !== null) {
2019
+ const name = (match[1] || match[2] || "").trim();
2020
+ const key = name.toLowerCase();
2021
+ if (!name || seen.has(key)) continue;
2022
+ seen.add(key);
2023
+ names.push(name);
2024
+ }
2025
+ return names;
2026
+ }
2027
+ function newMentions(previous, next) {
2028
+ const before = new Set(extractMentions(previous).map((n) => n.toLowerCase()));
2029
+ return extractMentions(next).filter((n) => !before.has(n.toLowerCase()));
2030
+ }
2031
+ function resolveMention(people, name) {
2032
+ const n = name.trim().toLowerCase();
2033
+ if (!n) return void 0;
2034
+ const exact = people.find((p) => p.email.toLowerCase() === n);
2035
+ if (exact) return exact;
2036
+ const matches = people.filter(
2037
+ (p) => p.email.toLowerCase().split("@")[0] === n || (p.name || "").toLowerCase() === n
2038
+ );
2039
+ return matches.length === 1 ? matches[0] : void 0;
2040
+ }
2041
+
2042
+ // src/governance/mention-notify.ts
2043
+ async function canOpenNode(nestId, nodeId, userId, email) {
2044
+ const access = await resolveNestAccess(nestId, userId);
2045
+ if (access.permission === "none") {
2046
+ return await resolveNodeGrant(nestId, userId, nodeId) !== null;
2047
+ }
2048
+ return canReadNode(nestId, nodeId, userId, email);
2049
+ }
2050
+ async function notifyMentions(params) {
2051
+ const { nestId, nodeId, previousBody, nextBody, actorEmail } = params;
2052
+ try {
2053
+ const names = newMentions(previousBody, nextBody);
2054
+ if (names.length === 0) return 0;
2055
+ const people = await listNestPeople(nestId);
2056
+ const actor = actorEmail.toLowerCase();
2057
+ const recipients = /* @__PURE__ */ new Map();
2058
+ for (const name of names) {
2059
+ const person = resolveMention(people, name);
2060
+ if (!person?.userId) continue;
2061
+ const key = person.email.toLowerCase();
2062
+ if (key === actor || recipients.has(key)) continue;
2063
+ recipients.set(key, { email: person.email, userId: person.userId });
2064
+ }
2065
+ if (recipients.size === 0) return 0;
2066
+ const label = await describeNode(nestId, nodeId);
2067
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2068
+ const db = getDb();
2069
+ let created = 0;
2070
+ for (const { email, userId } of recipients.values()) {
2071
+ if (!await canOpenNode(nestId, nodeId, userId, email)) continue;
2072
+ await db.run(
2073
+ `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
2074
+ VALUES (?, ?, ?, 'mention', ?, ?, ?)`,
2075
+ [
2076
+ uuid4(),
2077
+ nestId,
2078
+ email.toLowerCase(),
2079
+ nodeId,
2080
+ `${actorEmail} mentioned you in "${label}"`,
2081
+ now
2082
+ ]
2083
+ );
2084
+ created++;
2085
+ }
2086
+ return created;
2087
+ } catch (err) {
2088
+ console.error("[notify] mention fan-out failed", nestId, nodeId, err);
2089
+ return 0;
2090
+ }
2091
+ }
2092
+
1921
2093
  // src/nodes/service.ts
1922
2094
  async function userIdFromEmail(email) {
1923
2095
  const db = getDb();
@@ -1927,7 +2099,20 @@ async function userIdFromEmail(email) {
1927
2099
  );
1928
2100
  return row?.id ?? ANON_USER_ID;
1929
2101
  }
1930
- var normalizeTag2 = (t) => t.startsWith("#") ? t : `#${t}`;
2102
+ var storedTag = (t) => `#${String(t).trim().replace(/^#+/, "").toLowerCase()}`;
2103
+ function requireTag(raw) {
2104
+ const tag = storedTag(raw);
2105
+ if (!TAG_PATTERN.test(tag)) {
2106
+ throw new ValidationError(
2107
+ `"${String(raw).trim()}" isn't a usable tag \u2014 a tag starts with a letter and is built from letters a-z, digits, "_", ":" and "-".`
2108
+ );
2109
+ }
2110
+ return tag;
2111
+ }
2112
+ function requireTags(raw) {
2113
+ const tags = (raw || []).map((t) => String(t).trim()).filter(Boolean).map(requireTag);
2114
+ return [...new Set(tags)];
2115
+ }
1931
2116
  var MAX_TITLE_LENGTH = 120;
1932
2117
  function assertTitleLength(title) {
1933
2118
  if (title.length > MAX_TITLE_LENGTH) {
@@ -2000,7 +2185,7 @@ async function departmentTagFor(userEmail) {
2000
2185
  [userEmail]
2001
2186
  );
2002
2187
  const slug = slugify(String(row?.department ?? ""));
2003
- return slug ? normalizeTag2(`dept:${slug}`) : null;
2188
+ return slug ? storedTag(`dept:${slug}`) : null;
2004
2189
  } catch (err) {
2005
2190
  console.error("[dept-tag] department lookup failed (tag skipped):", err);
2006
2191
  return null;
@@ -2041,7 +2226,11 @@ var ACCEPTED_NODE_TYPES = /* @__PURE__ */ new Set([
2041
2226
  function toNodeResponse(node) {
2042
2227
  const fm = node.frontmatter;
2043
2228
  const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
2044
- const tags = Array.isArray(fm.tags) ? fm.tags.map((t) => String(t)) : [];
2229
+ const tags = Array.isArray(fm.tags) ? [
2230
+ ...new Set(
2231
+ fm.tags.map((t) => String(t).trim()).filter(Boolean).map(storedTag)
2232
+ )
2233
+ ] : [];
2045
2234
  return {
2046
2235
  id: node.id,
2047
2236
  title,
@@ -2376,7 +2565,8 @@ async function nestCensus(nestId, userId) {
2376
2565
  for (const d of visible) {
2377
2566
  const fm = d.frontmatter;
2378
2567
  types[fm.type || "document"] = (types[fm.type || "document"] || 0) + 1;
2379
- for (const t of fm.tags || []) tags[String(t)] = (tags[String(t)] || 0) + 1;
2568
+ for (const t of new Set((fm.tags || []).map((t2) => storedTag(String(t2)))))
2569
+ tags[t] = (tags[t] || 0) + 1;
2380
2570
  const folder = folderOfId(d.id);
2381
2571
  if (folder) direct.set(folder, (direct.get(folder) || 0) + 1);
2382
2572
  const edited = Date.parse(String(fm.updated_at || ""));
@@ -2470,7 +2660,7 @@ async function createNode(nestId, input, userEmail) {
2470
2660
  }
2471
2661
  }
2472
2662
  async function createNodeOnce(nestId, input, userEmail) {
2473
- const { storage, versions: versionManager } = await engineCache.get(nestId);
2663
+ const { storage } = await engineCache.get(nestId);
2474
2664
  assertTitleLength(input.title);
2475
2665
  assertTitleReadable(input.title);
2476
2666
  const slug = slugify(input.title).slice(0, 100).replace(/-+$/, "");
@@ -2480,7 +2670,7 @@ async function createNodeOnce(nestId, input, userEmail) {
2480
2670
  const prefix = folderSegments.length > 0 ? `nodes/${folderSegments.join("/")}/` : "nodes/";
2481
2671
  id = `${prefix}${slug || await generatedSlug(storage, prefix)}`;
2482
2672
  }
2483
- const tags = (input.tags || []).map(normalizeTag2);
2673
+ const tags = requireTags(input.tags);
2484
2674
  const requestedType = input.type || "document";
2485
2675
  if (requestedType === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
2486
2676
  throw new ValidationError("Artifact nodes are disabled on this server");
@@ -2527,7 +2717,7 @@ async function createNodeOnce(nestId, input, userEmail) {
2527
2717
  });
2528
2718
  }
2529
2719
  const deptTag = await departmentTagFor(userEmail);
2530
- if (deptTag && !tags.some((t) => t.toLowerCase() === deptTag)) {
2720
+ if (deptTag && !tags.includes(deptTag)) {
2531
2721
  tags.push(deptTag);
2532
2722
  }
2533
2723
  if (input.prime !== void 0) await assertMayFlagPrime(nestId, userEmail);
@@ -2591,13 +2781,6 @@ async function createNodeOnce(nestId, input, userEmail) {
2591
2781
  });
2592
2782
  await setApprovedVersion(nestId, id, savedVersion, userEmail);
2593
2783
  } else {
2594
- if (publishReason === null) {
2595
- try {
2596
- await versionManager.createVersion(node, userEmail);
2597
- } catch (err) {
2598
- console.error("VersionManager.createVersion failed (node create)", err);
2599
- }
2600
- }
2601
2784
  await createVersion({
2602
2785
  nestId,
2603
2786
  nodeId: id,
@@ -2609,6 +2792,13 @@ async function createNodeOnce(nestId, input, userEmail) {
2609
2792
  });
2610
2793
  }
2611
2794
  await trackEvent("node.create", { nestId, nodeId: id });
2795
+ await notifyMentions({
2796
+ nestId,
2797
+ nodeId: id,
2798
+ previousBody: "",
2799
+ nextBody: node.body || "",
2800
+ actorEmail: userEmail
2801
+ });
2612
2802
  return { node, version: savedVersion };
2613
2803
  });
2614
2804
  }
@@ -2671,7 +2861,7 @@ async function registerImportedDocuments(nestId, userEmail) {
2671
2861
  const engineMs = Date.now() - startedAt;
2672
2862
  const dbStartedAt = Date.now();
2673
2863
  for (const d of docs) {
2674
- const tags = d.tags.map((t) => normalizeTag2(String(t)));
2864
+ const tags = d.tags.map((t) => storedTag(String(t)));
2675
2865
  await createVersion({
2676
2866
  nestId,
2677
2867
  nodeId: d.id,
@@ -2714,6 +2904,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2714
2904
  } catch {
2715
2905
  throw new NotFoundError(`Node not found: ${nodeId}`);
2716
2906
  }
2907
+ const previousBody = node.body || "";
2717
2908
  const opInput = {};
2718
2909
  if (patch.content !== void 0) {
2719
2910
  node = { ...node, body: patch.content };
@@ -2724,7 +2915,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2724
2915
  opInput.append = "\n" + patch.append;
2725
2916
  }
2726
2917
  if (patch.tags) {
2727
- const nextTags = [...new Set(patch.tags.map(normalizeTag2))];
2918
+ const nextTags = requireTags(patch.tags);
2728
2919
  node = { ...node, frontmatter: { ...node.frontmatter, tags: nextTags } };
2729
2920
  opInput.tags = nextTags;
2730
2921
  }
@@ -2801,17 +2992,7 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2801
2992
  );
2802
2993
  node = await storage.readDocument(nodeId);
2803
2994
  await syncNodeTags(nestId, nodeId, currentTags);
2804
- try {
2805
- await versionManager.createVersion(node, userEmail, {
2806
- note: patch.changeNote
2807
- });
2808
- } catch (err) {
2809
- console.error(
2810
- "VersionManager.createVersion failed (node patch)",
2811
- err
2812
- );
2813
- throw err;
2814
- }
2995
+ await deleteSupersededDrafts(nestId, nodeId, newVersion);
2815
2996
  await createVersion({
2816
2997
  nestId,
2817
2998
  nodeId,
@@ -2863,6 +3044,13 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
2863
3044
  );
2864
3045
  responseVersion = publishedVersion;
2865
3046
  }
3047
+ await notifyMentions({
3048
+ nestId,
3049
+ nodeId,
3050
+ previousBody,
3051
+ nextBody: node.body || "",
3052
+ actorEmail: userEmail
3053
+ });
2866
3054
  return { node, version: responseVersion };
2867
3055
  } catch (err) {
2868
3056
  throw toSafeError(
@@ -3019,6 +3207,43 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
3019
3207
  return { node: moved, previousId: nodeId };
3020
3208
  });
3021
3209
  }
3210
+ async function discardDraft(nestId, nodeId, userEmail) {
3211
+ return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
3212
+ await assertNotReviewLocked(nestId, nodeId, userEmail, "discarding");
3213
+ const sealed = await getSealedVersion(nestId, nodeId);
3214
+ if (sealed == null) {
3215
+ await deleteNode(nestId, nodeId, userEmail);
3216
+ return { deleted: true, version: null };
3217
+ }
3218
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
3219
+ let raw;
3220
+ try {
3221
+ raw = await versionManager.reconstructVersion(nodeId, sealed);
3222
+ } catch (err) {
3223
+ console.error(
3224
+ "reconstructVersion failed (discard draft)",
3225
+ nestId,
3226
+ nodeId,
3227
+ sealed,
3228
+ err
3229
+ );
3230
+ throw new ValidationError(
3231
+ `Can't restore version ${sealed} of this document \u2014 its history no longer replays. Delete the document instead.`
3232
+ );
3233
+ }
3234
+ await storage.writeDocument(nodeId, raw);
3235
+ await storage.regenerateIndex();
3236
+ const restored = parseDocument(`${nodeId}.md`, raw, nodeId);
3237
+ await syncNodeTags(
3238
+ nestId,
3239
+ nodeId,
3240
+ (restored.frontmatter.tags || []).map(storedTag)
3241
+ );
3242
+ await deleteUnsealedVersions(nestId, nodeId, sealed);
3243
+ await trackEvent("node.discard_draft", { nestId, nodeId, version: sealed });
3244
+ return { deleted: false, version: sealed };
3245
+ });
3246
+ }
3022
3247
  async function deleteNode(nestId, nodeId, userEmail) {
3023
3248
  const { storage } = await engineCache.get(nestId);
3024
3249
  await assertNotReviewLocked(nestId, nodeId, userEmail, "deleting");
@@ -3325,7 +3550,10 @@ async function deleteUnsyncedFolder(folderName) {
3325
3550
  async function effectiveAccess(nestId, userId) {
3326
3551
  const access = await resolveNestAccess(nestId, userId);
3327
3552
  if (config.AUTH_MODE === "open" && access.ownerId === ANON_USER_ID) {
3328
- return { permission: "owner", access };
3553
+ return {
3554
+ permission: capForArchive("owner", access.archived),
3555
+ access: { ...access, rawPermission: "owner" }
3556
+ };
3329
3557
  }
3330
3558
  const permission = access.permission;
3331
3559
  if (permission === "none" && access.hasGrant) {
@@ -3342,12 +3570,14 @@ function isGrantOnly(access) {
3342
3570
  var nestRoutes = new Hono3();
3343
3571
  nestRoutes.get("/", async (c) => {
3344
3572
  const userId = c.get("userId");
3345
- const owned = await listNests(userId);
3346
- const shared = await listSharedNests(userId);
3347
- const publicExtras = await listPublicNests(userId);
3573
+ const archivedOnly = c.req.query("archived") === "1";
3574
+ const owned = await listNests(userId, { archived: archivedOnly });
3575
+ const shared = archivedOnly ? [] : await listSharedNests(userId);
3576
+ const publicExtras = archivedOnly ? [] : await listPublicNests(userId);
3348
3577
  const db = getDb();
3349
3578
  const ownerEmailSql = "SELECT email FROM users WHERE id = ?";
3350
3579
  const callerEmail = await resolveCallerEmail(userId);
3580
+ const pinned = await pinnedNestIds(userId);
3351
3581
  const myGrantRows = await db.all(
3352
3582
  "SELECT nest_id, target FROM grants WHERE user_id = ?",
3353
3583
  [userId]
@@ -3370,10 +3600,11 @@ nestRoutes.get("/", async (c) => {
3370
3600
  owner_email: owner_email2,
3371
3601
  roles: ["viewer"],
3372
3602
  grant_only: true,
3373
- stewardship_enabled: !!n.stewardship_enabled
3603
+ stewardship_enabled: !!n.stewardship_enabled,
3604
+ pinned: pinned.has(n.id)
3374
3605
  };
3375
3606
  }
3376
- const is_owner = permission === "owner";
3607
+ const is_owner = access.rawPermission === "owner";
3377
3608
  const needOwnerEmail = !is_owner && n.user_id !== ANON_USER_ID;
3378
3609
  const [roles, owner_email] = await Promise.all([
3379
3610
  is_owner ? Promise.resolve(["owner"]) : resolveGrantRoles(n.id, callerEmail, userId),
@@ -3388,7 +3619,8 @@ nestRoutes.get("/", async (c) => {
3388
3619
  owner_email,
3389
3620
  roles,
3390
3621
  grant_only: false,
3391
- stewardship_enabled: !!n.stewardship_enabled
3622
+ stewardship_enabled: !!n.stewardship_enabled,
3623
+ pinned: pinned.has(n.id)
3392
3624
  };
3393
3625
  };
3394
3626
  const seen = /* @__PURE__ */ new Set();
@@ -3610,24 +3842,56 @@ nestRoutes.patch("/:nestId", async (c) => {
3610
3842
  });
3611
3843
  return c.json({ nest });
3612
3844
  });
3613
- nestRoutes.delete("/:nestId", async (c) => {
3845
+ nestRoutes.post("/:nestId/pin", async (c) => {
3846
+ const nestId = c.req.param("nestId");
3847
+ const userId = c.get("userId");
3848
+ if (await effectivePermission(nestId, userId) === "none") {
3849
+ throw new NotFoundError("Nest not found");
3850
+ }
3851
+ await pinNest(userId, nestId);
3852
+ return c.json({ pinned: true });
3853
+ });
3854
+ nestRoutes.delete("/:nestId/pin", async (c) => {
3614
3855
  const nestId = c.req.param("nestId");
3615
3856
  const userId = c.get("userId");
3857
+ if (await effectivePermission(nestId, userId) === "none") {
3858
+ throw new NotFoundError("Nest not found");
3859
+ }
3860
+ await unpinNest(userId, nestId);
3861
+ return c.json({ pinned: false });
3862
+ });
3863
+ async function requireNestCaretaker(nestId, userId, verb) {
3616
3864
  const nest = await getNest(nestId);
3617
3865
  if (!nest) {
3618
3866
  throw new NotFoundError("Nest not found");
3619
3867
  }
3620
- const permission = await effectivePermission(nestId, userId);
3621
- const isAnonOwned = nest.user_id === ANON_USER_ID;
3622
- const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && await isLicenseAdminUserId(userId);
3623
- if (permission !== "owner" && !adminCaretaker) {
3868
+ const { access } = await effectiveAccess(nestId, userId);
3869
+ const adminCaretaker = config.AUTH_MODE !== "open" && nest.user_id === ANON_USER_ID && await isLicenseAdminUserId(userId);
3870
+ if (access.rawPermission !== "owner" && !adminCaretaker) {
3624
3871
  throw new ForbiddenError(
3625
- "You don't have permission to delete this nest. Only the nest owner can delete it."
3872
+ `You don't have permission to ${verb} this nest. Only the nest owner can ${verb} it.`
3626
3873
  );
3627
3874
  }
3875
+ return nest;
3876
+ }
3877
+ nestRoutes.delete("/:nestId", async (c) => {
3878
+ const nestId = c.req.param("nestId");
3879
+ await requireNestCaretaker(nestId, c.get("userId"), "delete");
3628
3880
  await deleteNest(nestId);
3629
3881
  return c.json({ deleted: true });
3630
3882
  });
3883
+ nestRoutes.post("/:nestId/archive", async (c) => {
3884
+ const nestId = c.req.param("nestId");
3885
+ await requireNestCaretaker(nestId, c.get("userId"), "archive");
3886
+ await archiveNest(nestId);
3887
+ return c.json({ archived: true });
3888
+ });
3889
+ nestRoutes.post("/:nestId/restore", async (c) => {
3890
+ const nestId = c.req.param("nestId");
3891
+ await requireNestCaretaker(nestId, c.get("userId"), "restore");
3892
+ await restoreNest(nestId);
3893
+ return c.json({ restored: true });
3894
+ });
3631
3895
  nestRoutes.get("/:nestId/settings", async (c) => {
3632
3896
  const nestId = c.req.param("nestId");
3633
3897
  const permission = await effectivePermission(nestId, c.get("userId"));
@@ -3696,7 +3960,7 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
3696
3960
  import { Hono as Hono4 } from "hono";
3697
3961
 
3698
3962
  // src/nests/sharing-service.ts
3699
- import { v4 as uuid4 } from "uuid";
3963
+ import { v4 as uuid5 } from "uuid";
3700
3964
  var VALID_PERMISSIONS = ["read", "write", "admin"];
3701
3965
  async function addCollaborator(params) {
3702
3966
  const { nestId } = params;
@@ -3723,10 +3987,10 @@ async function addCollaborator(params) {
3723
3987
  userId = existing.id;
3724
3988
  } else {
3725
3989
  const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
3726
- userId = uuid4();
3990
+ userId = uuid5();
3727
3991
  await db.run(
3728
3992
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
3729
- [userId, email, null, await hashPassword2(uuid4())]
3993
+ [userId, email, null, await hashPassword2(uuid5())]
3730
3994
  );
3731
3995
  }
3732
3996
  }
@@ -3764,7 +4028,7 @@ async function addCollaborator(params) {
3764
4028
  [params.grantedByEmail]
3765
4029
  ))?.id : void 0;
3766
4030
  const granterId = params.grantedByUserId || granterByEmail || ownerRow.user_id;
3767
- const collabId = uuid4();
4031
+ const collabId = uuid5();
3768
4032
  await db.run(
3769
4033
  "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)",
3770
4034
  [collabId, nestId, userId, params.permission, granterId]
@@ -3835,7 +4099,10 @@ sharingRoutes.get("/collaborators", async (c) => {
3835
4099
  roles: await resolveUserRoles(nestId, collab.email)
3836
4100
  });
3837
4101
  }
3838
- return c.json({ collaborators: enriched });
4102
+ return c.json({
4103
+ collaborators: enriched,
4104
+ people: await listNestPeople(nestId)
4105
+ });
3839
4106
  });
3840
4107
  sharingRoutes.post("/collaborators", async (c) => {
3841
4108
  const body = await c.req.json();
@@ -3955,7 +4222,7 @@ sharingRoutes.patch("/reader-mode", async (c) => {
3955
4222
 
3956
4223
  // src/nests/grant-routes.ts
3957
4224
  import { Hono as Hono5 } from "hono";
3958
- import { v4 as uuid5 } from "uuid";
4225
+ import { v4 as uuid6 } from "uuid";
3959
4226
  var grantRoutes = new Hono5();
3960
4227
  async function requireManage(c, nestId) {
3961
4228
  if (!await canManageStewards(nestId, c.get("userId"))) {
@@ -3970,10 +4237,10 @@ async function resolveGrantee(email) {
3970
4237
  const existing = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
3971
4238
  if (existing) return existing.id;
3972
4239
  const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
3973
- const id = uuid5();
4240
+ const id = uuid6();
3974
4241
  await db.run(
3975
4242
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
3976
- [id, e, null, await hashPassword2(uuid5())]
4243
+ [id, e, null, await hashPassword2(uuid6())]
3977
4244
  );
3978
4245
  return id;
3979
4246
  }
@@ -4075,7 +4342,7 @@ grantRoutes.delete("/:id", async (c) => {
4075
4342
  import { Hono as Hono6 } from "hono";
4076
4343
 
4077
4344
  // src/nests/publish-service.ts
4078
- import { v4 as uuid6 } from "uuid";
4345
+ import { v4 as uuid7 } from "uuid";
4079
4346
  import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
4080
4347
  var MODES = ["public", "email", "code", "invite"];
4081
4348
  var LINK_TTL_SECONDS = 60 * 60;
@@ -4138,8 +4405,13 @@ async function createPublish(params) {
4138
4405
  if (!await nodeExists(nestId, nodeId)) {
4139
4406
  throw new ValidationError(`No such document in this nest: ${nodeId}`);
4140
4407
  }
4408
+ if (await getApprovedVersion(nestId, nodeId) == null) {
4409
+ throw new ValidationError(
4410
+ "This document has no approved version, so a link to it would show nothing. Get it approved first, then publish."
4411
+ );
4412
+ }
4141
4413
  const db = getDb();
4142
- const id = uuid6();
4414
+ const id = uuid7();
4143
4415
  const slug = newSlug();
4144
4416
  const created = nowIso();
4145
4417
  const codeHash = mode === "code" && params.code ? await hashPassword(params.code.trim()) : null;
@@ -4170,7 +4442,7 @@ async function createPublish(params) {
4170
4442
  await db.run(
4171
4443
  `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4172
4444
  VALUES (?, ?, 'invite', ?, NULL, NULL, NULL, ?)`,
4173
- [uuid6(), id, email, created]
4445
+ [uuid7(), id, email, created]
4174
4446
  );
4175
4447
  }
4176
4448
  }
@@ -4179,7 +4451,12 @@ async function createPublish(params) {
4179
4451
  return row;
4180
4452
  }
4181
4453
  async function getPublishBySlug(slug) {
4182
- return await getDb().get("SELECT * FROM publish WHERE slug = ?", [slug]);
4454
+ return await getDb().get(
4455
+ `SELECT p.* FROM publish p
4456
+ JOIN nests n ON n.id = p.nest_id
4457
+ WHERE p.slug = ? AND n.archived_at IS NULL`,
4458
+ [slug]
4459
+ );
4183
4460
  }
4184
4461
  async function getPublishById(nestId, id) {
4185
4462
  return await getDb().get("SELECT * FROM publish WHERE id = ? AND nest_id = ?", [
@@ -4220,7 +4497,7 @@ async function mintMagicLink(publishId, email) {
4220
4497
  await getDb().run(
4221
4498
  `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4222
4499
  VALUES (?, ?, 'link', ?, ?, ?, NULL, ?)`,
4223
- [uuid6(), publishId, e, sha256Hex(token), expires, nowIso()]
4500
+ [uuid7(), publishId, e, sha256Hex(token), expires, nowIso()]
4224
4501
  );
4225
4502
  return token;
4226
4503
  }
@@ -4250,7 +4527,7 @@ async function createSession2(publishId, email) {
4250
4527
  await getDb().run(
4251
4528
  `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4252
4529
  VALUES (?, ?, 'session', ?, ?, ?, NULL, ?)`,
4253
- [uuid6(), publishId, email, sha256Hex(token), expires, nowIso()]
4530
+ [uuid7(), publishId, email, sha256Hex(token), expires, nowIso()]
4254
4531
  );
4255
4532
  return token;
4256
4533
  }
@@ -4267,7 +4544,7 @@ async function logView(publishId, email) {
4267
4544
  await getDb().run(
4268
4545
  `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4269
4546
  VALUES (?, ?, 'view', ?, NULL, NULL, NULL, ?)`,
4270
- [uuid6(), publishId, email, nowIso()]
4547
+ [uuid7(), publishId, email, nowIso()]
4271
4548
  );
4272
4549
  }
4273
4550
  async function listAllowlist(publishId) {
@@ -4360,7 +4637,7 @@ import { Hono as Hono7 } from "hono";
4360
4637
  import MarkdownIt from "markdown-it";
4361
4638
 
4362
4639
  // src/annotations/service.ts
4363
- import { v4 as uuid7 } from "uuid";
4640
+ import { v4 as uuid8 } from "uuid";
4364
4641
 
4365
4642
  // src/annotations/projection.ts
4366
4643
  var MAX_CONTEXT_CHARS = 250;
@@ -4510,7 +4787,7 @@ async function createThread(nestId, nodeId, input, authorEmail) {
4510
4787
  throw new Error("comment body is required");
4511
4788
  }
4512
4789
  const db = getDb();
4513
- const id = uuid7();
4790
+ const id = uuid8();
4514
4791
  const anchor = clampAnchor(input.anchor);
4515
4792
  const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
4516
4793
  await db.transaction(async (tx) => {
@@ -4529,7 +4806,7 @@ async function createThread(nestId, nodeId, input, authorEmail) {
4529
4806
  );
4530
4807
  await tx.run(
4531
4808
  "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4532
- [uuid7(), id, authorEmail, body]
4809
+ [uuid8(), id, authorEmail, body]
4533
4810
  );
4534
4811
  });
4535
4812
  return rowToThread(await getThreadRow(id));
@@ -4549,7 +4826,7 @@ async function addComment(nestId, nodeId, threadId, authorEmail, body) {
4549
4826
  await getScopedThreadRow(threadId, nestId, nodeId);
4550
4827
  await getDb().run(
4551
4828
  "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4552
- [uuid7(), threadId, authorEmail, trimmed]
4829
+ [uuid8(), threadId, authorEmail, trimmed]
4553
4830
  );
4554
4831
  return rowToThread(await getThreadRow(threadId));
4555
4832
  }
@@ -4569,6 +4846,21 @@ async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
4569
4846
  }
4570
4847
  return rowToThread(await getThreadRow(threadId));
4571
4848
  }
4849
+ async function resolveHostedNodeId(nestId, nodeId) {
4850
+ if (nodeId.startsWith("nodes/")) return nodeId;
4851
+ const row = await getDb().get(
4852
+ "SELECT is_imported FROM nests WHERE id = ?",
4853
+ [nestId]
4854
+ );
4855
+ if (!row?.is_imported) return `nodes/${nodeId}`;
4856
+ const { storage } = await engineCache.get(nestId);
4857
+ try {
4858
+ await storage.readDocument(nodeId);
4859
+ return nodeId;
4860
+ } catch {
4861
+ return `nodes/${nodeId}`;
4862
+ }
4863
+ }
4572
4864
  async function readArtifact(nestId, nodeId, version) {
4573
4865
  const { storage, versions: versionManager } = await engineCache.get(nestId);
4574
4866
  const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
@@ -5525,7 +5817,7 @@ nodeRoutes.post("/", async (c) => {
5525
5817
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
5526
5818
  const nestId = c.req.param("nestId");
5527
5819
  const nodeId = c.req.param("nodeId");
5528
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-HRVD7SIS.js");
5820
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-2YKGBP63.js");
5529
5821
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
5530
5822
  nestId,
5531
5823
  nodeId
@@ -5546,10 +5838,24 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
5546
5838
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
5547
5839
  const nestId = c.req.param("nestId");
5548
5840
  const nodeId = c.req.param("nodeId");
5549
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-TJRHRMMZ.js");
5841
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-6JK64IRT.js");
5550
5842
  const history = await getReviewHistory2(nestId, nodeId);
5551
5843
  return c.json({ reviews: history });
5552
5844
  });
5845
+ nodeRoutes.post("/:nodeId{.+}/discard", async (c) => {
5846
+ const nestId = c.req.param("nestId");
5847
+ const nodeId = c.req.param("nodeId");
5848
+ const userId = c.get("userId");
5849
+ const userEmail = await resolveCallerEmail(userId);
5850
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
5851
+ return c.json(
5852
+ { error: "Access denied \u2014 no steward assignment for this node" },
5853
+ 403
5854
+ );
5855
+ }
5856
+ const result = await discardDraft(nestId, nodeId, userEmail);
5857
+ return c.json({ ok: true, ...result });
5858
+ });
5553
5859
  nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
5554
5860
  const nestId = c.req.param("nestId");
5555
5861
  const nodeId = c.req.param("nodeId");
@@ -5873,7 +6179,7 @@ annotationRoutes.post("/:nodeId{.+}/annotations/:threadId/reopen", async (c) =>
5873
6179
  });
5874
6180
  annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
5875
6181
  const nestId = c.req.param("nestId");
5876
- const nodeId = getNodeId(c);
6182
+ const nodeId = await resolveHostedNodeId(nestId, getNodeId(c));
5877
6183
  const userId = c.get("userId");
5878
6184
  const userEmail = await resolveCallerEmail(userId);
5879
6185
  if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
@@ -7172,10 +7478,10 @@ async function listTraceEvents(filters = {}) {
7172
7478
  }
7173
7479
 
7174
7480
  // src/mcp/workflow-tools.ts
7175
- import { v4 as uuid10 } from "uuid";
7481
+ import { v4 as uuid11 } from "uuid";
7176
7482
 
7177
7483
  // src/workflow/run-service.ts
7178
- import { v4 as uuid8 } from "uuid";
7484
+ import { v4 as uuid9 } from "uuid";
7179
7485
  var RUNNABLE_TYPES = ["agent", "skill"];
7180
7486
  async function flowSubgraph(nestId, agentNode) {
7181
7487
  const db = getDb();
@@ -7305,7 +7611,7 @@ async function triggerRun(opts) {
7305
7611
  }
7306
7612
  const edges = await flowSubgraph(nestId, agentNode);
7307
7613
  const definitions = await referencedDefinitions(nestId, edges);
7308
- const id = `run_${uuid8()}`;
7614
+ const id = `run_${uuid9()}`;
7309
7615
  const db = getDb();
7310
7616
  await db.transaction(async (tx) => {
7311
7617
  if (parentRunId) {
@@ -7492,7 +7798,7 @@ async function listRuns(nestId, opts) {
7492
7798
 
7493
7799
  // src/workflow/edge-routes.ts
7494
7800
  import { Hono as Hono13 } from "hono";
7495
- import { v4 as uuid9 } from "uuid";
7801
+ import { v4 as uuid10 } from "uuid";
7496
7802
  function toResponse(e, t) {
7497
7803
  return {
7498
7804
  id: e.id,
@@ -7553,7 +7859,7 @@ async function validateCondition(nestId, mode, condition) {
7553
7859
  async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
7554
7860
  if (fromNode === toNode) {
7555
7861
  throw new ConflictError(
7556
- `cycle: ["${fromNode}","${toNode}"] \u2014 flow edges must form a DAG`
7862
+ `cycle: ${await nodeTrail(db, nestId, [fromNode])} cannot flow into itself`
7557
7863
  );
7558
7864
  }
7559
7865
  const rows = await db.all(
@@ -7582,7 +7888,7 @@ async function assertNoFlowCycle(db, nestId, fromNode, toNode) {
7582
7888
  }
7583
7889
  path.reverse().push(toNode);
7584
7890
  throw new ConflictError(
7585
- `cycle: ${JSON.stringify(path)} \u2014 flow edges must form a DAG`
7891
+ `cycle: ${await nodeTrail(db, nestId, path)} \u2014 flow edges must form a DAG`
7586
7892
  );
7587
7893
  }
7588
7894
  for (const nb of adj.get(cur) ?? []) {
@@ -7674,7 +7980,7 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
7674
7980
  );
7675
7981
  const metadata = body.metadata === void 0 || body.metadata === null ? null : JSON.stringify(body.metadata);
7676
7982
  const now = (/* @__PURE__ */ new Date()).toISOString();
7677
- const id = uuid9();
7983
+ const id = uuid10();
7678
7984
  await db.transaction(async (tx) => {
7679
7985
  await assertNoDuplicateEdge(tx, nestId, fromNode, toNode, type);
7680
7986
  if (type.is_flow) {
@@ -8075,7 +8381,7 @@ async function createEdge(ctx, args) {
8075
8381
  );
8076
8382
  const metadata = args.metadata === void 0 || args.metadata === null ? null : JSON.stringify(args.metadata);
8077
8383
  const now = (/* @__PURE__ */ new Date()).toISOString();
8078
- const id = uuid10();
8384
+ const id = uuid11();
8079
8385
  await db.transaction(async (tx) => {
8080
8386
  await assertNoDuplicateEdge(tx, ctx.nestId, fromNode, toNode, type);
8081
8387
  if (type.is_flow) {
@@ -8139,7 +8445,7 @@ async function upsertEdgeType(ctx, args) {
8139
8445
  `INSERT INTO edge_types
8140
8446
  (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
8141
8447
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
8142
- [uuid10(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
8448
+ [uuid11(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
8143
8449
  );
8144
8450
  }
8145
8451
  });
@@ -8172,6 +8478,13 @@ async function deleteEdgeType(nestId, id) {
8172
8478
 
8173
8479
  // src/mcp/tools.ts
8174
8480
  var MAX_HOPS = 10;
8481
+ var SEARCH_DEFAULT_LIMIT = 20;
8482
+ var SEARCH_MAX_LIMIT = 200;
8483
+ function normalizeSearchLimit(raw) {
8484
+ const n = Math.floor(Number(raw));
8485
+ if (!Number.isFinite(n) || n < 1) return SEARCH_DEFAULT_LIMIT;
8486
+ return Math.min(n, SEARCH_MAX_LIMIT);
8487
+ }
8175
8488
  function normalizeHops(raw) {
8176
8489
  if (raw == null) return 2;
8177
8490
  const n = Number(raw);
@@ -8215,11 +8528,15 @@ var TOOL_DEFINITIONS = [
8215
8528
  },
8216
8529
  {
8217
8530
  name: "context_search",
8218
- description: "Full-text keyword search across all node content, titles, tags, and metadata.",
8531
+ description: "Full-text keyword search across all node content, titles, tags, and metadata \u2014 the same index-backed match as REST GET /search. Returns `count` (everything found) and the first `limit` hits, title matches first.",
8219
8532
  inputSchema: {
8220
8533
  type: "object",
8221
8534
  properties: {
8222
- query: { type: "string", description: "Search terms" }
8535
+ query: { type: "string", description: "Search terms" },
8536
+ limit: {
8537
+ type: "number",
8538
+ description: "Max hits to return (default 20, max 200)"
8539
+ }
8223
8540
  },
8224
8541
  required: ["query"]
8225
8542
  }
@@ -8675,12 +8992,21 @@ var TOOL_DEFINITIONS = [
8675
8992
  }
8676
8993
  }
8677
8994
  ];
8678
- async function resolveLlmBody(ctx, node) {
8679
- if (!await canReadNode(ctx.nestId, node.id, ctx.userId, ctx.userEmail)) {
8995
+ async function batchedBodyGate(nestId) {
8996
+ const stewardshipEnabled = await isStewardshipEnabled(nestId);
8997
+ return {
8998
+ accessChecked: true,
8999
+ stewardshipEnabled,
9000
+ approvedVersions: stewardshipEnabled ? await getApprovedVersions(nestId) : null
9001
+ };
9002
+ }
9003
+ async function resolveLlmBody(ctx, node, gate) {
9004
+ if (!gate?.accessChecked && !await canReadNode(ctx.nestId, node.id, ctx.userId, ctx.userEmail)) {
8680
9005
  return null;
8681
9006
  }
8682
- if (!await isStewardshipEnabled(ctx.nestId)) return node.body || "";
8683
- const approved = await getApprovedVersion(ctx.nestId, node.id);
9007
+ const stewardship = gate ? gate.stewardshipEnabled : await isStewardshipEnabled(ctx.nestId);
9008
+ if (!stewardship) return node.body || "";
9009
+ const approved = gate ? gate.approvedVersions?.get(node.id) ?? null : await getApprovedVersion(ctx.nestId, node.id);
8684
9010
  if (approved == null) return null;
8685
9011
  try {
8686
9012
  return await ctx.versionManager.reconstructVersion(node.id, approved);
@@ -8787,39 +9113,46 @@ ${fmt(tags)}
8787
9113
  ${nodeList}`;
8788
9114
  }
8789
9115
  case "context_search": {
8790
- const docs = await storage.discoverDocuments();
8791
- const terms = (args.query || "").toLowerCase().split(/\s+/).filter(Boolean);
8792
- const enriched = await Promise.all(
8793
- docs.map(async (n) => ({ node: n, body: await resolveLlmBody(ctx, n) }))
9116
+ const query = String(args.query ?? "");
9117
+ const limit = normalizeSearchLimit(args.limit);
9118
+ const matches = matchDocuments(await discoverForSearch(storage), query).filter((n) => !isDerivedAnnotationsNode(n.id));
9119
+ const accessible = await filterAccessible(
9120
+ nestId,
9121
+ userId,
9122
+ userEmail,
9123
+ matches
8794
9124
  );
8795
- const visible = enriched.filter((e) => e.body !== null);
8796
- const matches = visible.filter(({ node: n, body }) => {
8797
- const hay = [
8798
- n.frontmatter.title,
8799
- body || "",
8800
- n.frontmatter.type || "",
8801
- ...n.frontmatter.tags || []
8802
- ].join(" ").toLowerCase();
8803
- return terms.every((t) => hay.includes(t));
8804
- });
8805
- if (!matches.length)
9125
+ const count = accessible.length;
9126
+ if (count === 0)
8806
9127
  return {
8807
- text: `No nodes matched search: "${args.query}"`,
8808
- data: { results: [] }
9128
+ text: `No nodes matched search: "${query}"`,
9129
+ data: { results: [], count }
8809
9130
  };
8810
- const results = matches.map(
8811
- ({ node: n, body }, i) => `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}
8812
- ${(body || "").slice(0, 200).replace(/\n/g, " ")}`
8813
- ).join("\n\n");
9131
+ const gate = await batchedBodyGate(nestId);
9132
+ const page = await Promise.all(
9133
+ accessible.slice(0, limit).map(async (node) => ({
9134
+ node,
9135
+ body: await resolveLlmBody(ctx, node, gate)
9136
+ }))
9137
+ );
9138
+ const results = page.map(({ node: n, body }, i) => {
9139
+ const head = `${i + 1}. **${n.frontmatter.title}** [${n.frontmatter.type || "document"}] ${(n.frontmatter.tags || []).join(" ")}`;
9140
+ const preview = body === null ? "_(no approved version yet \u2014 body withheld)_" : body.slice(0, 200).replace(/\n/g, " ");
9141
+ return `${head}
9142
+ ${preview}`;
9143
+ }).join("\n\n");
9144
+ const more = count > page.length ? ` (showing the first ${page.length}; pass \`limit\` for more)` : "";
8814
9145
  return {
8815
- text: `Found ${matches.length} node(s) matching "${args.query}":
9146
+ text: `Found ${count} node(s) matching "${query}"${more}:
8816
9147
 
8817
9148
  ${results}`,
8818
- // Same gated bodies the prose is built from — `resolveLlmBody`
8819
- // already refused everything this caller may not read. No `score`:
8820
- // this is a term-containment filter, not a ranked search.
9149
+ // `count` is everything found (like REST); `results` is the page.
9150
+ // Bodies are the gated ones the prose is built from. No `score`:
9151
+ // this is a term-containment filter with title-first ordering, not
9152
+ // a ranked search.
8821
9153
  data: {
8822
- results: matches.map(({ node: n, body }) => summarizeNode(n, body))
9154
+ results: page.map(({ node: n, body }) => summarizeNode(n, body)),
9155
+ count
8823
9156
  }
8824
9157
  };
8825
9158
  }
@@ -9327,7 +9660,9 @@ ${list}`;
9327
9660
  });
9328
9661
  return `Rejected the deletion request for **${label}**. It stays, and ${request.requestedBy} has been told why.`;
9329
9662
  }
9330
- if (request.targetType === "nest") {
9663
+ if (request.targetType === "nest_archive") {
9664
+ await archiveNest(nestId);
9665
+ } else if (request.targetType === "nest") {
9331
9666
  await deleteNest(nestId);
9332
9667
  } else if (request.targetType === "folder") {
9333
9668
  await deleteFolder(nestId, request.nodeId, userEmail);
@@ -9346,7 +9681,7 @@ ${list}`;
9346
9681
  targetType: request.targetType,
9347
9682
  baseUrl: ctx.baseUrl
9348
9683
  });
9349
- return `Deleted **${label}** \u2014 ${request.requestedBy}'s request was honoured. Irreversible.`;
9684
+ return request.targetType === "nest_archive" ? `Archived **${label}** \u2014 ${request.requestedBy}'s request was honoured. Nothing was deleted: the nest is read-only and hidden until someone restores it.` : `Deleted **${label}** \u2014 ${request.requestedBy}'s request was honoured. Irreversible.`;
9350
9685
  }
9351
9686
  case "context_review_queue": {
9352
9687
  const status = args.status || "pending";
@@ -9592,19 +9927,19 @@ ${list}`,
9592
9927
  if (!target) return "target is required (a node id or folder prefix).";
9593
9928
  if (!["read", "write"].includes(role)) return "role must be read or write.";
9594
9929
  try {
9595
- const { createGrant: createGrant2 } = await import("./grants-service-WC27TRY3.js");
9596
- const db = (await import("./client-7Z2IVCPW.js")).getDb();
9930
+ const { createGrant: createGrant2 } = await import("./grants-service-LSNZN6NG.js");
9931
+ const db = (await import("./client-BD6PAUCZ.js")).getDb();
9597
9932
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
9598
9933
  const e = normalizeEmail2(String(args.email || ""));
9599
9934
  if (!e) return "email is required.";
9600
9935
  let row = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
9601
9936
  if (!row) {
9602
9937
  const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
9603
- const { v4: uuid18 } = await import("uuid");
9604
- const id = uuid18();
9938
+ const { v4: uuid19 } = await import("uuid");
9939
+ const id = uuid19();
9605
9940
  await db.run(
9606
9941
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
9607
- [id, e, null, await hashPassword2(uuid18())]
9942
+ [id, e, null, await hashPassword2(uuid19())]
9608
9943
  );
9609
9944
  row = { id };
9610
9945
  }
@@ -9726,7 +10061,7 @@ mcpRoutes.all("/", async (c) => {
9726
10061
 
9727
10062
  // src/workflow/run-routes.ts
9728
10063
  import { Hono as Hono15 } from "hono";
9729
- import { v4 as uuid11 } from "uuid";
10064
+ import { v4 as uuid12 } from "uuid";
9730
10065
  var RUNNABLE_TYPES2 = ["agent", "skill"];
9731
10066
  var safeJson2 = (s) => {
9732
10067
  if (!s) return null;
@@ -9923,7 +10258,7 @@ runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
9923
10258
  }
9924
10259
  const bundle = await buildRunBundle(nestId, agentNode);
9925
10260
  const triggeredBy = await resolveCallerEmail(c.get("userId"));
9926
- const id = `run_${uuid11()}`;
10261
+ const id = `run_${uuid12()}`;
9927
10262
  await getDb().run(
9928
10263
  `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
9929
10264
  VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)`,
@@ -10136,7 +10471,7 @@ runDetailRoutes.get("/:id", requireWorkflowPlane, async (c) => {
10136
10471
 
10137
10472
  // src/workflow/schedule-routes.ts
10138
10473
  import { Hono as Hono16 } from "hono";
10139
- import { v4 as uuid12 } from "uuid";
10474
+ import { v4 as uuid13 } from "uuid";
10140
10475
  var RUNNABLE_TYPES3 = ["agent", "skill"];
10141
10476
  var MIN_EVERY = 5;
10142
10477
  var MAX_EVERY = 7 * 24 * 60;
@@ -10214,7 +10549,7 @@ scheduleRoutes.post("/", requireWorkflowPlane, async (c) => {
10214
10549
  );
10215
10550
  }
10216
10551
  const now = (/* @__PURE__ */ new Date()).toISOString();
10217
- const id = `sch_${uuid12()}`;
10552
+ const id = `sch_${uuid13()}`;
10218
10553
  await db.run(
10219
10554
  `INSERT INTO schedules (id, nest_id, agent_node, every_minutes, enabled, last_run_at, max_runs, runs_created, created_by, created_at, updated_at)
10220
10555
  VALUES (?, ?, ?, ?, ?, NULL, ?, 0, ?, ?, ?)`,
@@ -10270,7 +10605,7 @@ scheduleRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
10270
10605
 
10271
10606
  // src/notify/connector-routes.ts
10272
10607
  import { Hono as Hono17 } from "hono";
10273
- import { v4 as uuid13 } from "uuid";
10608
+ import { v4 as uuid14 } from "uuid";
10274
10609
  var KNOWN_EVENTS = [
10275
10610
  "review_requested",
10276
10611
  "review_approved",
@@ -10335,7 +10670,7 @@ connectorRoutes.post("/", async (c) => {
10335
10670
  const url = parseUrl(body.url);
10336
10671
  const events = parseEvents(body.events);
10337
10672
  const now = (/* @__PURE__ */ new Date()).toISOString();
10338
- const id = `con_${uuid13()}`;
10673
+ const id = `con_${uuid14()}`;
10339
10674
  await getDb().run(
10340
10675
  `INSERT INTO connectors (id, nest_id, channel, url, events, enabled, created_by, created_at, updated_at)
10341
10676
  VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`,
@@ -10483,7 +10818,9 @@ hookFireRoutes.post("/:token", async (c) => {
10483
10818
  }
10484
10819
  const db = getDb();
10485
10820
  const hook = await db.get(
10486
- "SELECT * FROM trigger_hooks WHERE id = ? AND enabled = 1",
10821
+ `SELECT h.* FROM trigger_hooks h
10822
+ JOIN nests n ON n.id = h.nest_id
10823
+ WHERE h.id = ? AND h.enabled = 1 AND n.archived_at IS NULL`,
10487
10824
  [token]
10488
10825
  );
10489
10826
  if (!hook) throw new NotFoundError("Not found");
@@ -10576,7 +10913,7 @@ hookFireRoutes.post("/:token", async (c) => {
10576
10913
 
10577
10914
  // src/definitions/routes.ts
10578
10915
  import { Hono as Hono19 } from "hono";
10579
- import { v4 as uuid14 } from "uuid";
10916
+ import { v4 as uuid15 } from "uuid";
10580
10917
  var definitionRoutes = new Hono19();
10581
10918
  definitionRoutes.get("/", async (c) => {
10582
10919
  const nestId = c.req.param("nestId");
@@ -10656,7 +10993,7 @@ definitionRoutes.post("/", async (c) => {
10656
10993
  await applyUpdate(tx, existing.id);
10657
10994
  targetId = existing.id;
10658
10995
  } else {
10659
- targetId = uuid14();
10996
+ targetId = uuid15();
10660
10997
  wasCreated = true;
10661
10998
  await tx.run(
10662
10999
  `INSERT INTO definitions (id, nest_id, term, definition, linked_tag, defined_by, created_at, updated_at)
@@ -10686,7 +11023,7 @@ definitionRoutes.delete("/:id", async (c) => {
10686
11023
 
10687
11024
  // src/nests/asset-routes.ts
10688
11025
  import { Hono as Hono20 } from "hono";
10689
- import { v4 as uuid15 } from "uuid";
11026
+ import { v4 as uuid16 } from "uuid";
10690
11027
  import { mkdir as mkdir2, readFile, writeFile } from "fs/promises";
10691
11028
  import { join as join4 } from "path";
10692
11029
  var assetRoutes = new Hono20();
@@ -10735,7 +11072,7 @@ assetRoutes.post("/", async (c) => {
10735
11072
  `${isVideo ? "video" : "image"} too large (${(file.size / (1024 * 1024)).toFixed(1)} MB, max ${maxBytes / (1024 * 1024)} MB for ${isVideo ? "video" : "images"})`
10736
11073
  );
10737
11074
  }
10738
- const name = `${uuid15()}.${ext === "jpeg" ? "jpg" : ext}`;
11075
+ const name = `${uuid16()}.${ext === "jpeg" ? "jpg" : ext}`;
10739
11076
  const dir = join4(resolveNestPath(nestId), "assets");
10740
11077
  await mkdir2(dir, { recursive: true });
10741
11078
  await writeFile(join4(dir, name), Buffer.from(await file.arrayBuffer()));
@@ -10795,10 +11132,15 @@ async function canRead(nestId, userId) {
10795
11132
  }
10796
11133
  async function resolveNestRef(ref, userId) {
10797
11134
  const db = getDb();
10798
- const byId = await db.get("SELECT * FROM nests WHERE id = ?", [ref]);
11135
+ const byId = await db.get(
11136
+ "SELECT * FROM nests WHERE id = ? AND archived_at IS NULL",
11137
+ [ref]
11138
+ );
10799
11139
  if (byId) return byId;
10800
11140
  const byName = await db.all(
10801
- "SELECT * FROM nests WHERE LOWER(name) = LOWER(?) OR LOWER(slug) = LOWER(?)",
11141
+ `SELECT * FROM nests
11142
+ WHERE archived_at IS NULL
11143
+ AND (LOWER(name) = LOWER(?) OR LOWER(slug) = LOWER(?))`,
10802
11144
  [ref, ref]
10803
11145
  );
10804
11146
  if (byName.length <= 1) return byName[0] ?? null;
@@ -10897,12 +11239,16 @@ var SERVER_TOOL_DEFINITIONS = [
10897
11239
  },
10898
11240
  {
10899
11241
  name: "context_search",
10900
- description: "Full-text keyword search across one nest.",
11242
+ description: "Full-text keyword search across one nest \u2014 the same index-backed match as REST GET /search. Returns `count` (everything found) and the first `limit` hits.",
10901
11243
  inputSchema: {
10902
11244
  type: "object",
10903
11245
  properties: {
10904
11246
  ...NEST_ARG,
10905
- query: { type: "string", description: "Search terms" }
11247
+ query: { type: "string", description: "Search terms" },
11248
+ limit: {
11249
+ type: "number",
11250
+ description: "Max hits to return (default 20, max 200)"
11251
+ }
10906
11252
  },
10907
11253
  required: ["nest", "query"]
10908
11254
  }
@@ -11203,6 +11549,26 @@ Claude Code / Cursor / VS Code connect natively via HTTP with the
11203
11549
  npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
11204
11550
  \`\`\`
11205
11551
 
11552
+ ## From the ctx CLI
11553
+
11554
+ The \`ctx\` CLI (\`@promptowl/contextnest-cli\` 2.2+) reads a hosted nest the
11555
+ same way it reads a local vault: register the MCP endpoint as a vault alias,
11556
+ then pass \`--vault <alias>\` to \`query\`, \`read\`, \`list\`, \`search\`. The key
11557
+ is read from the environment variable named by \`--bearer-env\` \u2014 never put it
11558
+ on the command line.
11559
+
11560
+ \`\`\`
11561
+ export CONTEXTNEST_API_KEY=<api-key>
11562
+ # every nest these credentials can read, through the server-level endpoint
11563
+ ctx vault add contextnest --url ${B}/mcp --bearer-env CONTEXTNEST_API_KEY
11564
+ # or one nest
11565
+ ctx vault add mynest --url ${B}/nests/<nest-id>/mcp --bearer-env CONTEXTNEST_API_KEY
11566
+ ctx query "#gtm" --vault mynest
11567
+ \`\`\`
11568
+
11569
+ Servers in "open" auth mode: drop \`--bearer-env\`. Pushing a local folder up
11570
+ is \`ctx push --server ${B} --nest <nest-id>\` with the same env var set.
11571
+
11206
11572
  ## npm packages
11207
11573
 
11208
11574
  - \`@promptowl/contextnest-community\` \u2014 this server
@@ -11354,7 +11720,7 @@ async function getNestFeed(params) {
11354
11720
  }
11355
11721
 
11356
11722
  // src/governance/comment-service.ts
11357
- import { v4 as uuid16 } from "uuid";
11723
+ import { v4 as uuid17 } from "uuid";
11358
11724
  async function createComment(params) {
11359
11725
  const db = getDb();
11360
11726
  const body = (params.body ?? "").trim();
@@ -11370,7 +11736,7 @@ async function createComment(params) {
11370
11736
  throw new Error("Parent comment not found on this node");
11371
11737
  }
11372
11738
  }
11373
- const id = uuid16();
11739
+ const id = uuid17();
11374
11740
  await db.run(
11375
11741
  `INSERT INTO comments
11376
11742
  (id, nest_id, node_id, version, anchor_start, anchor_end, anchor_text,
@@ -11885,12 +12251,12 @@ governanceRoutes.post("/request-deletion", async (c) => {
11885
12251
  if (!reason) {
11886
12252
  throw new ValidationError("A reason for the deletion request is required");
11887
12253
  }
11888
- if (body.targetType !== "folder" && body.targetType !== "nest") {
12254
+ if (body.targetType !== "folder" && body.targetType !== "nest" && body.targetType !== "nest_archive") {
11889
12255
  throw new ValidationError(
11890
- 'targetType must be "folder" or "nest" \u2014 flag a single document via POST /nests/:nestId/nodes/:nodeId/request-deletion'
12256
+ 'targetType must be "folder", "nest" or "nest_archive" \u2014 flag a single document via POST /nests/:nestId/nodes/:nodeId/request-deletion'
11891
12257
  );
11892
12258
  }
11893
- const target = body.targetType === "nest" ? nestId : (body.target || "").trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
12259
+ const target = isNestTarget(body.targetType) ? nestId : (body.target || "").trim().replace(/^\/+|\/+$/g, "").replace(/^nodes\//, "");
11894
12260
  if (body.targetType === "folder" && !target) {
11895
12261
  throw new ValidationError("target (the folder path) is required");
11896
12262
  }
@@ -11926,14 +12292,16 @@ governanceRoutes.post("/deletion-requests/:id/delete", async (c) => {
11926
12292
  }
11927
12293
  const actor = await getUserEmail4(c);
11928
12294
  const docTitle = await deletionTargetTitle(request);
11929
- if (request.targetType === "nest" || request.targetType === "folder") {
12295
+ if (isNestTarget(request.targetType) || request.targetType === "folder") {
11930
12296
  const denied = denyUnlessNestManager(
11931
12297
  c,
11932
- request.targetType === "nest" ? "a nest" : "a folder"
12298
+ request.targetType === "folder" ? "a folder" : "a nest"
11933
12299
  );
11934
12300
  if (denied) return denied;
11935
12301
  }
11936
- if (request.targetType === "nest") {
12302
+ if (request.targetType === "nest_archive") {
12303
+ await archiveNest(nestId);
12304
+ } else if (request.targetType === "nest") {
11937
12305
  await deleteNest(nestId);
11938
12306
  } else if (request.targetType === "folder") {
11939
12307
  await deleteFolder(nestId, request.nodeId, actor);
@@ -12112,11 +12480,18 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions/:version{[0-9]+}", async (c) =>
12112
12480
  403
12113
12481
  );
12114
12482
  }
12115
- const { versions: versionManager } = await engineCache.get(nestId);
12483
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
12116
12484
  try {
12117
12485
  const raw = await versionManager.reconstructVersion(nodeId, version);
12118
12486
  return c.json({ version, content: bodyOnly(nodeId, raw) });
12119
12487
  } catch (err) {
12488
+ try {
12489
+ const node = await storage.readDocument(nodeId);
12490
+ if ((node.frontmatter.version ?? 1) === version) {
12491
+ return c.json({ version, content: node.body ?? "" });
12492
+ }
12493
+ } catch {
12494
+ }
12120
12495
  console.error("reconstructVersion failed", nestId, nodeId, version, err);
12121
12496
  return c.json({ version, content: null }, 200);
12122
12497
  }
@@ -13387,11 +13762,15 @@ function createApp() {
13387
13762
  nestName2.set(n.id, n.name ?? n.id);
13388
13763
  const limit = Math.min(Math.max(Number(c.req.query("limit")) || 25, 1), 100);
13389
13764
  const offset = Math.max(Number(c.req.query("offset")) || 0, 0);
13765
+ const sort = c.req.query("sort") === "title" ? "title" : "updated";
13390
13766
  const { drafts, total } = await listMyDrafts({
13391
13767
  authorEmail: me,
13392
13768
  nestIds: [...nestName2.keys()],
13393
13769
  limit,
13394
- offset
13770
+ offset,
13771
+ q: c.req.query("q") ?? void 0,
13772
+ sort,
13773
+ nestNames: nestName2
13395
13774
  });
13396
13775
  return c.json({
13397
13776
  drafts: drafts.map((d) => ({
@@ -13405,8 +13784,8 @@ function createApp() {
13405
13784
  version: d.version,
13406
13785
  updated_at: d.updatedAt
13407
13786
  })),
13408
- // has_more off the REQUESTED page size, not the returned row count: the
13409
- // existence filter can shorten a page, and that must not read as "last".
13787
+ // `total` counts drafts whose document still exists — the same set the
13788
+ // page is sliced from — so this is exact in every sort and search mode.
13410
13789
  pagination: { total, limit, offset, has_more: offset + limit < total }
13411
13790
  });
13412
13791
  });
@@ -13500,6 +13879,8 @@ function createApp() {
13500
13879
  const userId = c.get("userId");
13501
13880
  const nestScope = c.get("nestScope");
13502
13881
  const isMcpTransport = parts.length === 2 && parts[1] === "mcp";
13882
+ const isNestLifecycleAction = parts.length === 2 && c.req.method === "POST" && (parts[1] === "archive" || parts[1] === "restore");
13883
+ const isPinAction = parts.length === 2 && parts[1] === "pin";
13503
13884
  const deny = async (status, error, extra) => {
13504
13885
  if (!isMcpTransport) return c.json({ error, ...extra }, status);
13505
13886
  let id = null;
@@ -13544,11 +13925,25 @@ function createApp() {
13544
13925
  }
13545
13926
  }
13546
13927
  if (config.AUTH_MODE === "open") {
13547
- c.set("nestPermission", "owner");
13928
+ const archived = await isNestArchived(nestId);
13929
+ if (archived) {
13930
+ if (isMcpTransport) return deny(404, "Nest not found");
13931
+ if (c.req.method !== "GET" && !isNestLifecycleAction) {
13932
+ return deny(
13933
+ 403,
13934
+ "This nest is archived, so it is read-only. Restore it to make changes.",
13935
+ { archived: true }
13936
+ );
13937
+ }
13938
+ }
13939
+ c.set("nestPermission", archived ? "read" : "owner");
13548
13940
  return next();
13549
13941
  }
13550
13942
  const access = await resolveNestAccess(nestId, userId);
13551
13943
  const permission = access.permission;
13944
+ if (access.archived && isMcpTransport) {
13945
+ return deny(404, "Nest not found");
13946
+ }
13552
13947
  const isReadQuery = parts.length === 2 && ["context", "query", "search", "export"].includes(parts[1]);
13553
13948
  let grantRole = null;
13554
13949
  let grantNestVisible = false;
@@ -13566,6 +13961,8 @@ function createApp() {
13566
13961
  grantNestVisible = access.hasGrant;
13567
13962
  } else if (isReadQuery) {
13568
13963
  grantNestVisible = access.hasGrant;
13964
+ } else if (isPinAction) {
13965
+ grantNestVisible = access.hasGrant;
13569
13966
  } else if (parts[1] === "definitions" && parts.length === 2 && c.req.method === "GET") {
13570
13967
  grantNestVisible = access.hasGrant;
13571
13968
  }
@@ -13599,12 +13996,12 @@ function createApp() {
13599
13996
  required = c.req.method === "GET" ? "write" : "admin";
13600
13997
  } else if (parts[1] === "hooks") {
13601
13998
  required = "write";
13602
- } else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isDeletionRequestAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
13999
+ } else if (c.req.method !== "GET" && !isStewardActionPath && !isCommentAction && !isDeletionRequestAction && !isNestLifecycleAction && !isPinAction && !isAnnotationAction && !isReadQuery && !isMcpTransport) {
13603
14000
  required = "write";
13604
14001
  }
13605
14002
  const isNodeAction = c.req.method === "POST" && parts.length >= 4 && (parts[parts.length - 1] === "revert" || parts[parts.length - 1] === "move");
13606
14003
  let stewardEditorBypass = false;
13607
- if (required === "write" && permission === "read" && parts[1] === "nodes") {
14004
+ if (required === "write" && permission === "read" && !access.archived && parts[1] === "nodes") {
13608
14005
  const userEmail = await resolveCallerEmail(userId);
13609
14006
  if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE" || isNodeAction)) {
13610
14007
  const idParts = isNodeAction ? parts.slice(2, -1) : parts.slice(2);
@@ -14000,7 +14397,7 @@ async function repairGraftedVersionChains(db) {
14000
14397
  }
14001
14398
 
14002
14399
  // src/workflow/scheduler.ts
14003
- import { v4 as uuid17 } from "uuid";
14400
+ import { v4 as uuid18 } from "uuid";
14004
14401
  function isDue(s, now) {
14005
14402
  if (!s.last_run_at) return true;
14006
14403
  const last = Date.parse(s.last_run_at);
@@ -14009,7 +14406,11 @@ function isDue(s, now) {
14009
14406
  }
14010
14407
  async function schedulerTick(now = Date.now()) {
14011
14408
  const db = getDb();
14012
- const due = (await db.all("SELECT * FROM schedules WHERE enabled = 1")).filter((s) => isDue(s, now));
14409
+ const due = (await db.all(
14410
+ `SELECT s.* FROM schedules s
14411
+ JOIN nests n ON n.id = s.nest_id
14412
+ WHERE s.enabled = 1 AND n.archived_at IS NULL`
14413
+ )).filter((s) => isDue(s, now));
14013
14414
  const created = [];
14014
14415
  for (const s of due) {
14015
14416
  try {
@@ -14024,7 +14425,7 @@ async function schedulerTick(now = Date.now()) {
14024
14425
  );
14025
14426
  continue;
14026
14427
  }
14027
- const id = `run_${uuid17()}`;
14428
+ const id = `run_${uuid18()}`;
14028
14429
  const nowIso2 = new Date(now).toISOString();
14029
14430
  await db.run(
14030
14431
  `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)