@promptowl/contextnest-community 1.1.0 → 1.2.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/CONFIGURATION.md CHANGED
@@ -49,6 +49,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
49
49
  | `AUTH_MODE` | `key` | `key` or `open`. See above. |
50
50
  | `PROMPTOWL_API_URL` | `https://app.promptowl.ai` | PromptOwl's API origin — used for device auth, license validation, telemetry. Override for air-gapped or test setups. |
51
51
  | `PROMPTOWL_KEY` | `""` | Your PromptOwl Community License key (`pk_...`). Unlicensed instances still run and serve reads, but every write returns `503` until a valid key is installed. Can also be set via the browser License Setup Page, which persists it to `ENV_FILE_PATH`. |
52
+ | `PROMPTOWL_SIGN_IN_GATE` | `open` | Restrict "Sign in with PromptOwl". `open` = anyone may; `admin-only` = only the license owner (admin) may, everyone else uses email/password (admin opens the login page with `?admin=1`); `disabled` = nobody may. Enforced server-side at `POST /auth/promptowl` and surfaced on the health endpoint. Unknown values fall back to `open`. |
52
53
  | `ENV_FILE_PATH` | `$cwd/.env` | Path to the `.env` file the license install flow writes `PROMPTOWL_KEY` into (alongside existing vars). Override when your `.env` lives outside the working directory. |
