@promptowl/contextnest-community 1.16.1 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CONFIGURATION.md +415 -415
  2. package/LICENSE.md +142 -142
  3. package/README.md +168 -169
  4. package/dist/{chunk-D6HICFVW.js → chunk-34UXRZXD.js} +59 -14
  5. package/dist/chunk-F66VCBLA.js +136 -0
  6. package/dist/{chunk-L7UCMGWZ.js → chunk-FUUJG6XP.js} +641 -285
  7. package/dist/{chunk-QJVRZT6M.js → chunk-HGN45I5P.js} +59 -21
  8. package/dist/{chunk-JMJLNEXE.js → chunk-HYSTFMFG.js} +43 -2
  9. package/dist/chunk-OOBZG6BW.js +379 -0
  10. package/dist/{chunk-OTGZQZKK.js → chunk-QUZXRLUA.js} +11 -10
  11. package/dist/{chunk-CLKZZZS2.js → chunk-TQACHVVA.js} +35 -4
  12. package/dist/{chunk-SLTQACJW.js → chunk-YB3LKF7U.js} +3 -1
  13. package/dist/{client-OBS5HOTA.js → client-SW5I6ZBK.js} +2 -2
  14. package/dist/engine-34E3YNX7.js +14 -0
  15. package/dist/external-edit-service-6Q6SHEOF.js +33 -0
  16. package/dist/{grants-service-I535QVTW.js → grants-service-ME7HIVHU.js} +5 -5
  17. package/dist/index.js +700 -526
  18. package/dist/{keys-73STFJJB.js → keys-CTOGAG3W.js} +5 -1
  19. package/dist/{migrations.postgres-CYKO5CFV.js → migrations.postgres-5VI4RDTS.js} +41 -4
  20. package/dist/{review-service-V2GR6VM3.js → review-service-H2M5ACPQ.js} +11 -9
  21. package/dist/{stewardship-service-LTREGVSJ.js → stewardship-service-U4CKL7Y5.js} +8 -4
  22. package/dist/{version-service-V6K5SZ3P.js → version-service-JWEN5UUW.js} +6 -4
  23. package/dist/web3/assets/{hootie-C2ocYkn4.svg → hootie-_U3ECslt.svg} +8 -8
  24. package/dist/web3/assets/index-DONv5_Ni.css +1 -0
  25. package/dist/web3/assets/index-jSGrnboh.js +1065 -0
  26. package/dist/web3/index.html +14 -14
  27. package/package.json +165 -165
  28. package/dist/chunk-XRK6SQSC.js +0 -58
  29. package/dist/web3/assets/index-DnQARUL3.css +0 -1
  30. package/dist/web3/assets/index-_m7WyozM.js +0 -1051
