@promptowl/contextnest-community 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  reject,
17
17
  safePublishDocument,
18
18
  submitForReview
19
- } from "./chunk-S2EWN2VA.js";
19
+ } from "./chunk-EDJRDWPL.js";
20
20
  import {
21
21
  checkConflict,
22
22
  createVersion,
@@ -25,11 +25,12 @@ import {
25
25
  getDisplayStatus,
26
26
  getVersions,
27
27
  setApprovedVersion
28
- } from "./chunk-7UTMBL6Z.js";
28
+ } from "./chunk-6AXBB65N.js";
29
29
  import {
30
30
  AppError,
31
31
  ConflictError,
32
32
  ForbiddenError,
33
+ LockedError,
33
34
  NotFoundError,
34
35
  ValidationError,
35
36
  canCreateInNest,
@@ -66,6 +67,7 @@ import {
66
67
  nestAllowsSelfApprove,
67
68
  permissionLevel,
68
69
  removeSteward,
70
+ renameNest,
69
71
  resolveNestPermission,
70
72
  resolveStewardsForNode,
71
73
  resolveStewardsWithFallback,
@@ -76,15 +78,16 @@ import {
76
78
  startTelemetryLoop,
77
79
  syncFromConfig,
78
80
  trackEvent,
81
+ uniqueNestName,
79
82
  updateSteward,
80
83
  validateLicense
81
- } from "./chunk-WCOUCBDJ.js";
84
+ } from "./chunk-SO74PQWI.js";
82
85
  import {
83
86
  ANON_EMAIL,
84
87
  ANON_USER_ID,
85
88
  config,
86
89
  getDb
87
- } from "./chunk-TDAX3JOT.js";
90
+ } from "./chunk-UEHFNBNR.js";
88
91
 
89
92
  // src/index.ts
90
93
  import { serve } from "@hono/node-server";
@@ -209,6 +212,11 @@ var authMiddleware = createMiddleware(async (c, next) => {
209
212
  return c.json({ error: "Missing or invalid credentials" }, 401);
210
213
  });
211
214
 
215
+ // src/shared/email.ts
216
+ function normalizeEmail(email) {
217
+ return email.trim().toLowerCase();
218
+ }
219
+
212
220
  // src/shared/rate-limit.ts
213
221
  var buckets = /* @__PURE__ */ new Map();
214
222
  function liveBucket(key, cutoff) {
@@ -272,6 +280,15 @@ function resolveCallerUserId(c) {
272
280
  }
273
281
  return null;
274
282
  }
