@promptowl/contextnest-community 1.0.0 → 1.0.1

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
@@ -7,24 +7,17 @@ import {
7
7
  parseBearerToken,
8
8
  verifyPassword
9
9
  } from "./chunk-7K2LLJXK.js";
10
- import {
11
- checkConflict,
12
- createVersion,
13
- getApprovedVersion,
14
- getCurrentVersion,
15
- getDisplayStatus,
16
- getVersions,
17
- setApprovedVersion
18
- } from "./chunk-BLOPZDPL.js";
19
10
  import {
20
11
  approve,
21
12
  cancelReview,
13
+ engineCache,
22
14
  getPendingReview,
23
15
  getReviewHistory,
24
16
  getReviewQueue,
25
17
  reject,
18
+ safePublishDocument,
26
19
  submitForReview
27
- } from "./chunk-XDCW4HTW.js";
20
+ } from "./chunk-5VHKEIAW.js";
28
21
  import {
29
22
  assignSteward,
30
23
  canUserAccess,
@@ -39,11 +32,22 @@ import {
39
32
  resolveStewardsForNode,
40
33
  resolveStewardsWithFallback,
41
34
  syncFromConfig
42
- } from "./chunk-2FXVMVZJ.js";
35
+ } from "./chunk-K22GWPT4.js";
36
+ import {
37
+ checkConflict,
38
+ createVersion,
39
+ getApprovedVersion,
40
+ getCurrentVersion,
41
+ getDisplayStatus,
42
+ getVersions,
43
+ setApprovedVersion
44
+ } from "./chunk-JMZ75ZCD.js";
43
45
  import {
46
+ ANON_EMAIL,
47
+ ANON_USER_ID,
44
48
  config,
45
49
  getDb
46
- } from "./chunk-2TW25QEA.js";
50
+ } from "./chunk-KQCWNHDM.js";
47
51
 
48
52
  // src/index.ts
49
53
  import { serve } from "@hono/node-server";
@@ -183,6 +187,12 @@ var NotFoundError = class extends AppError {
183
187
  this.name = "NotFoundError";
184
188
  }
185
189
  };