@@ -1,20 +1,22 @@
1
+ import {
2
+ canUserAccess,
3
+ canUserApprove,
4
+ resolveNestWideRoles,
5
+ resolveStewardsForNode,
6
+ stewardCoverageForUser
7
+ } from "./chunk-TQACHVVA.js";
1
8
  import {
2
9
  grantCoversNode,
3
10
  listUserGrants,
4
11
  resolveNodeGrant
5
- } from "./chunk-OTGZQZKK.js";
12
+ } from "./chunk-QUZXRLUA.js";
6
13
  import {
7
14
  createVersion,
8
15
  getApprovedVersion,
9
16
  getApprovedVersions,
10
17
  getCurrentVersion,
11
18
  setApprovedVersion
12
- } from "./chunk-D6HICFVW.js";
13
- import {
14
- canUserAccess,
15
- canUserApprove,
16
- resolveStewardsForNode
17
- } from "./chunk-CLKZZZS2.js";
19
+ } from "./chunk-34UXRZXD.js";
18
20
  import {
19
21
  buildDocContext,
20
22
  buildTitleMap,
@@ -28,10 +30,12 @@ import {
28
30
  notifyEmailForNest,
29
31
  nowExpr,
30
32
  opContext,
33
+ permissionLabel,
31
34
  prettyFolderPath,
35
+ resolveNestPermission,
32
36
  sendEmailToRecipient,
33
37
  titleForNode
34
- } from "./chunk-L7UCMGWZ.js";
38
+ } from "./chunk-FUUJG6XP.js";
35
39
  import {
36
40
  ConflictError,
37
41
  NotFoundError,
@@ -40,7 +44,7 @@ import {
40
44
  import {
41
45
  config,
42
46
  getDb
43
- } from "./chunk-JMJLNEXE.js";
47
+ } from "./chunk-HYSTFMFG.js";
44
48
 
45
49
  // src/governance/review-service.ts
46
50
  import { v4 as uuid3 } from "uuid";
@@ -168,7 +172,7 @@ async function notifyNestInvite(params) {
168
172
  nestId,
169
173
  inviteeEmail.toLowerCase(),
170
174
  nestId,
171
- `You were added to "${nestName2}" as ${permission}`,
175
+ `You were added to "${nestName2}" as ${permissionLabel(permission)}`,
172
176
  (/* @__PURE__ */ new Date()).toISOString()
173
177
  ]
174
178
  );