283
+ function deviceGateBlocked(c) {
284
+ const gate = config.PROMPTOWL_SIGN_IN_GATE;
285
+ if (gate === "open") return null;
286
+ const error = "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.";
287
+ if (gate === "disabled") return { error, gate };
288
+ const callerId = resolveCallerUserId(c);
289
+ if (callerId && !isLicenseAdminUserId(callerId)) return { error, gate };
290
+ return null;
291
+ }
275
292
  function setSessionCookie(c, sessionId) {
276
293
  c.header(
277
294
  "Set-Cookie",
@@ -290,12 +307,13 @@ authRoutes.post("/register", async (c) => {
290
307
  if (!body.email || !body.password) {
291
308
  throw new ValidationError("email and password are required");
292
309
  }
310
+ const email = normalizeEmail(body.email);
293
311
  const ip = clientIp(c);
294
312
  if (!tryConsume(`register:ip:${ip}`, REGISTER_LIMIT)) {
295
313
  return c.json({ error: "Too many registration attempts, try again later" }, 429);
296
314
  }
297
315
  const db = getDb();
298
- const existing = db.prepare("SELECT id, is_invited FROM users WHERE email = ?").get(body.email);
316
+ const existing = db.prepare("SELECT id, is_invited FROM users WHERE LOWER(email) = ?").get(email);
299
317
  let userId;
300
318
  const passwordHash = await hashPassword(body.password);
301
319
  if (existing && existing.is_invited === 1) {
@@ -303,15 +321,15 @@ authRoutes.post("/register", async (c) => {
303
321
  db.prepare(
304
322
  "UPDATE users SET password_hash = ?, name = COALESCE(?, name), is_invited = 0 WHERE id = ?"
305
323
  ).run(passwordHash, body.name || null, userId);
306
- trackEvent("user.register", { userId, email: body.email, claimed: true });
324
+ trackEvent("user.register", { userId, email, claimed: true });
307
325
  } else if (existing) {
308
326
  throw new ValidationError("Email already registered");
309
327
  } else {
310
328
  userId = uuid();
311
329
  db.prepare(
312
330
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
313
- ).run(userId, body.email, body.name || null, passwordHash);
314
- trackEvent("user.register", { userId, email: body.email });
331
+ ).run(userId, email, body.name || null, passwordHash);
332
+ trackEvent("user.register", { userId, email });
315
333
  }
316
334
  const sessionId = createSession(userId, c.req.header("User-Agent"));
317
335
  setSessionCookie(c, sessionId);
@@ -319,9 +337,9 @@ authRoutes.post("/register", async (c) => {
319
337
  {
320
338
  user: {
321
339
  id: userId,
322
- email: body.email,
340
+ email,
323
341
  name: body.name || null,
324
- is_admin: isLicenseAdminEmail(body.email)
342
+ is_admin: isLicenseAdminEmail(email)
325
343
  }
326
344
  },
327
345
  201
@@ -342,8 +360,8 @@ authRoutes.post("/login", async (c) => {
342
360
  }
343
361
  const db = getDb();
344
362
  const user = db.prepare(
345
- "SELECT id, email, name, password_hash, is_admin FROM users WHERE email = ?"
346
- ).get(body.email);
363
+ "SELECT id, email, name, password_hash, is_admin FROM users WHERE LOWER(email) = ?"
364
+ ).get(emailLower);
347
365
  const check = user ? await verifyPassword(body.password, user.password_hash) : { ok: false, needsRehash: false };
348
366
  if (!user || !check.ok) {
349
367
  if (hasIp) recordFailure(ipKey, LOGIN_LIMIT);
@@ -455,6 +473,8 @@ authRoutes.delete("/keys/:keyId", authMiddleware, async (c) => {
455
473
  return c.json({ deleted: true });
456
474
  });
457
475
  authRoutes.post("/device", async (c) => {
476
+ const blocked = deviceGateBlocked(c);
477
+ if (blocked) return c.json(blocked, 403);
458
478
  if (!tryConsume(`device:ip:${clientIp(c)}`, DEVICE_LIMIT)) {
459
479
  return c.json({ error: "Too many device auth attempts, try again later" }, 429);
460
480
  }
@@ -476,6 +496,8 @@ authRoutes.post("/device", async (c) => {
476
496
  return c.json(data);
477
497
  });
478
498
  authRoutes.get("/device/poll", async (c) => {
499
+ const blocked = deviceGateBlocked(c);
500
+ if (blocked) return c.json(blocked, 403);
479
501
  const code = c.req.query("code");
480
502
  const clientSecret = c.req.query("client_secret");
481
503
  if (!code || !clientSecret) {
@@ -511,15 +533,28 @@ authRoutes.post("/promptowl", async (c) => {
511
533
  401
512
534
  );
513
535
  }
536
+ const gate = config.PROMPTOWL_SIGN_IN_GATE;
537
+ if (gate !== "open") {
538
+ if (gate === "disabled" || !isLicenseAdminEmail(me.email)) {
539
+ return c.json(
540
+ {
541
+ error: "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.",
542
+ gate
543
+ },
544
+ 403
545
+ );
546
+ }
547
+ }
514
548
  const db = getDb();
515
- let user = db.prepare("SELECT id, email, name FROM users WHERE email = ?").get(me.email);
549
+ const meEmail = normalizeEmail(me.email);
550
+ let user = db.prepare("SELECT id, email, name FROM users WHERE LOWER(email) = ?").get(meEmail);
516
551
  if (!user) {
517
552
  const userId = uuid();
518
553
  const placeholderHash = await hashPassword(uuid());
519
554
  db.prepare(
520
555
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)"
521
- ).run(userId, me.email, me.name || null, placeholderHash);
522
- user = { id: userId, email: me.email, name: me.name || null };
556
+ ).run(userId, meEmail, me.name || null, placeholderHash);
557
+ user = { id: userId, email: meEmail, name: me.name || null };
523
558
  trackEvent("user.register", {
524
559
  userId,
525
560
  email: me.email,
@@ -592,9 +627,17 @@ authRoutes.post("/password", authMiddleware, async (c) => {
592
627
  const db = getDb();
593
628
  const userId = c.get("userId");
594
629
  const user = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId);
595
- if (!user) return c.json({ error: "User not found" }, 404);
630
+ if (!user) throw new ValidationError("User not found");
596
631
  const check = await verifyPassword(body.current, user.password_hash);
597
- if (!check.ok) return c.json({ error: "Invalid current password" }, 401);
632
+ if (!check.ok) {
633
+ throw new ValidationError("Current password is incorrect");
634
+ }
635
+ const sameAsCurrent = await verifyPassword(body.next, user.password_hash);
636
+ if (sameAsCurrent.ok) {
637
+ throw new ValidationError(
638
+ "new password must be different from the current password"
639
+ );
640
+ }
598
641
  const newHash = await hashPassword(body.next);
599
642
  db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
600
643
  newHash,
@@ -604,9 +647,96 @@ authRoutes.post("/password", authMiddleware, async (c) => {
604
647
  clearSessionCookie(c);
605
648
  return c.json({ ok: true });
606
649
  });
650
+ authRoutes.post("/admin/reset-password/:userId", async (c) => {
651
+ const callerId = resolveCallerUserId(c);
652
+ if (!callerId) {
653
+ return c.json({ error: "Authentication required." }, 401);
654
+ }
655
+ if (!isLicenseAdminUserId(callerId)) {
656
+ return c.json(
657
+ { error: "Only the license-admin user can reset passwords." },
658
+ 403
659
+ );
660
+ }
661
+ const targetId = c.req.param("userId");
662
+ const db = getDb();
663
+ const target = db.prepare("SELECT id, email FROM users WHERE id = ?").get(targetId);
664
+ if (!target) return c.json({ error: "User not found" }, 404);
665
+ let supplied;
666
+ try {
667
+ supplied = (await c.req.json()).password;
668
+ } catch {
669
+ supplied = void 0;
670
+ }
671
+ if (supplied && supplied.length < 8) {
672
+ throw new ValidationError("password must be at least 8 characters");
673
+ }
674
+ const generated = supplied ? null : uuid().replace(/-/g, "").slice(0, 16);
675
+ const newPassword = supplied ?? generated;
676
+ const newHash = await hashPassword(newPassword);
677
+ db.transaction(() => {
678
+ db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
679
+ newHash,
680
+ target.id
681
+ );
682
+ db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(target.id);
683
+ })();
684
+ deleteAllSessionsForUser(target.id);
685
+ trackEvent("admin.reset_password", { adminId: callerId, userId: target.id });
686
+ return c.json({
687
+ ok: true,
688
+ email: target.email,
689
+ keys_revoked: true,
690
+ // Plaintext returned ONCE, only when the server generated it.
691
+ temporary_password: generated ?? void 0
692
+ });
693
+ });
694
+ authRoutes.delete("/users/:userId", async (c) => {
695
+ const callerId = resolveCallerUserId(c);
696
+ if (!callerId) {
697
+ return c.json({ error: "Authentication required." }, 401);
698
+ }
699
+ if (!isLicenseAdminUserId(callerId)) {
700
+ return c.json(
701
+ { error: "Only the license-admin user can remove users." },
702
+ 403
703
+ );
704
+ }
705
+ const targetId = c.req.param("userId");
706
+ if (targetId === callerId) {
707
+ return c.json({ error: "You can't remove your own admin account." }, 400);
708
+ }
709
+ const db = getDb();
710
+ const target = db.prepare("SELECT id, email FROM users WHERE id = ?").get(targetId);
711
+ if (!target) return c.json({ error: "User not found" }, 404);
712
+ const ownedNests = db.prepare("SELECT COUNT(*) AS c FROM nests WHERE user_id = ?").get(target.id).c;
713
+ if (ownedNests > 0) {
714
+ return c.json(
715
+ {
716
+ error: `This user owns ${ownedNests} nest${ownedNests === 1 ? "" : "s"}. Transfer or delete them before removing the user.`,
717
+ owned_nests: ownedNests
718
+ },
719
+ 409
720
+ );
721
+ }
722
+ db.transaction(() => {
723
+ db.prepare(
724
+ "DELETE FROM stewards WHERE user_id = ? OR lower(user_email) = lower(?)"
725
+ ).run(target.id, target.email);
726
+ db.prepare("DELETE FROM nest_collaborators WHERE user_id = ?").run(
727
+ target.id
728
+ );
729
+ db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(target.id);
730
+ db.prepare("DELETE FROM users WHERE id = ?").run(target.id);
731
+ })();
732
+ deleteAllSessionsForUser(target.id);
733
+ trackEvent("admin.remove_user", { adminId: callerId, userId: target.id });
734
+ return c.json({ ok: true, email: target.email });
735
+ });
607
736
  authRoutes.post("/invite", async (c) => {
608
737
  const body = await c.req.json();
609
738
  if (!body.email) throw new ValidationError("email is required");
739
+ const email = normalizeEmail(body.email);
610
740
  const callerId = resolveCallerUserId(c);
611
741
  if (!callerId) {
612
742
  return c.json(
@@ -625,14 +755,14 @@ authRoutes.post("/invite", async (c) => {
625
755
  403
626
756
  );
627
757
  }
628
- let user = db.prepare("SELECT id, email FROM users WHERE email = ?").get(body.email);
758
+ let user = db.prepare("SELECT id, email FROM users WHERE LOWER(email) = ?").get(email);
629
759
  if (!user) {
630
760
  const userId = uuid();
631
761
  const placeholderHash = await hashPassword(uuid());
632
762
  db.prepare(
633
763
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
634
- ).run(userId, body.email, null, placeholderHash);
635
- user = { id: userId, email: body.email };
764
+ ).run(userId, email, null, placeholderHash);
765
+ user = { id: userId, email };
636
766
  }
637
767
  const apiKey = generateApiKey();
638
768
  const keyId = uuid();
@@ -949,7 +1079,7 @@ async function approveExternalEdit(input) {
949
1079
  const node = await storage.readDocument(input.documentId);
950
1080
  const versionNum = result.versionEntry.version;
951
1081
  const tags = node.frontmatter.tags || [];
952
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-UODXLAOJ.js");
1082
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-624QTR5K.js");
953
1083
  createVersion2({
954
1084
  nestId: input.nestId,
955
1085
  nodeId: input.documentId,
@@ -1251,8 +1381,10 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
1251
1381
  }
1252
1382
  const hasStewards = isStewardshipEnabled(nestId);
1253
1383
  const currentTags = node.frontmatter.tags || [];
1254
- if (getPendingReview(nestId, nodeId)) {
1255
- cancelReview({ nestId, nodeId, cancelledBy: userEmail });
1384
+ if (hasStewards && getPendingReview(nestId, nodeId)) {
1385
+ throw new LockedError(
1386
+ "This document is awaiting steward review and is locked. Approve or reject the pending review before editing."
1387
+ );
1256
1388
  }
1257
1389
  let responseVersion;
1258
1390
  if (hasStewards) {
@@ -1327,6 +1459,185 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
1327
1459
  return { node, version: responseVersion };
1328
1460
  }
1329
1461
 
1462
+ // src/nests/unsynced-service.ts
1463
+ import { readdirSync, readFileSync, rmSync, statSync } from "fs";
1464
+ import { join as join2, relative } from "path";
1465
+ var RESERVED = /* @__PURE__ */ new Set(["nests"]);
1466
+ function scanMarkdown(dir) {
1467
+ let count = 0;
1468
+ let size = 0;
1469
+ let entries = [];
1470
+ try {
1471
+ entries = readdirSync(dir, { withFileTypes: true });
1472
+ } catch {
1473
+ return { count, size };
1474
+ }
1475
+ for (const e of entries) {
1476
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
1477
+ const full = join2(dir, e.name);
1478
+ if (e.isDirectory()) {
1479
+ const sub = scanMarkdown(full);
1480
+ count += sub.count;
1481
+ size += sub.size;
1482
+ } else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
1483
+ try {
1484
+ size += statSync(full).size;
1485
+ count++;
1486
+ } catch {
1487
+ }
1488
+ }
1489
+ }
1490
+ return { count, size };
1491
+ }
1492
+ function hasDirectMarkdown(dir) {
1493
+ let entries = [];
1494
+ try {
1495
+ entries = readdirSync(dir, { withFileTypes: true });
1496
+ } catch {
1497
+ return false;
1498
+ }
1499
+ return entries.some(
1500
+ (e) => e.isFile() && e.name.toLowerCase().endsWith(".md")
1501
+ );
1502
+ }
1503
+ function collectLeafFolders(rel, abs) {
1504
+ if (hasDirectMarkdown(abs)) {
1505
+ const { count, size } = scanMarkdown(abs);
1506
+ const segments = rel.split("/").filter(Boolean);
1507
+ return [
1508
+ {
1509
+ name: rel,
1510
+ label: segments[segments.length - 1] || rel,
1511
+ mdCount: count,
1512
+ sizeBytes: size
1513
+ }
1514
+ ];
1515
+ }
1516
+ let entries = [];
1517
+ try {
1518
+ entries = readdirSync(abs, { withFileTypes: true });
1519
+ } catch {
1520
+ return [];
1521
+ }
1522
+ const out = [];
1523
+ for (const e of entries) {
1524
+ if (!e.isDirectory()) continue;
1525
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
1526
+ out.push(
1527
+ ...collectLeafFolders(`${rel}/${e.name}`, join2(abs, e.name))
1528
+ );
1529
+ }
1530
+ return out;
1531
+ }
1532
+ function listUnsyncedFolders() {
1533
+ const root = config.DATA_ROOT;
1534
+ let entries = [];
1535
+ try {
1536
+ entries = readdirSync(root, { withFileTypes: true });
1537
+ } catch {
1538
+ return [];
1539
+ }
1540
+ const out = [];
1541
+ for (const e of entries) {
1542
+ if (!e.isDirectory()) continue;
1543
+ if (e.name.startsWith(".")) continue;
1544
+ if (RESERVED.has(e.name)) continue;
1545
+ out.push(...collectLeafFolders(e.name, join2(root, e.name)));
1546
+ }
1547
+ out.sort((a, b) => a.name.localeCompare(b.name));
1548
+ console.log(
1549
+ `[unsynced] discovered ${out.length} candidate folder(s) under ${root}`
1550
+ );
1551
+ return out;
1552
+ }
1553
+ function assertSafeFolderName(name) {
1554
+ if (!name || name.includes("\\")) {
1555
+ throw new ValidationError("Invalid folder name");
1556
+ }
1557
+ const segments = name.split("/").filter(Boolean);
1558
+ if (segments.length === 0) {
1559
+ throw new ValidationError("Invalid folder name");
1560
+ }
1561
+ if (RESERVED.has(segments[0])) {
1562
+ throw new ValidationError("Folder is not eligible for sync");
1563
+ }
1564
+ for (const seg of segments) {
1565
+ if (seg === ".." || seg.startsWith(".") || seg === "node_modules") {
1566
+ throw new ValidationError("Folder is not eligible for sync");
1567
+ }
1568
+ }
1569
+ }
1570
+ function collectMarkdownFiles(dir, root) {
1571
+ const out = [];
1572
+ let entries = [];
1573
+ try {
1574
+ entries = readdirSync(dir, { withFileTypes: true });
1575
+ } catch {
1576
+ return out;
1577
+ }
1578
+ for (const e of entries) {
1579
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
1580
+ const full = join2(dir, e.name);
1581
+ if (e.isDirectory()) {
1582
+ out.push(...collectMarkdownFiles(full, root));
1583
+ } else if (e.isFile() && e.name.toLowerCase().endsWith(".md")) {
1584
+ try {
1585
+ const rel = relative(root, full);
1586
+ out.push({ path: rel, content: readFileSync(full, "utf-8") });
1587
+ } catch {
1588
+ }
1589
+ }
1590
+ }
1591
+ return out;
1592
+ }
1593
+ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
1594
+ console.log(
1595
+ `[unsynced] sync requested folder="${folderName}" user="${callerEmail}"`
1596
+ );
1597
+ assertSafeFolderName(folderName);
1598
+ const src = join2(config.DATA_ROOT, folderName);
1599
+ let stat;
1600
+ try {
1601
+ stat = statSync(src);
1602
+ } catch {
1603
+ console.warn(`[unsynced] source missing: ${src}`);
1604
+ throw new NotFoundError(`Folder not found: ${folderName}`);
1605
+ }
1606
+ if (!stat.isDirectory()) {
1607
+ throw new ValidationError(`Not a directory: ${folderName}`);
1608
+ }
1609
+ const files = collectMarkdownFiles(src, src);
1610
+ console.log(
1611
+ `[unsynced] collected ${files.length} markdown file(s) from ${src}`
1612
+ );
1613
+ if (files.length === 0) {
1614
+ throw new ValidationError(`Folder has no markdown to sync: ${folderName}`);
1615
+ }
1616
+ const segments = folderName.split("/").filter(Boolean);
1617
+ const baseName = segments[segments.length - 1] || folderName;
1618
+ const nestName = uniqueNestName(userId, baseName);
1619
+ if (nestName !== baseName) {
1620
+ console.log(
1621
+ `[unsynced] name "${baseName}" already in use, using "${nestName}" instead`
1622
+ );
1623
+ }
1624
+ const nest = await importNest(userId, nestName, files);
1625
+ console.log(
1626
+ `[unsynced] nest created id=${nest.id} name="${nest.name}" from folder="${folderName}"`
1627
+ );
1628
+ const documents = await registerImportedDocuments(nest.id, callerEmail);
1629
+ console.log(
1630
+ `[unsynced] registered ${documents} document(s) for nest ${nest.id}`
1631
+ );
1632
+ try {
1633
+ rmSync(src, { recursive: true, force: true });
1634
+ console.log(`[unsynced] removed source folder ${src}`);
1635
+ } catch (err) {
1636
+ console.error("[unsynced] failed to remove source folder", src, err);
1637
+ }
1638
+ return { nest, documents };
1639
+ }
1640
+
1330
1641
  // src/nests/routes.ts
1331
1642
  function effectivePermission(nestId, userId) {
1332
1643
  if (config.AUTH_MODE === "open") {
@@ -1389,6 +1700,29 @@ nestRoutes.post("/import", async (c) => {
1389
1700
  );
1390
1701
  return c.json({ nest, documents }, 201);
1391
1702
  });
1703
+ nestRoutes.get("/unsynced", async (c) => {
1704
+ const userId = c.get("userId");
1705
+ if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
1706
+ throw new ForbiddenError("Only the server admin can list unsynced folders");
1707
+ }
1708
+ return c.json({ folders: listUnsyncedFolders() });
1709
+ });
1710
+ nestRoutes.post("/unsynced/sync", async (c) => {
1711
+ const userId = c.get("userId");
1712
+ if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
1713
+ throw new ForbiddenError("Only the server admin can sync folders");
1714
+ }
1715
+ const body = await c.req.json();
1716
+ if (!body?.name) {
1717
+ throw new ValidationError("name is required");
1718
+ }
1719
+ const result = await syncUnsyncedFolder(
1720
+ userId,
1721
+ body.name,
1722
+ resolveCallerEmail(userId)
1723
+ );
1724
+ return c.json(result, 201);
1725
+ });
1392
1726
  nestRoutes.get("/:nestId", async (c) => {
1393
1727
  const nestId = c.req.param("nestId");
1394
1728
  const userId = c.get("userId");
@@ -1405,6 +1739,25 @@ nestRoutes.get("/:nestId", async (c) => {
1405
1739
  const nest = getNest(nestId);
1406
1740
  return c.json({ nest, permission, roles, myStewards });
1407
1741
  });
1742
+ nestRoutes.patch("/:nestId", async (c) => {
1743
+ const nestId = c.req.param("nestId");
1744
+ const userId = c.get("userId");
1745
+ const permission = effectivePermission(nestId, userId);
1746
+ if (permission === "none") {
1747
+ throw new NotFoundError("Nest not found");
1748
+ }
1749
+ if (permission !== "owner" && permission !== "admin") {
1750
+ throw new ForbiddenError(
1751
+ "Only the nest owner or an admin can rename a nest."
1752
+ );
1753
+ }
1754
+ const body = await c.req.json();
1755
+ const nest = renameNest(nestId, {
1756
+ name: body.name,
1757
+ description: body.description
1758
+ });
1759
+ return c.json({ nest });
1760
+ });
1408
1761
  nestRoutes.delete("/:nestId", async (c) => {
1409
1762
  const nestId = c.req.param("nestId");
1410
1763
  const userId = c.get("userId");
@@ -1480,7 +1833,8 @@ async function addCollaborator(params) {
1480
1833
  const db = getDb();
1481
1834
  let userId = params.userId;
1482
1835
  if (!userId && params.email) {
1483
- const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(params.email);
1836
+ const email = normalizeEmail(params.email);
1837
+ const existing = db.prepare("SELECT id FROM users WHERE LOWER(email) = ?").get(email);
1484
1838
  if (existing) {
1485
1839
  userId = existing.id;
1486
1840
  } else {
@@ -1488,7 +1842,7 @@ async function addCollaborator(params) {
1488
1842
  userId = uuid2();
1489
1843
  db.prepare(
1490
1844
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)"
1491
- ).run(userId, params.email, null, await hashPassword2(uuid2()));
1845
+ ).run(userId, email, null, await hashPassword2(uuid2()));
1492
1846
  }
1493
1847
  }
1494
1848
  if (!userId) {
@@ -1589,7 +1943,38 @@ sharingRoutes.patch("/visibility", async (c) => {
1589
1943
 
1590
1944
  // src/nodes/routes.ts
1591
1945
  import { Hono as Hono4 } from "hono";
1946
+
1947
+ // src/nodes/markdown-export.ts
1948
+ function nodeToMarkdown(node) {
1949
+ const tags = node.tags ?? [];
1950
+ return [
1951
+ "---",
1952
+ `title: ${JSON.stringify(node.title ?? "")}`,
1953
+ `tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
1954
+ `status: ${JSON.stringify(node.status ?? "")}`,
1955
+ `id: ${JSON.stringify(node.id)}`,
1956
+ "---",
1957
+ ""
1958
+ ].join("\n") + (node.body ?? "");
1959
+ }
1960
+ function nodesToMarkdown(nodes) {
1961
+ return nodes.map(nodeToMarkdown).join("\n\n---\n\n");
1962
+ }
1963
+ function isMarkdownFormat(c) {
1964
+ return c.req.query("format") === "markdown";
1965
+ }
1966
+
1967
+ // src/nodes/routes.ts
1592
1968
  var nodeRoutes = new Hono4();
1969
+ function nodeAsMarkdown(response, nodeId) {
1970
+ return nodeToMarkdown({
1971
+ id: nodeId,
1972
+ title: response.title,
1973
+ tags: response.tags,
1974
+ status: response.status,
1975
+ body: response.content
1976
+ });
1977
+ }
1593
1978
  nodeRoutes.get("/", async (c) => {
1594
1979
  const nestId = c.req.param("nestId");
1595
1980
  const userId = c.get("userId");
@@ -1628,7 +2013,7 @@ nodeRoutes.post("/", async (c) => {
1628
2013
  nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1629
2014
  const nestId = c.req.param("nestId");
1630
2015
  const nodeId = c.req.param("nodeId");
1631
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-3XGX7QIN.js");
2016
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-ZUSHJCNR.js");
1632
2017
  const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
1633
2018
  nestId,
1634
2019
  nodeId
@@ -1649,7 +2034,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1649
2034
  nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
1650
2035
  const nestId = c.req.param("nestId");
1651
2036
  const nodeId = c.req.param("nodeId");
1652
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-UODXLAOJ.js");
2037
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-624QTR5K.js");
1653
2038
  const allVersions = getVersions2(nestId, nodeId);
1654
2039
  const approved = getApprovedVersion2(nestId, nodeId);
1655
2040
  const db = getDb();
@@ -1680,10 +2065,45 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
1680
2065
  nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
1681
2066
  const nestId = c.req.param("nestId");
1682
2067
  const nodeId = c.req.param("nodeId");
1683
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-3OJIPYNV.js");
2068
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-QU7Q7XX2.js");
1684
2069
  const history = getReviewHistory2(nestId, nodeId);
1685
2070
  return c.json({ reviews: history });
1686
2071
  });
2072
+ nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
2073
+ const nestId = c.req.param("nestId");
2074
+ const nodeId = c.req.param("nodeId");
2075
+ const { versions: versionManager } = engineCache.get(nestId);
2076
+ const userId = c.get("userId");
2077
+ const userEmail = resolveCallerEmail(userId);
2078
+ if (!canReadNode(nestId, nodeId, userId, userEmail)) {
2079
+ return c.json(
2080
+ { error: "Access denied \u2014 no steward assignment for this node" },
2081
+ 403
2082
+ );
2083
+ }
2084
+ const body = await c.req.json().catch(() => ({}));
2085
+ const targetVersion = Number(body.targetVersion);
2086
+ if (!Number.isInteger(targetVersion) || targetVersion < 1) {
2087
+ throw new ValidationError("targetVersion (a positive integer) is required");
2088
+ }
2089
+ let raw;
2090
+ try {
2091
+ raw = await versionManager.reconstructVersion(nodeId, targetVersion);
2092
+ } catch {
2093
+ throw new NotFoundError(
2094
+ `Version ${targetVersion} not found for ${nodeId}`
2095
+ );
2096
+ }
2097
+ const content = bodyOnly(nodeId, raw);
2098
+ const { node, version } = await updateNode(
2099
+ nestId,
2100
+ nodeId,
2101
+ { content, changeNote: `Restored from version ${targetVersion}` },
2102
+ userEmail
2103
+ );
2104
+ trackEvent("node.revert", { nestId, nodeId, targetVersion });
2105
+ return c.json({ ok: true, version, node: toNodeResponse(node) });
2106
+ });
1687
2107
  nodeRoutes.get("/:nodeId{.+}", async (c) => {
1688
2108
  const nestId = c.req.param("nestId");
1689
2109
  const nodeId = c.req.param("nodeId");
@@ -1740,6 +2160,11 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
1740
2160
  }
1741
2161
  response.version = approved;
1742
2162
  response.status = "published";
2163
+ if (isMarkdownFormat(c)) {
2164
+ return c.body(nodeAsMarkdown(response, nodeId), 200, {
2165
+ "Content-Type": "text/markdown; charset=utf-8"
2166
+ });
2167
+ }
1743
2168
  return c.json({ node: response });
1744
2169
  }
1745
2170
  }
@@ -1748,6 +2173,11 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
1748
2173
  const pending = getPendingReview(nestId, nodeId);
1749
2174
  response.pendingReviewBy = pending?.requestedBy ?? null;
1750
2175
  }
2176
+ if (isMarkdownFormat(c)) {
2177
+ return c.body(nodeAsMarkdown(response, nodeId), 200, {
2178
+ "Content-Type": "text/markdown; charset=utf-8"
2179
+ });
2180
+ }
1751
2181
  return c.json({ node: response });
1752
2182
  });
1753
2183
  nodeRoutes.patch("/:nodeId{.+}", async (c) => {
@@ -1792,6 +2222,11 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
1792
2222
  const nestId = c.req.param("nestId");
1793
2223
  const nodeId = c.req.param("nodeId");
1794
2224
  const { storage } = engineCache.get(nestId);
2225
+ if (isStewardshipEnabled(nestId) && getPendingReview(nestId, nodeId)) {
2226
+ throw new LockedError(
2227
+ "This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
2228
+ );
2229
+ }
1795
2230
  try {
1796
2231
  await storage.deleteDocument(nodeId);
1797
2232
  } catch {
@@ -2017,6 +2452,32 @@ function compilePrompt(prompt, nestId, titles) {
2017
2452
  };
2018
2453
  }
2019
2454
 
2455
+ // src/nodes/readable-body.ts
2456
+ async function resolveReadableBody(nestId, nodeId, userId, workingBody) {
2457
+ if (!isPublicReader(nestId, userId)) return workingBody;
2458
+ const approved = getApprovedVersion(nestId, nodeId);
2459
+ if (approved == null) return "";
2460
+ try {
2461
+ const { versions } = engineCache.get(nestId);
2462
+ const raw = await versions.reconstructVersion(nodeId, approved);
2463
+ return bodyOnly(nodeId, raw);
2464
+ } catch {
2465
+ return "";
2466
+ }
2467
+ }
2468
+ async function resolveExportBody(nestId, nodeId, workingBody) {
2469
+ if (!isStewardshipEnabled(nestId)) return workingBody;
2470
+ const approved = getApprovedVersion(nestId, nodeId);
2471
+ if (approved == null) return null;
2472
+ try {
2473
+ const { versions } = engineCache.get(nestId);
2474
+ const raw = await versions.reconstructVersion(nodeId, approved);
2475
+ return bodyOnly(nodeId, raw);
2476
+ } catch {
2477
+ return null;
2478
+ }
2479
+ }
2480
+
2020
2481
  // src/nodes/query-routes.ts
2021
2482
  var queryRoutes = new Hono5();
2022
2483
  function approxTokens(text) {
@@ -2087,9 +2548,15 @@ queryRoutes.post("/context", async (c) => {
2087
2548
  const beforePermission = documents.length;
2088
2549
  const accessible = filterAccessible(nestId, userId, userEmail, documents);
2089
2550
  const permissionFiltered = beforePermission - accessible.length;
2551
+ const readable = await Promise.all(
2552
+ accessible.map(async (doc) => ({
2553
+ ...doc,
2554
+ body: await resolveReadableBody(nestId, doc.id, userId, doc.body || "")
2555
+ }))
2556
+ );
2090
2557
  const included = [];
2091
2558
  let tokenCount = 0;
2092
- for (const doc of accessible) {
2559
+ for (const doc of readable) {
2093
2560
  const block = formatContextBlock(doc);
2094
2561
  const blockTokens = approxTokens(block);
2095
2562
  if (tokenCount + blockTokens > maxTokens && included.length > 0) break;
@@ -2220,6 +2687,64 @@ queryRoutes.get("/context", async (c) => {
2220
2687
  const content = await storage.readContextMd();
2221
2688
  return c.json({ content: content || "" });
2222
2689
  });
2690
+ queryRoutes.get("/export", async (c) => {
2691
+ if (!isMarkdownFormat(c)) {
2692
+ throw new ValidationError("format=markdown is required");
2693
+ }
2694
+ const nestId = c.req.param("nestId");
2695
+ const { storage, query: queryEngine } = engineCache.get(nestId);
2696
+ const selector = c.req.query("selector")?.trim() || null;
2697
+ let documents;
2698
+ if (selector) {
2699
+ const result = await queryEngine.query(selector, { hops: 2, full: true });
2700
+ documents = result.documents;
2701
+ } else {
2702
+ documents = await storage.discoverDocuments();
2703
+ }
2704
+ const userId = c.get("userId");
2705
+ const userEmail = resolveCallerEmail(userId);
2706
+ const accessible = filterAccessible(nestId, userId, userEmail, documents);
2707
+ const governed = isStewardshipEnabled(nestId);
2708
+ const resolved = await Promise.all(
2709
+ accessible.map(async (n) => {
2710
+ const body = await resolveExportBody(nestId, n.id, n.body || "");
2711
+ if (body == null) return null;
2712
+ return {
2713
+ id: n.id,
2714
+ title: n.frontmatter.title,
2715
+ tags: n.frontmatter.tags || [],
2716
+ status: governed ? "published" : n.frontmatter.status,
2717
+ body
2718
+ };
2719
+ })
2720
+ );
2721
+ const fields = resolved.filter(
2722
+ (f) => f != null
2723
+ );
2724
+ const maxParam = parseInt(c.req.query("max_tokens") ?? "", 10);
2725
+ const maxTokens = Number.isFinite(maxParam) && maxParam > 0 ? maxParam : null;
2726
+ let included = fields;
2727
+ if (maxTokens) {
2728
+ const kept = [];
2729
+ let tokens = 0;
2730
+ for (const f of fields) {
2731
+ const t = approxTokens(nodeToMarkdown(f));
2732
+ if (tokens + t > maxTokens && kept.length > 0) break;
2733
+ kept.push(f);
2734
+ tokens += t;
2735
+ }
2736
+ included = kept;
2737
+ }
2738
+ trackEvent("nest.export", {
2739
+ nestId,
2740
+ count: included.length,
2741
+ selector,
2742
+ truncated_by_budget: fields.length - included.length
2743
+ });
2744
+ return c.body(nodesToMarkdown(included), 200, {
2745
+ "Content-Type": "text/markdown; charset=utf-8"
2746
+ });
2747
+ });
2223
2748
  queryRoutes.post("/publish", async (c) => {
2224
2749
  const body = await c.req.json();
2225
2750
  if (!body.documents?.length && !body.context_md) {
@@ -2505,6 +3030,26 @@ var TOOL_DEFINITIONS = [
2505
3030
  },
2506
3031
  required: ["email"]
2507
3032
  }
3033
+ },
3034
+ // ─── Unsynced Folder Tools ─────────────────────────────────────────
3035
+ {
3036
+ name: "context_unsynced_list",
3037
+ description: "List folders under the data root that are sync candidates \u2014 dropped in by the starter CLI or copied in manually, not yet converted into a nest. Server-admin only in non-open mode.",
3038
+ inputSchema: { type: "object", properties: {} }
3039
+ },
3040
+ {
3041
+ name: "context_sync_folder",
3042
+ description: "Convert a sibling folder under the data root into a real nest. Imports its markdown, seeds governance, and removes the source folder. Use context_unsynced_list to discover folder names. Server-admin only in non-open mode.",
3043
+ inputSchema: {
3044
+ type: "object",
3045
+ properties: {
3046
+ name: {
3047
+ type: "string",
3048
+ description: "Folder path relative to the data root (e.g. 'nodes/architecture'), as returned by context_unsynced_list."
3049
+ }
3050
+ },
3051
+ required: ["name"]
3052
+ }
2508
3053
  }
2509
3054
  ];
2510
3055
  async function resolveLlmBody(ctx, node) {
@@ -2519,7 +3064,7 @@ async function resolveLlmBody(ctx, node) {
2519
3064
  }
2520
3065
  }
2521
3066
  async function handleToolCall(toolName, args, ctx) {
2522
- const { storage, queryEngine, versionManager, nestId, userEmail } = ctx;
3067
+ const { storage, queryEngine, versionManager, nestId, userId, userEmail } = ctx;
2523
3068
  switch (toolName) {
2524
3069
  case "context_init": {
2525
3070
  const content = await storage.readContextMd();
@@ -2881,6 +3426,34 @@ ${list}`;
2881
3426
  return `Failed to share nest: ${err.message}`;
2882
3427
  }
2883
3428
  }
3429
+ case "context_unsynced_list": {
3430
+ if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
3431
+ return "You don't have permission to list unsynced folders. Server admin only.";
3432
+ }
3433
+ const folders = listUnsyncedFolders();
3434
+ if (!folders.length) return "No unsynced folders found.";
3435
+ const list = folders.map(
3436
+ (f, i) => `${i + 1}. **${f.label}** \`${f.name}\` \u2014 ${f.mdCount} md file(s), ${f.sizeBytes} bytes`
3437
+ ).join("\n");
3438
+ return `# Unsynced Folders (${folders.length})
3439
+
3440
+ ${list}`;
3441
+ }
3442
+ case "context_sync_folder": {
3443
+ if (config.AUTH_MODE !== "open" && !isLicenseAdminUserId(userId)) {
3444
+ return "You don't have permission to sync folders. Server admin only.";
3445
+ }
3446
+ try {
3447
+ const { nest, documents } = await syncUnsyncedFolder(
3448
+ userId,
3449
+ args.name,
3450
+ userEmail
3451
+ );
3452
+ return `Synced folder "${args.name}" \u2192 nest **${nest.name}** (${nest.id}) with ${documents} document(s).`;
3453
+ } catch (err) {
3454
+ return `Failed to sync folder: ${err.message}`;
3455
+ }
3456
+ }
2884
3457
  default:
2885
3458
  return `Unknown tool: ${toolName}`;
2886
3459
  }
@@ -2894,7 +3467,7 @@ function getUserEmail2(userId) {
2894
3467
  const user = db.prepare("SELECT email FROM users WHERE id = ?").get(userId);
2895
3468
  return user?.email || "anonymous@localhost";
2896
3469
  }
2897
- function createMcpServerForNest(nestId, userEmail) {
3470
+ function createMcpServerForNest(nestId, userId, userEmail) {
2898
3471
  const server = new McpServer(
2899
3472
  { name: `contextnest-${nestId}`, version: "1.0.0" },
2900
3473
  { capabilities: { tools: {} } }
@@ -2919,6 +3492,7 @@ function createMcpServerForNest(nestId, userEmail) {
2919
3492
  queryEngine: engine.query,
2920
3493
  versionManager: engine.versions,
2921
3494
  nestId,
3495
+ userId,
2922
3496
  userEmail
2923
3497
  });
2924
3498
  return { content: [{ type: "text", text }] };
@@ -2930,7 +3504,7 @@ mcpRoutes.all("/", async (c) => {
2930
3504
  const nestId = c.req.param("nestId");
2931
3505
  const userId = c.get("userId");
2932
3506
  const userEmail = getUserEmail2(userId);
2933
- const server = createMcpServerForNest(nestId, userEmail);
3507
+ const server = createMcpServerForNest(nestId, userId, userEmail);
2934
3508
  const transport = new WebStandardStreamableHTTPServerTransport({
2935
3509
  sessionIdGenerator: void 0,
2936
3510
  enableJsonResponse: true
@@ -2949,8 +3523,8 @@ mcpRoutes.all("/", async (c) => {
2949
3523
  import { Hono as Hono7 } from "hono";
2950
3524
 
2951
3525
  // src/governance/stewards-parser.ts
2952
- import { readFileSync, existsSync } from "fs";
2953
- import { join as join2 } from "path";
3526
+ import { readFileSync as readFileSync2, existsSync } from "fs";
3527
+ import { join as join3 } from "path";
2954
3528
  function parseStewardsYaml(content) {
2955
3529
  const result = { version: 1 };
2956
3530
  const lines = content.split("\n");
@@ -3015,15 +3589,15 @@ function parseEntry(str) {
3015
3589
  }
3016
3590
  function loadStewardsConfig(nestId) {
3017
3591
  const dataRoot = config.DATA_ROOT;
3018
- const nestPath = join2(dataRoot, "nests", nestId);
3592
+ const nestPath = join3(dataRoot, "nests", nestId);
3019
3593
  const candidates = [
3020
- join2(nestPath, "stewards.yaml"),
3021
- join2(nestPath, "stewards.yml"),
3022
- join2(nestPath, ".context", "stewards.yaml")
3594
+ join3(nestPath, "stewards.yaml"),
3595
+ join3(nestPath, "stewards.yml"),
3596
+ join3(nestPath, ".context", "stewards.yaml")
3023
3597
  ];
3024
3598
  for (const candidatePath of candidates) {
3025
3599
  if (existsSync(candidatePath)) {
3026
- const content = readFileSync(candidatePath, "utf-8");
3600
+ const content = readFileSync2(candidatePath, "utf-8");
3027
3601
  return parseStewardsYaml(content);
3028
3602
  }
3029
3603
  }
@@ -3174,8 +3748,19 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
3174
3748
  const nodeId = c.req.param("nodeId");
3175
3749
  const allVersions = getVersions(nestId, nodeId);
3176
3750
  const approved = getApprovedVersion(nestId, nodeId);
3751
+ const { versions: versionManager } = engineCache.get(nestId);
3752
+ const withContent = await Promise.all(
3753
+ allVersions.map(async (v) => {
3754
+ try {
3755
+ const raw = await versionManager.reconstructVersion(nodeId, v.version);
3756
+ return { ...v, content: bodyOnly(nodeId, raw) };
3757
+ } catch {
3758
+ return v;
3759
+ }
3760
+ })
3761
+ );
3177
3762
  return c.json({
3178
- versions: allVersions,
3763
+ versions: withContent,
3179
3764
  approvedVersion: approved,
3180
3765
  currentVersion: allVersions[0]?.version || 0
3181
3766
  });
@@ -3403,15 +3988,15 @@ function ensureAnonymousUser() {
3403
3988
  // src/app.ts
3404
3989
  import { serveStatic } from "@hono/node-server/serve-static";
3405
3990
  import { fileURLToPath } from "url";
3406
- import { dirname, join as join3, relative } from "path";
3991
+ import { dirname, join as join4, relative as relative2 } from "path";
3407
3992
  import { existsSync as existsSync2 } from "fs";
3408
3993
  var HERE = dirname(fileURLToPath(import.meta.url));
3409
3994
  var UI_DIR_CANDIDATES = [
3410
- join3(HERE, "web3"),
3411
- join3(process.cwd(), "dist", "web3")
3995
+ join4(HERE, "web3"),
3996
+ join4(process.cwd(), "dist", "web3")
3412
3997
  ];
3413
3998
  var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
3414
- var UI_DIR_REL = relative(process.cwd(), UI_DIR_ABS) || ".";
3999
+ var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
3415
4000
  var openModeMiddleware = createMiddleware2(async (c, next) => {
3416
4001
  const anonId = ensureAnonymousUser();
3417
4002
  c.set("userId", anonId);
@@ -3473,6 +4058,7 @@ function createApp() {
3473
4058
  version: "0.1.0",
3474
4059
  auth_mode: config.AUTH_MODE,
3475
4060
  logo_url: config.LOGO_URL,
4061
+ promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
3476
4062
  ...isSuspended() && { suspended_reason: getSuspensionReason() }
3477
4063
  })
3478
4064
  );
@@ -3542,7 +4128,14 @@ function createApp() {
3542
4128
  valid: true,
3543
4129
  tier: info.tier,
3544
4130
  org: info.org,
3545
- limits: info.limits
4131
+ limits: info.limits,
4132
+ // The key validated and is live now, but if it couldn't be written to
4133
+ // disk it'll be lost on restart — surface that instead of pretending
4134
+ // the setup is durable (the old behavior silently swallowed this).
4135
+ persisted: info.persisted,
4136
+ ...info.persisted ? {} : {
4137
+ warning: `License validated, but it could not be saved to disk (${info.persistError || "unknown error"}). It will be LOST on the next restart. Set ENV_FILE_PATH to a writable, persisted path (e.g. under DATA_ROOT) or provide PROMPTOWL_KEY via the container environment.`
4138
+ }
3546
4139
  });
3547
4140
  } catch (err) {
3548
4141
  const msg = err instanceof Error ? err.message : "Failed to install key";
@@ -3577,6 +4170,7 @@ function createApp() {
3577
4170
  const localPath = c.req.path.replace(/^\/nests\//, "");
3578
4171
  const parts = localPath.split("/").filter(Boolean);
3579
4172
  if (parts.length < 2) return next();
4173
+ if (parts[0] === "unsynced") return next();
3580
4174
  const nestId = parts[0];
3581
4175
  const userId = c.get("userId");
3582
4176
  const nestScope = c.get("nestScope");
@@ -3636,11 +4230,13 @@ function createApp() {
3636
4230
  } else if (c.req.method !== "GET" && !isStewardActionPath) {
3637
4231
  required = "write";
3638
4232
  }
4233
+ const isNodeRevert = c.req.method === "POST" && parts.length >= 4 && parts[parts.length - 1] === "revert";
3639
4234
  let stewardEditorBypass = false;
3640
4235
  if (required === "write" && permission === "read" && parts[1] === "nodes") {
3641
4236
  const userEmail = resolveCallerEmail(userId);
3642
- if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE")) {
3643
- const rawNodeId = parts.slice(2).join("/");
4237
+ if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE" || isNodeRevert)) {
4238
+ const idParts = isNodeRevert ? parts.slice(2, -1) : parts.slice(2);
4239
+ const rawNodeId = idParts.join("/");
3644
4240
  let nodeId = rawNodeId;
3645
4241
  try {
3646
4242
  nodeId = decodeURIComponent(rawNodeId);
@@ -3758,7 +4354,7 @@ function createApp() {
3758
4354
 
3759
4355
  // src/db/backfill.ts
3760
4356
  import { NestStorage } from "@promptowl/contextnest-engine";
3761
- import { join as join4 } from "path";
4357
+ import { join as join5 } from "path";
3762
4358
  var MIGRATION_ID = "005_backfill_node_versions_from_history";
3763
4359
  async function backfillNodeVersionsFromHistory(db) {
3764
4360
  const already = db.prepare("SELECT id FROM schema_migrations WHERE id = ?").get(MIGRATION_ID);
@@ -3779,7 +4375,7 @@ async function backfillNodeVersionsFromHistory(db) {
3779
4375
  let totalInserted = 0;
3780
4376
  let totalDocs = 0;
3781
4377
  for (const { id: nestId } of nests) {
3782
- const nestPath = join4(config.DATA_ROOT, "nests", nestId);
4378
+ const nestPath = join5(config.DATA_ROOT, "nests", nestId);
3783
4379
  const storage = new NestStorage(nestPath);
3784
4380
  let docs;
3785
4381
  try {