190
+ var ForbiddenError = class extends AppError {
191
+ constructor(message = "Forbidden") {
192
+ super(403, message);
193
+ this.name = "ForbiddenError";
194
+ }
195
+ };
186
196
  var ValidationError = class extends AppError {
187
197
  constructor(message) {
188
198
  super(400, message);
@@ -333,6 +343,8 @@ async function runLicenseWatcher() {
333
343
  try {
334
344
  watcherAbort = new AbortController();
335
345
  const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
346
+ const fetchTimeout = AbortSignal.timeout(30 * 1e3);
347
+ const signal = typeof AbortSignal.any === "function" ? AbortSignal.any([watcherAbort.signal, fetchTimeout]) : watcherAbort.signal;
336
348
  const res = await fetch(`${promptowlUrl}/api/license/listen`, {
337
349
  method: "POST",
338
350
  headers: { "Content-Type": "application/json" },
@@ -340,9 +352,13 @@ async function runLicenseWatcher() {
340
352
  key,
341
353
  since_updated_at: currentLicense ? (/* @__PURE__ */ new Date()).toISOString() : void 0
342
354
  }),
343
- signal: watcherAbort.signal
355
+ signal
344
356
  });
345
357
  if (!res.ok) {
358
+ if (res.status === 504 || res.status === 408 || res.status === 502) {
359
+ backoff = WATCHER_BACKOFF_MIN_MS;
360
+ continue;
361
+ }
346
362
  throw new Error(`listen returned ${res.status}`);
347
363
  }
348
364
  const data = await res.json();
@@ -375,6 +391,30 @@ async function runLicenseWatcher() {
375
391
  function sleep(ms) {
376
392
  return new Promise((r) => setTimeout(r, ms));
377
393
  }
394
+ var safetyPollHandle = null;
395
+ var SAFETY_POLL_INTERVAL_MS = 60 * 1e3;
396
+ function startLicenseSafetyPoll() {
397
+ if (safetyPollHandle) return;
398
+ safetyPollHandle = setInterval(async () => {
399
+ if (!config.PROMPTOWL_KEY) return;
400
+ try {
401
+ const wasValid = !!currentLicense?.valid;
402
+ await validateLicense({ forceFresh: true });
403
+ const isValid = !!currentLicense?.valid;
404
+ if (wasValid && !isValid) {
405
+ console.log("[license] safety poll detected revocation");
406
+ handleLicenseRevoked();
407
+ }
408
+ } catch (err) {
409
+ console.warn(
410
+ `[license] safety poll error: ${err.message}`
411
+ );
412
+ }
413
+ }, SAFETY_POLL_INTERVAL_MS);
414
+ if (typeof safetyPollHandle.unref === "function") {
415
+ safetyPollHandle.unref();
416
+ }
417
+ }
378
418
  function handleLicenseRevoked() {
379
419
  try {
380
420
  const db = getDb();
@@ -1030,9 +1070,9 @@ authRoutes.get("/teammates", async (c) => {
1030
1070
  (SELECT COUNT(*) FROM api_keys WHERE user_id = u.id) as key_count,
1031
1071
  (SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id) as last_active
1032
1072
  FROM users u
1033
- WHERE u.id != '00000000-0000-0000-0000-000000000000'
1073
+ WHERE u.id != ?
1034
1074
  ORDER BY u.created_at DESC`
1035
- ).all();
1075
+ ).all(ANON_USER_ID);
1036
1076
  const pendingStewards = db.prepare(
1037
1077
  `SELECT DISTINCT s.user_email AS email
1038
1078
  FROM stewards s
@@ -1067,6 +1107,9 @@ function resolveNestPermission(nestId, userId) {
1067
1107
  const nest = db.prepare("SELECT user_id, visibility FROM nests WHERE id = ?").get(nestId);
1068
1108
  if (!nest) return "none";
1069
1109
  if (nest.user_id === userId) return "owner";
1110
+ if (nest.user_id === ANON_USER_ID && isLicenseAdminUserId(userId)) {
1111
+ return "owner";
1112
+ }
1070
1113
  const directGrant = db.prepare(
1071
1114
  "SELECT permission FROM nest_collaborators WHERE nest_id = ? AND user_id = ?"
1072
1115
  ).get(nestId, userId);
@@ -1105,9 +1148,10 @@ async function createNest(userId, name, description) {
1105
1148
  const id = uuid2();
1106
1149
  const slug = toSlug(name);
1107
1150
  const db = getDb();
1151
+ const visibility = userId === ANON_USER_ID ? "public" : "private";
1108
1152
  db.prepare(
1109
- "INSERT INTO nests (id, user_id, name, slug, description) VALUES (?, ?, ?, ?, ?)"
1110
- ).run(id, userId, name, slug, description || null);
1153
+ "INSERT INTO nests (id, user_id, name, slug, description, visibility) VALUES (?, ?, ?, ?, ?, ?)"
1154
+ ).run(id, userId, name, slug, description || null, visibility);
1111
1155
  const path = nestPath(id);
1112
1156
  mkdirSync(path, { recursive: true });
1113
1157
  const storage = new NestStorage(path);
@@ -1115,14 +1159,17 @@ async function createNest(userId, name, description) {
1115
1159
  trackEvent("nest.create", { nestId: id, userId });
1116
1160
  return db.prepare("SELECT * FROM nests WHERE id = ?").get(id);
1117
1161
  }
1118
- var ANON_USER_ID = "00000000-0000-0000-0000-000000000000";
1119
1162
  function listNests(userId) {
1120
1163
  const db = getDb();
1121
1164
  if (userId === ANON_USER_ID) {
1122
1165
  return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(ANON_USER_ID);
1123
1166
  }
1167
+ const includeAnon = config.AUTH_MODE === "open" || isLicenseAdminUserId(userId);
1168
+ if (!includeAnon) {
1169
+ return db.prepare("SELECT * FROM nests WHERE user_id = ? ORDER BY created_at DESC").all(userId);
1170
+ }
1124
1171
  return db.prepare(
1125
- "SELECT * FROM nests WHERE user_id = ? OR user_id = ? ORDER BY created_at DESC"
1172
+ "SELECT * FROM nests WHERE user_id = ? OR (user_id = ? AND visibility = 'public') ORDER BY created_at DESC"
1126
1173
  ).all(userId, ANON_USER_ID);
1127
1174
  }
1128
1175
  function listSharedNests(userId) {
@@ -1140,23 +1187,35 @@ function getNest(nestId) {
1140
1187
  }
1141
1188
  async function deleteNest(nestId) {
1142
1189
  const db = getDb();
1143
- db.prepare("DELETE FROM api_keys WHERE nest_id = ?").run(nestId);
1144
- db.prepare("DELETE FROM nests WHERE id = ?").run(nestId);
1190
+ const wipe = db.transaction((id) => {
1191
+ db.prepare("DELETE FROM approved_versions WHERE nest_id = ?").run(id);
1192
+ db.prepare("DELETE FROM node_versions WHERE nest_id = ?").run(id);
1193
+ db.prepare("DELETE FROM review_requests WHERE nest_id = ?").run(id);
1194
+ db.prepare("DELETE FROM stewards WHERE nest_id = ?").run(id);
1195
+ db.prepare("DELETE FROM nest_collaborators WHERE nest_id = ?").run(id);
1196
+ try {
1197
+ db.prepare("DELETE FROM node_tag_index WHERE nest_id = ?").run(id);
1198
+ } catch {
1199
+ }
1200
+ db.prepare("DELETE FROM api_keys WHERE nest_id = ?").run(id);
1201
+ db.prepare("DELETE FROM nests WHERE id = ?").run(id);
1202
+ });
1203
+ wipe(nestId);
1145
1204
  const path = nestPath(nestId);
1146
1205
  try {
1147
1206
  rmSync(path, { recursive: true, force: true });
1148
- } catch {
1207
+ } catch (err) {
1208
+ console.warn(`[nests] failed to remove nest directory ${path}:`, err);
1149
1209
  }
1150
1210
  trackEvent("nest.delete", { nestId });
1151
1211
  }
1152
1212
 
1153
1213
  // src/nests/routes.ts
1154
- var ANON_USER_ID2 = "00000000-0000-0000-0000-000000000000";
1155
1214
  function effectivePermission(nestId, userId) {
1156
1215
  if (config.AUTH_MODE === "open") {
1157
1216
  const db = getDb();
1158
1217
  const nest = db.prepare("SELECT user_id FROM nests WHERE id = ?").get(nestId);
1159
- if (nest && nest.user_id === ANON_USER_ID2) return "owner";
1218
+ if (nest && nest.user_id === ANON_USER_ID) return "owner";
1160
1219
  }
1161
1220
  return resolveNestPermission(nestId, userId);
1162
1221
  }
@@ -1165,7 +1224,23 @@ nestRoutes.get("/", async (c) => {
1165
1224
  const userId = c.get("userId");
1166
1225
  const owned = listNests(userId);
1167
1226
  const shared = listSharedNests(userId);
1168
- return c.json({ nests: [...owned, ...shared] });
1227
+ const db = getDb();
1228
+ const ownerEmailStmt = db.prepare(
1229
+ "SELECT email FROM users WHERE id = ?"
1230
+ );
1231
+ const annotate = (n) => {
1232
+ const permission = effectivePermission(n.id, userId);
1233
+ const is_owner = permission === "owner";
1234
+ let owner_email = null;
1235
+ if (!is_owner && n.user_id !== ANON_USER_ID) {
1236
+ const row = ownerEmailStmt.get(n.user_id);
1237
+ owner_email = row?.email ?? null;
1238
+ }
1239
+ return { ...n, permission, is_owner, owner_email };
1240
+ };
1241
+ return c.json({
1242
+ nests: [...owned.map(annotate), ...shared.map(annotate)]
1243
+ });
1169
1244
  });
1170
1245
  nestRoutes.post("/", async (c) => {
1171
1246
  const body = await c.req.json();
@@ -1186,10 +1261,19 @@ nestRoutes.get("/:nestId", async (c) => {
1186
1261
  });
1187
1262
  nestRoutes.delete("/:nestId", async (c) => {
1188
1263
  const nestId = c.req.param("nestId");
1189
- const permission = effectivePermission(nestId, c.get("userId"));
1190
- if (permission !== "owner") {
1264
+ const userId = c.get("userId");
1265
+ const nest = getNest(nestId);
1266
+ if (!nest) {
1191
1267
  throw new NotFoundError("Nest not found");
1192
1268
  }
1269
+ const permission = effectivePermission(nestId, userId);
1270
+ const isAnonOwned = nest.user_id === ANON_USER_ID;
1271
+ const adminCaretaker = config.AUTH_MODE !== "open" && isAnonOwned && isLicenseAdminUserId(userId);
1272
+ if (permission !== "owner" && !adminCaretaker) {
1273
+ throw new ForbiddenError(
1274
+ "You don't have permission to delete this nest. Only the nest owner can delete it."
1275
+ );
1276
+ }
1193
1277
  await deleteNest(nestId);
1194
1278
  return c.json({ deleted: true });
1195
1279
  });
@@ -1306,37 +1390,26 @@ sharingRoutes.patch("/visibility", async (c) => {
1306
1390
  return c.json({ visibility: body.visibility });
1307
1391
  });
1308
1392
 
1393
+ // src/governance/access-guard.ts
1394
+ function resolveCallerEmail(userId) {
1395
+ if (!userId) return "admin@localhost";
1396
+ const db = getDb();
1397
+ const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
1398
+ return row?.email || "admin@localhost";
1399
+ }
1400
+ function canReadNode(nestId, nodeId, userEmail) {
1401
+ if (!isStewardshipEnabled(nestId)) return true;
1402
+ return canUserAccess(nestId, nodeId, userEmail).allowed;
1403
+ }
1404
+ function filterAccessible(nestId, userEmail, nodes) {
1405
+ if (!isStewardshipEnabled(nestId)) return nodes;
1406
+ return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
1407
+ }
1408
+
1309
1409
  // src/nodes/routes.ts
1310
1410
  import { Hono as Hono4 } from "hono";
1311
1411
  import { serializeDocument } from "@promptowl/contextnest-engine";
1312
1412
 
1313
- // src/nodes/engine.ts
1314
- import { join as join2 } from "path";
1315
- import {
1316
- NestStorage as NestStorage2,
1317
- GraphQueryEngine,
1318
- VersionManager
1319
- } from "@promptowl/contextnest-engine";
1320
- var NestEngineCache = class {
1321
- cache = /* @__PURE__ */ new Map();
1322
- get(nestId) {
1323
- let engine = this.cache.get(nestId);
1324
- if (!engine) {
1325
- const nestPath2 = join2(config.DATA_ROOT, "nests", nestId);
1326
- const storage = new NestStorage2(nestPath2);
1327
- const query = new GraphQueryEngine(storage);
1328
- const versions = new VersionManager(storage);
1329
- engine = { storage, query, versions };
1330
- this.cache.set(nestId, engine);
1331
- }
1332
- return engine;
1333
- }
1334
- evict(nestId) {
1335
- this.cache.delete(nestId);
1336
- }
1337
- };
1338
- var engineCache = new NestEngineCache();
1339
-
1340
1413
  // src/governance/tag-index-service.ts
1341
1414
  function normalizeTag(raw) {
1342
1415
  return raw.trim().replace(/^#+/, "").toLowerCase();
@@ -1367,20 +1440,220 @@ function removeNodeFromTagIndex(nestId, nodeId) {
1367
1440
  ).run(nestId, nodeId);
1368
1441
  }
1369
1442
 
1370
- // src/governance/access-guard.ts
1371
- function resolveCallerEmail(userId) {
1372
- if (!userId) return "admin@localhost";
1373
- const db = getDb();
1374
- const row = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
1375
- return row?.email || "admin@localhost";
1443
+ // src/governance/external-edit-service.ts
1444
+ import { readFile } from "fs/promises";
1445
+ import { join as join2 } from "path";
1446
+ import {
1447
+ detectDrift,
1448
+ stageSuggestion,
1449
+ approveSuggestion,
1450
+ rejectSuggestion,
1451
+ listSuggestions,
1452
+ readSuggestion,
1453
+ VersionManager
1454
+ } from "@promptowl/contextnest-engine";
1455
+ var communityRbac = {
1456
+ isCzar: () => false,
1457
+ canIngest: () => true,
1458
+ isDocOwner: () => true
1459
+ };
1460
+ function docPath(nestId, documentId) {
1461
+ return join2(config.DATA_ROOT, "nests", nestId, `${documentId}.md`);
1376
1462
  }
1377
- function canReadNode(nestId, nodeId, userEmail) {
1378
- if (!isStewardshipEnabled(nestId)) return true;
1379
- return canUserAccess(nestId, nodeId, userEmail).allowed;
1463
+ async function readRaw(nestId, documentId) {
1464
+ try {
1465
+ return await readFile(docPath(nestId, documentId), "utf-8");
1466
+ } catch {
1467
+ return null;
1468
+ }
1380
1469
  }
1381
- function filterAccessible(nestId, userEmail, nodes) {
1382
- if (!isStewardshipEnabled(nestId)) return nodes;
1383
- return nodes.filter((n) => canUserAccess(nestId, n.id, userEmail).allowed);
1470
+ async function loadChainHead(storage, documentId) {
1471
+ const history = await storage.readHistory(documentId);
1472
+ if (!history || history.versions.length === 0) return null;
1473
+ const latest = history.versions[history.versions.length - 1];
1474
+ try {
1475
+ const content = await new VersionManager(storage).reconstructVersion(
1476
+ documentId,
1477
+ latest.version
1478
+ );
1479
+ return { version: latest.version, content };
1480
+ } catch {
1481
+ return null;
1482
+ }
1483
+ }
1484
+ async function scanDocumentForDrift(nestId, documentId, actor = "system:scanner") {
1485
+ const res = await scanDocumentForDriftInternal(nestId, documentId, actor);
1486
+ return res?.meta ?? null;
1487
+ }
1488
+ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
1489
+ const { storage } = engineCache.get(nestId);
1490
+ const node = await storage.readDocument(documentId).catch(() => null);
1491
+ if (!node) return null;
1492
+ const raw = await readRaw(nestId, documentId);
1493
+ if (raw == null) return null;
1494
+ const drift = detectDrift(raw, node.frontmatter.checksum);
1495
+ if (!drift.drifted) return null;
1496
+ const approved = await loadChainHead(storage, documentId);
1497
+ if (!approved) return null;
1498
+ const existing = await listSuggestions(storage, documentId);
1499
+ const dup = existing.find((s) => s.proposed_hash === drift.actualHash);
1500
+ if (dup) return { meta: dup, created: false };
1501
+ const result = await stageSuggestion({
1502
+ storage,
1503
+ documentId,
1504
+ approvedRawContent: approved.content,
1505
+ proposedRawContent: raw,
1506
+ source: "out-of-band-edit",
1507
+ actor,
1508
+ docTier: "standard"
1509
+ });
1510
+ return { meta: result.meta, created: true };
1511
+ }
1512
+ async function scanNestForDrift(nestId, actor = "system:scanner") {
1513
+ const { storage } = engineCache.get(nestId);
1514
+ const docs = await storage.discoverDocuments();
1515
+ const results = await Promise.all(
1516
+ docs.map((doc) => scanDocumentForDriftInternal(nestId, doc.id, actor))
1517
+ );
1518
+ const staged = results.filter((r) => r?.created).length;
1519
+ return { scanned: docs.length, staged };
1520
+ }
1521
+ async function getPendingChange(nestId, documentId) {
1522
+ const { storage } = engineCache.get(nestId);
1523
+ const list = await listSuggestions(storage, documentId);
1524
+ if (list.length === 0) return null;
1525
+ const latest = list[list.length - 1];
1526
+ return {
1527
+ suggestion_id: latest.suggestion_id,
1528
+ detected_at: latest.detected_at,
1529
+ source: latest.source,
1530
+ proposed_hash: latest.proposed_hash
1531
+ };
1532
+ }
1533
+ async function listNestExternalEdits(nestId) {
1534
+ const { storage } = engineCache.get(nestId);
1535
+ const docs = await storage.discoverDocuments();
1536
+ const lists = await Promise.all(
1537
+ docs.map((doc) => listSuggestions(storage, doc.id))
1538
+ );
1539
+ const entries = lists.flat().map((meta) => ({
1540
+ suggestion_id: meta.suggestion_id,
1541
+ nest_id: nestId,
1542
+ document_id: meta.document_id,
1543
+ source: meta.source,
1544
+ detected_at: meta.detected_at,
1545
+ actor: meta.actor,
1546
+ target_hash: meta.target_hash,
1547
+ proposed_hash: meta.proposed_hash,
1548
+ note: meta.note
1549
+ }));
1550
+ return entries.sort((a, b) => b.detected_at.localeCompare(a.detected_at));
1551
+ }
1552
+ async function getExternalEditDetail(nestId, documentId, suggestionId) {
1553
+ const { storage } = engineCache.get(nestId);
1554
+ const found = await readSuggestion(storage, documentId, suggestionId);
1555
+ if (!found) return null;
1556
+ return {
1557
+ suggestion_id: found.meta.suggestion_id,
1558
+ nest_id: nestId,
1559
+ document_id: found.meta.document_id,
1560
+ source: found.meta.source,
1561
+ detected_at: found.meta.detected_at,
1562
+ actor: found.meta.actor,
1563
+ target_hash: found.meta.target_hash,
1564
+ proposed_hash: found.meta.proposed_hash,
1565
+ note: found.meta.note,
1566
+ patch: found.patch
1567
+ };
1568
+ }
1569
+ async function approveExternalEdit(input) {
1570
+ const { storage } = engineCache.get(input.nestId);
1571
+ let result;
1572
+ try {
1573
+ result = await approveSuggestion({
1574
+ storage,
1575
+ rbac: communityRbac,
1576
+ documentId: input.documentId,
1577
+ suggestionId: input.suggestionId,
1578
+ actor: input.actor,
1579
+ zone: "default",
1580
+ comment: input.comment
1581
+ });
1582
+ } catch (err) {
1583
+ console.error(
1584
+ `[external-edit] approveSuggestion failed for ${input.nestId}/${input.documentId} suggestion=${input.suggestionId}:`,
1585
+ err
1586
+ );
1587
+ throw err;
1588
+ }
1589
+ try {
1590
+ const node = await storage.readDocument(input.documentId);
1591
+ const versionNum = result.versionEntry.version;
1592
+ const tags = node.frontmatter.tags || [];
1593
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-TFEYNPH7.js");
1594
+ createVersion2({
1595
+ nestId: input.nestId,
1596
+ nodeId: input.documentId,
1597
+ version: versionNum,
1598
+ content: node.body || "",
1599
+ author: input.actor,
1600
+ status: "published",
1601
+ tags,
1602
+ changeNote: input.comment || "External edit approved"
1603
+ });
1604
+ setApprovedVersion2(input.nestId, input.documentId, versionNum, input.actor);
1605
+ } catch (err) {
1606
+ console.error(
1607
+ `[external-edit] failed to mirror approved version into node_versions for ${input.nestId}/${input.documentId}:`,
1608
+ err
1609
+ );
1610
+ }
1611
+ return result;
1612
+ }
1613
+ async function rejectExternalEdit(input) {
1614
+ const { storage } = engineCache.get(input.nestId);
1615
+ const result = await rejectSuggestion({
1616
+ storage,
1617
+ rbac: communityRbac,
1618
+ documentId: input.documentId,
1619
+ suggestionId: input.suggestionId,
1620
+ actor: input.actor,
1621
+ zone: "default",
1622
+ reason: input.reason
1623
+ });
1624
+ try {
1625
+ const approved = await loadChainHead(storage, input.documentId);
1626
+ if (approved) {
1627
+ await storage.writeDocument(input.documentId, approved.content);
1628
+ }
1629
+ } catch (err) {
1630
+ console.error(
1631
+ `[external-edit] revert-on-reject failed for ${input.nestId}/${input.documentId}:`,
1632
+ err
1633
+ );
1634
+ }
1635
+ return result;
1636
+ }
1637
+ var scannerTimer = null;
1638
+ async function scanAllNests() {
1639
+ const db = getDb();
1640
+ const rows = db.prepare("SELECT id FROM nests").all();
1641
+ await Promise.all(
1642
+ rows.map(
1643
+ ({ id }) => scanNestForDrift(id).catch(
1644
+ (err) => console.error(`[external-edit] scan failed for nest ${id}:`, err)
1645
+ )
1646
+ )
1647
+ );
1648
+ }
1649
+ function startDriftScanner(intervalMs = 3e4) {
1650
+ if (scannerTimer) return;
1651
+ scannerTimer = setInterval(() => {
1652
+ scanAllNests().catch(
1653
+ (err) => console.error("[external-edit] scanner tick failed:", err)
1654
+ );
1655
+ }, intervalMs);
1656
+ scannerTimer.unref?.();
1384
1657
  }
1385
1658
 
1386
1659
  // src/nodes/routes.ts
@@ -1400,7 +1673,8 @@ function toNodeResponse(node) {
1400
1673
  description: node.frontmatter.description,
1401
1674
  created_at: node.frontmatter.created_at,
1402
1675
  updated_at: node.frontmatter.updated_at,
1403
- content: node.body
1676
+ content: node.body,
1677
+ pendingChange: node.pendingChange ?? void 0
1404
1678
  };
1405
1679
  }
1406
1680
  nodeRoutes.get("/", async (c) => {
@@ -1409,11 +1683,27 @@ nodeRoutes.get("/", async (c) => {
1409
1683
  const documents = await storage.discoverDocuments();
1410
1684
  const userEmail = resolveCallerEmail(c.get("userId"));
1411
1685
  const accessible = filterAccessible(nestId, userEmail, documents);
1686
+ const pendingByDoc = /* @__PURE__ */ new Map();
1687
+ await Promise.all(
1688
+ accessible.map(async (doc) => {
1689
+ try {
1690
+ pendingByDoc.set(doc.id, await getPendingChange(nestId, doc.id));
1691
+ } catch {
1692
+ pendingByDoc.set(doc.id, null);
1693
+ }
1694
+ })
1695
+ );
1412
1696
  return c.json({
1413
1697
  count: accessible.length,
1414
1698
  nodes: accessible.map((doc) => {
1415
1699
  const r = toNodeResponse(doc);
1416
- r.status = getDisplayStatus(nestId, r.id);
1700
+ const pending = pendingByDoc.get(doc.id);
1701
+ if (pending) {
1702
+ r.pendingChange = pending;
1703
+ r.status = "external_edit_pending";
1704
+ } else {
1705
+ r.status = getDisplayStatus(nestId, r.id);
1706
+ }
1417
1707
  return r;
1418
1708
  })
1419
1709
  });
@@ -1431,7 +1721,8 @@ nodeRoutes.post("/", async (c) => {
1431
1721
  const tags = body.tags?.map((t) => t.startsWith("#") ? t : `#${t}`) || [];
1432
1722
  const hasStewards = isStewardshipEnabled(nestId);
1433
1723
  const initialStatus = hasStewards ? "draft" : "approved";
1434
- const node = {
1724
+ const initialVersion = hasStewards ? 1 : 0;
1725
+ let node = {
1435
1726
  id,
1436
1727
  filePath: "",
1437
1728
  frontmatter: {
@@ -1439,7 +1730,7 @@ nodeRoutes.post("/", async (c) => {
1439
1730
  type: body.type || "document",
1440
1731
  tags,
1441
1732
  status: body.status || initialStatus,
1442
- version: 1,
1733
+ version: initialVersion,
1443
1734
  created_at: now,
1444
1735
  updated_at: now,
1445
1736
  metadata: {
@@ -1454,22 +1745,51 @@ nodeRoutes.post("/", async (c) => {
1454
1745
  await storage.writeDocument(id, serialized);
1455
1746
  syncNodeTags(nestId, id, tags);
1456
1747
  const authorEmail = getUserEmail(c);
1457
- try {
1458
- await versionManager.createVersion(node, authorEmail);
1459
- } catch (err) {
1460
- console.error("VersionManager.createVersion failed (node create)", err);
1461
- }
1462
- createVersion({
1463
- nestId,
1464
- nodeId: id,
1465
- version: 1,
1466
- content: body.content,
1467
- author: authorEmail,
1468
- status: initialStatus,
1469
- tags
1470
- });
1471
- if (initialStatus === "approved") {
1472
- setApprovedVersion(nestId, id, 1, authorEmail);
1748
+ if (hasStewards) {
1749
+ try {
1750
+ await versionManager.createVersion(node, authorEmail);
1751
+ } catch (err) {
1752
+ console.error("VersionManager.createVersion failed (node create)", err);
1753
+ }
1754
+ createVersion({
1755
+ nestId,
1756
+ nodeId: id,
1757
+ version: 1,
1758
+ content: body.content,
1759
+ author: authorEmail,
1760
+ status: "draft",
1761
+ tags
1762
+ });
1763
+ } else {
1764
+ try {
1765
+ const result = await safePublishDocument(storage, id, {
1766
+ editedBy: authorEmail,
1767
+ note: "Auto-published on create (no stewards configured)"
1768
+ });
1769
+ const publishedVersion = result.node.frontmatter.version || 2;
1770
+ createVersion({
1771
+ nestId,
1772
+ nodeId: id,
1773
+ version: publishedVersion,
1774
+ content: result.node.body || "",
1775
+ author: authorEmail,
1776
+ status: "published",
1777
+ tags
1778
+ });
1779
+ setApprovedVersion(nestId, id, publishedVersion, authorEmail);
1780
+ node = result.node;
1781
+ } catch (err) {
1782
+ console.error("publishDocument failed (node create auto-publish)", err);
1783
+ createVersion({
1784
+ nestId,
1785
+ nodeId: id,
1786
+ version: 1,
1787
+ content: body.content,
1788
+ author: authorEmail,
1789
+ status: "draft",
1790
+ tags
1791
+ });
1792
+ }
1473
1793
  }
1474
1794
  trackEvent("node.create", { nestId, nodeId: id });
1475
1795
  const resolved = resolveStewardsForNode(nestId, id);
@@ -1485,7 +1805,7 @@ nodeRoutes.post("/", async (c) => {
1485
1805
  nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1486
1806
  const nestId = c.req.param("nestId");
1487
1807
  const nodeId = c.req.param("nodeId");
1488
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-ZJATH6OM.js");
1808
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-C5D2O7ZE.js");
1489
1809
  const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
1490
1810
  nestId,
1491
1811
  nodeId
@@ -1497,8 +1817,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1497
1817
  role: r.steward.role,
1498
1818
  scope: r.steward.scope,
1499
1819
  source: r.source,
1500
- priority: r.priority,
1501
- canApprove: r.steward.canApprove
1820
+ priority: r.priority
1502
1821
  })),
1503
1822
  fallbackToOwner,
1504
1823
  ownerEmail
@@ -1507,7 +1826,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1507
1826
  nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
1508
1827
  const nestId = c.req.param("nestId");
1509
1828
  const nodeId = c.req.param("nodeId");
1510
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-2MZJGE3H.js");
1829
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-TFEYNPH7.js");
1511
1830
  const allVersions = getVersions2(nestId, nodeId);
1512
1831
  const approved = getApprovedVersion2(nestId, nodeId);
1513
1832
  const db = getDb();
@@ -1538,7 +1857,7 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
1538
1857
  nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
1539
1858
  const nestId = c.req.param("nestId");
1540
1859
  const nodeId = c.req.param("nodeId");
1541
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-2JHZHZWJ.js");
1860
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-4WS3XL6K.js");
1542
1861
  const history = getReviewHistory2(nestId, nodeId);
1543
1862
  return c.json({ reviews: history });
1544
1863
  });
@@ -1555,12 +1874,21 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
1555
1874
  }
1556
1875
  let node;
1557
1876
  try {
1558
- node = await storage.readDocument(nodeId);
1877
+ node = await storage.readDocument(nodeId, { verifyChecksum: true });
1559
1878
  } catch {
1560
1879
  throw new NotFoundError(`Node not found: ${nodeId}`);
1561
1880
  }
1881
+ if (node.pendingChange) {
1882
+ try {
1883
+ await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
1884
+ const refreshed = await getPendingChange(nestId, nodeId);
1885
+ if (refreshed) node.pendingChange = refreshed;
1886
+ } catch (err) {
1887
+ console.error("[external-edit] stage-on-read failed:", err);
1888
+ }
1889
+ }
1562
1890
  const response = toNodeResponse(node);
1563
- response.status = getDisplayStatus(nestId, nodeId);
1891
+ response.status = node.pendingChange ? "external_edit_pending" : getDisplayStatus(nestId, nodeId);
1564
1892
  return c.json({ node: response });
1565
1893
  });
1566
1894
  nodeRoutes.patch("/:nodeId{.+}", async (c) => {
@@ -1615,51 +1943,99 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
1615
1943
  frontmatter: { ...node.frontmatter, title: body.title }
1616
1944
  };
1617
1945
  }
1618
- const currentVersion = getCurrentVersion(nestId, nodeId);
1619
- const newVersion = currentVersion + 1;
1620
- node = {
1621
- ...node,
1622
- frontmatter: {
1623
- ...node.frontmatter,
1624
- version: newVersion,
1625
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
1626
- }
1627
- };
1628
- const fm = Object.fromEntries(
1629
- Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
1630
- );
1631
- node = { ...node, frontmatter: fm };
1632
- const serialized = serializeDocument(node);
1633
- await storage.writeDocument(nodeId, serialized);
1634
1946
  const authorEmail = getUserEmail(c);
1635
1947
  const hasStewards = isStewardshipEnabled(nestId);
1636
1948
  const currentTags = node.frontmatter.tags || [];
1637
- syncNodeTags(nestId, nodeId, currentTags);
1638
- try {
1639
- await versionManager.createVersion(node, authorEmail, {
1640
- note: body.changeNote
1641
- });
1642
- } catch (err) {
1643
- console.error("VersionManager.createVersion failed (node patch)", err);
1644
- }
1645
- createVersion({
1646
- nestId,
1647
- nodeId,
1648
- version: newVersion,
1649
- content: node.body || "",
1650
- author: authorEmail,
1651
- status: hasStewards ? "draft" : "approved",
1652
- tags: currentTags,
1653
- changeNote: body.changeNote
1654
- });
1655
- const { cancelReview: cancelReview2, getPendingReview: getPendingReview2 } = await import("./review-service-2JHZHZWJ.js");
1949
+ const { cancelReview: cancelReview2, getPendingReview: getPendingReview2 } = await import("./review-service-4WS3XL6K.js");
1656
1950
  if (getPendingReview2(nestId, nodeId)) {
1657
1951
  cancelReview2({ nestId, nodeId, cancelledBy: authorEmail });
1658
1952
  }
1659
- if (!hasStewards) {
1660
- setApprovedVersion(nestId, nodeId, newVersion, authorEmail);
1953
+ let responseVersion;
1954
+ if (hasStewards) {
1955
+ const currentVersion = getCurrentVersion(nestId, nodeId);
1956
+ const newVersion = currentVersion + 1;
1957
+ node = {
1958
+ ...node,
1959
+ frontmatter: {
1960
+ ...node.frontmatter,
1961
+ version: newVersion,
1962
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
1963
+ // Strip the stale published-state checksum — the body just
1964
+ // changed, so leaving the old hash would make the next GET (with
1965
+ // verifyChecksum: true) flag the file as an external edit even
1966
+ // though *this* app wrote it. Engine treats absent checksum as
1967
+ // "no baseline" and skips drift detection until publish runs.
1968
+ checksum: void 0
1969
+ }
1970
+ };
1971
+ const fm = Object.fromEntries(
1972
+ Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
1973
+ );
1974
+ node = { ...node, frontmatter: fm };
1975
+ await storage.writeDocument(nodeId, serializeDocument(node));
1976
+ syncNodeTags(nestId, nodeId, currentTags);
1977
+ try {
1978
+ await versionManager.createVersion(node, authorEmail, {
1979
+ note: body.changeNote
1980
+ });
1981
+ } catch (err) {
1982
+ console.error("VersionManager.createVersion failed (node patch)", err);
1983
+ }
1984
+ createVersion({
1985
+ nestId,
1986
+ nodeId,
1987
+ version: newVersion,
1988
+ content: node.body || "",
1989
+ author: authorEmail,
1990
+ status: "draft",
1991
+ tags: currentTags,
1992
+ changeNote: body.changeNote
1993
+ });
1994
+ responseVersion = newVersion;
1995
+ } else {
1996
+ node = {
1997
+ ...node,
1998
+ frontmatter: {
1999
+ ...node.frontmatter,
2000
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
2001
+ // Drop the prior published-state checksum before the interim
2002
+ // write — publish recomputes it from the new body, but if
2003
+ // publish errors out the file would otherwise be left with new
2004
+ // body + stale checksum and drift on next read.
2005
+ checksum: void 0
2006
+ }
2007
+ };
2008
+ const fm = Object.fromEntries(
2009
+ Object.entries(node.frontmatter).filter(([, v]) => v !== void 0)
2010
+ );
2011
+ node = { ...node, frontmatter: fm };
2012
+ await storage.writeDocument(nodeId, serializeDocument(node));
2013
+ syncNodeTags(nestId, nodeId, currentTags);
2014
+ let publishedVersion = (node.frontmatter.version || 0) + 1;
2015
+ try {
2016
+ const result = await safePublishDocument(storage, nodeId, {
2017
+ editedBy: authorEmail,
2018
+ note: body.changeNote || "Auto-published on edit (no stewards)"
2019
+ });
2020
+ publishedVersion = result.node.frontmatter.version || publishedVersion;
2021
+ node = result.node;
2022
+ } catch (err) {
2023
+ console.error("publishDocument failed (node patch auto-publish)", err);
2024
+ }
2025
+ createVersion({
2026
+ nestId,
2027
+ nodeId,
2028
+ version: publishedVersion,
2029
+ content: node.body || "",
2030
+ author: authorEmail,
2031
+ status: "published",
2032
+ tags: currentTags,
2033
+ changeNote: body.changeNote
2034
+ });
2035
+ setApprovedVersion(nestId, nodeId, publishedVersion, authorEmail);
2036
+ responseVersion = publishedVersion;
1661
2037
  }
1662
- return c.json({ node: toNodeResponse(node), version: newVersion });
2038
+ return c.json({ node: toNodeResponse(node), version: responseVersion });
1663
2039
  });
1664
2040
  nodeRoutes.delete("/:nodeId{.+}", async (c) => {
1665
2041
  const nestId = c.req.param("nestId");
@@ -2346,13 +2722,13 @@ var TOOL_DEFINITIONS = [
2346
2722
  },
2347
2723
  {
2348
2724
  name: "context_assign_steward",
2349
- description: "Assign a data steward to govern a scope (nest, tag, folder pattern, or specific document). Stewards review and approve changes before they go live.",
2725
+ description: "Assign a data steward to govern a scope (nest, tag, or specific document). Stewards review and approve changes before they go live.",
2350
2726
  inputSchema: {
2351
2727
  type: "object",
2352
2728
  properties: {
2353
2729
  email: { type: "string", description: "Email of the person to assign as steward" },
2354
- scope: { type: "string", description: "Scope: nest (all docs), tag, folder, or document" },
2355
- target: { type: "string", description: "Scope target: tag name (e.g. #architecture), folder pattern (e.g. nodes/api-*), or document title" },
2730
+ scope: { type: "string", description: "Scope: nest (all docs), tag, or document" },
2731
+ target: { type: "string", description: "Scope target: tag name (e.g. #architecture) or document title" },
2356
2732
  role: { type: "string", description: "Role: reviewer (default), editor, or viewer" }
2357
2733
  },
2358
2734
  required: ["email", "scope"]
@@ -2625,7 +3001,7 @@ ${n.body || ""}`;
2625
3001
  return `No stewards configured for "${args.title}". Changes are auto-approved.`;
2626
3002
  }
2627
3003
  const list = resolved.map(
2628
- (r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.canApprove ? " \u2713 can approve" : ""}`
3004
+ (r, i) => `${i + 1}. **${r.steward.userEmail}** \u2014 ${r.steward.role} (${r.source})${r.steward.role === "reviewer" ? " \u2713 can approve" : ""}`
2629
3005
  ).join("\n");
2630
3006
  return `# Stewards for "${args.title}"
2631
3007
 
@@ -2637,12 +3013,12 @@ ${list}`;
2637
3013
  }
2638
3014
  const byScope = {};
2639
3015
  for (const s of allStewards) {
2640
- const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : s.scope === "folder" ? `Folder: ${s.nodePattern}` : `Document: ${s.nodePattern}`;
3016
+ const key = s.scope === "nest" ? "Nest-level" : s.scope === "tag" ? `Tag: ${s.tagName}` : `Document: ${s.nodePattern}`;
2641
3017
  (byScope[key] = byScope[key] || []).push(s);
2642
3018
  }
2643
3019
  const sections = Object.entries(byScope).map(
2644
3020
  ([scope, stewards]) => `## ${scope}
2645
- ${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.canApprove ? " \u2014 can approve" : ""}`).join("\n")}`
3021
+ ${stewards.map((s) => `- **${s.userEmail}** (${s.role})${s.role === "reviewer" ? " \u2014 can approve" : ""}`).join("\n")}`
2646
3022
  ).join("\n\n");
2647
3023
  return `# Data Stewards
2648
3024
 
@@ -2705,7 +3081,7 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
2705
3081
  if (!node) return `Node not found: ${args.title}`;
2706
3082
  const currentVersion = getCurrentVersion(ctx.nestId, node.id);
2707
3083
  try {
2708
- const request = approve({
3084
+ const request = await approve({
2709
3085
  nestId: ctx.nestId,
2710
3086
  nodeId: node.id,
2711
3087
  version: currentVersion,
@@ -2760,19 +3136,17 @@ ${list}`;
2760
3136
  }
2761
3137
  case "context_assign_steward": {
2762
3138
  const scope = args.scope;
2763
- if (!["nest", "tag", "folder", "document"].includes(scope)) {
2764
- return `Invalid scope "${args.scope}". Use: nest, tag, folder, or document.`;
3139
+ if (!["nest", "tag", "document"].includes(scope)) {
3140
+ return `Invalid scope "${args.scope}". Use: nest, tag, or document.`;
2765
3141
  }
2766
3142
  try {
2767
3143
  assignSteward({
2768
3144
  nestId: ctx.nestId,
2769
3145
  scope,
2770
- nodePattern: scope === "folder" || scope === "document" ? args.target : void 0,
3146
+ nodePattern: scope === "document" ? args.target : void 0,
2771
3147
  tagName: scope === "tag" ? args.target : void 0,
2772
3148
  userEmail: args.email,
2773
3149
  role: args.role || "reviewer",
2774
- canApprove: true,
2775
- canReject: true,
2776
3150
  assignedBy: ctx.userEmail,
2777
3151
  assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
2778
3152
  isActive: true
@@ -2863,9 +3237,6 @@ function parseStewardsYaml(content) {
2863
3237
  if (!currentSection || currentEntries.length === 0) return;
2864
3238
  if (currentSection === "nest") {
2865
3239
  result.nest = [...result.nest || [], ...currentEntries];
2866
- } else if (currentSection === "folders" && currentTarget) {
2867
- result.folders = result.folders || {};
2868
- result.folders[currentTarget] = currentEntries;
2869
3240
  } else if (currentSection === "tags" && currentTarget) {
2870
3241
  result.tags = result.tags || {};
2871
3242
  result.tags[currentTarget] = currentEntries;
@@ -2885,12 +3256,12 @@ function parseStewardsYaml(content) {
2885
3256
  if (key === "version") continue;
2886
3257
  if (key === "nest" || key === "data_room") {
2887
3258
  currentSection = "nest";
2888
- } else if (key === "folders") {
2889
- currentSection = "folders";
2890
3259
  } else if (key === "tags") {
2891
3260
  currentSection = "tags";
2892
3261
  } else if (key === "documents") {
2893
3262
  currentSection = "documents";
3263
+ } else if (key === "folders") {
3264
+ currentSection = null;
2894
3265
  }
2895
3266
  continue;
2896
3267
  }
@@ -2916,12 +3287,6 @@ function parseEntry(str) {
2916
3287
  const entry = { email: emailMatch[1] };
2917
3288
  const roleMatch = str.match(/role:\s*["']?(\w+)["']?/);
2918
3289
  if (roleMatch) entry.role = roleMatch[1];
2919
- if (str.includes("can_approve:")) {
2920
- entry.can_approve = str.includes("can_approve: true");
2921
- }
2922
- if (str.includes("can_reject:")) {
2923
- entry.can_reject = str.includes("can_reject: true");
2924
- }
2925
3290
  return entry;
2926
3291
  }
2927
3292
  function loadStewardsConfig(nestId) {
@@ -2959,12 +3324,16 @@ governanceRoutes.post("/stewards", async (c) => {
2959
3324
  const body = await c.req.json();
2960
3325
  const assignedBy = getUserEmail3(c);
2961
3326
  if (!body.scope) throw new ValidationError("scope is required");
3327
+ if (body.scope === "folder") {
3328
+ throw new ValidationError(
3329
+ "folder scope is no longer supported \u2014 use 'nest' or 'document'"
3330
+ );
3331
+ }
2962
3332
  if (Array.isArray(body.users)) {
2963
3333
  const created2 = await createStewardRecord({
2964
3334
  nestId,
2965
3335
  scope: body.scope,
2966
3336
  documentId: body.documentId,
2967
- folderPath: body.folderPath,
2968
3337
  tagName: body.tagName,
2969
3338
  users: body.users,
2970
3339
  assignedBy
@@ -2978,14 +3347,11 @@ governanceRoutes.post("/stewards", async (c) => {
2978
3347
  nestId,
2979
3348
  scope: body.scope,
2980
3349
  documentId: body.scope === "document" ? body.nodePattern : void 0,
2981
- folderPath: body.scope === "folder" ? body.nodePattern : void 0,
2982
3350
  tagName: body.scope === "tag" ? body.tagName : void 0,
2983
3351
  users: [
2984
3352
  {
2985
3353
  email: body.email,
2986
- role: body.role,
2987
- canApprove: body.canApprove,
2988
- canReject: body.canReject
3354
+ role: body.role
2989
3355
  }
2990
3356
  ],
2991
3357
  assignedBy
@@ -3019,6 +3385,21 @@ governanceRoutes.get("/review-queue", async (c) => {
3019
3385
  });
3020
3386
  return c.json(result);
3021
3387
  });
3388
+ governanceRoutes.get("/external-edits", async (c) => {
3389
+ const nestId = c.req.param("nestId");
3390
+ const refresh = c.req.query("refresh") === "true";
3391
+ if (refresh) {
3392
+ await scanNestForDrift(nestId, "user:refresh");
3393
+ }
3394
+ const entries = await listNestExternalEdits(nestId);
3395
+ return c.json({ entries, total: entries.length });
3396
+ });
3397
+ governanceRoutes.post("/external-edits/scan", async (c) => {
3398
+ const nestId = c.req.param("nestId");
3399
+ const actor = getUserEmail3(c);
3400
+ const result = await scanNestForDrift(nestId, actor);
3401
+ return c.json(result);
3402
+ });
3022
3403
  var governanceNodeRoutes = new Hono7();
3023
3404
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
3024
3405
  const nestId = c.req.param("nestId");
@@ -3031,8 +3412,7 @@ governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
3031
3412
  role: r.steward.role,
3032
3413
  scope: r.steward.scope,
3033
3414
  source: r.source,
3034
- priority: r.priority,
3035
- canApprove: r.steward.canApprove
3415
+ priority: r.priority
3036
3416
  })),
3037
3417
  fallbackToOwner,
3038
3418
  ownerEmail
@@ -3101,7 +3481,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
3101
3481
  const userEmail = getUserEmail3(c);
3102
3482
  const isAdmin = isSuperAdmin(userEmail);
3103
3483
  try {
3104
- const request = approve({
3484
+ const request = await approve({
3105
3485
  nestId,
3106
3486
  nodeId,
3107
3487
  version: getCurrentVersion(nestId, nodeId),
@@ -3153,6 +3533,90 @@ governanceNodeRoutes.get("/:nodeId{.+}/can-edit", async (c) => {
3153
3533
  const userEmail = getUserEmail3(c);
3154
3534
  return c.json(canUserEdit(nestId, nodeId, userEmail));
3155
3535
  });
3536
+ governanceNodeRoutes.get("/:nodeId{.+?}/external-edits", async (c) => {
3537
+ const nestId = c.req.param("nestId");
3538
+ const nodeId = c.req.param("nodeId");
3539
+ const pending = await getPendingChange(nestId, nodeId);
3540
+ return c.json({ pending });
3541
+ });
3542
+ governanceNodeRoutes.get(
3543
+ "/:nodeId{.+?}/external-edits/:suggestionId",
3544
+ async (c) => {
3545
+ const nestId = c.req.param("nestId");
3546
+ const nodeId = c.req.param("nodeId");
3547
+ const suggestionId = c.req.param("suggestionId");
3548
+ const detail = await getExternalEditDetail(
3549
+ nestId,
3550
+ nodeId,
3551
+ suggestionId
3552
+ );
3553
+ if (!detail) {
3554
+ return c.json({ error: "Suggestion not found" }, 404);
3555
+ }
3556
+ return c.json({ entry: detail });
3557
+ }
3558
+ );
3559
+ governanceNodeRoutes.post(
3560
+ "/:nodeId{.+?}/external-edits/:suggestionId/approve",
3561
+ async (c) => {
3562
+ const nestId = c.req.param("nestId");
3563
+ const nodeId = c.req.param("nodeId");
3564
+ const suggestionId = c.req.param("suggestionId");
3565
+ const body = await c.req.json().catch(() => ({}));
3566
+ const actor = getUserEmail3(c);
3567
+ try {
3568
+ const result = await approveExternalEdit({
3569
+ nestId,
3570
+ documentId: nodeId,
3571
+ suggestionId,
3572
+ actor,
3573
+ comment: body.comment
3574
+ });
3575
+ return c.json({
3576
+ approved: true,
3577
+ version: result.versionEntry.version,
3578
+ chainEvent: result.chainEvent.event_id
3579
+ });
3580
+ } catch (err) {
3581
+ console.error(
3582
+ `[external-edit-route] approve failed nest=${nestId} node=${nodeId} suggestion=${suggestionId}`,
3583
+ { message: err?.message, name: err?.name, stack: err?.stack }
3584
+ );
3585
+ return c.json(
3586
+ { error: err.message, name: err?.name },
3587
+ 400
3588
+ );
3589
+ }
3590
+ }
3591
+ );
3592
+ governanceNodeRoutes.post(
3593
+ "/:nodeId{.+?}/external-edits/:suggestionId/reject",
3594
+ async (c) => {
3595
+ const nestId = c.req.param("nestId");
3596
+ const nodeId = c.req.param("nodeId");
3597
+ const suggestionId = c.req.param("suggestionId");
3598
+ const body = await c.req.json().catch(() => ({}));
3599
+ if (!body.reason) {
3600
+ throw new ValidationError("Rejection reason is required");
3601
+ }
3602
+ const actor = getUserEmail3(c);
3603
+ try {
3604
+ const result = await rejectExternalEdit({
3605
+ nestId,
3606
+ documentId: nodeId,
3607
+ suggestionId,
3608
+ actor,
3609
+ reason: body.reason
3610
+ });
3611
+ return c.json({
3612
+ rejected: true,
3613
+ chainEvent: result.chainEvent.event_id
3614
+ });
3615
+ } catch (err) {
3616
+ return c.json({ error: err.message }, 400);
3617
+ }
3618
+ }
3619
+ );
3156
3620
  governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
3157
3621
  const nestId = c.req.param("nestId");
3158
3622
  const nodeId = c.req.param("nodeId");
@@ -3173,18 +3637,16 @@ function getUserEmail3(c) {
3173
3637
 
3174
3638
  // src/auth/anonymous.ts
3175
3639
  import bcrypt from "bcryptjs";
3176
- var ANON_USER_ID3 = "00000000-0000-0000-0000-000000000000";
3177
- var ANON_EMAIL = "admin@localhost";
3178
3640
  function ensureAnonymousUser() {
3179
3641
  const db = getDb();
3180
- const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(ANON_USER_ID3);
3642
+ const exists = db.prepare("SELECT id FROM users WHERE id = ?").get(ANON_USER_ID);
3181
3643
  if (!exists) {
3182
3644
  const placeholder = bcrypt.hashSync("anon-no-login", 4);
3183
3645
  db.prepare(
3184
3646
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
3185
- ).run(ANON_USER_ID3, ANON_EMAIL, "Admin", placeholder);
3647
+ ).run(ANON_USER_ID, ANON_EMAIL, "Admin", placeholder);
3186
3648
  }
3187
- return ANON_USER_ID3;
3649
+ return ANON_USER_ID;
3188
3650
  }
3189
3651
 
3190
3652
  // src/app.ts
@@ -3308,7 +3770,7 @@ function createApp() {
3308
3770
  return c.json(
3309
3771
  {
3310
3772
  valid: false,
3311
- error: "License key was saved but PromptOwl rejected it. Verify the key is correct and active."
3773
+ error: "PromptOwl rejected this license key. It wasn't saved. Verify the key is correct and active, then try again."
3312
3774
  },
3313
3775
  400
3314
3776
  );
@@ -3324,6 +3786,28 @@ function createApp() {
3324
3786
  return c.json({ error: msg }, 500);
3325
3787
  }
3326
3788
  });
3789
+ app.use("/stats", flexAuthMiddleware);
3790
+ app.get("/stats", async (c) => {
3791
+ const db = getDb();
3792
+ const userId = c.get("userId");
3793
+ const userEmail = resolveCallerEmail(userId);
3794
+ const visibleNests = [...listNests(userId), ...listSharedNests(userId)];
3795
+ let documents = 0;
3796
+ for (const nest of visibleNests) {
3797
+ try {
3798
+ const { storage } = engineCache.get(nest.id);
3799
+ const docs = await storage.discoverDocuments();
3800
+ documents += filterAccessible(nest.id, userEmail, docs).length;
3801
+ } catch {
3802
+ }
3803
+ }
3804
+ const usersRow = db.prepare("SELECT COUNT(*) as c FROM users").get();
3805
+ return c.json({
3806
+ nests: visibleNests.length,
3807
+ documents,
3808
+ users: usersRow.c
3809
+ });
3810
+ });
3327
3811
  const nestsApp = new Hono8();
3328
3812
  nestsApp.use("*", flexAuthMiddleware);
3329
3813
  nestsApp.use("*", async (c, next) => {
@@ -3479,9 +3963,108 @@ function createApp() {
3479
3963
  return app;
3480
3964
  }
3481
3965
 
3966
+ // src/db/backfill.ts
3967
+ import { NestStorage as NestStorage2 } from "@promptowl/contextnest-engine";
3968
+ import { join as join5 } from "path";
3969
+ var MIGRATION_ID = "005_backfill_node_versions_from_history";
3970
+ async function backfillNodeVersionsFromHistory(db) {
3971
+ const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
3972
+ if (already) return;
3973
+ const nests = db.prepare("SELECT id FROM nests").all();
3974
+ const insert = db.prepare(
3975
+ `INSERT OR IGNORE INTO node_versions
3976
+ (nest_id, node_id, version, content_hash, author, status, change_note, created_at)
3977
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
3978
+ );
3979
+ const approvedPin = db.prepare(
3980
+ `INSERT OR REPLACE INTO approved_versions
3981
+ (nest_id, node_id, approved_version, approved_by, approved_at)
3982
+ VALUES (?, ?, ?, ?, COALESCE(
3983
+ (SELECT approved_at FROM approved_versions WHERE nest_id = ? AND node_id = ?),
3984
+ datetime('now')))`
3985
+ );
3986
+ let totalInserted = 0;
3987
+ let totalDocs = 0;
3988
+ for (const { id: nestId } of nests) {
3989
+ const nestPath2 = join5(config.DATA_ROOT, "nests", nestId);
3990
+ const storage = new NestStorage2(nestPath2);
3991
+ let docs;
3992
+ try {
3993
+ docs = await storage.discoverDocuments();
3994
+ } catch (err) {
3995
+ console.warn(
3996
+ `[backfill] discoverDocuments failed for nest ${nestId}:`,
3997
+ err.message
3998
+ );
3999
+ continue;
4000
+ }
4001
+ for (const doc of docs) {
4002
+ totalDocs += 1;
4003
+ let history;
4004
+ try {
4005
+ history = await storage.readHistory(doc.id);
4006
+ } catch {
4007
+ history = null;
4008
+ }
4009
+ if (!history || history.versions.length === 0) continue;
4010
+ const existing = db.prepare(
4011
+ `SELECT version FROM node_versions WHERE nest_id = ? AND node_id = ?`
4012
+ ).all(nestId, doc.id);
4013
+ const known = new Set(existing.map((r) => r.version));
4014
+ const tagsJson = doc.frontmatter.tags ? JSON.stringify(doc.frontmatter.tags) : null;
4015
+ const latestVersion = history.versions[history.versions.length - 1].version;
4016
+ for (const entry of history.versions) {
4017
+ if (known.has(entry.version)) continue;
4018
+ insert.run(
4019
+ nestId,
4020
+ doc.id,
4021
+ entry.version,
4022
+ entry.content_hash || "",
4023
+ entry.edited_by || "system:backfill",
4024
+ "approved",
4025
+ entry.note || null,
4026
+ entry.edited_at || (/* @__PURE__ */ new Date()).toISOString()
4027
+ );
4028
+ totalInserted += 1;
4029
+ }
4030
+ const pin = db.prepare(
4031
+ `SELECT approved_version FROM approved_versions WHERE nest_id = ? AND node_id = ?`
4032
+ ).get(nestId, doc.id);
4033
+ if (!pin || pin.approved_version < latestVersion) {
4034
+ approvedPin.run(
4035
+ nestId,
4036
+ doc.id,
4037
+ latestVersion,
4038
+ history.versions[history.versions.length - 1].edited_by || "system:backfill",
4039
+ nestId,
4040
+ doc.id
4041
+ );
4042
+ }
4043
+ if (tagsJson) {
4044
+ const updateTags = db.prepare(
4045
+ `UPDATE node_versions SET tags_json = ?
4046
+ WHERE nest_id = ? AND node_id = ? AND version = ? AND tags_json IS NULL`
4047
+ );
4048
+ updateTags.run(tagsJson, nestId, doc.id, latestVersion);
4049
+ }
4050
+ }
4051
+ }
4052
+ db.prepare("INSERT OR IGNORE INTO schema_migrations (id) VALUES (?)").run(
4053
+ MIGRATION_ID
4054
+ );
4055
+ console.log(
4056
+ `[backfill] node_versions: scanned ${totalDocs} docs across ${nests.length} nests, inserted ${totalInserted} rows`
4057
+ );
4058
+ }
4059
+
3482
4060
  // src/index.ts
3483
4061
  async function main() {
3484
- getDb();
4062
+ const db = getDb();
4063
+ try {
4064
+ await backfillNodeVersionsFromHistory(db);
4065
+ } catch (err) {
4066
+ console.error("[backfill] node_versions backfill failed:", err);
4067
+ }
3485
4068
  const accessCfg = loadAccessConfig();
3486
4069
  if (accessCfg) {
3487
4070
  console.log(` Loaded access.yaml (mode: ${accessCfg.mode || "open"})`);
@@ -3503,6 +4086,11 @@ async function main() {
3503
4086
  }
3504
4087
  const app = createApp();
3505
4088
  startLicenseWatcher();
4089
+ startLicenseSafetyPoll();
4090
+ const driftScanIntervalMs = Number(process.env.DRIFT_SCAN_INTERVAL_MS) || 3e4;
4091
+ if (driftScanIntervalMs > 0) {
4092
+ startDriftScanner(driftScanIntervalMs);
4093
+ }
3506
4094
  startTelemetryLoop();
3507
4095
  trackEvent("server.start", {
3508
4096
  tier: license.tier,