53
54
  | `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
54
55
  | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
package/README.md CHANGED
@@ -97,6 +97,10 @@ For redistribution, hosted-service, OEM, or regulated-industry licensing, contac
97
97
  | Per-nest sharing + collaborators | ✅ | ✅ |
98
98
  | Public read-only nests | ✅ | ✅ |
99
99
  | Custom logo / branding | ✅ | ✅ |
100
+ | Admin password reset + user removal (in-platform) | ✅ | ✅ |
101
+ | Wiki backlinks, outline, hover-preview, link health | ✅ | ✅ |
102
+ | Rich editor — tables, callouts, toggles, code highlight, find/replace | ✅ | ✅ |
103
+ | Steward version revert | ✅ | ✅ |
100
104
  | MCP server for AI agents | ✅ | ✅ |
101
105
  | Centralized multi-tenant admin console | — | ✅ |
102
106
  | SSO / SAML / SCIM | — | ✅ |
@@ -106,6 +110,19 @@ For redistribution, hosted-service, OEM, or regulated-industry licensing, contac
106
110
 
107
111
  For Enterprise pricing and features, contact **hoot@promptowl.ai** or visit <https://promptowl.ai/contextnest/>.
108
112
 
113
+ ## What's new in 1.2.0
114
+
115
+ - **Admin user management (no email server needed)** — `POST /auth/admin/reset-password/:userId` sets a user's password or returns a one-time temp password; `DELETE /auth/users/:userId` removes a user and revokes their API keys, sessions, and role rows. New self-service **Change password** in the user menu blocks reusing the current password.
116
+ - **`PROMPTOWL_SIGN_IN_GATE`** — `open` / `admin-only` / `disabled` restricts "Sign in with PromptOwl" (admin reaches it via `?admin=1`). See [CONFIGURATION.md](./CONFIGURATION.md).
117
+ - **Plain-text agent view** — `?format=markdown` on a node returns frontmatter + body as `text/markdown` for LLM-friendly consumption.
118
+ - **Steward version revert** — `POST .../revert` restores an earlier version as a new one; pending-review docs are now editable (saving withdraws the review).
119
+ - **Editor — always-editable surface** — Notion-style buttery headline, selection bubble toolbar (format-on-highlight), turn-into block conversion, Cmd+F find & replace, rich blocks (tables, callouts, toggles, syntax-highlighted code, columns), and wiki/code-safety fixes.
120
+ - **Wiki usability** — backlinks panel ("Linked from N documents"), outline/TOC for 3+-heading docs, hover-preview on `[[wiki links]]`, multi-tag AND filtering with clickable chips, per-doc copy link, rename-safety warning for title-form links, link-health report (broken links + orphans), recently-edited strip.
121
+ - **Sharing clarity** — share button reads "Share nest"; dialog clarifies nest-wide scope; deep links open documents directly.
122
+ - **Security** — hardened scanner gate (PolinRider + Shai-Hulud preflight) in `bin/`.
123
+
124
+ Full history in [CHANGELOG.md](./CHANGELOG.md).
125
+
109
126
  ## What's new in 1.1.0
110
127
 
111
128
  - **Vault import** — import an existing folder of markdown files into a new nest in one step, from the dashboard ("Import folder") or via the API. Frontmatter, wiki links, and folder structure are preserved.
@@ -2,7 +2,7 @@ import {
2
2
  ANON_USER_ID,
3
3
  config,
4
4
  getDb
5
- } from "./chunk-TDAX3JOT.js";
5
+ } from "./chunk-G62P54ET.js";
6
6
 
7
7
  // src/governance/stewardship-service.ts
8
8
  import { v4 as uuid2 } from "uuid";
@@ -136,6 +136,12 @@ var ConflictError = class extends AppError {
136
136
  this.name = "ConflictError";
137
137
  }
138
138
  };
139
+ var LockedError = class extends AppError {
140
+ constructor(message = "Locked") {
141
+ super(423, message);
142
+ this.name = "LockedError";
143
+ }
144
+ };
139
145
 
140
146
  // src/nodes/engine.ts
141
147
  import {
@@ -1352,6 +1358,7 @@ export {
1352
1358
  ForbiddenError,
1353
1359
  ValidationError,
1354
1360
  ConflictError,
1361
+ LockedError,
1355
1362
  trackEvent,
1356
1363
  startTelemetryLoop,
1357
1364
  getCurrentLicense,
@@ -1,16 +1,16 @@
1
1
  import {
2
2
  createVersion,
3
3
  setApprovedVersion
4
- } from "./chunk-7UTMBL6Z.js";
4
+ } from "./chunk-LO54V4AU.js";
5
5
  import {
6
6
  buildTitleMap,
7
7
  canUserApprove,
8
8
  engineCache,
9
9
  resolveStewardsForNode
10
- } from "./chunk-WCOUCBDJ.js";
10
+ } from "./chunk-5MT4ZBVF.js";
11
11
  import {
12
12
  getDb
13
- } from "./chunk-TDAX3JOT.js";
13
+ } from "./chunk-G62P54ET.js";
14
14
 
15
15
  // src/governance/review-service.ts
16
16
  import { v4 as uuid } from "uuid";
@@ -38,6 +38,20 @@ var config = {
38
38
  get PROMPTOWL_KEY() {
39
39
  return process.env.PROMPTOWL_KEY || "";
40
40
  },
41
+ /**
42
+ * Restrict "Sign in with PromptOwl":
43
+ * "open" — anyone may sign in with PromptOwl (default)
44
+ * "admin-only" — only the license owner (admin) may; everyone else
45
+ * uses email/password. Admin reaches it via the login
46
+ * page's admin route (?admin=1).
47
+ * "disabled" — nobody may sign in with PromptOwl.
48
+ * Enforced server-side at the device entry points (POST /auth/device,
49
+ * GET /auth/device/poll) and the identity point (POST /auth/promptowl).
50
+ */
51
+ get PROMPTOWL_SIGN_IN_GATE() {
52
+ const v = (process.env.PROMPTOWL_SIGN_IN_GATE || "open").trim().toLowerCase();
53
+ return v === "admin-only" || v === "disabled" ? v : "open";
54
+ },
41
55
  /**
42
56
  * Path to the .env file the server reads its config from. Used by
43
57
  * the license install flow to persist PROMPTOWL_KEY alongside any
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  getDb
3
- } from "./chunk-TDAX3JOT.js";
3
+ } from "./chunk-G62P54ET.js";
4
4
 
5
5
  // src/governance/version-service.ts
6
6
  import { createHash } from "crypto";
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-E7E3JMQR.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-LO54V4AU.js";
29
29
  import {
30
30
  AppError,
31
31
  ConflictError,
32
32
  ForbiddenError,
33
+ LockedError,
33
34
  NotFoundError,
34
35
  ValidationError,
35
36
  canCreateInNest,
@@ -78,13 +79,13 @@ import {
78
79
  trackEvent,
79
80
  updateSteward,
80
81
  validateLicense
81
- } from "./chunk-WCOUCBDJ.js";
82
+ } from "./chunk-5MT4ZBVF.js";
82
83
  import {
83
84
  ANON_EMAIL,
84
85
  ANON_USER_ID,
85
86
  config,
86
87
  getDb
87
- } from "./chunk-TDAX3JOT.js";
88
+ } from "./chunk-G62P54ET.js";
88
89
 
89
90
  // src/index.ts
90
91
  import { serve } from "@hono/node-server";
@@ -272,6 +273,15 @@ function resolveCallerUserId(c) {
272
273
  }
273
274
  return null;
274
275
  }
276
+ function deviceGateBlocked(c) {
277
+ const gate = config.PROMPTOWL_SIGN_IN_GATE;
278
+ if (gate === "open") return null;
279
+ const error = "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.";
280
+ if (gate === "disabled") return { error, gate };
281
+ const callerId = resolveCallerUserId(c);
282
+ if (callerId && !isLicenseAdminUserId(callerId)) return { error, gate };
283
+ return null;
284
+ }
275
285
  function setSessionCookie(c, sessionId) {
276
286
  c.header(
277
287
  "Set-Cookie",
@@ -455,6 +465,8 @@ authRoutes.delete("/keys/:keyId", authMiddleware, async (c) => {
455
465
  return c.json({ deleted: true });
456
466
  });
457
467
  authRoutes.post("/device", async (c) => {
468
+ const blocked = deviceGateBlocked(c);
469
+ if (blocked) return c.json(blocked, 403);
458
470
  if (!tryConsume(`device:ip:${clientIp(c)}`, DEVICE_LIMIT)) {
459
471
  return c.json({ error: "Too many device auth attempts, try again later" }, 429);
460
472
  }
@@ -476,6 +488,8 @@ authRoutes.post("/device", async (c) => {
476
488
  return c.json(data);
477
489
  });
478
490
  authRoutes.get("/device/poll", async (c) => {
491
+ const blocked = deviceGateBlocked(c);
492
+ if (blocked) return c.json(blocked, 403);
479
493
  const code = c.req.query("code");
480
494
  const clientSecret = c.req.query("client_secret");
481
495
  if (!code || !clientSecret) {
@@ -511,6 +525,18 @@ authRoutes.post("/promptowl", async (c) => {
511
525
  401
512
526
  );
513
527
  }
528
+ const gate = config.PROMPTOWL_SIGN_IN_GATE;
529
+ if (gate !== "open") {
530
+ if (gate === "disabled" || !isLicenseAdminEmail(me.email)) {
531
+ return c.json(
532
+ {
533
+ error: "PromptOwl sign-in is restricted on this server. Use email and password, or contact your admin.",
534
+ gate
535
+ },
536
+ 403
537
+ );
538
+ }
539
+ }
514
540
  const db = getDb();
515
541
  let user = db.prepare("SELECT id, email, name FROM users WHERE email = ?").get(me.email);
516
542
  if (!user) {
@@ -592,9 +618,17 @@ authRoutes.post("/password", authMiddleware, async (c) => {
592
618
  const db = getDb();
593
619
  const userId = c.get("userId");
594
620
  const user = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(userId);
595
- if (!user) return c.json({ error: "User not found" }, 404);
621
+ if (!user) throw new ValidationError("User not found");
596
622
  const check = await verifyPassword(body.current, user.password_hash);
597
- if (!check.ok) return c.json({ error: "Invalid current password" }, 401);
623
+ if (!check.ok) {
624
+ throw new ValidationError("Current password is incorrect");
625
+ }
626
+ const sameAsCurrent = await verifyPassword(body.next, user.password_hash);
627
+ if (sameAsCurrent.ok) {
628
+ throw new ValidationError(
629
+ "new password must be different from the current password"
630
+ );
631
+ }
598
632
  const newHash = await hashPassword(body.next);
599
633
  db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
600
634
  newHash,
@@ -604,6 +638,92 @@ authRoutes.post("/password", authMiddleware, async (c) => {
604
638
  clearSessionCookie(c);
605
639
  return c.json({ ok: true });
606
640
  });
641
+ authRoutes.post("/admin/reset-password/:userId", async (c) => {
642
+ const callerId = resolveCallerUserId(c);
643
+ if (!callerId) {
644
+ return c.json({ error: "Authentication required." }, 401);
645
+ }
646
+ if (!isLicenseAdminUserId(callerId)) {
647
+ return c.json(
648
+ { error: "Only the license-admin user can reset passwords." },
649
+ 403
650
+ );
651
+ }
652
+ const targetId = c.req.param("userId");
653
+ const db = getDb();
654
+ const target = db.prepare("SELECT id, email FROM users WHERE id = ?").get(targetId);
655
+ if (!target) return c.json({ error: "User not found" }, 404);
656
+ let supplied;
657
+ try {
658
+ supplied = (await c.req.json()).password;
659
+ } catch {
660
+ supplied = void 0;
661
+ }
662
+ if (supplied && supplied.length < 8) {
663
+ throw new ValidationError("password must be at least 8 characters");
664
+ }
665
+ const generated = supplied ? null : uuid().replace(/-/g, "").slice(0, 16);
666
+ const newPassword = supplied ?? generated;
667
+ const newHash = await hashPassword(newPassword);
668
+ db.transaction(() => {
669
+ db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(
670
+ newHash,
671
+ target.id
672
+ );
673
+ db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(target.id);
674
+ })();
675
+ deleteAllSessionsForUser(target.id);
676
+ trackEvent("admin.reset_password", { adminId: callerId, userId: target.id });
677
+ return c.json({
678
+ ok: true,
679
+ email: target.email,
680
+ keys_revoked: true,
681
+ // Plaintext returned ONCE, only when the server generated it.
682
+ temporary_password: generated ?? void 0
683
+ });
684
+ });
685
+ authRoutes.delete("/users/:userId", async (c) => {
686
+ const callerId = resolveCallerUserId(c);
687
+ if (!callerId) {
688
+ return c.json({ error: "Authentication required." }, 401);
689
+ }
690
+ if (!isLicenseAdminUserId(callerId)) {
691
+ return c.json(
692
+ { error: "Only the license-admin user can remove users." },
693
+ 403
694
+ );
695
+ }
696
+ const targetId = c.req.param("userId");
697
+ if (targetId === callerId) {
698
+ return c.json({ error: "You can't remove your own admin account." }, 400);
699
+ }
700
+ const db = getDb();
701
+ const target = db.prepare("SELECT id, email FROM users WHERE id = ?").get(targetId);
702
+ if (!target) return c.json({ error: "User not found" }, 404);
703
+ const ownedNests = db.prepare("SELECT COUNT(*) AS c FROM nests WHERE user_id = ?").get(target.id).c;
704
+ if (ownedNests > 0) {
705
+ return c.json(
706
+ {
707
+ error: `This user owns ${ownedNests} nest${ownedNests === 1 ? "" : "s"}. Transfer or delete them before removing the user.`,
708
+ owned_nests: ownedNests
709
+ },
710
+ 409
711
+ );
712
+ }
713
+ db.transaction(() => {
714
+ db.prepare(
715
+ "DELETE FROM stewards WHERE user_id = ? OR lower(user_email) = lower(?)"
716
+ ).run(target.id, target.email);
717
+ db.prepare("DELETE FROM nest_collaborators WHERE user_id = ?").run(
718
+ target.id
719
+ );
720
+ db.prepare("DELETE FROM api_keys WHERE user_id = ?").run(target.id);
721
+ db.prepare("DELETE FROM users WHERE id = ?").run(target.id);
722
+ })();
723
+ deleteAllSessionsForUser(target.id);
724
+ trackEvent("admin.remove_user", { adminId: callerId, userId: target.id });
725
+ return c.json({ ok: true, email: target.email });
726
+ });
607
727
  authRoutes.post("/invite", async (c) => {
608
728
  const body = await c.req.json();
609
729
  if (!body.email) throw new ValidationError("email is required");
@@ -949,7 +1069,7 @@ async function approveExternalEdit(input) {
949
1069
  const node = await storage.readDocument(input.documentId);
950
1070
  const versionNum = result.versionEntry.version;
951
1071
  const tags = node.frontmatter.tags || [];
952
- const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-UODXLAOJ.js");
1072
+ const { createVersion: createVersion2, setApprovedVersion: setApprovedVersion2 } = await import("./version-service-OCZUV2QP.js");
953
1073
  createVersion2({
954
1074
  nestId: input.nestId,
955
1075
  nodeId: input.documentId,
@@ -1251,8 +1371,10 @@ async function updateNode(nestId, nodeId, patch, userEmail) {
1251
1371
  }
1252
1372
  const hasStewards = isStewardshipEnabled(nestId);
1253
1373
  const currentTags = node.frontmatter.tags || [];
1254
- if (getPendingReview(nestId, nodeId)) {
1255
- cancelReview({ nestId, nodeId, cancelledBy: userEmail });
1374
+ if (hasStewards && getPendingReview(nestId, nodeId)) {
1375
+ throw new LockedError(
1376
+ "This document is awaiting steward review and is locked. Approve or reject the pending review before editing."
1377
+ );
1256
1378
  }
1257
1379
  let responseVersion;
1258
1380
  if (hasStewards) {
@@ -1589,7 +1711,38 @@ sharingRoutes.patch("/visibility", async (c) => {
1589
1711
 
1590
1712
  // src/nodes/routes.ts
1591
1713
  import { Hono as Hono4 } from "hono";
1714
+
1715
+ // src/nodes/markdown-export.ts
1716
+ function nodeToMarkdown(node) {
1717
+ const tags = node.tags ?? [];
1718
+ return [
1719
+ "---",
1720
+ `title: ${JSON.stringify(node.title ?? "")}`,
1721
+ `tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
1722
+ `status: ${JSON.stringify(node.status ?? "")}`,
1723
+ `id: ${JSON.stringify(node.id)}`,
1724
+ "---",
1725
+ ""
1726
+ ].join("\n") + (node.body ?? "");
1727
+ }
1728
+ function nodesToMarkdown(nodes) {
1729
+ return nodes.map(nodeToMarkdown).join("\n\n---\n\n");
1730
+ }
1731
+ function isMarkdownFormat(c) {
1732
+ return c.req.query("format") === "markdown";
1733
+ }
1734
+
1735
+ // src/nodes/routes.ts
1592
1736
  var nodeRoutes = new Hono4();
