@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/README.md CHANGED
@@ -15,7 +15,7 @@ ContextNest Community Edition is a self-hosted server that lets you:
15
15
  - Export a nest as a portable bundle and re-import it on another self-hosted host
16
16
  - Apply stewardship workflows — draft, pending review, approved
17
17
  - Share nests with collaborators or publish them read-only to the public
18
- - Serve approved context to AI agents via MCP or HTTP — connect one by pasting a generated setup prompt or downloading a ready `.env`
18
+ - Serve approved context to AI agents via MCP or HTTP — connect one by pasting a generated setup prompt or downloading a ready `.env`, or register the nest in the ctx CLI (`ctx vault add <alias> --url <server>/nests/<id>/mcp --bearer-env CONTEXTNEST_API_KEY`, then `ctx query "#tag" --vault <alias>`)
19
19
  - Sync with the PromptOwl hosted platform for multi-user collaboration
20
20
 
21
21
  The server runs locally or on your own infrastructure. Your PromptOwl account handles authentication, entitlement, and governance metadata.
@@ -4,19 +4,19 @@ import {
4
4
  resolveNestWideRoles,
5
5
  resolveStewardsForNode,
6
6
  stewardCoverageForUser
7
- } from "./chunk-O5DYL7ZG.js";
7
+ } from "./chunk-N2DTJFCD.js";
8
8
  import {
9
9
  grantCoversNode,
10
10
  listUserGrants,
11
11
  resolveNodeGrant
12
- } from "./chunk-3LUUJUWS.js";
12
+ } from "./chunk-XWGEXQU3.js";
13
13
  import {
14
14
  createVersion,
15
15
  getApprovedVersion,
16
16
  getApprovedVersions,
17
17
  getCurrentVersion,
18
18
  setApprovedVersion
19
- } from "./chunk-KHMSDTJJ.js";
19
+ } from "./chunk-IGPJ74O4.js";
20
20
  import {
21
21
  buildDocContext,
22
22
  buildTitleMap,
@@ -36,7 +36,7 @@ import {
36
36
  resolveNestPermission,
37
37
  sendEmailToRecipient,
38
38
  titleForNode
39
- } from "./chunk-7CPP6FIU.js";
39
+ } from "./chunk-6JGQX4GA.js";
40
40
  import {
41
41
  ConflictError,
42
42
  NotFoundError,
@@ -45,7 +45,7 @@ import {
45
45
  import {
46
46
  config,
47
47
  getDb
48
- } from "./chunk-XCFKS65Y.js";
48
+ } from "./chunk-LPPKPEYI.js";
49
49
 
50
50
  // src/governance/review-service.ts
51
51
  import { v4 as uuid2 } from "uuid";
@@ -119,6 +119,20 @@ var safeJson = (s) => {
119
119
  };
120
120
 
121
121
  // src/workflow/flow-graph.ts
122
+ async function nodeTrail(db, nestId, ids) {
123
+ const unique = [...new Set(ids)];
124
+ const titles = /* @__PURE__ */ new Map();
125
+ try {
126
+ const rows = await db.all(
127
+ `SELECT node_id, title FROM node_index
128
+ WHERE nest_id = ? AND node_id IN (${unique.map(() => "?").join(",")})`,
129
+ [nestId, ...unique]
130
+ );
131
+ for (const r of rows) if (r.title) titles.set(r.node_id, r.title);
132
+ } catch {
133
+ }
134
+ return ids.map((id) => `"${titles.get(id) ?? id}"`).join(" \u2192 ");
135
+ }
122
136
  async function assertFlowGraphAcyclic(nestId, extraFlowTypeId) {
123
137
  const db = getDb();
124
138
  const rows = await db.all(
@@ -133,7 +147,7 @@ async function assertFlowGraphAcyclic(nestId, extraFlowTypeId) {
133
147
  for (const r of rows) {
134
148
  if (r.from_node === r.to_node) {
135
149
  throw new ConflictError(
136
- `cycle: ["${r.from_node}"] \u2014 a flow edge cannot be a self-loop`
150
+ `cycle: ${await nodeTrail(db, nestId, [r.from_node])} cannot flow into itself`
137
151
  );
138
152
  }
139
153
  nodes.add(r.from_node);
@@ -698,31 +712,60 @@ function withNodeWriteLock(key, fn) {
698
712
  // src/governance/review-service.ts
699
713
  async function submitForReview(params) {
700
714
  const db = getDb();
701
- const existing = await db.get(
702
- "SELECT id FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending'",
703
- [params.nestId, params.nodeId]
704
- );
705
- if (existing) {
706
- throw new Error("A review is already pending for this node");
707
- }
708
- const id = uuid2();
709
- await db.run(
710
- `INSERT INTO review_requests
715
+ const { id, version } = await withNodeWriteLock(
716
+ nodeWriteKey(params.nestId, params.nodeId),
717
+ async () => {
718
+ const existing = await db.get(
719
+ "SELECT id FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending'",
720
+ [params.nestId, params.nodeId]
721
+ );
722
+ if (existing) {
723
+ throw new Error("A review is already pending for this node");
724
+ }
725
+ const version2 = await getCurrentVersion(params.nestId, params.nodeId) || params.version;
726
+ const id2 = uuid2();
727
+ await db.run(
728
+ `INSERT INTO review_requests
711
729
  (id, nest_id, node_id, version, requested_by, request_note, priority)
712
730
  VALUES (?, ?, ?, ?, ?, ?, ?)`,
713
- [
714
- id,
715
- params.nestId,
716
- params.nodeId,
717
- params.version,
718
- params.requestedBy,
719
- params.note || null,
720
- params.priority || "normal"
721
- ]
722
- );
723
- await db.run(
724
- "UPDATE node_versions SET status = 'pending_review' WHERE nest_id = ? AND node_id = ? AND version = ?",
725
- [params.nestId, params.nodeId, params.version]
731
+ [
732
+ id2,
733
+ params.nestId,
734
+ params.nodeId,
735
+ version2,
736
+ params.requestedBy,
737
+ params.note || null,
738
+ params.priority || "normal"
739
+ ]
740
+ );
741
+ await db.run(
742
+ "UPDATE node_versions SET status = 'pending_review' WHERE nest_id = ? AND node_id = ? AND version = ?",
743
+ [params.nestId, params.nodeId, version2]
744
+ );
745
+ try {
746
+ const { storage, versions: versionManager } = await engineCache.get(
747
+ params.nestId
748
+ );
749
+ const node = await storage.readDocument(params.nodeId);
750
+ const history = await versionManager.getHistory(params.nodeId);
751
+ const alreadySealed = (history?.versions ?? []).some(
752
+ (v) => v.version === (node.frontmatter.version ?? 1)
753
+ );
754
+ if (!alreadySealed) {
755
+ await versionManager.createVersion(node, params.requestedBy, {
756
+ note: params.note || "Submitted for review"
757
+ });
758
+ }
759
+ } catch (err) {
760
+ console.error(
761
+ "VersionManager.createVersion failed (submit for review)",
762
+ params.nestId,
763
+ params.nodeId,
764
+ err
765
+ );
766
+ }
767
+ return { id: id2, version: version2 };
768
+ }
726
769
  );
727
770
  const reviewCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
728
771
  void dispatchEvent({
@@ -730,18 +773,18 @@ async function submitForReview(params) {
730
773
  nestId: params.nestId,
731
774
  subjectId: params.nodeId,
732
775
  actor: params.requestedBy,
733
- message: `Review requested on *${reviewCtx.label}* (v${params.version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
776
+ message: `Review requested on *${reviewCtx.label}* (v${version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
734
777
  });
735
778
  void notifyNestEvent(
736
779
  params.nestId,
737
- `:memo: Review requested on *${reviewCtx.label}* (v${params.version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`,
780
+ `:memo: Review requested on *${reviewCtx.label}* (v${version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`,
738
781
  {
739
782
  status: "pending_review",
740
783
  docTitle: reviewCtx.docTitle,
741
784
  path: reviewCtx.path,
742
785
  link: reviewCtx.link,
743
786
  actor: params.requestedBy,
744
- version: params.version,
787
+ version,
745
788
  note: params.note || null
746
789
  }
747
790
  );
@@ -765,7 +808,7 @@ async function submitForReview(params) {
765
808
  path: reviewCtx.path,
766
809
  link: reviewCtx.link,
767
810
  actor: params.requestedBy,
768
- version: params.version,
811
+ version,
769
812
  note: params.note || null
770
813
  });
771
814
  }
@@ -1106,9 +1149,12 @@ async function getPendingReview(nestId, nodeId) {
1106
1149
  );
1107
1150
  return row ? rowToReviewRequest(row) : null;
1108
1151
  }
1152
+ function isNestTarget(t) {
1153
+ return t === "nest" || t === "nest_archive";
1154
+ }
1109
1155
  async function deletionTargetTitle(request, titles) {
1110
1156
  if (request.targetType === "folder") return prettyFolderPath(request.nodeId);
1111
- if (request.targetType === "nest") return await nestName(request.nestId);
1157
+ if (isNestTarget(request.targetType)) return await nestName(request.nestId);
1112
1158
  return titles?.get(request.nodeId) ?? await titleForNode(request.nestId, request.nodeId);
1113
1159
  }
1114
1160
  async function nestAdminEmails(nestId) {
@@ -1145,7 +1191,7 @@ async function deletionTargetContext(request, baseUrl) {
1145
1191
  const name = await nestName(request.nestId);
1146
1192
  const label = request.targetType === "folder" ? prettyFolderPath(request.nodeId) : name;
1147
1193
  return {
1148
- noun: request.targetType,
1194
+ noun: request.targetType === "nest_archive" ? "nest" : request.targetType,
1149
1195
  label,
1150
1196
  docTitle: label,
1151
1197
  path: name,
@@ -1210,7 +1256,8 @@ async function notifyDeletionRequested(request, baseUrl) {
1210
1256
  async function notifyDeletionResolved(params) {
1211
1257
  try {
1212
1258
  const { outcome, resolvedBy, note } = params;
1213
- const verb = outcome === "deleted" ? "deleted" : "rejected the deletion of";
1259
+ const archivedInstead = outcome === "deleted" && params.targetType === "nest_archive";
1260
+ const verb = archivedInstead ? "archived" : outcome === "deleted" ? "deleted" : "rejected the deletion of";
1214
1261
  const line = `${resolvedBy} ${verb} *${params.docTitle}*${note ? ` \u2014 "${note}"` : ""}`;
1215
1262
  const recipient = params.requestedBy.toLowerCase();
1216
1263
  const canInbox = !(params.targetType === "nest" && outcome === "deleted");
@@ -1220,7 +1267,7 @@ async function notifyDeletionResolved(params) {
1220
1267
  [recipient],
1221
1268
  `deletion_${outcome}`,
1222
1269
  params.requestId,
1223
- outcome === "deleted" ? `"${params.docTitle}" was deleted by ${resolvedBy} \u2014 your request was accepted` : `Your deletion request for "${params.docTitle}" was rejected by ${resolvedBy}${note ? ` \u2014 "${note}"` : ""}`
1270
+ outcome === "deleted" ? `"${params.docTitle}" was ${archivedInstead ? "archived" : "deleted"} by ${resolvedBy} \u2014 your request was accepted` : `Your deletion request for "${params.docTitle}" was rejected by ${resolvedBy}${note ? ` \u2014 "${note}"` : ""}`
1224
1271
  );
1225
1272
  }
1226
1273
  void dispatchEvent({
@@ -1240,7 +1287,10 @@ async function notifyDeletionResolved(params) {
1240
1287
  if (recipient !== resolvedBy.toLowerCase()) {
1241
1288
  const link = params.baseUrl ? `${params.baseUrl}/?nest=${encodeURIComponent(params.nestId)}` : "";
1242
1289
  void sendEmailToRecipient(recipient, {
1243
- status: outcome === "deleted" ? "deletion_deleted" : "deletion_declined",
1290
+ // The email has to agree with the inbox row and the channel line above:
1291
+ // an honoured archive is not a deletion, and the deletion template says
1292
+ // the nest is gone.
1293
+ status: archivedInstead ? "deletion_archived" : outcome === "deleted" ? "deletion_deleted" : "deletion_declined",
1244
1294
  docTitle: params.docTitle,
1245
1295
  path: params.nestId,
1246
1296
  link: outcome === "deleted" ? link : void 0,
@@ -1261,12 +1311,13 @@ async function requestDeletion(params) {
1261
1311
  const db = getDb();
1262
1312
  const targetType = params.targetType || "document";
1263
1313
  const noun = targetType === "document" ? "node" : targetType === "folder" ? "folder" : "nest";
1314
+ const kind = targetType === "nest_archive" ? "An archive request" : "A deletion request";
1264
1315
  const existing = await db.get(
1265
1316
  "SELECT id FROM deletion_requests WHERE nest_id = ? AND node_id = ? AND target_type = ? AND status = 'pending'",
1266
1317
  [params.nestId, params.nodeId, targetType]
1267
1318
  );
1268
1319
  if (existing) {
1269
- throw new ConflictError(`A deletion request is already pending for this ${noun}`);
1320
+ throw new ConflictError(`${kind} is already pending for this ${noun}`);
1270
1321
  }
1271
1322
  const id = uuid2();
1272
1323
  try {
@@ -1289,7 +1340,7 @@ async function requestDeletion(params) {
1289
1340
  params.nodeId,
1290
1341
  err
1291
1342
  );
1292
- throw new ConflictError(`A deletion request is already pending for this ${noun}`);
1343
+ throw new ConflictError(`${kind} is already pending for this ${noun}`);
1293
1344
  }
1294
1345
  const request = await getDeletionRequest(id);
1295
1346
  await notifyDeletionRequested(request, params.baseUrl);
@@ -1437,6 +1488,7 @@ export {
1437
1488
  canRequestDeletion,
1438
1489
  filterAccessible,
1439
1490
  safeJson,
1491
+ nodeTrail,
1440
1492
  requireWorkflowPlane,
1441
1493
  seedDefaultEdgeTypes,
1442
1494
  parseConditionSchema,
@@ -1458,6 +1510,7 @@ export {
1458
1510
  getReviewQueue,
1459
1511
  getReviewHistory,
1460
1512
  getPendingReview,
1513
+ isNestTarget,
1461
1514
  deletionTargetTitle,
1462
1515
  notifyDeletionResolved,
1463
1516
  requestDeletion,
@@ -8,7 +8,7 @@ import {
8
8
  config,
9
9
  getDb,
10
10
  isEmailish
11
- } from "./chunk-XCFKS65Y.js";
11
+ } from "./chunk-LPPKPEYI.js";
12
12
  import {
13
13
  ANON_USER_ID
14
14
  } from "./chunk-YB3LKF7U.js";
@@ -680,6 +680,14 @@ var STATUS_META = {
680
680
  subjectWord: "deleted",
681
681
  label: "Deleted",
682
682
  color: "#dc2626"
683
+ },
684
+ // An honoured ARCHIVE request. Deliberately not the deletion palette: the
685
+ // nest is intact and restorable, and red "Deleted" would tell the requester
686
+ // the opposite of what happened.
687
+ deletion_archived: {
688
+ subjectWord: "archived",
689
+ label: "Archived",
690
+ color: "#0891b2"
683
691
  }
684
692
  };
685
693
  var cap = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
@@ -777,6 +785,14 @@ var TEMPLATES = {
777
785
  lineH: (x) => `${x.doc} has been deleted${x.byH}${x.noteH} \u2014 your deletion request was accepted.`,
778
786
  rows: governanceRows
779
787
  },
788
+ deletion_archived: {
789
+ heading: (x) => `${x.title} was ${x.meta.subjectWord}`,
790
+ detailsHeader: "Nest details",
791
+ linkLabel: "Open nest",
792
+ lineT: (x) => `${x.title} has been archived${x.byT}${x.noteT} \u2014 your request was accepted. Nothing was deleted: every document and version is intact, and an owner can restore it.`,
793
+ lineH: (x) => `${x.doc} has been archived${x.byH}${x.noteH} \u2014 your request was accepted. Nothing was deleted: every document and version is intact, and an owner can restore it.`,
794
+ rows: governanceRows
795
+ },
780
796
  steward: {
781
797
  heading: (x) => `You're now a steward of ${x.title}`,
782
798
  detailsHeader: "Details",
@@ -1259,7 +1275,9 @@ async function listNotifications(userEmail, opts = {}) {
1259
1275
  `SELECT n.id, n.nest_id, n.kind, n.subject_id, n.message, n.created_at, n.read_at,
1260
1276
  COALESCE(
1261
1277
  rr.node_id,
1262
- CASE WHEN dr.target_type = 'document' THEN dr.node_id END
1278
+ CASE WHEN dr.target_type = 'document' THEN dr.node_id END,
1279
+ -- A mention names the document directly; there's no request row.
1280
+ CASE WHEN n.kind = 'mention' THEN n.subject_id END
1263
1281
  ) AS node_id
1264
1282
  FROM notifications n
1265
1283
  LEFT JOIN review_requests rr ON rr.id = n.subject_id
@@ -1566,6 +1584,23 @@ function parseSharedTeams(json) {
1566
1584
  return [];
1567
1585
  }
1568
1586
  }
1587
+ async function listSharedTeamMembers(refs) {
1588
+ const ids = [...new Set(refs.map((r) => r.teamId).filter(Boolean))];
1589
+ if (ids.length === 0) return [];
1590
+ const rows = await getDb().all(
1591
+ `SELECT members FROM teams WHERE id IN (${ids.map(() => "?").join(",")})`,
1592
+ ids
1593
+ );
1594
+ const byEmail = /* @__PURE__ */ new Map();
1595
+ for (const row of rows) {
1596
+ for (const member of parseMembers(row.members)) {
1597
+ const key = (member.email || "").toLowerCase();
1598
+ if (!key || byEmail.has(key)) continue;
1599
+ byEmail.set(key, { userId: member.userId, email: member.email, name: null });
1600
+ }
1601
+ }
1602
+ return [...byEmail.values()];
1603
+ }
1569
1604
  async function listNestTeams(nestId) {
1570
1605
  const db = getDb();
1571
1606
  const row = await db.get("SELECT shared_teams FROM nests WHERE id = ?", [
@@ -1750,9 +1785,29 @@ var PERMISSION_LEVELS = {
1750
1785
  };
1751
1786
  var higher = (a, b) => b && PERMISSION_LEVELS[b] > PERMISSION_LEVELS[a] ? b : a;
1752
1787
  async function resolveNestAccess(nestId, userId) {
1788
+ const access = await resolveNestAccessRaw(nestId, userId);
1789
+ return {
1790
+ ...access,
1791
+ rawPermission: access.permission,
1792
+ permission: capForArchive(access.permission, access.archived)
1793
+ };
1794
+ }
1795
+ async function isNestArchived(nestId) {
1796
+ const row = await getDb().get(
1797
+ "SELECT archived_at FROM nests WHERE id = ?",
1798
+ [nestId]
1799
+ );
1800
+ return !!row?.archived_at;
1801
+ }
1802
+ function capForArchive(permission, archived) {
1803
+ if (!archived) return permission;
1804
+ return PERMISSION_LEVELS[permission] > PERMISSION_LEVELS.read ? "read" : permission;
1805
+ }
1806
+ async function resolveNestAccessRaw(nestId, userId) {
1753
1807
  const row = await getDb().get(
1754
1808
  `SELECT n.user_id AS owner_id,
1755
1809
  n.visibility AS visibility,
1810
+ n.archived_at AS archived_at,
1756
1811
  n.shared_teams AS shared_teams,
1757
1812
  u.email AS caller_email,
1758
1813
  nc.permission AS collab_permission,
@@ -1779,7 +1834,9 @@ async function resolveNestAccess(nestId, userId) {
1779
1834
  isSteward: false,
1780
1835
  hasGrant: false,
1781
1836
  permission: "none",
1782
- isPublicReader: false
1837
+ rawPermission: "none",
1838
+ isPublicReader: false,
1839
+ archived: false
1783
1840
  };
1784
1841
  if (!row) return absent;
1785
1842
  const base = {
@@ -1791,7 +1848,8 @@ async function resolveNestAccess(nestId, userId) {
1791
1848
  // Postgres returns integer 1, SQLite returns 1 — both truthy, neither a
1792
1849
  // boolean, so normalize rather than leaking the driver's shape.
1793
1850
  isSteward: !!row.steward_hit,
1794
- hasGrant: !!row.grant_hit
1851
+ hasGrant: !!row.grant_hit,
1852
+ archived: !!row.archived_at
1795
1853
  };
1796
1854
  if (row.owner_id === userId) {
1797
1855
  return { ...base, permission: "owner", isPublicReader: false };
@@ -1877,20 +1935,32 @@ async function isImportedNest(nestId) {
1877
1935
  );
1878
1936
  return !!row?.is_imported;
1879
1937
  }
1938
+ var MAX_NEST_NAME_LENGTH = 100;
1880
1939
  function plainName(name, emptyMessage = "Name cannot be empty") {
1881
1940
  const typed = (name ?? "").trim();
1882
1941
  if (!typed) throw new ValidationError(emptyMessage);
1883
- const stripped = typed.replace(/<[^>]*>/g, "").trim();
1884
- if (!stripped) {
1942
+ if (typed.length > MAX_NEST_NAME_LENGTH) {
1943
+ throw new ValidationError(
1944
+ `Nest names are capped at ${MAX_NEST_NAME_LENGTH} characters`
1945
+ );
1946
+ }
1947
+ if (/<\/?[a-zA-Z][^>]*>/.test(typed)) {
1885
1948
  throw new ValidationError(
1886
- "That's not a valid nest name \u2014 it's only HTML markup. Use plain text."
1949
+ "Nest names are plain text \u2014 remove the HTML tags."
1887
1950
  );
1888
1951
  }
1889
- return stripped;
1952
+ return typed;
1890
1953
  }
1891
1954
  function toSlug(name) {
1892
1955
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1893
1956
  }
1957
+ async function assertSlugFree(userId, slug, message, exceptId) {
1958
+ const conflict = await getDb().get(
1959
+ exceptId ? "SELECT id FROM nests WHERE user_id = ? AND slug = ? AND id != ? LIMIT 1" : "SELECT id FROM nests WHERE user_id = ? AND slug = ? LIMIT 1",
1960
+ exceptId ? [userId, slug, exceptId] : [userId, slug]
1961
+ );
1962
+ if (conflict) throw new ValidationError(message);
1963
+ }
1894
1964
  async function uniqueNestName(userId, baseName) {
1895
1965
  const db = getDb();
1896
1966
  let candidate = baseName;
@@ -2017,15 +2087,12 @@ async function renameNest(nestId, patch) {
2017
2087
  const trimmed = plainName(patch.name);
2018
2088
  const slug = toSlug(trimmed);
2019
2089
  if (!slug) throw new ValidationError("Name must contain at least one alphanumeric character");
2020
- const conflict = await db.get(
2021
- "SELECT id FROM nests WHERE user_id = ? AND slug = ? AND id != ? LIMIT 1",
2022
- [existing.user_id, slug, nestId]
2090
+ await assertSlugFree(
2091
+ existing.user_id,
2092
+ slug,
2093
+ "Another nest already uses this name. Pick a different one.",
2094
+ nestId
2023
2095
  );
2024
- if (conflict) {
2025
- throw new ValidationError(
2026
- `Another nest already uses this name. Pick a different one.`
2027
- );
2028
- }
2029
2096
  updates.push("name = ?", "slug = ?");
2030
2097
  params.push(trimmed, slug);
2031
2098
  }
@@ -2053,15 +2120,11 @@ async function createNest(userId, name, description) {
2053
2120
  "Name must contain at least one alphanumeric character"
2054
2121
  );
2055
2122
  }
2056
- const conflict = await db.get(
2057
- "SELECT id FROM nests WHERE user_id = ? AND slug = ? LIMIT 1",
2058
- [userId, slug]
2123
+ await assertSlugFree(
2124
+ userId,
2125
+ slug,
2126
+ "You already have a nest with this name. Pick a different one."
2059
2127
  );
2060
- if (conflict) {
2061
- throw new ValidationError(
2062
- "You already have a nest with this name. Pick a different one."
2063
- );
2064
- }
2065
2128
  const visibility = userId === ANON_USER_ID ? "public" : "private";
2066
2129
  await db.run(
2067
2130
  "INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)",
@@ -2079,6 +2142,11 @@ async function importNest(userId, name) {
2079
2142
  const db = getDb();
2080
2143
  const id = uuid3();
2081
2144
  const slug = toSlug(nm);
2145
+ await assertSlugFree(
2146
+ userId,
2147
+ slug,
2148
+ "A nest with this name already exists. Pick a different name."
2149
+ );
2082
2150
  const visibility = userId === ANON_USER_ID ? "public" : "private";
2083
2151
  mkdirSync(resolveNestPath(id), { recursive: true });
2084
2152
  await db.run(
@@ -2088,26 +2156,79 @@ async function importNest(userId, name) {
2088
2156
  trackEvent("nest.import", { nestId: id, userId });
2089
2157
  return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
2090
2158
  }
2091
- async function listNests(userId) {
2159
+ async function pinNest(userId, nestId) {
2160
+ const db = getDb();
2161
+ const existing = await db.get(
2162
+ "SELECT 1 FROM nest_pins WHERE user_id = ? AND nest_id = ?",
2163
+ [userId, nestId]
2164
+ );
2165
+ if (existing) return;
2166
+ try {
2167
+ await db.run(
2168
+ "INSERT INTO nest_pins (user_id, nest_id) VALUES (?, ?)",
2169
+ [userId, nestId]
2170
+ );
2171
+ } catch (err) {
2172
+ const now = await db.get(
2173
+ "SELECT 1 FROM nest_pins WHERE user_id = ? AND nest_id = ?",
2174
+ [userId, nestId]
2175
+ );
2176
+ if (!now) throw err;
2177
+ }
2178
+ }
2179
+ async function unpinNest(userId, nestId) {
2180
+ await getDb().run(
2181
+ "DELETE FROM nest_pins WHERE user_id = ? AND nest_id = ?",
2182
+ [userId, nestId]
2183
+ );
2184
+ }
2185
+ async function pinnedNestIds(userId) {
2186
+ const rows = await getDb().all(
2187
+ "SELECT nest_id FROM nest_pins WHERE user_id = ?",
2188
+ [userId]
2189
+ );
2190
+ return new Set(rows.map((r) => r.nest_id));
2191
+ }
2192
+ var LIVE_ONLY = "archived_at IS NULL";
2193
+ var LIVE_ONLY_N = "n.archived_at IS NULL";
2194
+ async function listNests(userId, opts = {}) {
2092
2195
  const db = getDb();
2196
+ const scope = opts.archived ? "archived_at IS NOT NULL" : LIVE_ONLY;
2093
2197
  if (userId === ANON_USER_ID) {
2094
2198
  return await db.all(
2095
- "SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
2199
+ `SELECT * FROM nests WHERE user_id = ? AND ${scope} ORDER BY created_at DESC`,
2096
2200
  [ANON_USER_ID]
2097
2201
  );
2098
2202
  }
2099
2203
  const includeAnon = config.AUTH_MODE === "open" || await isLicenseAdminUserId(userId);
2100
2204
  if (!includeAnon) {
2101
2205
  return await db.all(
2102
- "SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
2206
+ `SELECT * FROM nests WHERE user_id = ? AND ${scope} ORDER BY created_at DESC`,
2103
2207
  [userId]
2104
2208
  );
2105
2209
  }
2106
2210
  return await db.all(
2107
- "SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC",
2211
+ `SELECT * FROM nests WHERE (user_id = ? OR user_id = ?) AND ${scope}
2212
+ ORDER BY created_at DESC`,
2108
2213
  [userId, ANON_USER_ID]
2109
2214
  );
2110
2215
  }
2216
+ async function archiveNest(nestId) {
2217
+ await getDb().transaction(async (tx) => {
2218
+ await tx.run(
2219
+ "UPDATE nests SET archived_at = ? WHERE id = ? AND archived_at IS NULL",
2220
+ [(/* @__PURE__ */ new Date()).toISOString(), nestId]
2221
+ );
2222
+ await tx.run(
2223
+ `DELETE FROM deletion_requests
2224
+ WHERE nest_id = ? AND target_type = 'nest_archive' AND status = 'pending'`,
2225
+ [nestId]
2226
+ );
2227
+ });
2228
+ }
2229
+ async function restoreNest(nestId) {
2230
+ await getDb().run("UPDATE nests SET archived_at = NULL WHERE id = ?", [nestId]);
2231
+ }
2111
2232
  async function nestsSharedViaTeams(userId) {
2112
2233
  const db = getDb();
2113
2234
  const teams = await db.all(
@@ -2129,6 +2250,7 @@ async function nestsSharedViaTeams(userId) {
2129
2250
  const candidates = await db.all(
2130
2251
  `SELECT * FROM nests
2131
2252
  WHERE user_id != ?
2253
+ AND ${LIVE_ONLY}
2132
2254
  AND (${ids.map(() => "shared_teams LIKE ?").join(" OR ")})`,
2133
2255
  [userId, ...ids.map((id) => `%${id}%`)]
2134
2256
  );
@@ -2154,6 +2276,7 @@ async function listSharedNests(userId) {
2154
2276
  LEFT JOIN grants g
2155
2277
  ON g.nest_id = n.id AND g.user_id = ?
2156
2278
  WHERE n.user_id != ?
2279
+ AND ${LIVE_ONLY_N}
2157
2280
  AND (nc.user_id IS NOT NULL OR s.id IS NOT NULL OR g.id IS NOT NULL)
2158
2281
  ORDER BY n.created_at DESC`,
2159
2282
  [userId, userId, userId, userId]
@@ -2167,7 +2290,8 @@ async function listSharedNests(userId) {
2167
2290
  }
2168
2291
  if (await isServerAdminUserId(userId)) {
2169
2292
  const anon = await db.all(
2170
- "SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC",
2293
+ `SELECT * FROM nests WHERE user_id = ? AND ${LIVE_ONLY}
2294
+ ORDER BY created_at DESC`,
2171
2295
  [ANON_USER_ID]
2172
2296
  );
2173
2297
  for (const n of anon) {
@@ -2201,11 +2325,12 @@ async function listVisibleNests(userId, opts = {}) {
2201
2325
  ON s.nest_id = n.id AND s.is_active = 1
2202
2326
  AND LOWER(s.user_email) = LOWER(u.email)
2203
2327
  ${opts.readable ? "LEFT JOIN grants g ON g.nest_id = n.id AND g.user_id = ?" : ""}
2204
- WHERE n.user_id = ?
2328
+ WHERE ${LIVE_ONLY_N}
2329
+ AND (n.user_id = ?
2205
2330
  OR nc.user_id IS NOT NULL
2206
2331
  OR s.id IS NOT NULL
2207
2332
  ${opts.readable ? "OR g.id IS NOT NULL OR n.visibility = 'public'" : ""}
2208
- ${includeAnon ? "OR n.user_id = ?" : ""}
2333
+ ${includeAnon ? "OR n.user_id = ?" : ""})
2209
2334
  ORDER BY n.created_at DESC`,
2210
2335
  params
2211
2336
  );
@@ -2223,6 +2348,7 @@ async function listPublicNests(userId) {
2223
2348
  return await db.all(
2224
2349
  `SELECT n.* FROM nests n
2225
2350
  WHERE n.visibility = 'public'
2351
+ AND ${LIVE_ONLY_N}
2226
2352
  AND n.user_id != ?
2227
2353
  AND NOT EXISTS (
2228
2354
  SELECT 1 FROM nest_collaborators nc
@@ -2250,6 +2376,10 @@ async function deleteNest(nestId) {
2250
2376
  } catch {
2251
2377
  }
2252
2378
  await tx.run("DELETE FROM api_keys WHERE nest_id = ?", [id]);
2379
+ try {
2380
+ await tx.run("DELETE FROM nest_pins WHERE nest_id = ?", [id]);
2381
+ } catch {
2382
+ }
2253
2383
  await tx.run("DELETE FROM nests WHERE id = ?", [id]);
2254
2384
  });
2255
2385
  const path = resolveNestPath(nestId);
@@ -2283,7 +2413,7 @@ var META_BASENAMES = /* @__PURE__ */ new Set([
2283
2413
  ]);
2284
2414
  function isMetaFile(idOrPath) {
2285
2415
  const base = idOrPath.split(/[/\\]/).pop() ?? "";
2286
- return META_BASENAMES.has(base.replace(/\.md$/i, ""));
2416
+ return META_BASENAMES.has(base.replace(/\.md$/i, "").toUpperCase());
2287
2417
  }
2288
2418
 
2289
2419
  // src/shared/batch.ts
@@ -2457,8 +2587,8 @@ async function ensureNodeIndex(nestId) {
2457
2587
  return run;
2458
2588
  }
2459
2589
  async function rebuildNodeIndex(nestId) {
2460
- const { engineCache: engineCache2 } = await import("./engine-VZTSDUPB.js");
2461
- const { documentsWithSuggestions } = await import("./external-edit-service-3FVK3OD7.js");
2590
+ const { engineCache: engineCache2 } = await import("./engine-IKZQ46P7.js");
2591
+ const { documentsWithSuggestions } = await import("./external-edit-service-LTON57HO.js");
2462
2592
  const { storage, dropDiscoveryCache } = await engineCache2.get(nestId);
2463
2593
  dropDiscoveryCache();
2464
2594
  const startedAt = writeGeneration.get(nestId) ?? 0;
@@ -2739,7 +2869,12 @@ export {
2739
2869
  renameNest,
2740
2870
  createNest,
2741
2871
  importNest,
2872
+ pinNest,
2873
+ unpinNest,
2874
+ pinnedNestIds,
2742
2875
  listNests,
2876
+ archiveNest,
2877
+ restoreNest,
2743
2878
  listSharedNests,
2744
2879
  listVisibleNests,
2745
2880
  listPublicNests,
@@ -2786,12 +2921,16 @@ export {
2786
2921
  replaceTeamRoster,
2787
2922
  updateMemberRole,
2788
2923
  removeMember,
2924
+ parseSharedTeams,
2925
+ listSharedTeamMembers,
2789
2926
  listNestTeams,
2790
2927
  shareTeamWithNest,
2791
2928
  unshareTeamFromNest,
2792
2929
  resolveTeamRolesForUser,
2793
2930
  isServerAdminUserId,
2794
2931
  resolveNestAccess,
2932
+ isNestArchived,
2933
+ capForArchive,
2795
2934
  resolveNestPermission,
2796
2935
  permissionLevel,
2797
2936
  isPublicReader