@@ -179,10 +183,16 @@ async function notifyNestInvite(params) {
179
183
  async function listNotifications(userEmail, opts = {}) {
180
184
  const limit = Math.min(opts.limit ?? 50, 200);
181
185
  return await getDb().all(
182
- `SELECT id, nest_id, kind, subject_id, message, created_at, read_at
183
- FROM notifications
184
- WHERE LOWER(user_email) = LOWER(?)${opts.unreadOnly ? " AND read_at IS NULL" : ""}
185
- ORDER BY created_at DESC LIMIT ${limit}`,
186
+ `SELECT n.id, n.nest_id, n.kind, n.subject_id, n.message, n.created_at, n.read_at,
187
+ COALESCE(
188
+ rr.node_id,
189
+ CASE WHEN dr.target_type = 'document' THEN dr.node_id END
190
+ ) AS node_id
191
+ FROM notifications n
192
+ LEFT JOIN review_requests rr ON rr.id = n.subject_id
193
+ LEFT JOIN deletion_requests dr ON dr.id = n.subject_id
194
+ WHERE LOWER(n.user_email) = LOWER(?)${opts.unreadOnly ? " AND n.read_at IS NULL" : ""}
195
+ ORDER BY n.created_at DESC LIMIT ${limit}`,
186
196
  [userEmail]
187
197
  );
188
198
  }
@@ -225,20 +235,37 @@ async function canReadNode(nestId, nodeId, userId, userEmail) {
225
235
  if ((await canUserAccess(nestId, nodeId, userEmail)).allowed) return true;
226
236
  return await resolveNodeGrant(nestId, userId, nodeId) !== null;
227
237
  }
238
+ async function canRequestDeletion(params) {
239
+ const { nestId, userId, userEmail, nodeId } = params;
240
+ const permission = params.permission ?? await resolveNestPermission(nestId, userId);
241
+ if (permission === "owner" || permission === "admin" || permission === "write") {
242
+ return true;
243
+ }
244
+ const resolved = await resolveStewardsForNode(nestId, nodeId);
245
+ const isEditorSteward = resolved.some(
246
+ (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor" && (nodeId !== "" || r.steward.scope === "nest")
247
+ );
248
+ if (isEditorSteward) return true;
249
+ if (nodeId === "") return false;
250
+ return await resolveNodeGrant(nestId, userId, nodeId) === "write";
251
+ }
228
252
  async function filterAccessible(nestId, userId, userEmail, nodes, approvedVersions) {
229
253
  if (await isPublicReader(nestId, userId)) {
230
254
  const approved = approvedVersions ?? await getApprovedVersions(nestId);
231
255
  return nodes.filter((n) => approved.has(n.id));
232
256
  }
233
- if (!await isStewardshipEnabled(nestId)) return nodes;
234
257
  const grants = await listUserGrants(nestId, userId);
235
- const accessible = [];
236
- for (const n of nodes) {
237
- if ((await canUserAccess(nestId, n.id, userEmail)).allowed || grantCoversNode(grants, n.id)) {
238
- accessible.push(n);
239
- }
258
+ if (!await isStewardshipEnabled(nestId)) {
259
+ if (grants.length === 0) return nodes;
260
+ if (await resolveNestPermission(nestId, userId) !== "none") return nodes;
261
+ return nodes.filter((n) => grantCoversNode(grants, n.id));
240
262
  }
241
- return accessible;
263
+ if ((await resolveNestWideRoles(nestId, userEmail)).length > 0) return nodes;
264
+ const coverage = await stewardCoverageForUser(nestId, userEmail);
265
+ if (coverage.nestWide) return nodes;
266
+ return nodes.filter(
267
+ (n) => coverage.nodeIds.has(n.id) || grantCoversNode(grants, n.id)
268
+ );
242
269
  }
243
270
 
244
271
  // src/workflow/edge-type-routes.ts
@@ -1477,6 +1504,15 @@ async function listDeletionRequests(params) {
1477
1504
  async function dropDeletionRequest(id) {
1478
1505
  await getDb().run("DELETE FROM deletion_requests WHERE id = ?", [id]);
1479
1506
  }
1507
+ async function withdrawDeletionRequest(params) {
1508
+ const db = getDb();
1509
+ const pending = await getDeletionRequest(params.id);
1510
+ if (!pending || pending.nestId !== params.nestId || pending.status !== "pending" || pending.requestedBy.toLowerCase() !== params.withdrawnBy.toLowerCase()) {
1511
+ return null;
1512
+ }
1513
+ await db.run("DELETE FROM deletion_requests WHERE id = ?", [params.id]);
1514
+ return pending;
1515
+ }
1480
1516
  async function declineDeletion(params) {
1481
1517
  const db = getDb();
1482
1518
  const pending = await getDeletionRequest(params.id);
@@ -1556,6 +1592,7 @@ export {
1556
1592
  markNotificationsRead,
1557
1593
  resolveCallerEmail,
1558
1594
  canReadNode,
1595
+ canRequestDeletion,
1559
1596
  filterAccessible,
1560
1597
  safeJson,
1561
1598
  requireWorkflowPlane,
@@ -1586,5 +1623,6 @@ export {
1586
1623
  getDeletionRequest,
1587
1624
  listDeletionRequests,
1588
1625
  dropDeletionRequest,
1626
+ withdrawDeletionRequest,
1589
1627
  declineDeletion
1590
1628
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  ANON_USER_ID
3
- } from "./chunk-SLTQACJW.js";
3
+ } from "./chunk-YB3LKF7U.js";
4
4
 
5
5
  // src/db/client.ts
6
6
  import Database from "better-sqlite3";
@@ -741,6 +741,9 @@ function runMigrations(db) {
741
741
  if (!nestCols.includes("prime_tags")) {
742
742
  db.exec("ALTER TABLE nests ADD COLUMN prime_tags TEXT NOT NULL DEFAULT 'prime-document'");
743
743
  }
744
+ if (!nestCols.includes("index_synced_at")) {
745
+ db.exec("ALTER TABLE nests ADD COLUMN index_synced_at TEXT");
746
+ }
744
747
  const userCols = db.prepare("PRAGMA table_info(users)").all().map((c) => c.name);
745
748
  if (!userCols.includes("is_admin")) {
746
749
  db.exec("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0");
@@ -1531,6 +1534,44 @@ function runMigrations(db) {
1531
1534
  `);
1532
1535
  recordMigration("034_versions_author_status_index");
1533
1536
  }
1537
+ if (!hasMigration("035_multiple_api_keys_per_user")) {
1538
+ db.exec(`DROP INDEX IF EXISTS idx_api_keys_user_unique;`);
1539
+ recordMigration("035_multiple_api_keys_per_user");
1540
+ }
1541
+ if (!hasMigration("036_node_index")) {
1542
+ db.transaction(() => {
1543
+ db.exec(`
1544
+ CREATE TABLE IF NOT EXISTS node_index (
1545
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1546
+ node_id TEXT NOT NULL,
1547
+ folder TEXT NOT NULL DEFAULT '',
1548
+ title TEXT NOT NULL DEFAULT '',
1549
+ type TEXT NOT NULL DEFAULT 'document',
1550
+ status TEXT NOT NULL DEFAULT 'draft',
1551
+ updated_at TEXT,
1552
+ frontmatter_json TEXT NOT NULL,
1553
+ PRIMARY KEY (nest_id, node_id)
1554
+ );
1555
+ CREATE INDEX IF NOT EXISTS idx_node_index_folder
1556
+ ON node_index(nest_id, folder);
1557
+
1558
+ -- Which documents have an external edit staged against them.
1559
+ --
1560
+ -- Its own table rather than a node_index column: node_index rows are
1561
+ -- upserted with INSERT OR REPLACE on every document write, which
1562
+ -- resets any column the write does not carry \u2014 and a suggestion's
1563
+ -- existence has nothing to do with the document write that would
1564
+ -- clobber it. Kept in step at the three points that stage, approve or
1565
+ -- reject a suggestion, and refreshed wholesale by a rebuild.
1566
+ CREATE TABLE IF NOT EXISTS node_suggestions (
1567
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1568
+ node_id TEXT NOT NULL,
1569
+ PRIMARY KEY (nest_id, node_id)
1570
+ );
1571
+ `);
1572
+ })();
1573
+ recordMigration("036_node_index");
1574
+ }
1534
1575
  }
1535
1576
  function mergeCaseCollidingUsers(db) {
1536
1577
  const groups = db.prepare(
@@ -1748,7 +1789,7 @@ async function initDb() {
1748
1789
  if (config.DB_DRIVER === "postgres") {
1749
1790
  const { Pool } = await import("pg");
1750
1791
  const { PostgresAdapter } = await import("./adapter.postgres-YOODX2BI.js");
1751
- const { runPostgresMigrations } = await import("./migrations.postgres-CYKO5CFV.js");
1792
+ const { runPostgresMigrations } = await import("./migrations.postgres-5VI4RDTS.js");
1752
1793
  const pool = new Pool(buildPgConfig());
1753
1794
  adapter = new PostgresAdapter(pool);
1754
1795
  await runPostgresMigrations(adapter);
@@ -0,0 +1,379 @@
1
+ import {
2
+ engineCache,
3
+ indexedNodeIds,
4
+ markIndexStale,
5
+ resolveNestPath,
6
+ setSuggestionFlag
7
+ } from "./chunk-FUUJG6XP.js";
8
+ import {
9
+ getDb
10
+ } from "./chunk-HYSTFMFG.js";
11
+
12
+ // src/governance/external-edit-service.ts
13
+ import { readFile, readdir } from "fs/promises";
14
+ import { join, relative, sep } from "path";
15
+ import {
16
+ detectDrift,
17
+ stageSuggestion,
18
+ ChainEventLog,
19
+ approveSuggestion,
20
+ rejectSuggestion,
21
+ listSuggestions,
22
+ readSuggestion,
23
+ parseDocument,
24
+ VersionManager,
25
+ computeContentHash,
26
+ getChecksumContent
27
+ } from "@promptowl/contextnest-engine";
28
+ var communityRbac = {
29
+ isCzar: () => false,
30
+ canIngest: () => true,
31
+ isDocOwner: () => true
32
+ };
33
+ function docPath(nestId, documentId) {
34
+ return join(resolveNestPath(nestId), `${documentId}.md`);
35
+ }
36
+ async function readRaw(nestId, documentId) {
37
+ try {
38
+ return await readFile(docPath(nestId, documentId), "utf-8");
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+ async function loadChainHead(storage, documentId) {
44
+ const history = await storage.readHistory(documentId);
45
+ if (!history || history.versions.length === 0) return null;
46
+ const latest = history.versions[history.versions.length - 1];
47
+ try {
48
+ const content = await new VersionManager(storage).reconstructVersion(
49
+ documentId,
50
+ latest.version
51
+ );
52
+ return { version: latest.version, content };
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+ async function loadLatestApprovedNode(nestId, documentId) {
58
+ const { storage } = await engineCache.get(nestId);
59
+ const head = await loadChainHead(storage, documentId);
60
+ if (!head) return null;
61
+ return parseDocument(docPath(nestId, documentId), head.content, documentId);
62
+ }
63
+ async function bodyMatchesLatestVersion(storage, documentId, liveBodyHash) {
64
+ const head = await loadChainHead(storage, documentId);
65
+ if (!head) return false;
66
+ const headBodyHash = computeContentHash(getChecksumContent(head.content));
67
+ return headBodyHash === liveBodyHash;
68
+ }
69
+ async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
70
+ const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
71
+ return res?.meta ?? null;
72
+ }
73
+ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
74
+ const { storage } = await engineCache.get(nestId);
75
+ const node = await storage.readDocument(documentId).catch(() => null);
76
+ if (!node) return null;
77
+ const raw = await readRaw(nestId, documentId);
78
+ if (raw == null) return null;
79
+ const drift = detectDrift(raw, node.frontmatter.checksum);
80
+ if (!drift.drifted) return null;
81
+ if (await bodyMatchesLatestVersion(storage, documentId, drift.actualHash)) {
82
+ return null;
83
+ }
84
+ const approved = await loadChainHead(storage, documentId);
85
+ if (!approved) return null;
86
+ const existing = await listSuggestions(storage, documentId);
87
+ const dup = existing.find((s) => s.proposed_hash === drift.actualHash);
88
+ if (dup) {
89
+ await setSuggestionFlag(nestId, documentId, true);
90
+ return { meta: dup, created: false };
91
+ }
92
+ const result = await stageSuggestion({
93
+ storage,
94
+ documentId,
95
+ approvedRawContent: approved.content,
96
+ proposedRawContent: raw,
97
+ source: "out-of-band-edit",
98
+ actor,
99
+ docTier: "standard"
100
+ });
101
+ await setSuggestionFlag(nestId, documentId, true);
102
+ return { meta: result.meta, created: true };
103
+ }
104
+ async function refreshSuggestionFlag(nestId, storage, documentId) {
105
+ try {
106
+ const remaining = await listSuggestions(storage, documentId);
107
+ await setSuggestionFlag(nestId, documentId, remaining.length > 0);
108
+ } catch (err) {
109
+ console.error("[external-edit] flag refresh failed", nestId, documentId, err);
110
+ }
111
+ }
112
+ async function scanNestForDrift(nestId, actor = "system:scanner") {
113
+ const { storage, dropDiscoveryCache } = await engineCache.get(nestId);
114
+ dropDiscoveryCache();
115
+ const docs = await storage.discoverDocuments();
116
+ const results = await Promise.all(
117
+ docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
118
+ );
119
+ const staged = results.filter((r) => r?.created).length;
120
+ const onDisk = await storage.discoverDocuments({ includeRetired: true });
121
+ const indexed = await indexedNodeIds(nestId);
122
+ const membershipMoved = indexed.size !== onDisk.length || onDisk.some((d) => !indexed.has(d.id));
123
+ if (staged > 0 || membershipMoved) await markIndexStale(nestId);
124
+ return { scanned: docs.length, staged };
125
+ }
126
+ async function documentsWithSuggestions(nestId) {
127
+ const root = resolveNestPath(nestId);
128
+ const ids = /* @__PURE__ */ new Set();
129
+ let entries;
130
+ try {
131
+ entries = await readdir(root, { recursive: true, withFileTypes: true });
132
+ } catch (err) {
133
+ if (err?.code !== "ENOENT") {
134
+ console.error("[external-edit] suggestion scan failed", nestId, err);
135
+ }
136
+ return ids;
137
+ }
138
+ for (const e of entries) {
139
+ if (!e.isFile() || !e.name.endsWith(".meta.yaml")) continue;
140
+ const dir = e.parentPath ?? e.path ?? root;
141
+ const rel = relative(root, dir).split(sep).join("/");
142
+ const segs = rel.split("/");
143
+ const marker = segs.lastIndexOf("_suggestions");
144
+ if (marker === -1 || segs.length !== marker + 2) continue;
145
+ ids.add([...segs.slice(0, marker), segs[marker + 1]].filter(Boolean).join("/"));
146
+ }
147
+ return ids;
148
+ }
149
+ async function getPendingChange(nestId, documentId) {
150
+ const { storage } = await engineCache.get(nestId);
151
+ const list = await listSuggestions(storage, documentId);
152
+ for (let i = list.length - 1; i >= 0; i--) {
153
+ const meta = list[i];
154
+ if (await bodyMatchesLatestVersion(storage, documentId, meta.proposed_hash)) {
155
+ continue;
156
+ }
157
+ return {
158
+ suggestion_id: meta.suggestion_id,
159
+ detected_at: meta.detected_at,
160
+ source: meta.source,
161
+ proposed_hash: meta.proposed_hash
162
+ };
163
+ }
164
+ return null;
165
+ }
166
+ async function listNestExternalEdits(nestId) {
167
+ const { storage } = await engineCache.get(nestId);
168
+ const docs = await storage.discoverDocuments();
169
+ const lists = await Promise.all(
170
+ docs.map(async (doc) => {
171
+ const metas = await listSuggestions(storage, doc.id);
172
+ const fresh = [];
173
+ for (const meta of metas) {
174
+ if (await bodyMatchesLatestVersion(storage, doc.id, meta.proposed_hash)) {
175
+ continue;
176
+ }
177
+ fresh.push(meta);
178
+ }
179
+ return fresh;
180
+ })
181
+ );
182
+ const entries = lists.flat().map((meta) => ({
183
+ suggestion_id: meta.suggestion_id,
184
+ nest_id: nestId,
185
+ document_id: meta.document_id,
186
+ source: meta.source,
187
+ detected_at: meta.detected_at,
188
+ actor: meta.actor,
189
+ target_hash: meta.target_hash,
190
+ proposed_hash: meta.proposed_hash,
191
+ note: meta.note
192
+ }));
193
+ return entries.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
194
+ }
195
+ async function getExternalEditDetail(nestId, documentId, suggestionId) {
196
+ const { storage } = await engineCache.get(nestId);
197
+ const found = await readSuggestion(storage, documentId, suggestionId);
198
+ if (!found) return null;
199
+ return {
200
+ suggestion_id: found.meta.suggestion_id,
201
+ nest_id: nestId,
202
+ document_id: found.meta.document_id,
203
+ source: found.meta.source,
204
+ detected_at: found.meta.detected_at,
205
+ actor: found.meta.actor,
206
+ target_hash: found.meta.target_hash,
207
+ proposed_hash: found.meta.proposed_hash,
208
+ note: found.meta.note,
209
+ patch: found.patch
210
+ };
211
+ }
212
+ async function recordVerdict(nestId, event) {
213
+ try {
214
+ const { storage } = await engineCache.get(nestId);
215
+ await new ChainEventLog(storage).append(event);
216
+ return true;
217
+ } catch (err) {
218
+ console.error(
219
+ `[external-edit] failed to record verdict ${event.event_id} for nest ${nestId}:`,
220
+ err
221
+ );
222
+ return false;
223
+ }
224
+ }
225
+ async function listExternalEditVerdicts(nestId, documentId) {
226
+ try {
227
+ const { storage } = await engineCache.get(nestId);
228
+ const events = await new ChainEventLog(storage).readByDocument(documentId);
229
+ return events.flatMap((e) => {
230
+ const meta = e.action_metadata || {};
231
+ if (typeof meta.suggestion_id !== "string") return [];
232
+ const reason = meta.rejection_reason;
233
+ const comment = meta.approval_comment;
234
+ return [
235
+ {
236
+ suggestion_id: meta.suggestion_id,
237
+ status: typeof reason === "string" ? "rejected" : "approved",
238
+ actor: e.actor,
239
+ at: e.timestamp,
240
+ note: typeof reason === "string" ? reason : typeof comment === "string" ? comment : void 0,
241
+ source: typeof meta.source === "string" ? meta.source : void 0
242
+ }
243
+ ];
244
+ });
245
+ } catch (err) {
246
+ console.error(
247
+ `[external-edit] failed to read verdicts for ${nestId}/${documentId}:`,
248
+ err
249
+ );
250
+ return [];
251
+ }
252
+ }
253
+ async function mirrorVersion(input) {
254
+ const { storage } = await engineCache.get(input.nestId);
255
+ const node = await storage.readDocument(input.documentId);
256
+ const { upsertVersion, setApprovedVersion } = await import("./version-service-JWEN5UUW.js");
257
+ await upsertVersion({
258
+ nestId: input.nestId,
259
+ nodeId: input.documentId,
260
+ version: input.version,
261
+ content: node.body || "",
262
+ author: input.actor,
263
+ status: "published",
264
+ tags: node.frontmatter.tags || [],
265
+ changeNote: input.note
266
+ });
267
+ await setApprovedVersion(
268
+ input.nestId,
269
+ input.documentId,
270
+ input.version,
271
+ input.actor
272
+ );
273
+ }
274
+ async function approveExternalEdit(input) {
275
+ const { storage } = await engineCache.get(input.nestId);
276
+ let result;
277
+ try {
278
+ result = await approveSuggestion({
279
+ storage,
280
+ rbac: communityRbac,
281
+ documentId: input.documentId,
282
+ suggestionId: input.suggestionId,
283
+ actor: input.actor,
284
+ zone: "default",
285
+ comment: input.comment
286
+ });
287
+ } catch (err) {
288
+ console.error(
289
+ `[external-edit] approveSuggestion failed for ${input.nestId}/${input.documentId} suggestion=${input.suggestionId}:`,
290
+ err
291
+ );
292
+ throw err;
293
+ }
294
+ try {
295
+ await mirrorVersion({
296
+ nestId: input.nestId,
297
+ documentId: input.documentId,
298
+ version: result.versionEntry.version,
299
+ actor: input.actor,
300
+ note: input.comment ? `External edit approved: ${input.comment}` : "External edit approved"
301
+ });
302
+ } catch (err) {
303
+ console.error(
304
+ `[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
305
+ err
306
+ );
307
+ }
308
+ const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
309
+ await refreshSuggestionFlag(input.nestId, storage, input.documentId);
310
+ return { ...result, historyRecorded };
311
+ }
312
+ async function rejectExternalEdit(input) {
313
+ const { storage } = await engineCache.get(input.nestId);
314
+ const result = await rejectSuggestion({
315
+ storage,
316
+ rbac: communityRbac,
317
+ documentId: input.documentId,
318
+ suggestionId: input.suggestionId,
319
+ actor: input.actor,
320
+ zone: "default",
321
+ reason: input.reason
322
+ });
323
+ const historyRecorded = await recordVerdict(input.nestId, result.chainEvent);
324
+ try {
325
+ const approved = await loadChainHead(storage, input.documentId);
326
+ if (approved) {
327
+ await storage.writeDocument(input.documentId, approved.content);
328
+ }
329
+ } catch (err) {
330
+ console.error(
331
+ `[external-edit] revert-on-reject failed for ${input.nestId}/${input.documentId}:`,
332
+ err
333
+ );
334
+ }
335
+ await refreshSuggestionFlag(input.nestId, storage, input.documentId);
336
+ return { ...result, historyRecorded };
337
+ }
338
+ var scannerTimer = null;
339
+ async function scanAllNests() {
340
+ const db = getDb();
341
+ const rows = await db.all("SELECT id FROM nests");
342
+ await Promise.all(
343
+ rows.map(
344
+ ({ id }) => scanNestForDrift(id).catch(
345
+ (err) => console.error(`[external-edit] scan failed for nest ${id}:`, err)
346
+ )
347
+ )
348
+ );
349
+ }
350
+ function startDriftScanner(intervalMs = 3e4) {
351
+ if (scannerTimer) return;
352
+ scannerTimer = setInterval(() => {
353
+ scanAllNests().catch(
354
+ (err) => console.error("[external-edit] scanner tick failed:", err)
355
+ );
356
+ }, intervalMs);
357
+ scannerTimer.unref?.();
358
+ }
359
+ function stopDriftScanner() {
360
+ if (scannerTimer) {
361
+ clearInterval(scannerTimer);
362
+ scannerTimer = null;
363
+ }
364
+ }
365
+
366
+ export {
367
+ loadLatestApprovedNode,
368
+ scanDocumentForDrift,
369
+ scanNestForDrift,
370
+ documentsWithSuggestions,
371
+ getPendingChange,
372
+ listNestExternalEdits,
373
+ getExternalEditDetail,
374
+ listExternalEditVerdicts,
375
+ approveExternalEdit,
376
+ rejectExternalEdit,
377
+ startDriftScanner,
378
+ stopDriftScanner
379
+ };
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-GUNJTORH.js";
4
4
  import {
5
5
  getDb
6
- } from "./chunk-JMJLNEXE.js";
6
+ } from "./chunk-HYSTFMFG.js";
7
7
 
8
8
  // src/governance/grants-service.ts
9
9
  import { v4 as uuid } from "uuid";
@@ -22,14 +22,6 @@ async function resolveNodeGrant(nestId, userId, nodeId) {
22
22
  }
23
23
  return best;
24
24
  }
25
- async function hasAnyGrant(nestId, userId) {
26
- if (!userId) return false;
27
- const row = await getDb().get(
28
- "SELECT 1 AS x FROM grants WHERE nest_id = ? AND user_id = ? LIMIT 1",
29
- [nestId, userId]
30
- );
31
- return !!row;
32
- }
33
25
  async function listUserGrants(nestId, userId) {
34
26
  if (!userId) return [];
35
27
  return await getDb().all(
@@ -42,6 +34,15 @@ function grantCoversNode(grants, nodeId) {
42
34
  (g) => nodeId === g.target || nodeId.startsWith(g.target + "/")
43
35
  );
44
36
  }
37
+ function grantRoleForNode(grants, nodeId) {
38
+ let best = null;
39
+ for (const g of grants) {
40
+ if (nodeId === g.target || nodeId.startsWith(g.target + "/")) {
41
+ if (!best || rank(g.role) > rank(best)) best = g.role;
42
+ }
43
+ }
44
+ return best;
45
+ }
45
46
  async function createGrant(params) {
46
47
  const { nestId, targetType, target, userId, role, grantedBy } = params;
47
48
  if (!["document", "folder"].includes(targetType)) {
@@ -94,9 +95,9 @@ async function deleteGrant(nestId, id) {
94
95
 
95
96
  export {
96
97
  resolveNodeGrant,
97
- hasAnyGrant,
98
98
  listUserGrants,
99
99
  grantCoversNode,
100
+ grantRoleForNode,
100
101
  createGrant,
101
102
  listGrants,
102
103
  deleteGrant