1737
+ function nodeAsMarkdown(response, nodeId) {
1738
+ return nodeToMarkdown({
1739
+ id: nodeId,
1740
+ title: response.title,
1741
+ tags: response.tags,
1742
+ status: response.status,
1743
+ body: response.content
1744
+ });
1745
+ }
1593
1746
  nodeRoutes.get("/", async (c) => {
1594
1747
  const nestId = c.req.param("nestId");
1595
1748
  const userId = c.get("userId");
@@ -1628,7 +1781,7 @@ nodeRoutes.post("/", async (c) => {
1628
1781
  nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1629
1782
  const nestId = c.req.param("nestId");
1630
1783
  const nodeId = c.req.param("nodeId");
1631
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-3XGX7QIN.js");
1784
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-VOD5HY3I.js");
1632
1785
  const { stewards, fallbackToOwner, ownerEmail } = resolveStewardsWithFallback2(
1633
1786
  nestId,
1634
1787
  nodeId
@@ -1649,7 +1802,7 @@ nodeRoutes.get("/:nodeId{.+?}/stewards", async (c) => {
1649
1802
  nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
1650
1803
  const nestId = c.req.param("nestId");
1651
1804
  const nodeId = c.req.param("nodeId");
1652
- const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-UODXLAOJ.js");
1805
+ const { getVersions: getVersions2, getApprovedVersion: getApprovedVersion2 } = await import("./version-service-OCZUV2QP.js");
1653
1806
  const allVersions = getVersions2(nestId, nodeId);
1654
1807
  const approved = getApprovedVersion2(nestId, nodeId);
1655
1808
  const db = getDb();
@@ -1680,10 +1833,45 @@ nodeRoutes.get("/:nodeId{.+?}/versions", async (c) => {
1680
1833
  nodeRoutes.get("/:nodeId{.+?}/reviews", async (c) => {
1681
1834
  const nestId = c.req.param("nestId");
1682
1835
  const nodeId = c.req.param("nodeId");
1683
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-3OJIPYNV.js");
1836
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-GYX3AW6E.js");
1684
1837
  const history = getReviewHistory2(nestId, nodeId);
1685
1838
  return c.json({ reviews: history });
1686
1839
  });
1840
+ nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
1841
+ const nestId = c.req.param("nestId");
1842
+ const nodeId = c.req.param("nodeId");
1843
+ const { versions: versionManager } = engineCache.get(nestId);
1844
+ const userId = c.get("userId");
1845
+ const userEmail = resolveCallerEmail(userId);
1846
+ if (!canReadNode(nestId, nodeId, userId, userEmail)) {
1847
+ return c.json(
1848
+ { error: "Access denied \u2014 no steward assignment for this node" },
1849
+ 403
1850
+ );
1851
+ }
1852
+ const body = await c.req.json().catch(() => ({}));
1853
+ const targetVersion = Number(body.targetVersion);
1854
+ if (!Number.isInteger(targetVersion) || targetVersion < 1) {
1855
+ throw new ValidationError("targetVersion (a positive integer) is required");
1856
+ }
1857
+ let raw;
1858
+ try {
1859
+ raw = await versionManager.reconstructVersion(nodeId, targetVersion);
1860
+ } catch {
1861
+ throw new NotFoundError(
1862
+ `Version ${targetVersion} not found for ${nodeId}`
1863
+ );
1864
+ }
1865
+ const content = bodyOnly(nodeId, raw);
1866
+ const { node, version } = await updateNode(
1867
+ nestId,
1868
+ nodeId,
1869
+ { content, changeNote: `Restored from version ${targetVersion}` },
1870
+ userEmail
1871
+ );
1872
+ trackEvent("node.revert", { nestId, nodeId, targetVersion });
1873
+ return c.json({ ok: true, version, node: toNodeResponse(node) });
1874
+ });
1687
1875
  nodeRoutes.get("/:nodeId{.+}", async (c) => {
1688
1876
  const nestId = c.req.param("nestId");
1689
1877
  const nodeId = c.req.param("nodeId");
@@ -1740,6 +1928,11 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
1740
1928
  }
1741
1929
  response.version = approved;
1742
1930
  response.status = "published";
1931
+ if (isMarkdownFormat(c)) {
1932
+ return c.body(nodeAsMarkdown(response, nodeId), 200, {
1933
+ "Content-Type": "text/markdown; charset=utf-8"
1934
+ });
1935
+ }
1743
1936
  return c.json({ node: response });
1744
1937
  }
1745
1938
  }
@@ -1748,6 +1941,11 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
1748
1941
  const pending = getPendingReview(nestId, nodeId);
1749
1942
  response.pendingReviewBy = pending?.requestedBy ?? null;
1750
1943
  }
1944
+ if (isMarkdownFormat(c)) {
1945
+ return c.body(nodeAsMarkdown(response, nodeId), 200, {
1946
+ "Content-Type": "text/markdown; charset=utf-8"
1947
+ });
1948
+ }
1751
1949
  return c.json({ node: response });
1752
1950
  });
1753
1951
  nodeRoutes.patch("/:nodeId{.+}", async (c) => {
@@ -1792,6 +1990,11 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
1792
1990
  const nestId = c.req.param("nestId");
1793
1991
  const nodeId = c.req.param("nodeId");
1794
1992
  const { storage } = engineCache.get(nestId);
1993
+ if (isStewardshipEnabled(nestId) && getPendingReview(nestId, nodeId)) {
1994
+ throw new LockedError(
1995
+ "This document is awaiting steward review and is locked. Approve or reject the pending review before deleting."
1996
+ );
1997
+ }
1795
1998
  try {
1796
1999
  await storage.deleteDocument(nodeId);
1797
2000
  } catch {
@@ -2017,6 +2220,32 @@ function compilePrompt(prompt, nestId, titles) {
2017
2220
  };
2018
2221
  }
2019
2222
 
2223
+ // src/nodes/readable-body.ts
2224
+ async function resolveReadableBody(nestId, nodeId, userId, workingBody) {
2225
+ if (!isPublicReader(nestId, userId)) return workingBody;
2226
+ const approved = getApprovedVersion(nestId, nodeId);
2227
+ if (approved == null) return "";
2228
+ try {
2229
+ const { versions } = engineCache.get(nestId);
2230
+ const raw = await versions.reconstructVersion(nodeId, approved);
2231
+ return bodyOnly(nodeId, raw);
2232
+ } catch {
2233
+ return "";
2234
+ }
2235
+ }
2236
+ async function resolveExportBody(nestId, nodeId, workingBody) {
2237
+ if (!isStewardshipEnabled(nestId)) return workingBody;
2238
+ const approved = getApprovedVersion(nestId, nodeId);
2239
+ if (approved == null) return null;
2240
+ try {
2241
+ const { versions } = engineCache.get(nestId);
2242
+ const raw = await versions.reconstructVersion(nodeId, approved);
2243
+ return bodyOnly(nodeId, raw);
2244
+ } catch {
2245
+ return null;
2246
+ }
2247
+ }
2248
+
2020
2249
  // src/nodes/query-routes.ts
2021
2250
  var queryRoutes = new Hono5();
2022
2251
  function approxTokens(text) {
@@ -2087,9 +2316,15 @@ queryRoutes.post("/context", async (c) => {
2087
2316
  const beforePermission = documents.length;
2088
2317
  const accessible = filterAccessible(nestId, userId, userEmail, documents);
2089
2318
  const permissionFiltered = beforePermission - accessible.length;
2319
+ const readable = await Promise.all(
2320
+ accessible.map(async (doc) => ({
2321
+ ...doc,
2322
+ body: await resolveReadableBody(nestId, doc.id, userId, doc.body || "")
2323
+ }))
2324
+ );
2090
2325
  const included = [];
2091
2326
  let tokenCount = 0;
2092
- for (const doc of accessible) {
2327
+ for (const doc of readable) {
2093
2328
  const block = formatContextBlock(doc);
2094
2329
  const blockTokens = approxTokens(block);
2095
2330
  if (tokenCount + blockTokens > maxTokens && included.length > 0) break;
@@ -2220,6 +2455,64 @@ queryRoutes.get("/context", async (c) => {
2220
2455
  const content = await storage.readContextMd();
2221
2456
  return c.json({ content: content || "" });
2222
2457
  });
2458
+ queryRoutes.get("/export", async (c) => {
2459
+ if (!isMarkdownFormat(c)) {
2460
+ throw new ValidationError("format=markdown is required");
2461
+ }
2462
+ const nestId = c.req.param("nestId");
2463
+ const { storage, query: queryEngine } = engineCache.get(nestId);
2464
+ const selector = c.req.query("selector")?.trim() || null;
2465
+ let documents;
2466
+ if (selector) {
2467
+ const result = await queryEngine.query(selector, { hops: 2, full: true });
2468
+ documents = result.documents;
2469
+ } else {
2470
+ documents = await storage.discoverDocuments();
2471
+ }
2472
+ const userId = c.get("userId");
2473
+ const userEmail = resolveCallerEmail(userId);
2474
+ const accessible = filterAccessible(nestId, userId, userEmail, documents);
2475
+ const governed = isStewardshipEnabled(nestId);
2476
+ const resolved = await Promise.all(
2477
+ accessible.map(async (n) => {
2478
+ const body = await resolveExportBody(nestId, n.id, n.body || "");
2479
+ if (body == null) return null;
2480
+ return {
2481
+ id: n.id,
2482
+ title: n.frontmatter.title,
2483
+ tags: n.frontmatter.tags || [],
2484
+ status: governed ? "published" : n.frontmatter.status,
2485
+ body
2486
+ };
2487
+ })
2488
+ );
2489
+ const fields = resolved.filter(
2490
+ (f) => f != null
2491
+ );
2492
+ const maxParam = parseInt(c.req.query("max_tokens") ?? "", 10);
2493
+ const maxTokens = Number.isFinite(maxParam) && maxParam > 0 ? maxParam : null;
2494
+ let included = fields;
2495
+ if (maxTokens) {
2496
+ const kept = [];
2497
+ let tokens = 0;
2498
+ for (const f of fields) {
2499
+ const t = approxTokens(nodeToMarkdown(f));
2500
+ if (tokens + t > maxTokens && kept.length > 0) break;
2501
+ kept.push(f);
2502
+ tokens += t;
2503
+ }
2504
+ included = kept;
2505
+ }
2506
+ trackEvent("nest.export", {
2507
+ nestId,
2508
+ count: included.length,
2509
+ selector,
2510
+ truncated_by_budget: fields.length - included.length
2511
+ });
2512
+ return c.body(nodesToMarkdown(included), 200, {
2513
+ "Content-Type": "text/markdown; charset=utf-8"
2514
+ });
2515
+ });
2223
2516
  queryRoutes.post("/publish", async (c) => {
2224
2517
  const body = await c.req.json();
2225
2518
  if (!body.documents?.length && !body.context_md) {
@@ -3174,8 +3467,19 @@ governanceNodeRoutes.get("/:nodeId{.+}/versions", async (c) => {
3174
3467
  const nodeId = c.req.param("nodeId");
3175
3468
  const allVersions = getVersions(nestId, nodeId);
3176
3469
  const approved = getApprovedVersion(nestId, nodeId);
3470
+ const { versions: versionManager } = engineCache.get(nestId);
3471
+ const withContent = await Promise.all(
3472
+ allVersions.map(async (v) => {
3473
+ try {
3474
+ const raw = await versionManager.reconstructVersion(nodeId, v.version);
3475
+ return { ...v, content: bodyOnly(nodeId, raw) };
3476
+ } catch {
3477
+ return v;
3478
+ }
3479
+ })
3480
+ );
3177
3481
  return c.json({
3178
- versions: allVersions,
3482
+ versions: withContent,
3179
3483
  approvedVersion: approved,
3180
3484
  currentVersion: allVersions[0]?.version || 0
3181
3485
  });
@@ -3473,6 +3777,7 @@ function createApp() {
3473
3777
  version: "0.1.0",
3474
3778
  auth_mode: config.AUTH_MODE,
3475
3779
  logo_url: config.LOGO_URL,
3780
+ promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
3476
3781
  ...isSuspended() && { suspended_reason: getSuspensionReason() }
3477
3782
  })
3478
3783
  );
@@ -3636,11 +3941,13 @@ function createApp() {
3636
3941
  } else if (c.req.method !== "GET" && !isStewardActionPath) {
3637
3942
  required = "write";
3638
3943
  }
3944
+ const isNodeRevert = c.req.method === "POST" && parts.length >= 4 && parts[parts.length - 1] === "revert";
3639
3945
  let stewardEditorBypass = false;
3640
3946
  if (required === "write" && permission === "read" && parts[1] === "nodes") {
3641
3947
  const userEmail = resolveCallerEmail(userId);
3642
- if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE")) {
3643
- const rawNodeId = parts.slice(2).join("/");
3948
+ if (parts.length >= 3 && (c.req.method === "PATCH" || c.req.method === "DELETE" || isNodeRevert)) {
3949
+ const idParts = isNodeRevert ? parts.slice(2, -1) : parts.slice(2);
3950
+ const rawNodeId = idParts.join("/");
3644
3951
  let nodeId = rawNodeId;
3645
3952
  try {
3646
3953
  nodeId = decodeURIComponent(rawNodeId);
@@ -6,12 +6,12 @@ import {
6
6
  getReviewQueue,
7
7
  reject,
8
8
  submitForReview
9
- } from "./chunk-S2EWN2VA.js";
10
- import "./chunk-7UTMBL6Z.js";
9
+ } from "./chunk-E7E3JMQR.js";
10
+ import "./chunk-LO54V4AU.js";
11
11
  import {
12
12
  canUserApprove
13
- } from "./chunk-WCOUCBDJ.js";
14
- import "./chunk-TDAX3JOT.js";
13
+ } from "./chunk-5MT4ZBVF.js";
14
+ import "./chunk-G62P54ET.js";
15
15
  export {
16
16
  approve,
17
17
  canUserApprove,
@@ -20,8 +20,8 @@ import {
20
20
  syncFromConfig,
21
21
  updateSteward,
22
22
  updateStewardRole
23
- } from "./chunk-WCOUCBDJ.js";
24
- import "./chunk-TDAX3JOT.js";
23
+ } from "./chunk-5MT4ZBVF.js";
24
+ import "./chunk-G62P54ET.js";
25
25
  export {
26
26
  assignSteward,
27
27
  canCreateInNest,
@@ -11,8 +11,8 @@ import {
11
11
  hashContent,
12
12
  setApprovedVersion,
13
13
  systemAuthor
14
- } from "./chunk-7UTMBL6Z.js";
15
- import "./chunk-TDAX3JOT.js";
14
+ } from "./chunk-LO54V4AU.js";
15
+ import "./chunk-G62P54ET.js";
16
16
  export {
17
17
  SYSTEM_AUTHOR_PREFIX,
18
18
  checkConflict,