@promptowl/contextnest-community 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CONFIGURATION.md +83 -1
  2. package/dist/{chunk-GSYMJ3A4.js → chunk-3LUUJUWS.js} +1 -1
  3. package/dist/{chunk-ENDHMQPD.js → chunk-7CPP6FIU.js} +251 -39
  4. package/dist/{chunk-TNAZJYL5.js → chunk-EY7WOKJR.js} +3 -3
  5. package/dist/{chunk-FBDEESNJ.js → chunk-GVUBZTI3.js} +26 -184
  6. package/dist/{chunk-BXUVIAMP.js → chunk-KHMSDTJJ.js} +2 -2
  7. package/dist/{chunk-3GMGLYTZ.js → chunk-O5DYL7ZG.js} +2 -2
  8. package/dist/{chunk-HYSTFMFG.js → chunk-XCFKS65Y.js} +107 -1
  9. package/dist/{client-SW5I6ZBK.js → client-7Z2IVCPW.js} +1 -1
  10. package/dist/{engine-V4DY4PYN.js → engine-VZTSDUPB.js} +2 -2
  11. package/dist/{external-edit-service-3NK5DGLA.js → external-edit-service-3FVK3OD7.js} +3 -3
  12. package/dist/{grants-service-CADI6LIB.js → grants-service-WC27TRY3.js} +2 -2
  13. package/dist/index.js +2203 -1056
  14. package/dist/{migrations.postgres-5VI4RDTS.js → migrations.postgres-L42OXR7K.js} +47 -1
  15. package/dist/{review-service-LK4M2C43.js → review-service-TJRHRMMZ.js} +6 -6
  16. package/dist/{stewardship-service-JAFSU5GN.js → stewardship-service-HRVD7SIS.js} +3 -3
  17. package/dist/{version-service-G7SFFKFO.js → version-service-5PIFT2OY.js} +3 -3
  18. package/dist/web3/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  19. package/dist/web3/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  20. package/dist/web3/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  21. package/dist/web3/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  22. package/dist/web3/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  23. package/dist/web3/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  24. package/dist/web3/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  25. package/dist/web3/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  26. package/dist/web3/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  27. package/dist/web3/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  28. package/dist/web3/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  29. package/dist/web3/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  30. package/dist/web3/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  31. package/dist/web3/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  32. package/dist/web3/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  33. package/dist/web3/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  34. package/dist/web3/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  35. package/dist/web3/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  36. package/dist/web3/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  37. package/dist/web3/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  38. package/dist/web3/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  39. package/dist/web3/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  40. package/dist/web3/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  41. package/dist/web3/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  42. package/dist/web3/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  43. package/dist/web3/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  44. package/dist/web3/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  45. package/dist/web3/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  46. package/dist/web3/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  47. package/dist/web3/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  48. package/dist/web3/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  49. package/dist/web3/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  50. package/dist/web3/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  51. package/dist/web3/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  52. package/dist/web3/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  53. package/dist/web3/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  54. package/dist/web3/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  55. package/dist/web3/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  56. package/dist/web3/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  57. package/dist/web3/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  58. package/dist/web3/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  59. package/dist/web3/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  60. package/dist/web3/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  61. package/dist/web3/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  62. package/dist/web3/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  63. package/dist/web3/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  64. package/dist/web3/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  65. package/dist/web3/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  66. package/dist/web3/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  67. package/dist/web3/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  68. package/dist/web3/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  69. package/dist/web3/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  70. package/dist/web3/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  71. package/dist/web3/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  72. package/dist/web3/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  73. package/dist/web3/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  74. package/dist/web3/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  75. package/dist/web3/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  76. package/dist/web3/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  77. package/dist/web3/assets/index-D4UG3T04.js +1365 -0
  78. package/dist/web3/assets/index-Dw1UTDIk.css +1 -0
  79. package/dist/web3/index.html +2 -2
  80. package/package.json +6 -1
  81. package/dist/web3/assets/index-C26nM5Kn.css +0 -1
  82. package/dist/web3/assets/index-C4oalc2K.js +0 -1065
package/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- addWatcher,
4
3
  approve,
5
4
  canReadNode,
6
5
  canRequestDeletion,
@@ -21,16 +20,11 @@ import {
21
20
  getReviewQueue,
22
21
  isSafeConnectorUrl,
23
22
  listDeletionRequests,
24
- listNotifications,
25
- listWatchers,
26
- markNotificationsRead,
27
23
  nodeWriteKey,
28
24
  notifyDeletionResolved,
29
25
  notifyNestEvent,
30
- notifyNestInvite,
31
26
  parseConditionSchema,
32
27
  reject,
33
- removeWatcher,
34
28
  repointPendingReview,
35
29
  requestDeletion,
36
30
  requireWorkflowPlane,
@@ -41,7 +35,7 @@ import {
41
35
  submitForReview,
42
36
  withNodeWriteLock,
43
37
  withdrawDeletionRequest
44
- } from "./chunk-FBDEESNJ.js";
38
+ } from "./chunk-GVUBZTI3.js";
45
39
  import {
46
40
  canCreateInNest,
47
41
  canManageStewards,
@@ -61,7 +55,7 @@ import {
61
55
  resolveUserRoles,
62
56
  syncFromConfig,
63
57
  updateSteward
64
- } from "./chunk-3GMGLYTZ.js";
58
+ } from "./chunk-O5DYL7ZG.js";
65
59
  import {
66
60
  createGrant,
67
61
  deleteGrant,
@@ -70,7 +64,7 @@ import {
70
64
  listGrants,
71
65
  listUserGrants,
72
66
  resolveNodeGrant
73
- } from "./chunk-GSYMJ3A4.js";
67
+ } from "./chunk-3LUUJUWS.js";
74
68
  import {
75
69
  generateApiKey,
76
70
  getKeyPrefix,
@@ -93,7 +87,7 @@ import {
93
87
  listMyDrafts,
94
88
  setApprovedVersion,
95
89
  upsertVersion
96
- } from "./chunk-BXUVIAMP.js";
90
+ } from "./chunk-KHMSDTJJ.js";
97
91
  import {
98
92
  approveExternalEdit,
99
93
  getExternalEditDetail,
@@ -105,9 +99,10 @@ import {
105
99
  scanDocumentForDrift,
106
100
  scanNestForDrift,
107
101
  startDriftScanner
108
- } from "./chunk-TNAZJYL5.js";
102
+ } from "./chunk-EY7WOKJR.js";
109
103
  import {
110
104
  addMember,
105
+ addWatcher,
111
106
  assertSafeNodeId,
112
107
  buildTitleMap,
113
108
  canApproveWith,
@@ -135,6 +130,7 @@ import {
135
130
  insertOrIgnore,
136
131
  installLicenseKey,
137
132
  isConfigSuperAdmin,
133
+ isEmailConfigured,
138
134
  isLicenseAdminEmail,
139
135
  isLicenseAdminUserId,
140
136
  isPublicReader,
@@ -144,13 +140,16 @@ import {
144
140
  isSuspended,
145
141
  listNestTeams,
146
142
  listNests,
143
+ listNotifications,
147
144
  listPublicNests,
148
145
  listSharedNests,
149
146
  listTeamsForUser,
150
147
  listVisibleNests,
148
+ listWatchers,
151
149
  loadAccessConfig,
152
150
  loadGrantedSuperAdmins,
153
151
  loadServerSettings,
152
+ markNotificationsRead,
154
153
  nestAllowsSelfApprove,
155
154
  nestName,
156
155
  nestPrimeTags,
@@ -158,6 +157,7 @@ import {
158
157
  nestStorageRoot,
159
158
  nodesWithSuggestions,
160
159
  normalizeTag,
160
+ notifyNestInvite,
161
161
  nowExpr,
162
162
  opContext,
163
163
  permissionLevel,
@@ -167,6 +167,7 @@ import {
167
167
  rebuildNodeIndex,
168
168
  removeMember,
169
169
  removeNodeFromTagIndex,
170
+ removeWatcher,
170
171
  renameNest,
171
172
  renameTeam,
172
173
  replaceTeamRoster,
@@ -180,6 +181,7 @@ import {
180
181
  setAllowSelfApprove,
181
182
  setPrimeOnlyReview,
182
183
  setPrimeTags,
184
+ setReaderMode,
183
185
  setStewardshipEnabled,
184
186
  shareTeamWithNest,
185
187
  startLicenseSafetyPoll,
@@ -191,7 +193,7 @@ import {
191
193
  updateMemberRole,
192
194
  validateLicense,
193
195
  verifySmtp
194
- } from "./chunk-ENDHMQPD.js";
196
+ } from "./chunk-7CPP6FIU.js";
195
197
  import {
196
198
  AppError,
197
199
  ConflictError,
@@ -209,7 +211,7 @@ import {
209
211
  initDb,
210
212
  isEmailListish,
211
213
  isEmailish
212
- } from "./chunk-HYSTFMFG.js";
214
+ } from "./chunk-XCFKS65Y.js";
213
215
  import {
214
216
  ANON_EMAIL,
215
217
  ANON_USER_ID,
@@ -223,7 +225,7 @@ import {
223
225
  import { serve } from "@hono/node-server";
224
226
 
225
227
  // src/app.ts
226
- import { Hono as Hono21 } from "hono";
228
+ import { Hono as Hono23 } from "hono";
227
229
  import { LinearRouter } from "hono/router/linear-router";
228
230
  import { createMiddleware as createMiddleware2 } from "hono/factory";
229
231
  import { cors } from "hono/cors";
@@ -253,7 +255,7 @@ function applyArtifactSandboxHeaders(c) {
253
255
 
254
256
  // src/auth/routes.ts
255
257
  import { Hono as Hono2 } from "hono";
256
- import { v4 as uuid2 } from "uuid";
258
+ import { v4 as uuid3 } from "uuid";
257
259
 
258
260
  // src/auth/middleware.ts
259
261
  import { createMiddleware } from "hono/factory";
@@ -475,8 +477,39 @@ function b64urlToBuffer(input) {
475
477
  const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - input.length % 4);
476
478
  return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
477
479
  }
480
+ function bufferToB64url(buf) {
481
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
482
+ }
478
483
  var SsoTokenError = class extends Error {
479
484
  };
485
+ var MCP_TICKET_MIN_TTL = 30;
486
+ var MCP_TICKET_MAX_TTL = 3600;
487
+ function signMcpTicket(input, secret, nowSec = Math.floor(Date.now() / 1e3)) {
488
+ if (!secret) throw new SsoTokenError("MCP signing secret not configured");
489
+ const sub = (input.sub ?? "").trim();
490
+ if (!isValidTicketSub(sub)) throw new SsoTokenError("Invalid ticket subject");
491
+ const ttl = Math.max(
492
+ MCP_TICKET_MIN_TTL,
493
+ Math.min(MCP_TICKET_MAX_TTL, Math.floor(input.ttlSeconds))
494
+ );
495
+ const headerB64 = bufferToB64url(
496
+ Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }))
497
+ );
498
+ const claims = {
499
+ sub,
500
+ scope: "mcp",
501
+ iat: nowSec,
502
+ exp: nowSec + ttl
503
+ };
504
+ if (input.aud) claims.aud = input.aud;
505
+ if (input.name) claims.name = input.name;
506
+ const payloadB64 = bufferToB64url(Buffer.from(JSON.stringify(claims)));
507
+ const signingInput = `${headerB64}.${payloadB64}`;
508
+ const sig = bufferToB64url(
509
+ createHmac("sha256", secret).update(signingInput).digest()
510
+ );
511
+ return `${signingInput}.${sig}`;
512
+ }
480
513
  function verifySsoTicket(token, secret, nowSec = Math.floor(Date.now() / 1e3)) {
481
514
  if (!secret) throw new SsoTokenError("SSO secret not configured");
482
515
  if (!token || typeof token !== "string") {
@@ -556,14 +589,15 @@ var NONCE_COOKIE = "cnst_oidc_nonce";
556
589
  var VERIFIER_COOKIE = "cnst_oidc_verifier";
557
590
  var FLOW_COOKIE_TTL_SECS = 10 * 60;
558
591
  var DISCOVERY_TTL_MS = 5 * 6e4;
559
- var discoveryCache = null;
592
+ var discoveryCache = /* @__PURE__ */ new Map();
560
593
  function isOidcReady() {
561
594
  return config.OIDC_ENABLED && !!config.OIDC_ISSUER && !!config.OIDC_CLIENT_ID && !!config.OIDC_CLIENT_SECRET;
562
595
  }
563
596
  async function discover(issuer) {
564
597
  const now = Date.now();
565
- if (discoveryCache && discoveryCache.issuer === issuer && now - discoveryCache.fetchedAt < DISCOVERY_TTL_MS) {
566
- return { doc: discoveryCache.doc, jwks: discoveryCache.jwks };
598
+ const cached = discoveryCache.get(issuer);
599
+ if (cached && now - cached.fetchedAt < DISCOVERY_TTL_MS) {
600
+ return { doc: cached.doc, jwks: cached.jwks };
567
601
  }
568
602
  const res = await fetch(`${issuer}/.well-known/openid-configuration`, {
569
603
  headers: { Accept: "application/json" },
@@ -582,12 +616,7 @@ async function discover(issuer) {
582
616
  );
583
617
  }
584
618
  const jwks = createRemoteJWKSet(new URL(doc.jwks_uri));
585
- discoveryCache = {
586
- issuer,
587
- doc,
588
- jwks,
589
- fetchedAt: now
590
- };
619
+ discoveryCache.set(issuer, { doc, jwks, fetchedAt: now });
591
620
  return { doc, jwks };
592
621
  }
593
622
  function base64url(buf) {
@@ -852,6 +881,184 @@ oidcRoutes.get("/logout", async (c) => {
852
881
  return c.redirect(home, 302);
853
882
  });
854
883
 
884
+ // src/auth/token-exchange.ts
885
+ import { v4 as uuid2 } from "uuid";
886
+ import { jwtVerify as jwtVerify2 } from "jose";
887
+ var TX_LIMIT = { max: 60, windowMs: 15 * 6e4 };
888
+ var ExchangeError = class extends Error {
889
+ constructor(code, status, message) {
890
+ super(message ?? code);
891
+ this.code = code;
892
+ this.status = status;
893
+ }
894
+ code;
895
+ status;
896
+ };
897
+ function expectedIssuer(provider, msTenantId) {
898
+ if (provider === "microsoft") {
899
+ return msTenantId ? `https://login.microsoftonline.com/${msTenantId}/v2.0` : "";
900
+ }
901
+ return "https://accounts.google.com";
902
+ }
903
+ function providerConfig(p) {
904
+ if (p === "microsoft") {
905
+ return {
906
+ enabled: config.MS_TOKEN_EXCHANGE_ENABLED,
907
+ issuer: expectedIssuer("microsoft", config.MS_TENANT_ID),
908
+ audience: config.MS_CLIENT_ID
909
+ };
910
+ }
911
+ return {
912
+ enabled: config.GOOGLE_TOKEN_EXCHANGE_ENABLED,
913
+ issuer: expectedIssuer("google", ""),
914
+ audience: config.GOOGLE_CLIENT_ID
915
+ };
916
+ }
917
+ async function verifySubjectToken(provider, subjectToken) {
918
+ const pc = providerConfig(provider);
919
+ if (!pc.issuer || !pc.audience) {
920
+ throw new ExchangeError("provider_misconfigured", 400);
921
+ }
922
+ let discovered;
923
+ try {
924
+ discovered = await discover(pc.issuer);
925
+ } catch (err) {
926
+ console.error(`[token-exchange] discovery failed for ${pc.issuer}:`, err);
927
+ throw new ExchangeError("provider_misconfigured", 400);
928
+ }
929
+ let claims;
930
+ try {
931
+ const { payload } = await jwtVerify2(subjectToken, discovered.jwks, {
932
+ issuer: discovered.doc.issuer,
933
+ audience: pc.audience,
934
+ algorithms: ["RS256"],
935
+ // Same leeway verifySsoTicket allows, so modest clock skew doesn't
936
+ // bounce a legitimate token on one path and pass on the other.
937
+ clockTolerance: 30
938
+ });
939
+ claims = payload;
940
+ } catch (err) {
941
+ console.warn(
942
+ `[token-exchange] subject_token rejected (${provider}):`,
943
+ err instanceof Error ? err.message : err
944
+ );
945
+ throw new ExchangeError("invalid_subject_token", 401);
946
+ }
947
+ if (claims.email_verified === false || claims.email_verified === "false") {
948
+ throw new ExchangeError("email_not_verified", 403);
949
+ }
950
+ const raw = typeof claims.email === "string" && claims.email || typeof claims.preferred_username === "string" && claims.preferred_username || "";
951
+ const email = normalizeEmail(raw);
952
+ if (!email || !isEmailish(email)) {
953
+ throw new ExchangeError("invalid_subject_token", 401);
954
+ }
955
+ const allowed = config.OIDC_ALLOWED_DOMAINS;
956
+ if (allowed.length > 0 && !allowed.includes(email.slice(email.lastIndexOf("@") + 1))) {
957
+ throw new ExchangeError("domain_not_allowed", 403);
958
+ }
959
+ const name = typeof claims.name === "string" && claims.name.trim() ? claims.name.trim() : null;
960
+ return { email, name, department: normalizeDepartment(claims.department) };
961
+ }
962
+ async function resolveUser(email, name, department) {
963
+ const db = getDb();
964
+ const existing = await db.get(
965
+ "SELECT id FROM users WHERE LOWER(email) = ?",
966
+ [email]
967
+ );
968
+ if (existing) return withDepartment(existing, department);
969
+ if (!config.OIDC_AUTO_PROVISION || config.OIDC_ALLOWED_DOMAINS.length === 0) {
970
+ throw new ExchangeError("no_account", 403);
971
+ }
972
+ const userId = uuid2();
973
+ const placeholderHash = await hashPassword(uuid2());
974
+ try {
975
+ await db.run(
976
+ "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
977
+ [userId, email, name, placeholderHash]
978
+ );
979
+ trackEvent("user.register", { userId, email, method: "token-exchange" });
980
+ return withDepartment({ id: userId }, department);
981
+ } catch (insErr) {
982
+ const raced = await db.get(
983
+ "SELECT id FROM users WHERE LOWER(email) = ?",
984
+ [email]
985
+ );
986
+ if (!raced) throw insErr;
987
+ return withDepartment(raced, department);
988
+ }
989
+ }
990
+ async function withDepartment(user, department) {
991
+ try {
992
+ await getDb().run("UPDATE users SET department = ? WHERE id = ?", [
993
+ department,
994
+ user.id
995
+ ]);
996
+ } catch (err) {
997
+ console.error("[token-exchange] department upsert failed:", err);
998
+ }
999
+ return user;
1000
+ }
1001
+ async function handleTokenExchange(c) {
1002
+ if (!config.SSO_TOKEN_EXCHANGE_ENABLED || !config.MCP_SIGNING_SECRET) {
1003
+ return c.json(
1004
+ { error: "Token exchange is not enabled on this server", code: "disabled" },
1005
+ 404
1006
+ );
1007
+ }
1008
+ if (!tryConsume(`token-exchange:${clientIp(c)}`, TX_LIMIT)) {
1009
+ return c.json({ error: "Too many requests", code: "rate_limited" }, 429);
1010
+ }
1011
+ let body;
1012
+ try {
1013
+ const parsed = await c.req.json();
1014
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1015
+ throw new Error("body is not a JSON object");
1016
+ }
1017
+ body = parsed;
1018
+ } catch {
1019
+ return c.json({ error: "Invalid JSON body", code: "bad_request" }, 400);
1020
+ }
1021
+ const provider = body.provider;
1022
+ if (provider !== "microsoft" && provider !== "google") {
1023
+ return c.json({ error: "Unknown or missing provider", code: "bad_request" }, 400);
1024
+ }
1025
+ if (!providerConfig(provider).enabled) {
1026
+ return c.json({ error: "Provider not enabled", code: "provider_disabled" }, 400);
1027
+ }
1028
+ const subjectToken = typeof body.subject_token === "string" ? body.subject_token.trim() : "";
1029
+ if (!subjectToken) {
1030
+ return c.json({ error: "Missing subject_token", code: "bad_request" }, 400);
1031
+ }
1032
+ try {
1033
+ const { email, name, department } = await verifySubjectToken(
1034
+ provider,
1035
+ subjectToken
1036
+ );
1037
+ const user = await resolveUser(email, name, department);
1038
+ const token = signMcpTicket(
1039
+ {
1040
+ sub: email,
1041
+ aud: config.PUBLIC_BASE_URL || void 0,
1042
+ name: name ?? void 0,
1043
+ ttlSeconds: config.MCP_TOKEN_TTL_SECONDS
1044
+ },
1045
+ config.MCP_SIGNING_SECRET
1046
+ );
1047
+ trackEvent("token_exchange.mint", { userId: user.id, provider });
1048
+ return c.json({
1049
+ access_token: token,
1050
+ token_type: "Bearer",
1051
+ expires_in: config.MCP_TOKEN_TTL_SECONDS
1052
+ });
1053
+ } catch (err) {
1054
+ if (err instanceof ExchangeError) {
1055
+ return c.json({ error: err.message, code: err.code }, err.status);
1056
+ }
1057
+ console.error("[token-exchange] unexpected error:", err);
1058
+ return c.json({ error: "Token exchange failed", code: "server_error" }, 500);
1059
+ }
1060
+ }
1061
+
855
1062
  // src/auth/routes.ts
856
1063
  function isServerAdminEmail(email) {
857
1064
  return isLicenseAdminEmail(email) || isSuperAdmin(email);
@@ -939,8 +1146,8 @@ async function provisionPromptowlUser(c, rawEmail, rawName) {
939
1146
  [meEmail]
940
1147
  );
941
1148
  if (!user) {
942
- const userId = uuid2();
943
- const placeholderHash = await hashPassword(uuid2());
1149
+ const userId = uuid3();
1150
+ const placeholderHash = await hashPassword(uuid3());
944
1151
  await db.run(
945
1152
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
946
1153
  [userId, meEmail, rawName || null, placeholderHash]
@@ -974,6 +1181,7 @@ async function provisionPromptowlUser(c, rawEmail, rawName) {
974
1181
  }
975
1182
  var authRoutes = new Hono2();
976
1183
  authRoutes.route("/oidc", oidcRoutes);
1184
+ authRoutes.post("/token-exchange", handleTokenExchange);
977
1185
  authRoutes.post("/register", async (c) => {
978
1186
  if (config.MANUAL_SIGN_IN !== "open") {
979
1187
  return c.json(
@@ -1012,7 +1220,7 @@ authRoutes.post("/register", async (c) => {
1012
1220
  }
1013
1221
  throw new ValidationError("Email already registered");
1014
1222
  }
1015
- const userId = uuid2();
1223
+ const userId = uuid3();
1016
1224
  const passwordHash = await hashPassword(body.password);
1017
1225
  await db.run(
1018
1226
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
@@ -1114,7 +1322,7 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
1114
1322
  });
1115
1323
  if (!check.ok) return c.json({ error: check.error }, check.status);
1116
1324
  const apiKey = generateApiKey();
1117
- const keyId = uuid2();
1325
+ const keyId = uuid3();
1118
1326
  await db.run(
1119
1327
  "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)",
1120
1328
  [
@@ -1143,7 +1351,7 @@ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
1143
1351
  if (!check.ok) return c.json({ error: check.error }, check.status);
1144
1352
  const prior = check.key;
1145
1353
  const apiKey = generateApiKey();
1146
- const keyId = uuid2();
1354
+ const keyId = uuid3();
1147
1355
  const nestScope = "nest_id" in body ? body.nest_id || null : prior.nest_id;
1148
1356
  const replaced = await db.transaction(async (tx) => {
1149
1357
  const removed = await tx.run(
@@ -1451,7 +1659,7 @@ authRoutes.post("/admin/reset-password/:userId", async (c) => {
1451
1659
  if (supplied !== void 0) {
1452
1660
  assertValidPassword(supplied);
1453
1661
  }
1454
- const generated = supplied ? null : uuid2().replace(/-/g, "").slice(0, 16);
1662
+ const generated = supplied ? null : uuid3().replace(/-/g, "").slice(0, 16);
1455
1663
  const newPassword = supplied ?? generated;
1456
1664
  const newHash = await hashPassword(newPassword);
1457
1665
  await db.transaction(async (tx) => {
@@ -1562,7 +1770,7 @@ authRoutes.post("/invite", async (c) => {
1562
1770
  409
1563
1771
  );
1564
1772
  }
1565
- const tempPassword = uuid2().replace(/-/g, "").slice(0, 16);
1773
+ const tempPassword = uuid3().replace(/-/g, "").slice(0, 16);
1566
1774
  const passwordHash = await hashPassword(tempPassword);
1567
1775
  let userId;
1568
1776
  if (existing) {
@@ -1572,7 +1780,7 @@ authRoutes.post("/invite", async (c) => {
1572
1780
  userId
1573
1781
  ]);
1574
1782
  } else {
1575
- userId = uuid2();
1783
+ userId = uuid3();
1576
1784
  await db.run(
1577
1785
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
1578
1786
  [userId, email, null, passwordHash]
@@ -1661,9 +1869,10 @@ import {
1661
1869
  isRejected,
1662
1870
  filterDocuments,
1663
1871
  DocumentNotFoundError,
1664
- NODE_TYPES
1872
+ NODE_TYPES,
1873
+ generateIndexMd
1665
1874
  } from "@promptowl/contextnest-engine";
1666
- import { rename, mkdir } from "fs/promises";
1875
+ import { rename, mkdir, rm, stat } from "fs/promises";
1667
1876
  import { dirname, join } from "path";
1668
1877
 
1669
1878
  // src/governance/safe-publish.ts
@@ -1914,6 +2123,9 @@ function engineScopesFolders() {
1914
2123
  }
1915
2124
  return folderScopeSupported;
1916
2125
  }
2126
+ function isDerivedAnnotationsNode(id) {
2127
+ return id.endsWith("--annotations");
2128
+ }
1917
2129
  async function getNodeForCaller(nestId, selector, userId, userEmail, opts = {}) {
1918
2130
  if (selector.id && !await canReadNode(nestId, selector.id, userId, userEmail)) {
1919
2131
  throw new ForbiddenError(
@@ -1992,6 +2204,9 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
1992
2204
  includeRetired: true
1993
2205
  });
1994
2206
  }
2207
+ if (!filters.includeDerived) {
2208
+ documents = documents.filter((d) => !isDerivedAnnotationsNode(d.id));
2209
+ }
1995
2210
  const scoped = filters.folder === void 0 ? documents : documents.filter((d) => folderOfId(d.id) === filters.folder);
1996
2211
  const nodes = scoped.map((d) => ({
1997
2212
  id: d.id,
@@ -2094,10 +2309,37 @@ function withSubtreeTotals(direct) {
2094
2309
  )
2095
2310
  })).sort((a, b) => a.path.localeCompare(b.path));
2096
2311
  }
2312
+ async function createEmptyFolder(nestId, folder) {
2313
+ const segments = folderToSegments(folder);
2314
+ if (segments.length === 0) {
2315
+ throw new ValidationError("folder is required");
2316
+ }
2317
+ const path = segments.join("/");
2318
+ const prefix = await storagePrefix(nestId);
2319
+ const vaultPath = prefix ? `${prefix}/${path}` : path;
2320
+ if (await vaultFolderExists(nestId, path)) return path;
2321
+ const title = segments[segments.length - 1].replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
2322
+ const { storage } = await engineCache.get(nestId);
2323
+ await storage.writeIndexMd(vaultPath, generateIndexMd(vaultPath, title, []));
2324
+ return path;
2325
+ }
2326
+ async function vaultFolderPaths(nestId, opts = {}) {
2327
+ const prefix = await storagePrefix(nestId);
2328
+ const scope = [prefix, opts.folder].filter(Boolean).join("/");
2329
+ const { storage } = await engineCache.get(nestId);
2330
+ const entries = await storage.listFolders({
2331
+ ...scope ? { folder: scope } : {},
2332
+ ...opts.recursive === false ? { recursive: false } : {}
2333
+ });
2334
+ return entries.map(
2335
+ (e) => prefix && e.path.startsWith(`${prefix}/`) ? e.path.slice(prefix.length + 1) : e.path
2336
+ ).filter(Boolean);
2337
+ }
2097
2338
  async function listNestFolders(nestId, opts = {}) {
2098
2339
  if (opts.folder !== void 0) assertSafeFolder(opts.folder);
2099
2340
  await ensureNodeIndex(nestId);
2100
2341
  const counts = /* @__PURE__ */ new Map();
2342
+ for (const path of await vaultFolderPaths(nestId, opts)) counts.set(path, 0);
2101
2343
  for (const { path, count } of await indexedFolderCounts(nestId)) {
2102
2344
  counts.set(path, count);
2103
2345
  let parent = path;
@@ -2122,7 +2364,9 @@ var RECENT_LIMIT = 3;
2122
2364
  async function nestCensus(nestId, userId) {
2123
2365
  const userEmail = await resolveCallerEmail(userId);
2124
2366
  await ensureNodeIndex(nestId);
2125
- const documents = await readIndexedNodes(nestId);
2367
+ const documents = (await readIndexedNodes(nestId)).filter(
2368
+ (d) => !isDerivedAnnotationsNode(d.id)
2369
+ );
2126
2370
  const visible = await filterAccessible(nestId, userId, userEmail, documents);
2127
2371
  const tags = {};
2128
2372
  const types = {};
@@ -2143,6 +2387,7 @@ async function nestCensus(nestId, userId) {
2143
2387
  const segs = folder.split("/");
2144
2388
  for (let i = 1; i <= segs.length; i++) paths.add(segs.slice(0, i).join("/"));
2145
2389
  }
2390
+ for (const path of await vaultFolderPaths(nestId)) paths.add(path);
2146
2391
  const folders = withSubtreeTotals(
2147
2392
  [...paths].map((path) => ({ path, count: direct.get(path) ?? 0 }))
2148
2393
  );
@@ -2186,7 +2431,10 @@ async function nestCensus(nestId, userId) {
2186
2431
  };
2187
2432
  }
2188
2433
  async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
2189
- return listNodesForCaller(nestId, await userIdFromEmail(userEmail), filters);
2434
+ return listNodesForCaller(nestId, await userIdFromEmail(userEmail), {
2435
+ includeDerived: true,
2436
+ ...filters
2437
+ });
2190
2438
  }
2191
2439
  async function actorAutoPublishes(nestId, userEmail) {
2192
2440
  if (!await nestAllowsSelfApprove(nestId)) return false;
@@ -2651,6 +2899,10 @@ var NODE_KEYED_TABLES = [
2651
2899
  // A live capability token bound to one agent node — deleting the agent must
2652
2900
  // revoke it, or a later node at that id answers the old webhook.
2653
2901
  ["trigger_hooks", "agent_node"],
2902
+ // A live public link pinned to one node. Deleting the document must kill the
2903
+ // link, and moving it must carry the link along — otherwise /p/<slug> serves
2904
+ // whatever node next takes that id.
2905
+ ["publish", "node_id"],
2654
2906
  ["edges", "from_node"],
2655
2907
  ["edges", "to_node"]
2656
2908
  ];
@@ -2814,6 +3066,7 @@ async function deleteFolder(nestId, folder, userEmail) {
2814
3066
  const docs = await storage.discoverDocuments();
2815
3067
  const targets = docs.map((d) => d.id).filter((id) => isIdUnderFolder(id, rel));
2816
3068
  if (targets.length === 0) {
3069
+ if (await removeVaultFolder(nestId, rel)) return [];
2817
3070
  throw new NotFoundError(`No documents found under folder: ${folder}`);
2818
3071
  }
2819
3072
  const deleted = [];
@@ -2828,13 +3081,35 @@ async function deleteFolder(nestId, folder, userEmail) {
2828
3081
  );
2829
3082
  }
2830
3083
  }
3084
+ await removeVaultFolder(nestId, rel);
2831
3085
  return deleted;
2832
3086
  }
3087
+ async function vaultFolderDir(nestId, rel) {
3088
+ const prefix = await storagePrefix(nestId);
3089
+ return join(
3090
+ resolveNestPath(nestId),
3091
+ ...prefix ? [prefix] : [],
3092
+ ...rel.split("/")
3093
+ );
3094
+ }
3095
+ async function vaultFolderExists(nestId, rel) {
3096
+ if (!rel) return false;
3097
+ try {
3098
+ return (await stat(await vaultFolderDir(nestId, rel))).isDirectory();
3099
+ } catch {
3100
+ return false;
3101
+ }
3102
+ }
3103
+ async function removeVaultFolder(nestId, rel) {
3104
+ if (!await vaultFolderExists(nestId, rel)) return false;
3105
+ await rm(await vaultFolderDir(nestId, rel), { recursive: true, force: true });
3106
+ return true;
3107
+ }
2833
3108
 
2834
3109
  // src/nests/unsynced-service.ts
2835
3110
  import { readdirSync, readFileSync, statSync } from "fs";
2836
3111
  import { join as join2, relative, resolve } from "path";
2837
- import { rm } from "fs/promises";
3112
+ import { rm as rm2 } from "fs/promises";
2838
3113
  var RESERVED = /* @__PURE__ */ new Set(["nests"]);
2839
3114
  function isNestStorageRoot(absPath) {
2840
3115
  return resolve(absPath) === resolve(nestStorageRoot());
@@ -2977,14 +3252,14 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
2977
3252
  if (isNestStorageRoot(src)) {
2978
3253
  throw new ValidationError("Folder is not eligible for sync");
2979
3254
  }
2980
- let stat;
3255
+ let stat2;
2981
3256
  try {
2982
- stat = statSync(src);
3257
+ stat2 = statSync(src);
2983
3258
  } catch {
2984
3259
  console.warn(`[unsynced] source missing: ${src}`);
2985
3260
  throw new NotFoundError(`Folder not found: ${folderName}`);
2986
3261
  }
2987
- if (!stat.isDirectory()) {
3262
+ if (!stat2.isDirectory()) {
2988
3263
  throw new ValidationError(`Not a directory: ${folderName}`);
2989
3264
  }
2990
3265
  const files = collectMarkdownFiles(src, src);
@@ -3019,7 +3294,7 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
3019
3294
  `[unsynced] registered ${documents} document(s) for nest ${nest.id}`
3020
3295
  );
3021
3296
  try {
3022
- await rm(src, { recursive: true, force: true });
3297
+ await rm2(src, { recursive: true, force: true });
3023
3298
  console.log(`[unsynced] removed source folder ${src}`);
3024
3299
  } catch (err) {
3025
3300
  console.error("[unsynced] failed to remove source folder", src, err);
@@ -3033,16 +3308,16 @@ async function deleteUnsyncedFolder(folderName) {
3033
3308
  if (isNestStorageRoot(src)) {
3034
3309
  throw new ValidationError("Folder is not eligible for delete");
3035
3310
  }
3036
- let stat;
3311
+ let stat2;
3037
3312
  try {
3038
- stat = statSync(src);
3313
+ stat2 = statSync(src);
3039
3314
  } catch {
3040
3315
  throw new NotFoundError(`Folder not found: ${folderName}`);
3041
3316
  }
3042
- if (!stat.isDirectory()) {
3317
+ if (!stat2.isDirectory()) {
3043
3318
  throw new ValidationError(`Not a directory: ${folderName}`);
3044
3319
  }
3045
- await rm(src, { recursive: true, force: true });
3320
+ await rm2(src, { recursive: true, force: true });
3046
3321
  console.log(`[unsynced] deleted folder ${src}`);
3047
3322
  }
3048
3323
 
@@ -3421,7 +3696,7 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
3421
3696
  import { Hono as Hono4 } from "hono";
3422
3697
 
3423
3698
  // src/nests/sharing-service.ts
3424
- import { v4 as uuid3 } from "uuid";
3699
+ import { v4 as uuid4 } from "uuid";
3425
3700
  var VALID_PERMISSIONS = ["read", "write", "admin"];
3426
3701
  async function addCollaborator(params) {
3427
3702
  const { nestId } = params;
@@ -3448,10 +3723,10 @@ async function addCollaborator(params) {
3448
3723
  userId = existing.id;
3449
3724
  } else {
3450
3725
  const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
3451
- userId = uuid3();
3726
+ userId = uuid4();
3452
3727
  await db.run(
3453
3728
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
3454
- [userId, email, null, await hashPassword2(uuid3())]
3729
+ [userId, email, null, await hashPassword2(uuid4())]
3455
3730
  );
3456
3731
  }
3457
3732
  }
@@ -3489,7 +3764,7 @@ async function addCollaborator(params) {
3489
3764
  [params.grantedByEmail]
3490
3765
  ))?.id : void 0;
3491
3766
  const granterId = params.grantedByUserId || granterByEmail || ownerRow.user_id;
3492
- const collabId = uuid3();
3767
+ const collabId = uuid4();
3493
3768
  await db.run(
3494
3769
  "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)",
3495
3770
  [collabId, nestId, userId, params.permission, granterId]
@@ -3608,6 +3883,16 @@ sharingRoutes.patch("/visibility", async (c) => {
3608
3883
  if (!body.visibility || !["private", "public"].includes(body.visibility)) {
3609
3884
  throw new ValidationError("visibility must be private or public");
3610
3885
  }
3886
+ if (body.visibility === "public" && body.acknowledge_public !== true) {
3887
+ return c.json(
3888
+ {
3889
+ error: "Publishing this nest requires acknowledgement.",
3890
+ requires_acknowledgement: true,
3891
+ warning: "This makes the nest readable by anyone on the internet, outside your organization."
3892
+ },
3893
+ 409
3894
+ );
3895
+ }
3611
3896
  const db = getDb();
3612
3897
  const nestId = c.req.param("nestId");
3613
3898
  const result = await db.run("UPDATE nests SET visibility = ? WHERE id = ?", [
@@ -3622,10 +3907,55 @@ sharingRoutes.patch("/visibility", async (c) => {
3622
3907
  ]);
3623
3908
  return c.json({ visibility: row.visibility });
3624
3909
  });
3910
+ sharingRoutes.patch("/reader-mode", async (c) => {
3911
+ const userId = c.get("userId");
3912
+ if (config.AUTH_MODE !== "open" && !await isServerAdminUserId(userId)) {
3913
+ throw new ForbiddenError(
3914
+ "Only the server admin can configure reader mode"
3915
+ );
3916
+ }
3917
+ const body = await c.req.json();
3918
+ if (body.enabled !== void 0 && typeof body.enabled !== "boolean") {
3919
+ throw new ValidationError("enabled must be a boolean");
3920
+ }
3921
+ let homeNode;
3922
+ if (body.home_node !== void 0) {
3923
+ if (body.home_node === null) {
3924
+ homeNode = null;
3925
+ } else if (typeof body.home_node === "string") {
3926
+ const trimmed = body.home_node.trim();
3927
+ homeNode = trimmed === "" ? null : trimmed;
3928
+ } else {
3929
+ throw new ValidationError("home_node must be a string or null");
3930
+ }
3931
+ }
3932
+ if (body.enabled === void 0 && homeNode === void 0) {
3933
+ throw new ValidationError(
3934
+ "provide enabled and/or home_node to update"
3935
+ );
3936
+ }
3937
+ const db = getDb();
3938
+ const nestId = c.req.param("nestId");
3939
+ const exists = await db.get("SELECT id FROM nests WHERE id = ?", [
3940
+ nestId
3941
+ ]);
3942
+ if (!exists) {
3943
+ throw new NotFoundError("Nest not found");
3944
+ }
3945
+ await setReaderMode(nestId, { enabled: body.enabled, homeNode });
3946
+ const saved = await db.get(
3947
+ "SELECT reader_mode, reader_home_node FROM nests WHERE id = ?",
3948
+ [nestId]
3949
+ );
3950
+ return c.json({
3951
+ reader_mode: saved.reader_mode,
3952
+ reader_home_node: saved.reader_home_node
3953
+ });
3954
+ });
3625
3955
 
3626
3956
  // src/nests/grant-routes.ts
3627
3957
  import { Hono as Hono5 } from "hono";
3628
- import { v4 as uuid4 } from "uuid";
3958
+ import { v4 as uuid5 } from "uuid";
3629
3959
  var grantRoutes = new Hono5();
3630
3960
  async function requireManage(c, nestId) {
3631
3961
  if (!await canManageStewards(nestId, c.get("userId"))) {
@@ -3640,10 +3970,10 @@ async function resolveGrantee(email) {
3640
3970
  const existing = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
3641
3971
  if (existing) return existing.id;
3642
3972
  const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
3643
- const id = uuid4();
3973
+ const id = uuid5();
3644
3974
  await db.run(
3645
3975
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
3646
- [id, e, null, await hashPassword2(uuid4())]
3976
+ [id, e, null, await hashPassword2(uuid5())]
3647
3977
  );
3648
3978
  return id;
3649
3979
  }
@@ -3741,1059 +4071,1718 @@ grantRoutes.delete("/:id", async (c) => {
3741
4071
  return c.json({ deleted: true });
3742
4072
  });
3743
4073
 
3744
- // src/nests/nest-team-routes.ts
4074
+ // src/nests/publish-routes.ts
3745
4075
  import { Hono as Hono6 } from "hono";
3746
- var nestTeamRoutes = new Hono6();
3747
- nestTeamRoutes.get("/", async (c) => {
3748
- const nestId = c.req.param("nestId");
3749
- const refs = await listNestTeams(nestId);
3750
- const teams = [];
3751
- for (const ref of refs) {
3752
- const team = await getTeam(ref.teamId).catch(() => null);
3753
- teams.push({
3754
- teamId: ref.teamId,
3755
- teamName: team?.name ?? ref.teamName,
3756
- members: team?.members ?? []
3757
- });
3758
- }
3759
- return c.json({ teams });
3760
- });
3761
- nestTeamRoutes.post("/", async (c) => {
3762
- const body = await c.req.json();
3763
- if (!body.team_id) {
3764
- throw new ValidationError("team_id is required");
3765
- }
3766
- const userId = c.get("userId");
3767
- const teams = await shareTeamWithNest({
3768
- nestId: c.req.param("nestId"),
3769
- teamId: body.team_id,
3770
- callerUserId: userId,
3771
- allowAdmin: await isLicenseAdminUserId(userId),
3772
- baseUrl: requestBaseUrl(c.req.url)
3773
- });
3774
- return c.json({ teams }, 201);
3775
- });
3776
- nestTeamRoutes.delete("/:teamId", async (c) => {
3777
- const teams = await unshareTeamFromNest({
3778
- nestId: c.req.param("nestId"),
3779
- teamId: c.req.param("teamId")
3780
- });
3781
- return c.json({ teams });
3782
- });
3783
-
3784
- // src/governance/team-routes.ts
3785
- import { Hono as Hono7 } from "hono";
3786
4076
 
3787
- // src/governance/promptowl-teams.ts
3788
- var PROMPTOWL_SOURCE = "promptowl";
3789
- var IMPORTABLE_PROMPTOWL_ROLES = ["owner", "editor"];
3790
- function canImportPromptowlTeam(team) {
3791
- return IMPORTABLE_PROMPTOWL_ROLES.includes(
3792
- (team.your_role || "").toLowerCase()
3793
- );
3794
- }
3795
- function mapPromptowlRole(role) {
3796
- switch ((role || "").toLowerCase()) {
3797
- case "owner":
3798
- return "admin";
3799
- case "editor":
3800
- return "editor";
3801
- default:
3802
- return "viewer";
3803
- }
3804
- }
3805
- async function fetchOwnedPromptowlTeams(token) {
3806
- if (!token?.trim()) throw new ValidationError("token is required");
3807
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
3808
- let res;
4077
+ // src/nests/publish-service.ts
4078
+ import { v4 as uuid6 } from "uuid";
4079
+ import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
4080
+ var MODES = ["public", "email", "code", "invite"];
4081
+ var LINK_TTL_SECONDS = 60 * 60;
4082
+ var SESSION_TTL_SECONDS = 24 * 60 * 60;
4083
+ function sha256Hex(s) {
4084
+ return createHash2("sha256").update(s).digest("hex");
4085
+ }
4086
+ async function nodeExists(nestId, nodeId) {
3809
4087
  try {
3810
- res = await fetch(`${promptowlUrl}/api/user/teams`, {
3811
- headers: {
3812
- ...config.PROMPTOWL_FETCH_HEADERS,
3813
- Authorization: `Bearer ${token}`
3814
- }
3815
- });
4088
+ const { storage } = await engineCache.get(nestId);
4089
+ await storage.readDocument(nodeId);
4090
+ return true;
3816
4091
  } catch {
3817
- throw new ValidationError("Could not reach PromptOwl. Check this server's network access.");
4092
+ return false;
3818
4093
  }
3819
- if (res.status === 401) {
3820
- throw new UnauthorizedError(
3821
- "PromptOwl rejected this approval. Approve again and retry."
3822
- );
4094
+ }
4095
+ function newSlug() {
4096
+ return randomBytes3(8).toString("base64url");
4097
+ }
4098
+ function newToken() {
4099
+ return randomBytes3(32).toString("hex");
4100
+ }
4101
+ function nowIso() {
4102
+ return (/* @__PURE__ */ new Date()).toISOString();
4103
+ }
4104
+ function isPast(iso) {
4105
+ return !!iso && new Date(iso).getTime() < Date.now();
4106
+ }
4107
+ function isLivePublish(row) {
4108
+ return !!row && !row.revoked_at && !isPast(row.expires_at);
4109
+ }
4110
+ function toPublishResponse(row) {
4111
+ return {
4112
+ id: row.id,
4113
+ slug: row.slug,
4114
+ node_id: row.node_id,
4115
+ access_mode: row.access_mode,
4116
+ comments_enabled: row.comments_enabled === 1,
4117
+ watermark: row.watermark === 1,
4118
+ no_download: row.no_download === 1,
4119
+ created_at: row.created_at,
4120
+ expires_at: row.expires_at,
4121
+ revoked_at: row.revoked_at
4122
+ };
4123
+ }
4124
+ async function createPublish(params) {
4125
+ const { nestId, mode, createdBy } = params;
4126
+ const nodeId = (params.nodeId || "").trim();
4127
+ if (!MODES.includes(mode)) {
4128
+ throw new ValidationError("access_mode must be public | email | code | invite");
3823
4129
  }
3824
- if (!res.ok) {
3825
- throw new ValidationError(
3826
- `PromptOwl could not list your teams (HTTP ${res.status}).`
3827
- );
4130
+ if (!nodeId) throw new ValidationError("node_id is required");
4131
+ assertSafeNodeId(nodeId);
4132
+ if (mode === "code" && !params.code?.trim()) {
4133
+ throw new ValidationError("code is required for a code-gated publish");
3828
4134
  }
3829
- const data = await res.json();
3830
- return Array.isArray(data.teams) ? data.teams : [];
3831
- }
3832
- async function syncPromptowlTeams(params) {
3833
- if (!params.teamIds?.length) {
3834
- throw new ValidationError("team_ids is required");
4135
+ if (params.expiresAt && Number.isNaN(new Date(params.expiresAt).getTime())) {
4136
+ throw new ValidationError("expires_at must be an ISO date");
3835
4137
  }
3836
- const owned = await fetchOwnedPromptowlTeams(params.token);
3837
- const byId = new Map(owned.map((t) => [t.id, t]));
3838
- const selected = [];
3839
- for (const id of params.teamIds) {
3840
- const team = byId.get(id);
3841
- if (!team) {
3842
- throw new ValidationError(
3843
- "One of the selected teams isn't available to import. Refresh the list and try again."
3844
- );
3845
- }
3846
- if (!canImportPromptowlTeam(team)) {
3847
- throw new ForbiddenError(
3848
- `You're view-only on "${team.name}" in PromptOwl, so you can't import it. Ask an owner or editor of that team to import it.`
4138
+ if (!await nodeExists(nestId, nodeId)) {
4139
+ throw new ValidationError(`No such document in this nest: ${nodeId}`);
4140
+ }
4141
+ const db = getDb();
4142
+ const id = uuid6();
4143
+ const slug = newSlug();
4144
+ const created = nowIso();
4145
+ const codeHash = mode === "code" && params.code ? await hashPassword(params.code.trim()) : null;
4146
+ await db.run(
4147
+ `INSERT INTO publish
4148
+ (id, nest_id, node_id, slug, access_mode, code_hash, comments_enabled,
4149
+ watermark, no_download, created_by, created_at, expires_at, revoked_at)
4150
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
4151
+ [
4152
+ id,
4153
+ nestId,
4154
+ nodeId,
4155
+ slug,
4156
+ mode,
4157
+ codeHash,
4158
+ params.commentsEnabled ? 1 : 0,
4159
+ params.watermark ? 1 : 0,
4160
+ params.noDownload ? 1 : 0,
4161
+ createdBy,
4162
+ created,
4163
+ params.expiresAt ?? null
4164
+ ]
4165
+ );
4166
+ if (mode === "invite") {
4167
+ for (const raw of params.allowlist ?? []) {
4168
+ const email = normalizeEmail(raw);
4169
+ if (!email) continue;
4170
+ await db.run(
4171
+ `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4172
+ VALUES (?, ?, 'invite', ?, NULL, NULL, NULL, ?)`,
4173
+ [uuid6(), id, email, created]
3849
4174
  );
3850
4175
  }
3851
- selected.push(team);
3852
4176
  }
3853
- const db = getDb();
3854
- const caller = await db.get("SELECT email FROM users WHERE id = ?", [
3855
- params.callerUserId
4177
+ const row = await getPublishBySlug(slug);
4178
+ if (!row) throw new ValidationError("failed to create publish");
4179
+ return row;
4180
+ }
4181
+ async function getPublishBySlug(slug) {
4182
+ return await getDb().get("SELECT * FROM publish WHERE slug = ?", [slug]);
4183
+ }
4184
+ async function getPublishById(nestId, id) {
4185
+ return await getDb().get("SELECT * FROM publish WHERE id = ? AND nest_id = ?", [
4186
+ id,
4187
+ nestId
3856
4188
  ]);
3857
- if (!caller?.email) {
3858
- throw new ValidationError("Could not resolve your account.");
3859
- }
3860
- const results = [];
3861
- for (const poTeam of selected) {
3862
- results.push(await syncOne(poTeam, params.callerUserId, caller.email));
3863
- }
3864
- return results;
3865
4189
  }
3866
- async function syncOne(poTeam, callerUserId, callerEmail) {
3867
- const existing = await findTeamByExternalId({
3868
- ownerId: callerUserId,
3869
- source: PROMPTOWL_SOURCE,
3870
- externalId: poTeam.id
3871
- });
3872
- const team = existing ?? await createTeam({
3873
- name: poTeam.name,
3874
- ownerId: callerUserId,
3875
- source: PROMPTOWL_SOURCE,
3876
- externalId: poTeam.id
3877
- });
3878
- if (existing && existing.name !== poTeam.name) {
3879
- await renameTeam({
3880
- teamId: team.id,
3881
- name: poTeam.name,
3882
- callerUserId,
3883
- allowAdmin: false
3884
- });
3885
- }
3886
- const desired = await desiredRoster(poTeam, callerEmail);
3887
- const before = new Map(team.members.map((m) => [m.email, m.role]));
3888
- const after = new Map(desired.map((m) => [m.email, m.role]));
3889
- const added = [...after.keys()].filter((e) => !before.has(e));
3890
- const removed = [...before.keys()].filter((e) => !after.has(e));
3891
- const updated = [...after.keys()].filter(
3892
- (e) => before.has(e) && before.get(e) !== after.get(e)
4190
+ async function listPublish(nestId) {
4191
+ return await getDb().all(
4192
+ "SELECT * FROM publish WHERE nest_id = ? ORDER BY created_at DESC",
4193
+ [nestId]
3893
4194
  );
3894
- if (added.length || removed.length || updated.length) {
3895
- await replaceTeamRoster({
3896
- teamId: team.id,
3897
- members: desired,
3898
- callerUserId,
3899
- allowAdmin: false
3900
- });
3901
- }
3902
- return {
3903
- team_id: team.id,
3904
- external_id: poTeam.id,
3905
- name: poTeam.name,
3906
- created: !existing,
3907
- added,
3908
- removed,
3909
- updated
3910
- };
3911
4195
  }
3912
- async function desiredRoster(poTeam, callerEmail) {
3913
- const caller = normalizeEmail(callerEmail);
3914
- const roster = [];
3915
- const seen = /* @__PURE__ */ new Set();
3916
- for (const poMember of poTeam.members || []) {
3917
- if (!poMember?.email) continue;
3918
- const email = normalizeEmail(poMember.email);
3919
- if (email === caller || seen.has(email)) continue;
3920
- seen.add(email);
3921
- const identity = await resolveMemberIdentity({ email });
3922
- roster.push({
3923
- userId: identity.userId,
3924
- email: identity.email,
3925
- role: mapPromptowlRole(poMember.role)
3926
- });
4196
+ async function revokePublish(nestId, id) {
4197
+ const res = await getDb().run(
4198
+ "UPDATE publish SET revoked_at = ? WHERE id = ? AND nest_id = ? AND revoked_at IS NULL",
4199
+ [nowIso(), id, nestId]
4200
+ );
4201
+ return res.changes > 0;
4202
+ }
4203
+ async function verifyCode(row, code) {
4204
+ if (row.access_mode !== "code" || !row.code_hash) return false;
4205
+ return (await verifyPassword((code || "").trim(), row.code_hash)).ok;
4206
+ }
4207
+ async function isAllowlisted(publishId, email) {
4208
+ const e = normalizeEmail(email);
4209
+ if (!e) return false;
4210
+ const row = await getDb().get(
4211
+ "SELECT 1 AS x FROM access_grant WHERE publish_id = ? AND kind = 'invite' AND email = ? LIMIT 1",
4212
+ [publishId, e]
4213
+ );
4214
+ return !!row;
4215
+ }
4216
+ async function mintMagicLink(publishId, email) {
4217
+ const e = normalizeEmail(email);
4218
+ const token = newToken();
4219
+ const expires = new Date(Date.now() + LINK_TTL_SECONDS * 1e3).toISOString();
4220
+ await getDb().run(
4221
+ `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4222
+ VALUES (?, ?, 'link', ?, ?, ?, NULL, ?)`,
4223
+ [uuid6(), publishId, e, sha256Hex(token), expires, nowIso()]
4224
+ );
4225
+ return token;
4226
+ }
4227
+ async function consumeMagicLink(publishId, token) {
4228
+ if (!token) return null;
4229
+ const row = await getDb().get(
4230
+ "SELECT id, email, expires_at, used_at FROM access_grant WHERE publish_id = ? AND kind = 'link' AND token_hash = ?",
4231
+ [publishId, sha256Hex(token)]
4232
+ );
4233
+ if (!row || row.used_at || isPast(row.expires_at)) return null;
4234
+ const publishRow = await getDb().get("SELECT access_mode FROM publish WHERE id = ?", [
4235
+ publishId
4236
+ ]);
4237
+ if (publishRow?.access_mode === "invite") {
4238
+ if (!row.email || !await isAllowlisted(publishId, row.email)) return null;
3927
4239
  }
3928
- return roster;
4240
+ const res = await getDb().run(
4241
+ "UPDATE access_grant SET used_at = ? WHERE id = ? AND used_at IS NULL",
4242
+ [nowIso(), row.id]
4243
+ );
4244
+ if (res.changes === 0) return null;
4245
+ return row.email;
4246
+ }
4247
+ async function createSession2(publishId, email) {
4248
+ const token = newToken();
4249
+ const expires = new Date(Date.now() + SESSION_TTL_SECONDS * 1e3).toISOString();
4250
+ await getDb().run(
4251
+ `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4252
+ VALUES (?, ?, 'session', ?, ?, ?, NULL, ?)`,
4253
+ [uuid6(), publishId, email, sha256Hex(token), expires, nowIso()]
4254
+ );
4255
+ return token;
4256
+ }
4257
+ async function verifySession(publishId, token) {
4258
+ if (!token) return null;
4259
+ const row = await getDb().get(
4260
+ "SELECT email, expires_at FROM access_grant WHERE publish_id = ? AND kind = 'session' AND token_hash = ?",
4261
+ [publishId, sha256Hex(token)]
4262
+ );
4263
+ if (!row || isPast(row.expires_at)) return null;
4264
+ return { email: row.email };
4265
+ }
4266
+ async function logView(publishId, email) {
4267
+ await getDb().run(
4268
+ `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4269
+ VALUES (?, ?, 'view', ?, NULL, NULL, NULL, ?)`,
4270
+ [uuid6(), publishId, email, nowIso()]
4271
+ );
4272
+ }
4273
+ async function listAllowlist(publishId) {
4274
+ const rows = await getDb().all(
4275
+ "SELECT email FROM access_grant WHERE publish_id = ? AND kind = 'invite' ORDER BY created_at ASC",
4276
+ [publishId]
4277
+ );
4278
+ return rows.map((r) => r.email ?? "").filter(Boolean);
4279
+ }
4280
+ async function listViews(publishId) {
4281
+ return await getDb().all(
4282
+ "SELECT email, created_at FROM access_grant WHERE publish_id = ? AND kind = 'view' ORDER BY created_at DESC LIMIT 500",
4283
+ [publishId]
4284
+ );
3929
4285
  }
3930
4286
 
3931
- // src/governance/team-routes.ts
3932
- var teamRoutes = new Hono7();
3933
- var PROMPTOWL_LIMIT = { max: 10, windowMs: 15 * 6e4 };
3934
- var CONNECT_REQUIRED = "promptowl_connect_required";
3935
- var CONNECT_REQUIRED_STATUS = 428;
3936
- async function withPromptowlToken(c, run) {
3937
- if (!config.PROMPTOWL_TEAMS_ENABLED) {
3938
- return c.json({ error: "Not found" }, 404);
3939
- }
3940
- if (!tryConsume(`po-teams:ip:${clientIp(c)}`, PROMPTOWL_LIMIT)) {
3941
- return c.json({ error: "Too many PromptOwl requests, try again later" }, 429);
3942
- }
3943
- const token = getPromptowlTokenFromRequest(c);
3944
- if (!token) {
3945
- return c.json(
3946
- {
3947
- error: "Sign in with PromptOwl to import teams.",
3948
- code: CONNECT_REQUIRED
3949
- },
3950
- CONNECT_REQUIRED_STATUS
4287
+ // src/nests/publish-routes.ts
4288
+ var publishRoutes = new Hono6();
4289
+ async function requireManage2(c, nestId) {
4290
+ if (!await canManageStewards(nestId, c.get("userId"))) {
4291
+ throw new ForbiddenError(
4292
+ "Only a nest admin, the nest owner, or the server admin can publish documents."
3951
4293
  );
3952
4294
  }
3953
- try {
3954
- return c.json(await run(token));
3955
- } catch (err) {
3956
- if (err instanceof UnauthorizedError) {
3957
- c.header(
3958
- "Set-Cookie",
3959
- buildPromptowlTokenCookie("", { secure: isSecureRequest(c), clear: true }),
3960
- { append: true }
3961
- );
3962
- return c.json(
3963
- { error: err.message, code: CONNECT_REQUIRED },
3964
- CONNECT_REQUIRED_STATUS
3965
- );
3966
- }
3967
- throw err;
3968
- }
3969
4295
  }
3970
- teamRoutes.get(
3971
- "/promptowl",
3972
- async (c) => withPromptowlToken(c, async (token) => ({
3973
- teams: await fetchOwnedPromptowlTeams(token)
3974
- }))
3975
- );
3976
- teamRoutes.post("/promptowl/sync", async (c) => {
3977
- const body = await c.req.json().catch(() => ({}));
3978
- return withPromptowlToken(c, async (token) => ({
3979
- results: await syncPromptowlTeams({
3980
- callerUserId: c.get("userId"),
3981
- token,
3982
- teamIds: body.team_ids ?? []
3983
- })
3984
- }));
3985
- });
3986
- teamRoutes.post("/", async (c) => {
4296
+ publishRoutes.post("/", async (c) => {
4297
+ const nestId = c.req.param("nestId");
4298
+ await requireManage2(c, nestId);
3987
4299
  const body = await c.req.json();
3988
- const team = await createTeam({
3989
- name: body.name ?? "",
3990
- ownerId: c.get("userId")
3991
- });
3992
- return c.json({ team }, 201);
3993
- });
3994
- teamRoutes.get("/", async (c) => {
3995
- const teams = await listTeamsForUser(c.get("userId"));
3996
- return c.json({ teams });
3997
- });
3998
- teamRoutes.get("/:teamId", async (c) => {
3999
- const userId = c.get("userId");
4000
- const team = await getTeam(c.req.param("teamId"));
4001
- const isMember = team.members.some((m) => m.userId === userId);
4002
- if (team.owner_id !== userId && !isMember && !await isLicenseAdminUserId(userId)) {
4003
- throw new ForbiddenError("You don't have access to this team.");
4300
+ const mode = body.access_mode ?? "public";
4301
+ if ((mode === "email" || mode === "invite") && !isEmailConfigured()) {
4302
+ throw new ValidationError(
4303
+ "Email and invite links need email set up on this server (SMTP_URL and NOTIFY_EMAIL_FROM). Use a public or passcode link instead."
4304
+ );
4004
4305
  }
4005
- return c.json({ team });
4006
- });
4007
- teamRoutes.patch("/:teamId", async (c) => {
4008
- const body = await c.req.json();
4009
- const userId = c.get("userId");
4010
- const team = await renameTeam({
4011
- teamId: c.req.param("teamId"),
4012
- name: body.name ?? "",
4013
- callerUserId: userId,
4014
- allowAdmin: await isLicenseAdminUserId(userId)
4015
- });
4016
- return c.json({ team });
4017
- });
4018
- teamRoutes.delete("/:teamId", async (c) => {
4019
- const userId = c.get("userId");
4020
- await deleteTeam({
4021
- teamId: c.req.param("teamId"),
4022
- callerUserId: userId,
4023
- allowAdmin: await isLicenseAdminUserId(userId)
4306
+ const row = await createPublish({
4307
+ nestId,
4308
+ nodeId: (body.node_id ?? "").trim(),
4309
+ mode,
4310
+ commentsEnabled: !!body.comments_enabled,
4311
+ watermark: !!body.watermark,
4312
+ noDownload: !!body.no_download,
4313
+ code: body.code ?? null,
4314
+ allowlist: Array.isArray(body.allowlist) ? body.allowlist : [],
4315
+ expiresAt: body.expires_at ?? null,
4316
+ createdBy: c.get("userId")
4024
4317
  });
4025
- return c.json({ removed: true });
4318
+ const link = `${requestBaseUrl(c.req.url)}/p/${row.slug}`;
4319
+ if (mode === "invite") {
4320
+ for (const email of await listAllowlist(row.id)) {
4321
+ void sendEmailToRecipient(email, {
4322
+ status: "shared",
4323
+ docTitle: "A document has been shared with you",
4324
+ path: "PromptOwl \xB7 ContextNest",
4325
+ permission: "view",
4326
+ link
4327
+ });
4328
+ }
4329
+ }
4330
+ return c.json({ publish: toPublishResponse(row), link }, 201);
4026
4331
  });
4027
- teamRoutes.post("/:teamId/members", async (c) => {
4028
- const body = await c.req.json();
4029
- const userId = c.get("userId");
4030
- const team = await addMember({
4031
- teamId: c.req.param("teamId"),
4032
- email: body.email,
4033
- userId: body.user_id,
4034
- role: body.role ?? "",
4035
- callerUserId: userId,
4036
- allowAdmin: await isLicenseAdminUserId(userId)
4332
+ publishRoutes.get("/", async (c) => {
4333
+ const nestId = c.req.param("nestId");
4334
+ await requireManage2(c, nestId);
4335
+ const base = requestBaseUrl(c.req.url);
4336
+ const rows = await listPublish(nestId);
4337
+ return c.json({
4338
+ count: rows.length,
4339
+ publishes: rows.map((r) => ({ ...toPublishResponse(r), link: `${base}/p/${r.slug}` }))
4037
4340
  });
4038
- return c.json({ team }, 201);
4039
4341
  });
4040
- teamRoutes.patch("/:teamId/members/:userId", async (c) => {
4041
- const body = await c.req.json();
4042
- const userId = c.get("userId");
4043
- const team = await updateMemberRole({
4044
- teamId: c.req.param("teamId"),
4045
- userId: c.req.param("userId"),
4046
- role: body.role ?? "",
4047
- callerUserId: userId,
4048
- allowAdmin: await isLicenseAdminUserId(userId)
4049
- });
4050
- return c.json({ team });
4342
+ publishRoutes.get("/:id/views", async (c) => {
4343
+ const nestId = c.req.param("nestId");
4344
+ await requireManage2(c, nestId);
4345
+ const row = await getPublishById(nestId, c.req.param("id"));
4346
+ if (!row) throw new NotFoundError("Publish not found");
4347
+ const views = await listViews(row.id);
4348
+ return c.json({ count: views.length, views });
4051
4349
  });
4052
- teamRoutes.delete("/:teamId/members/:userId", async (c) => {
4053
- const userId = c.get("userId");
4054
- const team = await removeMember({
4055
- teamId: c.req.param("teamId"),
4056
- userId: c.req.param("userId"),
4057
- callerUserId: userId,
4058
- allowAdmin: await isLicenseAdminUserId(userId)
4059
- });
4060
- return c.json({ team });
4350
+ publishRoutes.delete("/:id", async (c) => {
4351
+ const nestId = c.req.param("nestId");
4352
+ await requireManage2(c, nestId);
4353
+ const ok = await revokePublish(nestId, c.req.param("id"));
4354
+ if (!ok) throw new NotFoundError("Publish not found");
4355
+ return c.json({ revoked: true });
4061
4356
  });
4062
4357
 
4063
- // src/nodes/search.ts
4064
- async function discoverForSearch(storage) {
4065
- return storage.discoverDocuments({ includeRetired: true });
4358
+ // src/nests/publish-render-routes.ts
4359
+ import { Hono as Hono7 } from "hono";
4360
+ import MarkdownIt from "markdown-it";
4361
+
4362
+ // src/annotations/service.ts
4363
+ import { v4 as uuid7 } from "uuid";
4364
+
4365
+ // src/annotations/projection.ts
4366
+ var MAX_CONTEXT_CHARS = 250;
4367
+ var MAX_QUOTE_CHARS = 1e3;
4368
+ function clampAnchor(raw) {
4369
+ if (!raw) return null;
4370
+ const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
4371
+ if (!quote) return null;
4372
+ const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
4373
+ const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
4374
+ const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
4375
+ return { quote, before, after, ...line !== void 0 ? { line } : {} };
4066
4376
  }
4067
- function titleScore(title, query) {
4068
- const t = (title || "").toLowerCase();
4069
- const q = query.trim().toLowerCase();
4070
- if (!q) return 0;
4071
- if (t === q) return 4;
4072
- let midWord = false;
4073
- for (let i = t.indexOf(q); i !== -1; i = t.indexOf(q, i + 1)) {
4074
- if (i === 0) return 3;
4075
- if (!/[a-z0-9]/.test(t[i - 1])) return 2;
4076
- midWord = true;
4377
+ function oneLine(s) {
4378
+ return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
4379
+ }
4380
+ function renderAnchor(anchor) {
4381
+ if (!anchor) {
4382
+ return "<!-- anchor: whole-artifact -->";
4077
4383
  }
4078
- if (midWord) return 1;
4079
- const terms = q.split(/\s+/).filter(Boolean);
4080
- if (terms.length > 1 && terms.every((term) => t.includes(term))) return 1;
4081
- return 0;
4384
+ const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
4385
+ const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
4386
+ anchor.after
4387
+ )}\u2026`;
4388
+ return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
4082
4389
  }
4083
- function matchDocuments(documents, query) {
4084
- const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
4085
- if (terms.length === 0) return [];
4086
- return documents.filter((node) => {
4087
- const haystack = [
4088
- node.frontmatter.title,
4089
- node.body || "",
4090
- node.frontmatter.type || "",
4091
- ...node.frontmatter.tags || []
4092
- ].join(" ").toLowerCase();
4093
- return terms.every((term) => haystack.includes(term));
4094
- }).sort(
4095
- (a, b) => titleScore(b.frontmatter.title, query) - titleScore(a.frontmatter.title, query)
4390
+ function statusHeading(thread) {
4391
+ if (thread.status === "resolved") {
4392
+ const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
4393
+ const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
4394
+ const meta = who || when ? ` (${who}${when})` : "";
4395
+ return `\u2705 RESOLVED${meta}`;
4396
+ }
4397
+ return "\u{1F7E0} OPEN";
4398
+ }
4399
+ function snapshotLabel(v) {
4400
+ return v == null ? "Unpinned" : `Snapshot v${v}`;
4401
+ }
4402
+ function orderThreads(threads) {
4403
+ return [...threads].sort((a, b) => {
4404
+ if (a.status !== b.status) return a.status === "open" ? -1 : 1;
4405
+ return a.createdAt.localeCompare(b.createdAt);
4406
+ });
4407
+ }
4408
+ function projectThreadsToMarkdown(artifactTitle, threads) {
4409
+ const lines = [];
4410
+ lines.push(`# ${artifactTitle} \u2014 Annotations`);
4411
+ lines.push("");
4412
+ lines.push(
4413
+ "> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
4096
4414
  );
4415
+ lines.push("");
4416
+ if (threads.length === 0) {
4417
+ lines.push("_No annotations yet._");
4418
+ lines.push("");
4419
+ return lines.join("\n");
4420
+ }
4421
+ const buckets2 = /* @__PURE__ */ new Map();
4422
+ for (const t of threads) {
4423
+ const key = t.snapshotVersion ?? null;
4424
+ (buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
4425
+ }
4426
+ const keys = [...buckets2.keys()].sort((a, b) => {
4427
+ if (a == null) return 1;
4428
+ if (b == null) return -1;
4429
+ return b - a;
4430
+ });
4431
+ for (const key of keys) {
4432
+ lines.push(`## ${snapshotLabel(key)}`);
4433
+ lines.push("");
4434
+ for (const thread of orderThreads(buckets2.get(key))) {
4435
+ lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
4436
+ lines.push(renderAnchor(thread.anchor));
4437
+ for (const comment of thread.comments) {
4438
+ lines.push(
4439
+ `- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
4440
+ );
4441
+ }
4442
+ lines.push("");
4443
+ }
4444
+ }
4445
+ return lines.join("\n");
4097
4446
  }
4098
- function bodySnippet(body, query) {
4099
- const flat = body.replace(/\s+/g, " ").trim();
4100
- const terms = (query || "").toLowerCase().split(/\s+/).filter(Boolean);
4101
- const at = terms.map((t) => flat.toLowerCase().indexOf(t)).filter((i) => i >= 0).sort((a, b) => a - b)[0];
4102
- if (at === void 0 || at < 80) return flat.slice(0, 200);
4103
- const start = Math.max(0, at - 60);
4104
- return `\u2026${flat.slice(start, start + 200)}`;
4447
+
4448
+ // src/annotations/service.ts
4449
+ async function loadComments(threadId) {
4450
+ const db = getDb();
4451
+ const rows = await db.all(
4452
+ "SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
4453
+ [threadId]
4454
+ );
4455
+ return rows.map((r) => ({
4456
+ id: r.id,
4457
+ author: r.author,
4458
+ body: r.body,
4459
+ createdAt: r.created_at
4460
+ }));
4105
4461
  }
4106
- function toSearchHit(node, query) {
4462
+ async function rowToThread(row) {
4463
+ let anchor = null;
4464
+ if (row.anchor_json) {
4465
+ try {
4466
+ anchor = JSON.parse(row.anchor_json);
4467
+ } catch {
4468
+ anchor = null;
4469
+ }
4470
+ }
4107
4471
  return {
4108
- id: node.id,
4109
- title: node.frontmatter.title,
4110
- type: node.frontmatter.type || "document",
4111
- tags: node.frontmatter.tags || [],
4112
- snippet: bodySnippet(node.body || "", query)
4472
+ id: row.id,
4473
+ nestId: row.nest_id,
4474
+ nodeId: row.node_id,
4475
+ snapshotVersion: row.snapshot_version,
4476
+ anchor,
4477
+ status: row.status,
4478
+ createdBy: row.created_by,
4479
+ createdAt: row.created_at,
4480
+ resolvedBy: row.resolved_by,
4481
+ resolvedAt: row.resolved_at,
4482
+ comments: await loadComments(row.id)
4113
4483
  };
4114
4484
  }
4115
-
4116
- // src/nodes/routes.ts
4117
- import { Hono as Hono8 } from "hono";
4118
-
4119
- // src/nodes/markdown-export.ts
4120
- import { parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
4121
- function nodeToMarkdown(node) {
4122
- const tags = node.tags ?? [];
4123
- return [
4124
- "---",
4125
- `title: ${JSON.stringify(node.title ?? "")}`,
4126
- `tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
4127
- `status: ${JSON.stringify(node.status ?? "")}`,
4128
- `id: ${JSON.stringify(node.id)}`,
4129
- "---",
4130
- ""
4131
- ].join("\n") + (node.body ?? "");
4132
- }
4133
- function nodesToMarkdown(nodes) {
4134
- return nodes.map(nodeToMarkdown).join("\n\n---\n\n");
4485
+ async function getThreadRow(threadId) {
4486
+ return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
4135
4487
  }
4136
- function isMarkdownFormat(c) {
4137
- return c.req.query("format") === "markdown";
4488
+ async function countThreadsByNode(nestId) {
4489
+ const rows = await getDb().all(
4490
+ `SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
4491
+ FROM annotation_threads
4492
+ WHERE nest_id = ?
4493
+ GROUP BY node_id`,
4494
+ [nestId]
4495
+ );
4496
+ const out = {};
4497
+ for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
4498
+ return out;
4138
4499
  }
4139
- function isRawFormat(c) {
4140
- const format = c.req.query("format");
4141
- if (format !== void 0) return format === "raw";
4142
- const accept = c.req.header("Accept") ?? "";
4143
- return accept.split(",").some((part) => part.trim().split(";")[0].trim().toLowerCase() === "text/markdown");
4500
+ async function listThreads(nestId, nodeId) {
4501
+ const rows = await getDb().all(
4502
+ "SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
4503
+ [nestId, nodeId]
4504
+ );
4505
+ return Promise.all(rows.map(rowToThread));
4144
4506
  }
4145
- var RAW_CONTENT_TYPES = {
4146
- artifact: "text/html; charset=utf-8",
4147
- tool: "text/plain; charset=utf-8",
4148
- table: "text/plain; charset=utf-8"
4149
- };
4150
- function rawContentType(nodeType) {
4151
- return RAW_CONTENT_TYPES[nodeType || "document"] ?? "text/markdown; charset=utf-8";
4507
+ async function createThread(nestId, nodeId, input, authorEmail) {
4508
+ const body = (input.body ?? "").trim();
4509
+ if (!body) {
4510
+ throw new Error("comment body is required");
4511
+ }
4512
+ const db = getDb();
4513
+ const id = uuid7();
4514
+ const anchor = clampAnchor(input.anchor);
4515
+ const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
4516
+ await db.transaction(async (tx) => {
4517
+ await tx.run(
4518
+ `INSERT INTO annotation_threads
4519
+ (id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
4520
+ VALUES (?, ?, ?, ?, ?, 'open', ?)`,
4521
+ [
4522
+ id,
4523
+ nestId,
4524
+ nodeId,
4525
+ snapshot,
4526
+ anchor ? JSON.stringify(anchor) : null,
4527
+ authorEmail
4528
+ ]
4529
+ );
4530
+ await tx.run(
4531
+ "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4532
+ [uuid7(), id, authorEmail, body]
4533
+ );
4534
+ });
4535
+ return rowToThread(await getThreadRow(id));
4152
4536
  }
4153
- function frontmatterBlockOf(raw) {
4154
- if (!raw || !raw.startsWith("---")) return "";
4155
- try {
4156
- const body = parseDocument2("frontmatter-split.md", raw, "frontmatter-split").body ?? "";
4157
- if (raw.endsWith(body)) return raw.slice(0, raw.length - body.length);
4158
- } catch {
4537
+ async function getScopedThreadRow(threadId, nestId, nodeId) {
4538
+ const row = await getThreadRow(threadId);
4539
+ if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
4540
+ throw new NotFoundError(`Thread not found: ${threadId}`);
4159
4541
  }
4160
- const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
4161
- return m ? `---
4162
- ${m[1]}
4163
- ---
4164
-
4165
- ` : "";
4542
+ return row;
4166
4543
  }
4167
-
4168
- // src/nodes/routes.ts
4169
- var nodeRoutes = new Hono8();
4170
- function nodeAsMarkdown(response, nodeId) {
4171
- return nodeToMarkdown({
4172
- id: nodeId,
4173
- title: response.title,
4174
- tags: response.tags,
4175
- status: response.status,
4176
- body: response.content
4177
- });
4544
+ async function addComment(nestId, nodeId, threadId, authorEmail, body) {
4545
+ const trimmed = (body ?? "").trim();
4546
+ if (!trimmed) {
4547
+ throw new Error("comment body is required");
4548
+ }
4549
+ await getScopedThreadRow(threadId, nestId, nodeId);
4550
+ await getDb().run(
4551
+ "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4552
+ [uuid7(), threadId, authorEmail, trimmed]
4553
+ );
4554
+ return rowToThread(await getThreadRow(threadId));
4178
4555
  }
4179
- function rawNodeBody(c, response, storedRaw) {
4180
- const body = response.content ?? "";
4181
- const payload = c.req.query("frontmatter") === "1" ? frontmatterBlockOf(storedRaw) + body : body;
4182
- c.header("X-Content-Type-Options", "nosniff");
4183
- if ((response.type || "document") === "artifact") {
4184
- applyArtifactSandboxHeaders(c);
4556
+ async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
4557
+ await getScopedThreadRow(threadId, nestId, nodeId);
4558
+ const db = getDb();
4559
+ if (status === "resolved") {
4560
+ await db.run(
4561
+ `UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
4562
+ [byEmail, threadId]
4563
+ );
4564
+ } else {
4565
+ await db.run(
4566
+ "UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
4567
+ [threadId]
4568
+ );
4185
4569
  }
4186
- return c.body(payload, 200, {
4187
- "Content-Type": rawContentType(response.type)
4188
- });
4570
+ return rowToThread(await getThreadRow(threadId));
4189
4571
  }
4190
- nodeRoutes.get("/", async (c) => {
4191
- const nestId = c.req.param("nestId");
4192
- const userId = c.get("userId");
4193
- const q = c.req.query();
4194
- const limit = q.limit ? parseInt(q.limit, 10) : void 0;
4195
- const nodes = await listNodesForCaller(nestId, userId, {
4196
- ...q.type ? { type: q.type } : {},
4197
- ...q.tag ? { tag: q.tag } : {},
4198
- ...q.status ? { status: q.status } : {},
4199
- // Lets an owner preview what making the nest public would expose.
4200
- ...q.approved_only === "1" || q.approved_only === "true" ? { approvedOnly: true } : {},
4201
- // Ignore junk rather than 400 — a bad ?limit should not fail a listing.
4202
- ...limit !== void 0 && Number.isFinite(limit) && limit > 0 ? { limit } : {},
4203
- // Present (even as "") = one folder level only, for the lazily-expanded
4204
- // document tree. Absent = the whole nest, as every existing caller expects.
4205
- ...q.folder !== void 0 ? { folder: q.folder } : {},
4206
- // Bodies are off by default — the list is served from the DB index, which
4207
- // holds metadata only. Opt back in and the listing crawls the vault for
4208
- // them; kept for any client that was reading `content` off this route.
4209
- ...q.content === "1" || q.content === "true" ? { withContent: true } : {}
4210
- });
4211
- return c.json({ count: nodes.length, nodes });
4212
- });
4213
- nodeRoutes.post("/", async (c) => {
4214
- const body = await c.req.json();
4215
- if (!body.title || !body.content) {
4216
- throw new ValidationError("title and content are required");
4572
+ async function readArtifact(nestId, nodeId, version) {
4573
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
4574
+ const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
4575
+ let title = nodeId;
4576
+ let type = null;
4577
+ let liveHtml = "";
4578
+ let liveExists = true;
4579
+ try {
4580
+ const node = await storage.readDocument(nodeId);
4581
+ title = node.frontmatter?.title || nodeId;
4582
+ type = node.frontmatter?.type || null;
4583
+ liveHtml = node.body || "";
4584
+ } catch {
4585
+ liveExists = false;
4217
4586
  }
4218
- if (body.folder !== void 0 && typeof body.folder !== "string") {
4219
- throw new ValidationError('folder must be a string path like "gtm/deals"');
4587
+ if (target != null) {
4588
+ try {
4589
+ const raw = await versionManager.reconstructVersion(nodeId, target);
4590
+ return { title, html: bodyOnly(nodeId, raw), version: target, type };
4591
+ } catch (err) {
4592
+ console.warn(
4593
+ `[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
4594
+ err
4595
+ );
4596
+ }
4220
4597
  }
4221
- const nestId = c.req.param("nestId");
4222
- const authorEmail = await getUserEmail(c);
4223
- const { node } = await createNode(
4224
- nestId,
4225
- {
4226
- title: body.title,
4227
- content: body.content,
4228
- type: body.type,
4229
- tags: body.tags,
4230
- scope: body.scope,
4231
- status: body.status,
4232
- schedule: body.schedule,
4233
- folder: body.folder,
4234
- prime: body.prime
4235
- },
4236
- authorEmail
4237
- );
4238
- const resolved = await resolveStewardsForNode(nestId, node.id);
4239
- return c.json({
4240
- node: toNodeResponse(node),
4241
- stewards: resolved.length > 0 ? resolved.map((r) => ({
4242
- email: r.steward.userEmail,
4243
- role: r.steward.role,
4244
- source: r.source
4245
- })) : void 0
4246
- }, 201);
4247
- });
4248
- nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4249
- const nestId = c.req.param("nestId");
4250
- const nodeId = c.req.param("nodeId");
4251
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-JAFSU5GN.js");
4252
- const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
4253
- nestId,
4254
- nodeId
4255
- );
4256
- return c.json({
4257
- nodeId,
4258
- stewards: stewards.map((r) => ({
4259
- email: r.steward.userEmail,
4260
- role: r.steward.role,
4261
- scope: r.steward.scope,
4262
- source: r.source,
4263
- priority: r.priority
4264
- })),
4265
- fallbackToOwner,
4266
- ownerEmail
4267
- });
4268
- });
4269
- nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
4270
- const nestId = c.req.param("nestId");
4271
- const nodeId = c.req.param("nodeId");
4272
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-LK4M2C43.js");
4273
- const history = await getReviewHistory2(nestId, nodeId);
4274
- return c.json({ reviews: history });
4275
- });
4276
- nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
4277
- const nestId = c.req.param("nestId");
4278
- const nodeId = c.req.param("nodeId");
4279
- const { versions: versionManager } = await engineCache.get(nestId);
4280
- const userId = c.get("userId");
4281
- const userEmail = await resolveCallerEmail(userId);
4282
- if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
4283
- return c.json(
4284
- { error: "Access denied \u2014 no steward assignment for this node" },
4285
- 403
4598
+ if (!liveExists) {
4599
+ throw new NotFoundError(`Artifact not found: ${nodeId}`);
4600
+ }
4601
+ return { title, html: liveHtml, version: target, type };
4602
+ }
4603
+ function derivedAnnotationsId(sourceNodeId) {
4604
+ const base = sourceNodeId.replace(/^nodes\//, "");
4605
+ return `nodes/${base}--annotations`;
4606
+ }
4607
+ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
4608
+ try {
4609
+ let title = nodeId;
4610
+ let type = null;
4611
+ try {
4612
+ const { storage } = await engineCache.get(nestId);
4613
+ const node = await storage.readDocument(nodeId);
4614
+ title = node.frontmatter?.title || nodeId;
4615
+ type = node.frontmatter?.type || "document";
4616
+ } catch {
4617
+ }
4618
+ if (type !== null && type !== "artifact") return;
4619
+ const threads = await listThreads(nestId, nodeId);
4620
+ const derivedTitle = `${title.slice(0, 100)} \u2014 Annotations`;
4621
+ const markdown2 = projectThreadsToMarkdown(title, threads);
4622
+ const derivedId = derivedAnnotationsId(nodeId);
4623
+ try {
4624
+ await updateNode(nestId, derivedId, { content: markdown2 }, userEmail);
4625
+ } catch (err) {
4626
+ if (err instanceof NotFoundError) {
4627
+ await createNode(
4628
+ nestId,
4629
+ {
4630
+ id: derivedId,
4631
+ title: derivedTitle,
4632
+ content: markdown2,
4633
+ type: "document",
4634
+ tags: ["annotations"]
4635
+ },
4636
+ userEmail
4637
+ );
4638
+ } else {
4639
+ throw err;
4640
+ }
4641
+ }
4642
+ } catch (err) {
4643
+ console.warn(
4644
+ `[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
4645
+ err
4286
4646
  );
4287
4647
  }
4288
- const body = await c.req.json().catch(() => ({}));
4289
- const targetVersion = Number(body.targetVersion);
4290
- if (!Number.isInteger(targetVersion) || targetVersion < 1) {
4291
- throw new ValidationError("targetVersion (a positive integer) is required");
4648
+ }
4649
+
4650
+ // src/nests/publish-render-routes.ts
4651
+ var publishRenderRoutes = new Hono7();
4652
+ var SESSION_TTL_SECONDS2 = 24 * 60 * 60;
4653
+ var GATE_LIMIT = { max: 10, windowMs: 10 * 6e4 };
4654
+ var COMMENT_LIMIT = { max: 5, windowMs: 10 * 6e4 };
4655
+ var COMMENT_MAX_CHARS = 4e3;
4656
+ var markdown = new MarkdownIt({ html: false, linkify: true, breaks: false });
4657
+ var PROSE_CSS = `
4658
+ body{margin:0;background:#fff;color:#1c1917;font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
4659
+ main{max-width:820px;margin:0 auto;padding:48px 24px}
4660
+ h1,h2,h3,h4{color:#142a80;line-height:1.25;margin:1.8em 0 .6em}
4661
+ h1{font-size:2em;margin-top:0}h2{font-size:1.5em}h3{font-size:1.2em}h4{font-size:1.05em}
4662
+ p,ul,ol,blockquote,table{margin:0 0 1.1em}
4663
+ ul,ol{padding-left:1.5em}li{margin:.3em 0}
4664
+ a{color:#142a80}
4665
+ hr{border:0;border-top:1px solid #e7e5e4;margin:2em 0}
4666
+ blockquote{border-left:3px solid #d6d3d1;margin-left:0;padding:.2em 0 .2em 1em;color:#57534e}
4667
+ code{background:#f5f5f4;border-radius:4px;padding:.15em .35em;font:.9em/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
4668
+ pre{background:#f5f5f4;border-radius:9px;padding:14px 16px;overflow-x:auto}
4669
+ pre code{background:none;padding:0}
4670
+ table{border-collapse:collapse;width:100%;display:block;overflow-x:auto}
4671
+ th,td{border:1px solid #e7e5e4;padding:8px 10px;text-align:left}
4672
+ th{background:#fafaf9}
4673
+ img{max-width:100%}
4674
+ `;
4675
+ function cookieName(slug) {
4676
+ return `cnp_${slug}`;
4677
+ }
4678
+ function readCookie2(c, name) {
4679
+ const raw = c.req.header("cookie");
4680
+ if (!raw) return null;
4681
+ for (const part of raw.split(";")) {
4682
+ const eq = part.indexOf("=");
4683
+ if (eq === -1) continue;
4684
+ if (part.slice(0, eq).trim() === name) {
4685
+ try {
4686
+ return decodeURIComponent(part.slice(eq + 1).trim());
4687
+ } catch {
4688
+ return part.slice(eq + 1).trim();
4689
+ }
4690
+ }
4292
4691
  }
4293
- let raw;
4692
+ return null;
4693
+ }
4694
+ function setSessionCookie2(c, slug, token) {
4695
+ const parts = [
4696
+ `${cookieName(slug)}=${encodeURIComponent(token)}`,
4697
+ "HttpOnly",
4698
+ `Path=/p/${slug}`,
4699
+ "SameSite=Lax",
4700
+ `Max-Age=${SESSION_TTL_SECONDS2}`
4701
+ ];
4702
+ if (isSecureRequest(c)) parts.push("Secure");
4703
+ c.header("Set-Cookie", parts.join("; "));
4704
+ }
4705
+ function esc(s) {
4706
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4707
+ }
4708
+ var NO_DOWNLOAD_CSS = `<style>
4709
+ @media print{html,body{display:none !important}}
4710
+ html,body{-webkit-user-select:none;-ms-user-select:none;user-select:none}
4711
+ img,a,svg,canvas{-webkit-user-drag:none;user-drag:none}
4712
+ </style>`;
4713
+ var SHELL_PRINT_BLANK = `<style>@media print{html,body{display:none !important}}</style>`;
4714
+ var NO_DOWNLOAD_SCRIPT = `<script>
4715
+ (function () {
4716
+ function stop(e) { e.preventDefault(); return false; }
4717
+ ["contextmenu", "copy", "cut", "dragstart", "selectstart"].forEach(function (evt) {
4718
+ document.addEventListener(evt, stop, { capture: true });
4719
+ });
4720
+ document.addEventListener("keydown", function (e) {
4721
+ var k = (e.key || "").toLowerCase();
4722
+ // Save, print, copy, cut, select-all, view-source.
4723
+ if ((e.ctrlKey || e.metaKey) && ["s", "p", "c", "x", "a", "u"].indexOf(k) !== -1) return stop(e);
4724
+ // DevTools. Blocking these is courtesy at best \u2014 the browser menu still
4725
+ // opens them \u2014 but it stops the reflex.
4726
+ if (k === "f12") return stop(e);
4727
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && ["i", "j", "c"].indexOf(k) !== -1) return stop(e);
4728
+ }, { capture: true });
4729
+ // Belt and braces with the print stylesheet, for engines that ignore it.
4730
+ window.addEventListener("beforeprint", function () { document.body.style.display = "none"; });
4731
+ window.addEventListener("afterprint", function () { document.body.style.display = ""; });
4732
+ })();
4733
+ </script>`;
4734
+ function overlayMarkup(row, viewerEmail) {
4735
+ const parts = [];
4736
+ if (row.no_download === 1) {
4737
+ parts.push(NO_DOWNLOAD_CSS, NO_DOWNLOAD_SCRIPT);
4738
+ }
4739
+ if (row.watermark === 1 && viewerEmail) {
4740
+ parts.push(
4741
+ `<style>#cn-watermark{position:fixed;inset:0;z-index:2147483647;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center}#cn-watermark span{font:700 42px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:rgba(20,42,128,.10);transform:rotate(-24deg);white-space:nowrap}</style><div id="cn-watermark" aria-hidden="true"><span>${esc(viewerEmail)}</span></div>`
4742
+ );
4743
+ }
4744
+ return parts.join("");
4745
+ }
4746
+ async function publishedTitle(row) {
4294
4747
  try {
4295
- raw = await versionManager.reconstructVersion(nodeId, targetVersion);
4748
+ return (await readArtifact(row.nest_id, row.node_id)).title || row.node_id;
4296
4749
  } catch {
4297
- throw new NotFoundError(
4298
- `Version ${targetVersion} not found for ${nodeId}`
4299
- );
4750
+ return row.node_id;
4300
4751
  }
4301
- const content = bodyOnly(nodeId, raw);
4302
- const { node, version } = await updateNode(
4303
- nestId,
4304
- nodeId,
4305
- { content, changeNote: `Restored from version ${targetVersion}` },
4306
- userEmail
4307
- );
4308
- await trackEvent("node.revert", { nestId, nodeId, targetVersion });
4309
- return c.json({ ok: true, version, node: toNodeResponse(node) });
4310
- });
4311
- nodeRoutes.post("/:nodeId{.+}/move", async (c) => {
4312
- const nestId = c.req.param("nestId");
4313
- const nodeId = c.req.param("nodeId");
4314
- const userId = c.get("userId");
4315
- const userEmail = await resolveCallerEmail(userId);
4316
- if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
4317
- return c.json(
4318
- { error: "Access denied \u2014 no steward assignment for this node" },
4319
- 403
4320
- );
4752
+ }
4753
+ function withOverlays(html, extra) {
4754
+ if (!extra) return html;
4755
+ const at = html.toLowerCase().lastIndexOf("</body>");
4756
+ return at === -1 ? html + extra : html.slice(0, at) + extra + html.slice(at);
4757
+ }
4758
+ async function renderPublished(c, row, viewerEmail) {
4759
+ let artifact;
4760
+ try {
4761
+ artifact = await readArtifact(row.nest_id, row.node_id);
4762
+ } catch {
4763
+ return c.html(unavailablePage(), 404);
4321
4764
  }
4322
- const body = await c.req.json().catch(() => ({}));
4323
- if (typeof body.folder !== "string") {
4324
- throw new ValidationError('folder (a string; "" for the nest root) is required');
4765
+ if (artifact.version == null) return c.html(unavailablePage(), 404);
4766
+ const type = String(artifact.type ?? "document");
4767
+ const body = artifact.html ?? "";
4768
+ const isHtml = type === "artifact" || /^\s*<(?:!doctype|html)\b/i.test(body);
4769
+ await logView(row.id, viewerEmail);
4770
+ applyArtifactSandboxHeaders(c);
4771
+ const overlays = overlayMarkup(row, viewerEmail);
4772
+ if (isHtml) {
4773
+ return c.body(withOverlays(body, overlays), 200, {
4774
+ "Content-Type": "text/html; charset=utf-8"
4775
+ });
4325
4776
  }
4326
- const { node, previousId } = await moveNode(nestId, nodeId, body.folder, userEmail);
4327
- return c.json({ ok: true, node: toNodeResponse(node), previousId });
4777
+ const title = esc(artifact.title || row.node_id);
4778
+ const heading = /^\s*#\s/.test(body) ? "" : `<h1>${title}</h1>`;
4779
+ const shell = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title><style>${PROSE_CSS}</style></head><body><main>${heading}${markdown.render(body)}</main>${overlays}</body></html>`;
4780
+ return c.body(shell, 200, { "Content-Type": "text/html; charset=utf-8" });
4781
+ }
4782
+ var EYE_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>`;
4783
+ var EYE_OFF_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9.9 4.24A9.1 9.1 0 0 1 12 4c6.4 0 10 7 10 7a17.6 17.6 0 0 1-2.2 3.14M6.6 6.6A17.7 17.7 0 0 0 2 11s3.6 7 10 7a9 9 0 0 0 5.4-1.6"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/><path d="m2 2 20 20"/></svg>`;
4784
+ var GATE_EYE_SCRIPT = `<script>
4785
+ (function () {
4786
+ var input = document.getElementById("cn-code");
4787
+ var btn = document.getElementById("cn-eye");
4788
+ btn.addEventListener("click", function () {
4789
+ var show = input.type === "password";
4790
+ var at = input.selectionStart;
4791
+ input.type = show ? "text" : "password";
4792
+ btn.innerHTML = show ? ${JSON.stringify(EYE_OFF_SVG)} : ${JSON.stringify(EYE_SVG)};
4793
+ btn.setAttribute("aria-label", show ? "Hide code" : "Show code");
4794
+ btn.setAttribute("aria-pressed", show ? "true" : "false");
4795
+ input.focus();
4796
+ try { input.setSelectionRange(at, at); } catch (e) {}
4797
+ });
4798
+ })();
4799
+ </script>`;
4800
+ function gatePage(row, message) {
4801
+ const wantsCode = row.access_mode === "code";
4802
+ const field = wantsCode ? `<label>Access code<span class="wrap">
4803
+ <input id="cn-code" name="code" type="password" autocomplete="off" required autofocus>
4804
+ <button type="button" id="cn-eye" class="eye" aria-label="Show code" aria-pressed="false">${EYE_SVG}</button>
4805
+ </span></label>` : `<label>Your email<input name="email" type="email" autocomplete="email" required autofocus></label>`;
4806
+ const cta = wantsCode ? "Unlock" : "Send me a link";
4807
+ const note = wantsCode ? "This document is protected by a passcode." : "Enter your email and we'll send you a one-time link to open it.";
4808
+ const err = message ? `<p class="err">${esc(message)}</p>` : "";
4809
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Protected document</title><style>
4810
+ :root{--indigo:#142a80;--owl:#FF6500}
4811
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f5;color:#1c1917;font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
4812
+ .card{background:#fff;border:1px solid #e7e5e4;border-radius:14px;box-shadow:0 4px 24px rgba(20,42,128,.08);padding:32px 34px;max-width:380px;width:calc(100% - 32px)}
4813
+ h1{font-size:20px;color:var(--indigo);margin:0 0 6px}
4814
+ p{color:#78716c;margin:0 0 18px;font-size:14px}
4815
+ label{display:block;font-size:13px;font-weight:600;color:#44403c;margin-bottom:16px}
4816
+ input{display:block;width:100%;margin-top:6px;padding:10px 12px;border:1px solid #d6d3d1;border-radius:9px;font:inherit;box-sizing:border-box}
4817
+ .wrap{display:block;position:relative}
4818
+ .wrap input{padding-right:44px}
4819
+ button{width:100%;background:var(--indigo);color:#fff;font-weight:700;border:0;border-radius:9px;padding:11px;font-size:15px;cursor:pointer}
4820
+ button.eye{position:absolute;top:6px;right:0;width:40px;height:calc(100% - 6px);background:none;border:0;padding:0;color:#78716c;display:grid;place-items:center;cursor:pointer}
4821
+ button.eye:hover{color:#44403c}
4822
+ button.eye svg{width:18px;height:18px;display:block}
4823
+ .err{color:#be123c;font-weight:600}
4824
+ .brand{font-size:12px;color:#a8a29e;margin:18px 0 0;text-align:center}
4825
+ </style></head><body><form class="card" method="POST" action="/p/${esc(row.slug)}/gate">
4826
+ <h1>Protected document</h1><p>${esc(note)}</p>${err}${field}
4827
+ <button type="submit">${cta}</button>
4828
+ <p class="brand">Secured by PromptOwl \xB7 ContextNest</p>
4829
+ </form>${wantsCode ? GATE_EYE_SCRIPT : ""}</body></html>`;
4830
+ }
4831
+ function unavailablePage() {
4832
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Link unavailable</title><style>
4833
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f5;color:#1c1917;font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
4834
+ .card{background:#fff;border:1px solid #e7e5e4;border-radius:14px;box-shadow:0 4px 24px rgba(20,42,128,.08);padding:32px 34px;max-width:420px;width:calc(100% - 32px);text-align:center}
4835
+ h1{font-size:20px;color:#142a80;margin:0 0 8px}
4836
+ p{color:#78716c;margin:0;font-size:14px}
4837
+ .brand{font-size:12px;color:#a8a29e;margin:18px 0 0}
4838
+ </style></head><body><div class="card">
4839
+ <h1>This link is no longer available</h1>
4840
+ <p>It may have been revoked by its owner, or it may have expired. Ask whoever shared it with you for a new link.</p>
4841
+ <p class="brand">Secured by PromptOwl \xB7 ContextNest</p>
4842
+ </div></body></html>`;
4843
+ }
4844
+ function sentPage() {
4845
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Check your email</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f4f5;color:#1c1917;font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}.card{background:#fff;border:1px solid #e7e5e4;border-radius:14px;box-shadow:0 4px 24px rgba(20,42,128,.08);padding:32px 34px;max-width:380px;text-align:center}h1{font-size:20px;color:#142a80;margin:0 0 6px}p{color:#78716c;margin:0;font-size:14px}</style></head><body><div class="card"><h1>Check your email</h1><p>If your address has access, a one-time link to open this document is on its way.</p></div></body></html>`;
4846
+ }
4847
+ function readerShell(row, title, viewerEmail) {
4848
+ const emailField = viewerEmail ? `<p class="who">Commenting as <strong>${esc(viewerEmail)}</strong></p>` : `<label>Your email<input id="cn-email" type="email" autocomplete="email" required></label>`;
4849
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(title)}</title><style>
4850
+ html,body{margin:0;height:100%}
4851
+ #cn-frame{border:0;width:100%;height:100%;display:block}
4852
+ #cn-open{position:fixed;right:18px;bottom:18px;z-index:20;background:#142a80;color:#fff;font:600 14px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;padding:11px 16px;border:0;border-radius:9999px;cursor:pointer;box-shadow:0 4px 14px rgba(20,42,128,.32)}
4853
+ #cn-back{position:fixed;inset:0;z-index:30;background:rgba(28,25,23,.45);display:none;place-items:center}
4854
+ #cn-back[data-open]{display:grid}
4855
+ .card{background:#fff;border-radius:14px;box-shadow:0 10px 40px rgba(20,42,128,.22);padding:28px 30px;width:min(460px,calc(100% - 32px));font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;color:#1c1917;box-sizing:border-box}
4856
+ .card h2{font-size:20px;color:#142a80;margin:0 0 6px}
4857
+ .card p{color:#78716c;margin:0 0 18px;font-size:14px}
4858
+ .who strong{color:#44403c}
4859
+ label{display:block;font-size:13px;font-weight:600;color:#44403c;margin-bottom:16px}
4860
+ input,textarea{display:block;width:100%;margin-top:6px;padding:10px 12px;border:1px solid #d6d3d1;border-radius:9px;font:inherit;box-sizing:border-box}
4861
+ textarea{min-height:130px;resize:vertical}
4862
+ .row{display:flex;gap:10px}
4863
+ button{flex:1;background:#142a80;color:#fff;font-weight:700;border:0;border-radius:9px;padding:11px;font-size:15px;cursor:pointer}
4864
+ button.ghost{background:#f5f5f4;color:#44403c}
4865
+ button[disabled]{opacity:.6;cursor:default}
4866
+ .msg{font-weight:600;margin:0 0 14px;font-size:14px}
4867
+ .msg.err{color:#be123c}.msg.ok{color:#15803d}
4868
+ </style></head><body>
4869
+ <iframe id="cn-frame" src="/p/${esc(row.slug)}/view" title="${esc(title)}"></iframe>
4870
+ <button id="cn-open" type="button">Leave a comment</button>
4871
+ <div id="cn-back"><div class="card" role="dialog" aria-modal="true" aria-label="Leave a comment">
4872
+ <h2>Leave a comment</h2>
4873
+ <p>Your comment goes to this document's stewards for review \u2014 it is not posted publicly.</p>
4874
+ <p class="msg" id="cn-msg" hidden></p>
4875
+ ${emailField}
4876
+ <label>Comment<textarea id="cn-body" maxlength="${COMMENT_MAX_CHARS}" required></textarea></label>
4877
+ <div class="row">
4878
+ <button type="button" class="ghost" id="cn-cancel">Cancel</button>
4879
+ <button type="button" id="cn-send">Send comment</button>
4880
+ </div>
4881
+ </div></div>
4882
+ <script>
4883
+ (function () {
4884
+ var back = document.getElementById("cn-back");
4885
+ var msg = document.getElementById("cn-msg");
4886
+ var send = document.getElementById("cn-send");
4887
+ var bodyEl = document.getElementById("cn-body");
4888
+ var emailEl = document.getElementById("cn-email");
4889
+ function show(text, ok) {
4890
+ msg.textContent = text;
4891
+ msg.className = "msg " + (ok ? "ok" : "err");
4892
+ msg.hidden = false;
4893
+ }
4894
+ function open() { msg.hidden = true; back.setAttribute("data-open", ""); (emailEl || bodyEl).focus(); }
4895
+ function close() { back.removeAttribute("data-open"); }
4896
+ document.getElementById("cn-open").addEventListener("click", open);
4897
+ document.getElementById("cn-cancel").addEventListener("click", close);
4898
+ back.addEventListener("click", function (e) { if (e.target === back) close(); });
4899
+ document.addEventListener("keydown", function (e) { if (e.key === "Escape") close(); });
4900
+ send.addEventListener("click", function () {
4901
+ var payload = { body: bodyEl.value };
4902
+ if (emailEl) payload.email = emailEl.value;
4903
+ send.disabled = true;
4904
+ fetch("/p/${esc(row.slug)}/comments", {
4905
+ method: "POST",
4906
+ headers: { "Content-Type": "application/json" },
4907
+ body: JSON.stringify(payload),
4908
+ })
4909
+ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
4910
+ .then(function (res) {
4911
+ if (!res.ok) { show(res.d.error || "Could not send that comment.", false); return; }
4912
+ bodyEl.value = "";
4913
+ show("Thanks \u2014 your comment is with the stewards.", true);
4914
+ setTimeout(close, 1600);
4915
+ })
4916
+ .catch(function () { show("Could not reach the server.", false); })
4917
+ .then(function () { send.disabled = false; });
4918
+ });
4919
+ })();
4920
+ </script>${row.no_download === 1 ? SHELL_PRINT_BLANK : ""}</body></html>`;
4921
+ }
4922
+ var SHELL_CSP = [
4923
+ "default-src 'none'",
4924
+ "script-src 'unsafe-inline'",
4925
+ "style-src 'unsafe-inline'",
4926
+ "frame-src 'self'",
4927
+ "connect-src 'self'"
4928
+ ].join("; ");
4929
+ async function resolveViewer(c, row) {
4930
+ if (row.access_mode === "public") return { email: null };
4931
+ const token = readCookie2(c, cookieName(row.slug));
4932
+ return token ? await verifySession(row.id, token) : null;
4933
+ }
4934
+ publishRenderRoutes.get("/:slug", async (c) => {
4935
+ const slug = c.req.param("slug");
4936
+ const row = await getPublishBySlug(slug);
4937
+ if (!isLivePublish(row)) return c.html(unavailablePage(), 404);
4938
+ const viewer = await resolveViewer(c, row);
4939
+ if (!viewer) return c.html(gatePage(row));
4940
+ if (row.comments_enabled !== 1) return renderPublished(c, row, viewer.email);
4941
+ const title = await publishedTitle(row);
4942
+ c.header("Content-Security-Policy", SHELL_CSP);
4943
+ c.header("Referrer-Policy", "no-referrer");
4944
+ return c.html(readerShell(row, title, viewer.email));
4945
+ });
4946
+ publishRenderRoutes.get("/:slug/view", async (c) => {
4947
+ const slug = c.req.param("slug");
4948
+ const row = await getPublishBySlug(slug);
4949
+ if (!isLivePublish(row)) return c.html(unavailablePage(), 404);
4950
+ const viewer = await resolveViewer(c, row);
4951
+ if (!viewer) return c.html(unavailablePage(), 404);
4952
+ return renderPublished(c, row, viewer.email);
4953
+ });
4954
+ publishRenderRoutes.post("/:slug/comments", async (c) => {
4955
+ const slug = c.req.param("slug");
4956
+ const row = await getPublishBySlug(slug);
4957
+ if (!isLivePublish(row) || row.comments_enabled !== 1) {
4958
+ return c.json({ error: "Not found" }, 404);
4959
+ }
4960
+ const viewer = await resolveViewer(c, row);
4961
+ if (!viewer) return c.json({ error: "Not found" }, 404);
4962
+ if (!tryConsume(`publish-comment:${slug}:${clientIp(c)}`, COMMENT_LIMIT)) {
4963
+ return c.json({ error: "Too many comments. Try again in a few minutes." }, 429);
4964
+ }
4965
+ const input = await c.req.json().catch(() => ({}));
4966
+ const email = viewer.email ?? normalizeEmail(String(input.email ?? ""));
4967
+ if (!isEmailish(email)) {
4968
+ return c.json({ error: "Enter a valid email address." }, 400);
4969
+ }
4970
+ const body = String(input.body ?? "").trim().slice(0, COMMENT_MAX_CHARS);
4971
+ if (!body) {
4972
+ return c.json({ error: "Write a comment first." }, 400);
4973
+ }
4974
+ await createThread(row.nest_id, row.node_id, { body, anchor: null }, email);
4975
+ return c.json({ ok: true });
4328
4976
  });
4329
- nodeRoutes.get("/:nodeId{.+}/watchers", async (c) => {
4330
- const nestId = c.req.param("nestId");
4331
- const nodeId = c.req.param("nodeId");
4332
- return c.json({ watchers: await listWatchers(nestId, nodeId) });
4977
+ publishRenderRoutes.post("/:slug/gate", async (c) => {
4978
+ const slug = c.req.param("slug");
4979
+ const row = await getPublishBySlug(slug);
4980
+ if (!isLivePublish(row)) return c.html(unavailablePage(), 404);
4981
+ if (!tryConsume(`publish-gate:${slug}:${clientIp(c)}`, GATE_LIMIT)) {
4982
+ return c.html(gatePage(row, "Too many attempts. Try again in a few minutes."), 429);
4983
+ }
4984
+ if (row.access_mode === "public") return c.redirect(`/p/${slug}`);
4985
+ const form = await c.req.parseBody();
4986
+ if (row.access_mode === "code") {
4987
+ if (!await verifyCode(row, String(form.code ?? ""))) {
4988
+ return c.html(gatePage(row, "That code is not correct."), 401);
4989
+ }
4990
+ setSessionCookie2(c, slug, await createSession2(row.id, null));
4991
+ return c.redirect(`/p/${slug}`);
4992
+ }
4993
+ const email = normalizeEmail(String(form.email ?? ""));
4994
+ if (!isEmailish(email)) {
4995
+ return c.html(gatePage(row, "Enter a valid email address."), 400);
4996
+ }
4997
+ const allowed = row.access_mode !== "invite" || await isAllowlisted(row.id, email);
4998
+ const linkToken = await mintMagicLink(row.id, email);
4999
+ if (!allowed) return c.html(sentPage());
5000
+ const link = `${requestBaseUrl(c.req.url)}/p/${slug}/verify?token=${linkToken}`;
5001
+ void sendEmailToRecipient(email, {
5002
+ status: "shared",
5003
+ docTitle: "A document has been shared with you",
5004
+ path: "PromptOwl \xB7 ContextNest",
5005
+ link
5006
+ });
5007
+ return c.html(sentPage());
4333
5008
  });
4334
- nodeRoutes.post("/:nodeId{.+}/watchers", async (c) => {
4335
- const nestId = c.req.param("nestId");
4336
- const nodeId = c.req.param("nodeId");
4337
- const body = await c.req.json().catch(() => ({}));
4338
- const email = typeof body.email === "string" ? body.email.trim() : "";
4339
- if (!email || !email.includes("@")) {
4340
- throw new ValidationError("email is required");
5009
+ publishRenderRoutes.get("/:slug/verify", async (c) => {
5010
+ const slug = c.req.param("slug");
5011
+ const row = await getPublishBySlug(slug);
5012
+ if (!isLivePublish(row)) return c.html(unavailablePage(), 404);
5013
+ const email = await consumeMagicLink(row.id, c.req.query("token") ?? "");
5014
+ if (!email) {
5015
+ return c.html(gatePage(row, "That link is invalid or has expired. Request a new one."), 401);
4341
5016
  }
4342
- await addWatcher(nestId, nodeId, email, await getUserEmail(c));
4343
- return c.json({ watchers: await listWatchers(nestId, nodeId) }, 201);
5017
+ setSessionCookie2(c, slug, await createSession2(row.id, email));
5018
+ return c.redirect(`/p/${slug}`);
4344
5019
  });
4345
- nodeRoutes.delete("/:nodeId{.+}/watchers", async (c) => {
5020
+
5021
+ // src/nests/nest-team-routes.ts
5022
+ import { Hono as Hono8 } from "hono";
5023
+ var nestTeamRoutes = new Hono8();
5024
+ nestTeamRoutes.get("/", async (c) => {
4346
5025
  const nestId = c.req.param("nestId");
4347
- const nodeId = c.req.param("nodeId");
4348
- const email = c.req.query("email")?.trim() ?? "";
4349
- if (!email) throw new ValidationError("email query param is required");
4350
- if (!await removeWatcher(nestId, nodeId, email)) {
4351
- throw new NotFoundError("Not watching");
5026
+ const refs = await listNestTeams(nestId);
5027
+ const teams = [];
5028
+ for (const ref of refs) {
5029
+ const team = await getTeam(ref.teamId).catch(() => null);
5030
+ teams.push({
5031
+ teamId: ref.teamId,
5032
+ teamName: team?.name ?? ref.teamName,
5033
+ members: team?.members ?? []
5034
+ });
4352
5035
  }
4353
- return c.json({ watchers: await listWatchers(nestId, nodeId) });
5036
+ return c.json({ teams });
4354
5037
  });
4355
- nodeRoutes.get("/:nodeId{.+}", async (c) => {
4356
- const nestId = c.req.param("nestId");
4357
- const nodeId = c.req.param("nodeId");
4358
- const { storage, versions: versionManager } = await engineCache.get(nestId);
5038
+ nestTeamRoutes.post("/", async (c) => {
5039
+ const body = await c.req.json();
5040
+ if (!body.team_id) {
5041
+ throw new ValidationError("team_id is required");
5042
+ }
4359
5043
  const userId = c.get("userId");
4360
- const userEmail = await resolveCallerEmail(userId);
4361
- let node = await getNodeForCaller(
4362
- nestId,
4363
- { id: nodeId },
4364
- userId,
4365
- userEmail,
4366
- { verifyChecksum: true }
5044
+ const teams = await shareTeamWithNest({
5045
+ nestId: c.req.param("nestId"),
5046
+ teamId: body.team_id,
5047
+ callerUserId: userId,
5048
+ allowAdmin: await isLicenseAdminUserId(userId),
5049
+ baseUrl: requestBaseUrl(c.req.url)
5050
+ });
5051
+ return c.json({ teams }, 201);
5052
+ });
5053
+ nestTeamRoutes.delete("/:teamId", async (c) => {
5054
+ const teams = await unshareTeamFromNest({
5055
+ nestId: c.req.param("nestId"),
5056
+ teamId: c.req.param("teamId")
5057
+ });
5058
+ return c.json({ teams });
5059
+ });
5060
+
5061
+ // src/governance/team-routes.ts
5062
+ import { Hono as Hono9 } from "hono";
5063
+
5064
+ // src/governance/promptowl-teams.ts
5065
+ var PROMPTOWL_SOURCE = "promptowl";
5066
+ var IMPORTABLE_PROMPTOWL_ROLES = ["owner", "editor"];
5067
+ function canImportPromptowlTeam(team) {
5068
+ return IMPORTABLE_PROMPTOWL_ROLES.includes(
5069
+ (team.your_role || "").toLowerCase()
4367
5070
  );
4368
- if (node.pendingChange) {
4369
- try {
4370
- await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
4371
- const refreshed = await getPendingChange(nestId, nodeId);
4372
- if (refreshed) node.pendingChange = refreshed;
4373
- } catch (err) {
4374
- console.error("[external-edit] stage-on-read failed:", err);
4375
- }
4376
- try {
4377
- const latest = await loadLatestApprovedNode(nestId, nodeId);
4378
- if (latest) {
4379
- node = { ...latest, pendingChange: node.pendingChange };
5071
+ }
5072
+ function mapPromptowlRole(role) {
5073
+ switch ((role || "").toLowerCase()) {
5074
+ case "owner":
5075
+ return "admin";
5076
+ case "editor":
5077
+ return "editor";
5078
+ default:
5079
+ return "viewer";
5080
+ }
5081
+ }
5082
+ async function fetchOwnedPromptowlTeams(token) {
5083
+ if (!token?.trim()) throw new ValidationError("token is required");
5084
+ const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
5085
+ let res;
5086
+ try {
5087
+ res = await fetch(`${promptowlUrl}/api/user/teams`, {
5088
+ headers: {
5089
+ ...config.PROMPTOWL_FETCH_HEADERS,
5090
+ Authorization: `Bearer ${token}`
4380
5091
  }
4381
- } catch (err) {
4382
- console.error("[external-edit] reconstruct-latest failed:", err);
4383
- }
5092
+ });
5093
+ } catch {
5094
+ throw new ValidationError("Could not reach PromptOwl. Check this server's network access.");
4384
5095
  }
4385
- const response = toNodeResponse(node);
4386
- const publicReader = await isPublicReader(nestId, userId);
4387
- if (!publicReader) {
4388
- const people = (await resolveAuthorship(nestId, [
4389
- { id: nodeId, frontmatterAuthor: node.frontmatter?.author ?? null }
4390
- ])).get(nodeId);
4391
- if (people) {
4392
- response.author = people.author;
4393
- response.collaborators = people.collaborators;
4394
- }
5096
+ if (res.status === 401) {
5097
+ throw new UnauthorizedError(
5098
+ "PromptOwl rejected this approval. Approve again and retry."
5099
+ );
4395
5100
  }
4396
- response.myGrant = await resolveNodeGrant(nestId, userId, nodeId);
4397
- if (publicReader) {
4398
- const approved = await getApprovedVersion(nestId, nodeId);
4399
- if (approved != null) {
4400
- let approvedRaw = "";
4401
- try {
4402
- approvedRaw = await versionManager.reconstructVersion(
4403
- nodeId,
4404
- approved
4405
- );
4406
- response.content = bodyOnly(nodeId, approvedRaw);
4407
- } catch (err) {
4408
- console.error(
4409
- "reconstructVersion failed (public single)",
4410
- nodeId,
4411
- approved,
4412
- err
4413
- );
4414
- response.content = "";
4415
- }
4416
- response.version = approved;
4417
- response.status = "published";
4418
- if (isRawFormat(c)) {
4419
- return rawNodeBody(c, response, approvedRaw);
4420
- }
4421
- if (isMarkdownFormat(c)) {
4422
- return c.body(nodeAsMarkdown(response, nodeId), 200, {
4423
- "Content-Type": "text/markdown; charset=utf-8"
4424
- });
4425
- }
4426
- return c.json({ node: response });
5101
+ if (!res.ok) {
5102
+ throw new ValidationError(
5103
+ `PromptOwl could not list your teams (HTTP ${res.status}).`
5104
+ );
5105
+ }
5106
+ const data = await res.json();
5107
+ return Array.isArray(data.teams) ? data.teams : [];
5108
+ }
5109
+ async function syncPromptowlTeams(params) {
5110
+ if (!params.teamIds?.length) {
5111
+ throw new ValidationError("team_ids is required");
5112
+ }
5113
+ const owned = await fetchOwnedPromptowlTeams(params.token);
5114
+ const byId = new Map(owned.map((t) => [t.id, t]));
5115
+ const selected = [];
5116
+ for (const id of params.teamIds) {
5117
+ const team = byId.get(id);
5118
+ if (!team) {
5119
+ throw new ValidationError(
5120
+ "One of the selected teams isn't available to import. Refresh the list and try again."
5121
+ );
5122
+ }
5123
+ if (!canImportPromptowlTeam(team)) {
5124
+ throw new ForbiddenError(
5125
+ `You're view-only on "${team.name}" in PromptOwl, so you can't import it. Ask an owner or editor of that team to import it.`
5126
+ );
4427
5127
  }
5128
+ selected.push(team);
4428
5129
  }
4429
- response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
4430
- if (response.status === "pending_review") {
4431
- const pending = await getPendingReview(nestId, nodeId);
4432
- response.pendingReviewBy = pending?.requestedBy ?? null;
5130
+ const db = getDb();
5131
+ const caller = await db.get("SELECT email FROM users WHERE id = ?", [
5132
+ params.callerUserId
5133
+ ]);
5134
+ if (!caller?.email) {
5135
+ throw new ValidationError("Could not resolve your account.");
4433
5136
  }
4434
- response.deletionRequest = await getLatestDeletionRequest(
4435
- nestId,
4436
- nodeId
5137
+ const results = [];
5138
+ for (const poTeam of selected) {
5139
+ results.push(await syncOne(poTeam, params.callerUserId, caller.email));
5140
+ }
5141
+ return results;
5142
+ }
5143
+ async function syncOne(poTeam, callerUserId, callerEmail) {
5144
+ const existing = await findTeamByExternalId({
5145
+ ownerId: callerUserId,
5146
+ source: PROMPTOWL_SOURCE,
5147
+ externalId: poTeam.id
5148
+ });
5149
+ const team = existing ?? await createTeam({
5150
+ name: poTeam.name,
5151
+ ownerId: callerUserId,
5152
+ source: PROMPTOWL_SOURCE,
5153
+ externalId: poTeam.id
5154
+ });
5155
+ if (existing && existing.name !== poTeam.name) {
5156
+ await renameTeam({
5157
+ teamId: team.id,
5158
+ name: poTeam.name,
5159
+ callerUserId,
5160
+ allowAdmin: false
5161
+ });
5162
+ }
5163
+ const desired = await desiredRoster(poTeam, callerEmail);
5164
+ const before = new Map(team.members.map((m) => [m.email, m.role]));
5165
+ const after = new Map(desired.map((m) => [m.email, m.role]));
5166
+ const added = [...after.keys()].filter((e) => !before.has(e));
5167
+ const removed = [...before.keys()].filter((e) => !after.has(e));
5168
+ const updated = [...after.keys()].filter(
5169
+ (e) => before.has(e) && before.get(e) !== after.get(e)
4437
5170
  );
4438
- if (isRawFormat(c)) {
4439
- return rawNodeBody(c, response, node.rawContent);
5171
+ if (added.length || removed.length || updated.length) {
5172
+ await replaceTeamRoster({
5173
+ teamId: team.id,
5174
+ members: desired,
5175
+ callerUserId,
5176
+ allowAdmin: false
5177
+ });
4440
5178
  }
4441
- if (isMarkdownFormat(c)) {
4442
- return c.body(nodeAsMarkdown(response, nodeId), 200, {
4443
- "Content-Type": "text/markdown; charset=utf-8"
5179
+ return {
5180
+ team_id: team.id,
5181
+ external_id: poTeam.id,
5182
+ name: poTeam.name,
5183
+ created: !existing,
5184
+ added,
5185
+ removed,
5186
+ updated
5187
+ };
5188
+ }
5189
+ async function desiredRoster(poTeam, callerEmail) {
5190
+ const caller = normalizeEmail(callerEmail);
5191
+ const roster = [];
5192
+ const seen = /* @__PURE__ */ new Set();
5193
+ for (const poMember of poTeam.members || []) {
5194
+ if (!poMember?.email) continue;
5195
+ const email = normalizeEmail(poMember.email);
5196
+ if (email === caller || seen.has(email)) continue;
5197
+ seen.add(email);
5198
+ const identity = await resolveMemberIdentity({ email });
5199
+ roster.push({
5200
+ userId: identity.userId,
5201
+ email: identity.email,
5202
+ role: mapPromptowlRole(poMember.role)
4444
5203
  });
4445
5204
  }
4446
- return c.json({ node: response });
4447
- });
4448
- nodeRoutes.patch("/:nodeId{.+}", async (c) => {
4449
- const nestId = c.req.param("nestId");
4450
- const nodeId = c.req.param("nodeId");
4451
- const body = await c.req.json();
4452
- const baseVersionHeader = c.req.header("X-Base-Version");
4453
- if (baseVersionHeader) {
4454
- const baseVersion = parseInt(baseVersionHeader, 10);
4455
- const conflict = await checkConflict(nestId, nodeId, baseVersion);
4456
- if (conflict.conflict) {
5205
+ return roster;
5206
+ }
5207
+
5208
+ // src/governance/team-routes.ts
5209
+ var teamRoutes = new Hono9();
5210
+ var PROMPTOWL_LIMIT = { max: 10, windowMs: 15 * 6e4 };
5211
+ var CONNECT_REQUIRED = "promptowl_connect_required";
5212
+ var CONNECT_REQUIRED_STATUS = 428;
5213
+ async function withPromptowlToken(c, run) {
5214
+ if (!config.PROMPTOWL_TEAMS_ENABLED) {
5215
+ return c.json({ error: "Not found" }, 404);
5216
+ }
5217
+ if (!tryConsume(`po-teams:ip:${clientIp(c)}`, PROMPTOWL_LIMIT)) {
5218
+ return c.json({ error: "Too many PromptOwl requests, try again later" }, 429);
5219
+ }
5220
+ const token = getPromptowlTokenFromRequest(c);
5221
+ if (!token) {
5222
+ return c.json(
5223
+ {
5224
+ error: "Sign in with PromptOwl to import teams.",
5225
+ code: CONNECT_REQUIRED
5226
+ },
5227
+ CONNECT_REQUIRED_STATUS
5228
+ );
5229
+ }
5230
+ try {
5231
+ return c.json(await run(token));
5232
+ } catch (err) {
5233
+ if (err instanceof UnauthorizedError) {
5234
+ c.header(
5235
+ "Set-Cookie",
5236
+ buildPromptowlTokenCookie("", { secure: isSecureRequest(c), clear: true }),
5237
+ { append: true }
5238
+ );
4457
5239
  return c.json(
4458
- {
4459
- error: "Version conflict",
4460
- your_version: baseVersion,
4461
- current_version: conflict.currentVersion,
4462
- updated_by: conflict.updatedBy,
4463
- updated_at: conflict.updatedAt,
4464
- rejected_content: body.content || body.append || null
4465
- },
4466
- 409
5240
+ { error: err.message, code: CONNECT_REQUIRED },
5241
+ CONNECT_REQUIRED_STATUS
4467
5242
  );
4468
5243
  }
5244
+ throw err;
4469
5245
  }
4470
- const authorEmail = await getUserEmail(c);
4471
- const { node, version: responseVersion } = await updateNode(
4472
- nestId,
4473
- nodeId,
4474
- {
4475
- content: body.content,
4476
- append: body.append,
4477
- tags: body.tags,
4478
- title: body.title,
4479
- status: body.status,
4480
- changeNote: body.changeNote,
4481
- schedule: body.schedule,
4482
- prime: body.prime
4483
- },
4484
- authorEmail
4485
- );
4486
- return c.json({ node: toNodeResponse(node), version: responseVersion });
5246
+ }
5247
+ teamRoutes.get(
5248
+ "/promptowl",
5249
+ async (c) => withPromptowlToken(c, async (token) => ({
5250
+ teams: await fetchOwnedPromptowlTeams(token)
5251
+ }))
5252
+ );
5253
+ teamRoutes.post("/promptowl/sync", async (c) => {
5254
+ const body = await c.req.json().catch(() => ({}));
5255
+ return withPromptowlToken(c, async (token) => ({
5256
+ results: await syncPromptowlTeams({
5257
+ callerUserId: c.get("userId"),
5258
+ token,
5259
+ teamIds: body.team_ids ?? []
5260
+ })
5261
+ }));
4487
5262
  });
4488
- nodeRoutes.delete("/:nodeId{.+}", async (c) => {
4489
- const nestId = c.req.param("nestId");
4490
- const nodeId = c.req.param("nodeId");
4491
- await deleteNode(nestId, nodeId, await getUserEmail(c));
4492
- return c.json({ deleted: true });
5263
+ teamRoutes.post("/", async (c) => {
5264
+ const body = await c.req.json();
5265
+ const team = await createTeam({
5266
+ name: body.name ?? "",
5267
+ ownerId: c.get("userId")
5268
+ });
5269
+ return c.json({ team }, 201);
4493
5270
  });
4494
- async function getUserEmail(c) {
5271
+ teamRoutes.get("/", async (c) => {
5272
+ const teams = await listTeamsForUser(c.get("userId"));
5273
+ return c.json({ teams });
5274
+ });
5275
+ teamRoutes.get("/:teamId", async (c) => {
4495
5276
  const userId = c.get("userId");
4496
- const db = getDb();
4497
- const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
4498
- return user?.email || "anonymous@localhost";
4499
- }
4500
-
4501
- // src/annotations/routes.ts
4502
- import { Hono as Hono9 } from "hono";
4503
-
4504
- // src/annotations/service.ts
4505
- import { v4 as uuid5 } from "uuid";
5277
+ const team = await getTeam(c.req.param("teamId"));
5278
+ const isMember = team.members.some((m) => m.userId === userId);
5279
+ if (team.owner_id !== userId && !isMember && !await isLicenseAdminUserId(userId)) {
5280
+ throw new ForbiddenError("You don't have access to this team.");
5281
+ }
5282
+ return c.json({ team });
5283
+ });
5284
+ teamRoutes.patch("/:teamId", async (c) => {
5285
+ const body = await c.req.json();
5286
+ const userId = c.get("userId");
5287
+ const team = await renameTeam({
5288
+ teamId: c.req.param("teamId"),
5289
+ name: body.name ?? "",
5290
+ callerUserId: userId,
5291
+ allowAdmin: await isLicenseAdminUserId(userId)
5292
+ });
5293
+ return c.json({ team });
5294
+ });
5295
+ teamRoutes.delete("/:teamId", async (c) => {
5296
+ const userId = c.get("userId");
5297
+ await deleteTeam({
5298
+ teamId: c.req.param("teamId"),
5299
+ callerUserId: userId,
5300
+ allowAdmin: await isLicenseAdminUserId(userId)
5301
+ });
5302
+ return c.json({ removed: true });
5303
+ });
5304
+ teamRoutes.post("/:teamId/members", async (c) => {
5305
+ const body = await c.req.json();
5306
+ const userId = c.get("userId");
5307
+ const team = await addMember({
5308
+ teamId: c.req.param("teamId"),
5309
+ email: body.email,
5310
+ userId: body.user_id,
5311
+ role: body.role ?? "",
5312
+ callerUserId: userId,
5313
+ allowAdmin: await isLicenseAdminUserId(userId)
5314
+ });
5315
+ return c.json({ team }, 201);
5316
+ });
5317
+ teamRoutes.patch("/:teamId/members/:userId", async (c) => {
5318
+ const body = await c.req.json();
5319
+ const userId = c.get("userId");
5320
+ const team = await updateMemberRole({
5321
+ teamId: c.req.param("teamId"),
5322
+ userId: c.req.param("userId"),
5323
+ role: body.role ?? "",
5324
+ callerUserId: userId,
5325
+ allowAdmin: await isLicenseAdminUserId(userId)
5326
+ });
5327
+ return c.json({ team });
5328
+ });
5329
+ teamRoutes.delete("/:teamId/members/:userId", async (c) => {
5330
+ const userId = c.get("userId");
5331
+ const team = await removeMember({
5332
+ teamId: c.req.param("teamId"),
5333
+ userId: c.req.param("userId"),
5334
+ callerUserId: userId,
5335
+ allowAdmin: await isLicenseAdminUserId(userId)
5336
+ });
5337
+ return c.json({ team });
5338
+ });
4506
5339
 
4507
- // src/annotations/projection.ts
4508
- var MAX_CONTEXT_CHARS = 250;
4509
- var MAX_QUOTE_CHARS = 1e3;
4510
- function clampAnchor(raw) {
4511
- if (!raw) return null;
4512
- const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
4513
- if (!quote) return null;
4514
- const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
4515
- const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
4516
- const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
4517
- return { quote, before, after, ...line !== void 0 ? { line } : {} };
4518
- }
4519
- function oneLine(s) {
4520
- return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
4521
- }
4522
- function renderAnchor(anchor) {
4523
- if (!anchor) {
4524
- return "<!-- anchor: whole-artifact -->";
4525
- }
4526
- const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
4527
- const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
4528
- anchor.after
4529
- )}\u2026`;
4530
- return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
5340
+ // src/nodes/search.ts
5341
+ async function discoverForSearch(storage) {
5342
+ return storage.discoverDocuments({ includeRetired: true });
4531
5343
  }
4532
- function statusHeading(thread) {
4533
- if (thread.status === "resolved") {
4534
- const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
4535
- const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
4536
- const meta = who || when ? ` (${who}${when})` : "";
4537
- return `\u2705 RESOLVED${meta}`;
5344
+ function titleScore(title, query) {
5345
+ const t = (title || "").toLowerCase();
5346
+ const q = query.trim().toLowerCase();
5347
+ if (!q) return 0;
5348
+ if (t === q) return 4;
5349
+ let midWord = false;
5350
+ for (let i = t.indexOf(q); i !== -1; i = t.indexOf(q, i + 1)) {
5351
+ if (i === 0) return 3;
5352
+ if (!/[a-z0-9]/.test(t[i - 1])) return 2;
5353
+ midWord = true;
4538
5354
  }
4539
- return "\u{1F7E0} OPEN";
4540
- }
4541
- function snapshotLabel(v) {
4542
- return v == null ? "Unpinned" : `Snapshot v${v}`;
4543
- }
4544
- function orderThreads(threads) {
4545
- return [...threads].sort((a, b) => {
4546
- if (a.status !== b.status) return a.status === "open" ? -1 : 1;
4547
- return a.createdAt.localeCompare(b.createdAt);
4548
- });
5355
+ if (midWord) return 1;
5356
+ const terms = q.split(/\s+/).filter(Boolean);
5357
+ if (terms.length > 1 && terms.every((term) => t.includes(term))) return 1;
5358
+ return 0;
4549
5359
  }
4550
- function projectThreadsToMarkdown(artifactTitle, threads) {
4551
- const lines = [];
4552
- lines.push(`# ${artifactTitle} \u2014 Annotations`);
4553
- lines.push("");
4554
- lines.push(
4555
- "> Auto-generated from review comments, grouped by the artifact snapshot each was anchored to. **Open** threads are unresolved feedback for the next iteration; **resolved** threads were already addressed (don't re-break them)."
5360
+ function matchDocuments(documents, query) {
5361
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
5362
+ if (terms.length === 0) return [];
5363
+ return documents.filter((node) => {
5364
+ const haystack = [
5365
+ node.frontmatter.title,
5366
+ node.body || "",
5367
+ node.frontmatter.type || "",
5368
+ ...node.frontmatter.tags || []
5369
+ ].join(" ").toLowerCase();
5370
+ return terms.every((term) => haystack.includes(term));
5371
+ }).sort(
5372
+ (a, b) => titleScore(b.frontmatter.title, query) - titleScore(a.frontmatter.title, query)
4556
5373
  );
4557
- lines.push("");
4558
- if (threads.length === 0) {
4559
- lines.push("_No annotations yet._");
4560
- lines.push("");
4561
- return lines.join("\n");
4562
- }
4563
- const buckets2 = /* @__PURE__ */ new Map();
4564
- for (const t of threads) {
4565
- const key = t.snapshotVersion ?? null;
4566
- (buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
4567
- }
4568
- const keys = [...buckets2.keys()].sort((a, b) => {
4569
- if (a == null) return 1;
4570
- if (b == null) return -1;
4571
- return b - a;
4572
- });
4573
- for (const key of keys) {
4574
- lines.push(`## ${snapshotLabel(key)}`);
4575
- lines.push("");
4576
- for (const thread of orderThreads(buckets2.get(key))) {
4577
- lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
4578
- lines.push(renderAnchor(thread.anchor));
4579
- for (const comment of thread.comments) {
4580
- lines.push(
4581
- `- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
4582
- );
4583
- }
4584
- lines.push("");
4585
- }
4586
- }
4587
- return lines.join("\n");
4588
5374
  }
4589
-
4590
- // src/annotations/service.ts
4591
- async function loadComments(threadId) {
4592
- const db = getDb();
4593
- const rows = await db.all(
4594
- "SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
4595
- [threadId]
4596
- );
4597
- return rows.map((r) => ({
4598
- id: r.id,
4599
- author: r.author,
4600
- body: r.body,
4601
- createdAt: r.created_at
4602
- }));
5375
+ function bodySnippet(body, query) {
5376
+ const flat = body.replace(/\s+/g, " ").trim();
5377
+ const terms = (query || "").toLowerCase().split(/\s+/).filter(Boolean);
5378
+ const at = terms.map((t) => flat.toLowerCase().indexOf(t)).filter((i) => i >= 0).sort((a, b) => a - b)[0];
5379
+ if (at === void 0 || at < 80) return flat.slice(0, 200);
5380
+ const start = Math.max(0, at - 60);
5381
+ return `\u2026${flat.slice(start, start + 200)}`;
4603
5382
  }
4604
- async function rowToThread(row) {
4605
- let anchor = null;
4606
- if (row.anchor_json) {
4607
- try {
4608
- anchor = JSON.parse(row.anchor_json);
4609
- } catch {
4610
- anchor = null;
4611
- }
4612
- }
5383
+ function toSearchHit(node, query) {
4613
5384
  return {
4614
- id: row.id,
4615
- nestId: row.nest_id,
4616
- nodeId: row.node_id,
4617
- snapshotVersion: row.snapshot_version,
4618
- anchor,
4619
- status: row.status,
4620
- createdBy: row.created_by,
4621
- createdAt: row.created_at,
4622
- resolvedBy: row.resolved_by,
4623
- resolvedAt: row.resolved_at,
4624
- comments: await loadComments(row.id)
5385
+ id: node.id,
5386
+ title: node.frontmatter.title,
5387
+ type: node.frontmatter.type || "document",
5388
+ tags: node.frontmatter.tags || [],
5389
+ snippet: bodySnippet(node.body || "", query)
4625
5390
  };
4626
5391
  }
4627
- async function getThreadRow(threadId) {
4628
- return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
5392
+
5393
+ // src/nodes/routes.ts
5394
+ import { Hono as Hono10 } from "hono";
5395
+
5396
+ // src/nodes/markdown-export.ts
5397
+ import { parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
5398
+ function nodeToMarkdown(node) {
5399
+ const tags = node.tags ?? [];
5400
+ return [
5401
+ "---",
5402
+ `title: ${JSON.stringify(node.title ?? "")}`,
5403
+ `tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
5404
+ `status: ${JSON.stringify(node.status ?? "")}`,
5405
+ `id: ${JSON.stringify(node.id)}`,
5406
+ "---",
5407
+ ""
5408
+ ].join("\n") + (node.body ?? "");
4629
5409
  }
4630
- async function countThreadsByNode(nestId) {
4631
- const rows = await getDb().all(
4632
- `SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
4633
- FROM annotation_threads
4634
- WHERE nest_id = ?
4635
- GROUP BY node_id`,
4636
- [nestId]
4637
- );
4638
- const out = {};
4639
- for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
4640
- return out;
5410
+ function nodesToMarkdown(nodes) {
5411
+ return nodes.map(nodeToMarkdown).join("\n\n---\n\n");
4641
5412
  }
4642
- async function listThreads(nestId, nodeId) {
4643
- const rows = await getDb().all(
4644
- "SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
4645
- [nestId, nodeId]
4646
- );
4647
- return Promise.all(rows.map(rowToThread));
5413
+ function isMarkdownFormat(c) {
5414
+ return c.req.query("format") === "markdown";
4648
5415
  }
4649
- async function createThread(nestId, nodeId, input, authorEmail) {
4650
- const body = (input.body ?? "").trim();
4651
- if (!body) {
4652
- throw new Error("comment body is required");
5416
+ function isRawFormat(c) {
5417
+ const format = c.req.query("format");
5418
+ if (format !== void 0) return format === "raw";
5419
+ const accept = c.req.header("Accept") ?? "";
5420
+ return accept.split(",").some((part) => part.trim().split(";")[0].trim().toLowerCase() === "text/markdown");
5421
+ }
5422
+ var RAW_CONTENT_TYPES = {
5423
+ artifact: "text/html; charset=utf-8",
5424
+ tool: "text/plain; charset=utf-8",
5425
+ table: "text/plain; charset=utf-8"
5426
+ };
5427
+ function rawContentType(nodeType) {
5428
+ return RAW_CONTENT_TYPES[nodeType || "document"] ?? "text/markdown; charset=utf-8";
5429
+ }
5430
+ function frontmatterBlockOf(raw) {
5431
+ if (!raw || !raw.startsWith("---")) return "";
5432
+ try {
5433
+ const body = parseDocument2("frontmatter-split.md", raw, "frontmatter-split").body ?? "";
5434
+ if (raw.endsWith(body)) return raw.slice(0, raw.length - body.length);
5435
+ } catch {
4653
5436
  }
4654
- const db = getDb();
4655
- const id = uuid5();
4656
- const anchor = clampAnchor(input.anchor);
4657
- const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
4658
- await db.transaction(async (tx) => {
4659
- await tx.run(
4660
- `INSERT INTO annotation_threads
4661
- (id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
4662
- VALUES (?, ?, ?, ?, ?, 'open', ?)`,
4663
- [
4664
- id,
4665
- nestId,
4666
- nodeId,
4667
- snapshot,
4668
- anchor ? JSON.stringify(anchor) : null,
4669
- authorEmail
4670
- ]
4671
- );
4672
- await tx.run(
4673
- "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4674
- [uuid5(), id, authorEmail, body]
4675
- );
5437
+ const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
5438
+ return m ? `---
5439
+ ${m[1]}
5440
+ ---
5441
+
5442
+ ` : "";
5443
+ }
5444
+
5445
+ // src/nodes/routes.ts
5446
+ var nodeRoutes = new Hono10();
5447
+ function nodeAsMarkdown(response, nodeId) {
5448
+ return nodeToMarkdown({
5449
+ id: nodeId,
5450
+ title: response.title,
5451
+ tags: response.tags,
5452
+ status: response.status,
5453
+ body: response.content
4676
5454
  });
4677
- return rowToThread(await getThreadRow(id));
4678
5455
  }
4679
- async function getScopedThreadRow(threadId, nestId, nodeId) {
4680
- const row = await getThreadRow(threadId);
4681
- if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
4682
- throw new NotFoundError(`Thread not found: ${threadId}`);
5456
+ function rawNodeBody(c, response, storedRaw) {
5457
+ const body = response.content ?? "";
5458
+ const payload = c.req.query("frontmatter") === "1" ? frontmatterBlockOf(storedRaw) + body : body;
5459
+ c.header("X-Content-Type-Options", "nosniff");
5460
+ if ((response.type || "document") === "artifact") {
5461
+ applyArtifactSandboxHeaders(c);
4683
5462
  }
4684
- return row;
5463
+ return c.body(payload, 200, {
5464
+ "Content-Type": rawContentType(response.type)
5465
+ });
4685
5466
  }
4686
- async function addComment(nestId, nodeId, threadId, authorEmail, body) {
4687
- const trimmed = (body ?? "").trim();
4688
- if (!trimmed) {
4689
- throw new Error("comment body is required");
5467
+ nodeRoutes.get("/", async (c) => {
5468
+ const nestId = c.req.param("nestId");
5469
+ const userId = c.get("userId");
5470
+ const q = c.req.query();
5471
+ const limit = q.limit ? parseInt(q.limit, 10) : void 0;
5472
+ const nodes = await listNodesForCaller(nestId, userId, {
5473
+ ...q.type ? { type: q.type } : {},
5474
+ ...q.tag ? { tag: q.tag } : {},
5475
+ ...q.status ? { status: q.status } : {},
5476
+ // Lets an owner preview what making the nest public would expose.
5477
+ ...q.approved_only === "1" || q.approved_only === "true" ? { approvedOnly: true } : {},
5478
+ // Ignore junk rather than 400 — a bad ?limit should not fail a listing.
5479
+ ...limit !== void 0 && Number.isFinite(limit) && limit > 0 ? { limit } : {},
5480
+ // Present (even as "") = one folder level only, for the lazily-expanded
5481
+ // document tree. Absent = the whole nest, as every existing caller expects.
5482
+ ...q.folder !== void 0 ? { folder: q.folder } : {},
5483
+ // Bodies are off by default — the list is served from the DB index, which
5484
+ // holds metadata only. Opt back in and the listing crawls the vault for
5485
+ // them; kept for any client that was reading `content` off this route.
5486
+ ...q.content === "1" || q.content === "true" ? { withContent: true } : {}
5487
+ });
5488
+ return c.json({ count: nodes.length, nodes });
5489
+ });
5490
+ nodeRoutes.post("/", async (c) => {
5491
+ const body = await c.req.json();
5492
+ if (!body.title || !body.content) {
5493
+ throw new ValidationError("title and content are required");
4690
5494
  }
4691
- await getScopedThreadRow(threadId, nestId, nodeId);
4692
- await getDb().run(
4693
- "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4694
- [uuid5(), threadId, authorEmail, trimmed]
5495
+ if (body.folder !== void 0 && typeof body.folder !== "string") {
5496
+ throw new ValidationError('folder must be a string path like "gtm/deals"');
5497
+ }
5498
+ const nestId = c.req.param("nestId");
5499
+ const authorEmail = await getUserEmail(c);
5500
+ const { node } = await createNode(
5501
+ nestId,
5502
+ {
5503
+ title: body.title,
5504
+ content: body.content,
5505
+ type: body.type,
5506
+ tags: body.tags,
5507
+ scope: body.scope,
5508
+ status: body.status,
5509
+ schedule: body.schedule,
5510
+ folder: body.folder,
5511
+ prime: body.prime
5512
+ },
5513
+ authorEmail
4695
5514
  );
4696
- return rowToThread(await getThreadRow(threadId));
4697
- }
4698
- async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
4699
- await getScopedThreadRow(threadId, nestId, nodeId);
4700
- const db = getDb();
4701
- if (status === "resolved") {
4702
- await db.run(
4703
- `UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
4704
- [byEmail, threadId]
4705
- );
4706
- } else {
4707
- await db.run(
4708
- "UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
4709
- [threadId]
5515
+ const resolved = await resolveStewardsForNode(nestId, node.id);
5516
+ return c.json({
5517
+ node: toNodeResponse(node),
5518
+ stewards: resolved.length > 0 ? resolved.map((r) => ({
5519
+ email: r.steward.userEmail,
5520
+ role: r.steward.role,
5521
+ source: r.source
5522
+ })) : void 0
5523
+ }, 201);
5524
+ });
5525
+ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
5526
+ const nestId = c.req.param("nestId");
5527
+ const nodeId = c.req.param("nodeId");
5528
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-HRVD7SIS.js");
5529
+ const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
5530
+ nestId,
5531
+ nodeId
5532
+ );
5533
+ return c.json({
5534
+ nodeId,
5535
+ stewards: stewards.map((r) => ({
5536
+ email: r.steward.userEmail,
5537
+ role: r.steward.role,
5538
+ scope: r.steward.scope,
5539
+ source: r.source,
5540
+ priority: r.priority
5541
+ })),
5542
+ fallbackToOwner,
5543
+ ownerEmail
5544
+ });
5545
+ });
5546
+ nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
5547
+ const nestId = c.req.param("nestId");
5548
+ const nodeId = c.req.param("nodeId");
5549
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-TJRHRMMZ.js");
5550
+ const history = await getReviewHistory2(nestId, nodeId);
5551
+ return c.json({ reviews: history });
5552
+ });
5553
+ nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
5554
+ const nestId = c.req.param("nestId");
5555
+ const nodeId = c.req.param("nodeId");
5556
+ const { versions: versionManager } = await engineCache.get(nestId);
5557
+ const userId = c.get("userId");
5558
+ const userEmail = await resolveCallerEmail(userId);
5559
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
5560
+ return c.json(
5561
+ { error: "Access denied \u2014 no steward assignment for this node" },
5562
+ 403
4710
5563
  );
4711
5564
  }
4712
- return rowToThread(await getThreadRow(threadId));
4713
- }
4714
- async function readArtifact(nestId, nodeId, version) {
4715
- const { storage, versions: versionManager } = await engineCache.get(nestId);
4716
- const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
4717
- let title = nodeId;
4718
- let type = null;
4719
- let liveHtml = "";
4720
- let liveExists = true;
5565
+ const body = await c.req.json().catch(() => ({}));
5566
+ const targetVersion = Number(body.targetVersion);
5567
+ if (!Number.isInteger(targetVersion) || targetVersion < 1) {
5568
+ throw new ValidationError("targetVersion (a positive integer) is required");
5569
+ }
5570
+ let raw;
4721
5571
  try {
4722
- const node = await storage.readDocument(nodeId);
4723
- title = node.frontmatter?.title || nodeId;
4724
- type = node.frontmatter?.type || null;
4725
- liveHtml = node.body || "";
5572
+ raw = await versionManager.reconstructVersion(nodeId, targetVersion);
4726
5573
  } catch {
4727
- liveExists = false;
5574
+ throw new NotFoundError(
5575
+ `Version ${targetVersion} not found for ${nodeId}`
5576
+ );
4728
5577
  }
4729
- if (target != null) {
4730
- try {
4731
- const raw = await versionManager.reconstructVersion(nodeId, target);
4732
- return { title, html: bodyOnly(nodeId, raw), version: target, type };
4733
- } catch (err) {
4734
- console.warn(
4735
- `[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
4736
- err
4737
- );
4738
- }
5578
+ const content = bodyOnly(nodeId, raw);
5579
+ const { node, version } = await updateNode(
5580
+ nestId,
5581
+ nodeId,
5582
+ { content, changeNote: `Restored from version ${targetVersion}` },
5583
+ userEmail
5584
+ );
5585
+ await trackEvent("node.revert", { nestId, nodeId, targetVersion });
5586
+ return c.json({ ok: true, version, node: toNodeResponse(node) });
5587
+ });
5588
+ nodeRoutes.post("/:nodeId{.+}/move", async (c) => {
5589
+ const nestId = c.req.param("nestId");
5590
+ const nodeId = c.req.param("nodeId");
5591
+ const userId = c.get("userId");
5592
+ const userEmail = await resolveCallerEmail(userId);
5593
+ if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
5594
+ return c.json(
5595
+ { error: "Access denied \u2014 no steward assignment for this node" },
5596
+ 403
5597
+ );
4739
5598
  }
4740
- if (!liveExists) {
4741
- throw new NotFoundError(`Artifact not found: ${nodeId}`);
5599
+ const body = await c.req.json().catch(() => ({}));
5600
+ if (typeof body.folder !== "string") {
5601
+ throw new ValidationError('folder (a string; "" for the nest root) is required');
4742
5602
  }
4743
- return { title, html: liveHtml, version: target, type };
4744
- }
4745
- function derivedAnnotationsId(sourceNodeId) {
4746
- const base = sourceNodeId.replace(/^nodes\//, "");
4747
- return `nodes/${base}--annotations`;
4748
- }
4749
- async function syncAnnotationsNode(nestId, nodeId, userEmail) {
4750
- try {
4751
- let title = nodeId;
4752
- let type = null;
5603
+ const { node, previousId } = await moveNode(nestId, nodeId, body.folder, userEmail);
5604
+ return c.json({ ok: true, node: toNodeResponse(node), previousId });
5605
+ });
5606
+ nodeRoutes.get("/:nodeId{.+}/watchers", async (c) => {
5607
+ const nestId = c.req.param("nestId");
5608
+ const nodeId = c.req.param("nodeId");
5609
+ return c.json({ watchers: await listWatchers(nestId, nodeId) });
5610
+ });
5611
+ nodeRoutes.post("/:nodeId{.+}/watchers", async (c) => {
5612
+ const nestId = c.req.param("nestId");
5613
+ const nodeId = c.req.param("nodeId");
5614
+ const body = await c.req.json().catch(() => ({}));
5615
+ const email = typeof body.email === "string" ? body.email.trim() : "";
5616
+ if (!email || !email.includes("@")) {
5617
+ throw new ValidationError("email is required");
5618
+ }
5619
+ await addWatcher(nestId, nodeId, email, await getUserEmail(c));
5620
+ return c.json({ watchers: await listWatchers(nestId, nodeId) }, 201);
5621
+ });
5622
+ nodeRoutes.delete("/:nodeId{.+}/watchers", async (c) => {
5623
+ const nestId = c.req.param("nestId");
5624
+ const nodeId = c.req.param("nodeId");
5625
+ const email = c.req.query("email")?.trim() ?? "";
5626
+ if (!email) throw new ValidationError("email query param is required");
5627
+ if (!await removeWatcher(nestId, nodeId, email)) {
5628
+ throw new NotFoundError("Not watching");
5629
+ }
5630
+ return c.json({ watchers: await listWatchers(nestId, nodeId) });
5631
+ });
5632
+ nodeRoutes.get("/:nodeId{.+}", async (c) => {
5633
+ const nestId = c.req.param("nestId");
5634
+ const nodeId = c.req.param("nodeId");
5635
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
5636
+ const userId = c.get("userId");
5637
+ const userEmail = await resolveCallerEmail(userId);
5638
+ let node = await getNodeForCaller(
5639
+ nestId,
5640
+ { id: nodeId },
5641
+ userId,
5642
+ userEmail,
5643
+ { verifyChecksum: true }
5644
+ );
5645
+ if (node.pendingChange) {
4753
5646
  try {
4754
- const { storage } = await engineCache.get(nestId);
4755
- const node = await storage.readDocument(nodeId);
4756
- title = node.frontmatter?.title || nodeId;
4757
- type = node.frontmatter?.type || "document";
4758
- } catch {
5647
+ await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
5648
+ const refreshed = await getPendingChange(nestId, nodeId);
5649
+ if (refreshed) node.pendingChange = refreshed;
5650
+ } catch (err) {
5651
+ console.error("[external-edit] stage-on-read failed:", err);
4759
5652
  }
4760
- if (type !== null && type !== "artifact") return;
4761
- const threads = await listThreads(nestId, nodeId);
4762
- const derivedTitle = `${title.slice(0, 100)} \u2014 Annotations`;
4763
- const markdown = projectThreadsToMarkdown(title, threads);
4764
- const derivedId = derivedAnnotationsId(nodeId);
4765
5653
  try {
4766
- await updateNode(nestId, derivedId, { content: markdown }, userEmail);
5654
+ const latest = await loadLatestApprovedNode(nestId, nodeId);
5655
+ if (latest) {
5656
+ node = { ...latest, pendingChange: node.pendingChange };
5657
+ }
4767
5658
  } catch (err) {
4768
- if (err instanceof NotFoundError) {
4769
- await createNode(
4770
- nestId,
4771
- {
4772
- id: derivedId,
4773
- title: derivedTitle,
4774
- content: markdown,
4775
- type: "document",
4776
- tags: ["annotations"]
4777
- },
4778
- userEmail
5659
+ console.error("[external-edit] reconstruct-latest failed:", err);
5660
+ }
5661
+ }
5662
+ const response = toNodeResponse(node);
5663
+ const publicReader = await isPublicReader(nestId, userId);
5664
+ if (!publicReader) {
5665
+ const people = (await resolveAuthorship(nestId, [
5666
+ { id: nodeId, frontmatterAuthor: node.frontmatter?.author ?? null }
5667
+ ])).get(nodeId);
5668
+ if (people) {
5669
+ response.author = people.author;
5670
+ response.collaborators = people.collaborators;
5671
+ }
5672
+ }
5673
+ response.myGrant = await resolveNodeGrant(nestId, userId, nodeId);
5674
+ if (publicReader) {
5675
+ const approved = await getApprovedVersion(nestId, nodeId);
5676
+ if (approved != null) {
5677
+ let approvedRaw = "";
5678
+ try {
5679
+ approvedRaw = await versionManager.reconstructVersion(
5680
+ nodeId,
5681
+ approved
4779
5682
  );
4780
- } else {
4781
- throw err;
5683
+ response.content = bodyOnly(nodeId, approvedRaw);
5684
+ } catch (err) {
5685
+ console.error(
5686
+ "reconstructVersion failed (public single)",
5687
+ nodeId,
5688
+ approved,
5689
+ err
5690
+ );
5691
+ response.content = "";
5692
+ }
5693
+ response.version = approved;
5694
+ response.status = "published";
5695
+ if (isRawFormat(c)) {
5696
+ return rawNodeBody(c, response, approvedRaw);
5697
+ }
5698
+ if (isMarkdownFormat(c)) {
5699
+ return c.body(nodeAsMarkdown(response, nodeId), 200, {
5700
+ "Content-Type": "text/markdown; charset=utf-8"
5701
+ });
4782
5702
  }
5703
+ return c.json({ node: response });
5704
+ }
5705
+ }
5706
+ response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
5707
+ if (response.status === "pending_review") {
5708
+ const pending = await getPendingReview(nestId, nodeId);
5709
+ response.pendingReviewBy = pending?.requestedBy ?? null;
5710
+ }
5711
+ response.deletionRequest = await getLatestDeletionRequest(
5712
+ nestId,
5713
+ nodeId
5714
+ );
5715
+ if (isRawFormat(c)) {
5716
+ return rawNodeBody(c, response, node.rawContent);
5717
+ }
5718
+ if (isMarkdownFormat(c)) {
5719
+ return c.body(nodeAsMarkdown(response, nodeId), 200, {
5720
+ "Content-Type": "text/markdown; charset=utf-8"
5721
+ });
5722
+ }
5723
+ return c.json({ node: response });
5724
+ });
5725
+ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
5726
+ const nestId = c.req.param("nestId");
5727
+ const nodeId = c.req.param("nodeId");
5728
+ const body = await c.req.json();
5729
+ const baseVersionHeader = c.req.header("X-Base-Version");
5730
+ if (baseVersionHeader) {
5731
+ const baseVersion = parseInt(baseVersionHeader, 10);
5732
+ const conflict = await checkConflict(nestId, nodeId, baseVersion);
5733
+ if (conflict.conflict) {
5734
+ return c.json(
5735
+ {
5736
+ error: "Version conflict",
5737
+ your_version: baseVersion,
5738
+ current_version: conflict.currentVersion,
5739
+ updated_by: conflict.updatedBy,
5740
+ updated_at: conflict.updatedAt,
5741
+ rejected_content: body.content || body.append || null
5742
+ },
5743
+ 409
5744
+ );
4783
5745
  }
4784
- } catch (err) {
4785
- console.warn(
4786
- `[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
4787
- err
4788
- );
4789
5746
  }
5747
+ const authorEmail = await getUserEmail(c);
5748
+ const { node, version: responseVersion } = await updateNode(
5749
+ nestId,
5750
+ nodeId,
5751
+ {
5752
+ content: body.content,
5753
+ append: body.append,
5754
+ tags: body.tags,
5755
+ title: body.title,
5756
+ status: body.status,
5757
+ changeNote: body.changeNote,
5758
+ schedule: body.schedule,
5759
+ prime: body.prime
5760
+ },
5761
+ authorEmail
5762
+ );
5763
+ return c.json({ node: toNodeResponse(node), version: responseVersion });
5764
+ });
5765
+ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
5766
+ const nestId = c.req.param("nestId");
5767
+ const nodeId = c.req.param("nodeId");
5768
+ await deleteNode(nestId, nodeId, await getUserEmail(c));
5769
+ return c.json({ deleted: true });
5770
+ });
5771
+ async function getUserEmail(c) {
5772
+ const userId = c.get("userId");
5773
+ const db = getDb();
5774
+ const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
5775
+ return user?.email || "anonymous@localhost";
4790
5776
  }
4791
5777
 
5778
+ // src/annotations/routes.ts
5779
+ import { Hono as Hono11 } from "hono";
5780
+
4792
5781
  // src/annotations/types.ts
4793
5782
  var ARTIFACT_NODE_TYPE = "artifact";
4794
5783
 
4795
5784
  // src/annotations/routes.ts
4796
- var annotationRoutes = new Hono9();
5785
+ var annotationRoutes = new Hono11();
4797
5786
  function getNodeId(c) {
4798
5787
  const raw = c.req.param("nodeId");
4799
5788
  try {
@@ -4907,7 +5896,7 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
4907
5896
  });
4908
5897
 
4909
5898
  // src/nodes/query-routes.ts
4910
- import { Hono as Hono10 } from "hono";
5899
+ import { Hono as Hono12 } from "hono";
4911
5900
 
4912
5901
  // src/tables/service.ts
4913
5902
  function splitCsvLine(line) {
@@ -5408,7 +6397,9 @@ function extractWikiTargets(body) {
5408
6397
  }
5409
6398
  async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
5410
6399
  const { storage } = await engineCache.get(nestId);
5411
- const docs = await storage.discoverDocuments();
6400
+ const docs = (await storage.discoverDocuments()).filter(
6401
+ (d) => !isDerivedAnnotationsNode(d.id)
6402
+ );
5412
6403
  const accessible = await filterAccessible(nestId, userId, userEmail, docs);
5413
6404
  const idSet = new Set(accessible.map((d) => d.id));
5414
6405
  const titleToId = /* @__PURE__ */ new Map();
@@ -5539,7 +6530,7 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
5539
6530
  }
5540
6531
 
5541
6532
  // src/nodes/query-routes.ts
5542
- var queryRoutes = new Hono10();
6533
+ var queryRoutes = new Hono12();
5543
6534
  queryRoutes.get("/changes", async (c) => {
5544
6535
  const nestId = c.req.param("nestId");
5545
6536
  const userId = c.get("userId");
@@ -5892,7 +6883,9 @@ queryRoutes.get("/search", async (c) => {
5892
6883
  }
5893
6884
  const nestId = c.req.param("nestId");
5894
6885
  const { storage } = await engineCache.get(nestId);
5895
- const matches = matchDocuments(await discoverForSearch(storage), q);
6886
+ const matches = matchDocuments(await discoverForSearch(storage), q).filter(
6887
+ (n) => !isDerivedAnnotationsNode(n.id)
6888
+ );
5896
6889
  const userId = c.get("userId");
5897
6890
  const userEmail = await resolveCallerEmail(userId);
5898
6891
  const accessible = await filterAccessible(nestId, userId, userEmail, matches);
@@ -5904,7 +6897,9 @@ queryRoutes.get("/search", async (c) => {
5904
6897
  });
5905
6898
  queryRoutes.get("/overview", async (c) => {
5906
6899
  const { storage } = await engineCache.get(c.req.param("nestId"));
5907
- const documents = await storage.discoverDocuments();
6900
+ const documents = (await storage.discoverDocuments()).filter(
6901
+ (d) => !isDerivedAnnotationsNode(d.id)
6902
+ );
5908
6903
  const types = {};
5909
6904
  const tags = {};
5910
6905
  for (const node of documents) {
@@ -5939,6 +6934,14 @@ queryRoutes.get("/folders", async (c) => {
5939
6934
  });
5940
6935
  return c.json({ folders });
5941
6936
  });
6937
+ queryRoutes.post("/folders", async (c) => {
6938
+ const body = await c.req.json().catch(() => ({}));
6939
+ if (typeof body.folder !== "string" || !body.folder.trim()) {
6940
+ throw new ValidationError('folder (a string like "gtm/deals") is required');
6941
+ }
6942
+ const folder = await createEmptyFolder(c.req.param("nestId"), body.folder);
6943
+ return c.json({ folder }, 201);
6944
+ });
5942
6945
  queryRoutes.post("/reindex", async (c) => {
5943
6946
  if (permissionLevel(c.get("nestPermission")) < permissionLevel("write")) {
5944
6947
  throw new ForbiddenError("Reindexing a nest requires write access");
@@ -6089,7 +7092,7 @@ queryRoutes.post("/publish", async (c) => {
6089
7092
  });
6090
7093
 
6091
7094
  // src/mcp/routes.ts
6092
- import { Hono as Hono12 } from "hono";
7095
+ import { Hono as Hono14 } from "hono";
6093
7096
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6094
7097
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
6095
7098
 
@@ -6169,10 +7172,10 @@ async function listTraceEvents(filters = {}) {
6169
7172
  }
6170
7173
 
6171
7174
  // src/mcp/workflow-tools.ts
6172
- import { v4 as uuid8 } from "uuid";
7175
+ import { v4 as uuid10 } from "uuid";
6173
7176
 
6174
7177
  // src/workflow/run-service.ts
6175
- import { v4 as uuid6 } from "uuid";
7178
+ import { v4 as uuid8 } from "uuid";
6176
7179
  var RUNNABLE_TYPES = ["agent", "skill"];
6177
7180
  async function flowSubgraph(nestId, agentNode) {
6178
7181
  const db = getDb();
@@ -6302,7 +7305,7 @@ async function triggerRun(opts) {
6302
7305
  }
6303
7306
  const edges = await flowSubgraph(nestId, agentNode);
6304
7307
  const definitions = await referencedDefinitions(nestId, edges);
6305
- const id = `run_${uuid6()}`;
7308
+ const id = `run_${uuid8()}`;
6306
7309
  const db = getDb();
6307
7310
  await db.transaction(async (tx) => {
6308
7311
  if (parentRunId) {
@@ -6488,8 +7491,8 @@ async function listRuns(nestId, opts) {
6488
7491
  }
6489
7492
 
6490
7493
  // src/workflow/edge-routes.ts
6491
- import { Hono as Hono11 } from "hono";
6492
- import { v4 as uuid7 } from "uuid";
7494
+ import { Hono as Hono13 } from "hono";
7495
+ import { v4 as uuid9 } from "uuid";
6493
7496
  function toResponse(e, t) {
6494
7497
  return {
6495
7498
  id: e.id,
@@ -6603,7 +7606,7 @@ async function assertNoDuplicateEdge(db, nestId, fromNode, toNode, type, exclude
6603
7606
  );
6604
7607
  }
6605
7608
  }
6606
- var edgeRoutes = new Hono11();
7609
+ var edgeRoutes = new Hono13();
6607
7610
  edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
6608
7611
  const nestId = c.req.param("nestId");
6609
7612
  const db = getDb();
@@ -6671,7 +7674,7 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
6671
7674
  );
6672
7675
  const metadata = body.metadata === void 0 || body.metadata === null ? null : JSON.stringify(body.metadata);
6673
7676
  const now = (/* @__PURE__ */ new Date()).toISOString();
6674
- const id = uuid7();
7677
+ const id = uuid9();
6675
7678
  await db.transaction(async (tx) => {
6676
7679
  await assertNoDuplicateEdge(tx, nestId, fromNode, toNode, type);
6677
7680
  if (type.is_flow) {
@@ -7072,7 +8075,7 @@ async function createEdge(ctx, args) {
7072
8075
  );
7073
8076
  const metadata = args.metadata === void 0 || args.metadata === null ? null : JSON.stringify(args.metadata);
7074
8077
  const now = (/* @__PURE__ */ new Date()).toISOString();
7075
- const id = uuid8();
8078
+ const id = uuid10();
7076
8079
  await db.transaction(async (tx) => {
7077
8080
  await assertNoDuplicateEdge(tx, ctx.nestId, fromNode, toNode, type);
7078
8081
  if (type.is_flow) {
@@ -7136,7 +8139,7 @@ async function upsertEdgeType(ctx, args) {
7136
8139
  `INSERT INTO edge_types
7137
8140
  (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
7138
8141
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
7139
- [uuid8(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
8142
+ [uuid10(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
7140
8143
  );
7141
8144
  }
7142
8145
  });
@@ -8589,19 +9592,19 @@ ${list}`,
8589
9592
  if (!target) return "target is required (a node id or folder prefix).";
8590
9593
  if (!["read", "write"].includes(role)) return "role must be read or write.";
8591
9594
  try {
8592
- const { createGrant: createGrant2 } = await import("./grants-service-CADI6LIB.js");
8593
- const db = (await import("./client-SW5I6ZBK.js")).getDb();
9595
+ const { createGrant: createGrant2 } = await import("./grants-service-WC27TRY3.js");
9596
+ const db = (await import("./client-7Z2IVCPW.js")).getDb();
8594
9597
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
8595
9598
  const e = normalizeEmail2(String(args.email || ""));
8596
9599
  if (!e) return "email is required.";
8597
9600
  let row = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
8598
9601
  if (!row) {
8599
9602
  const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
8600
- const { v4: uuid16 } = await import("uuid");
8601
- const id = uuid16();
9603
+ const { v4: uuid18 } = await import("uuid");
9604
+ const id = uuid18();
8602
9605
  await db.run(
8603
9606
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
8604
- [id, e, null, await hashPassword2(uuid16())]
9607
+ [id, e, null, await hashPassword2(uuid18())]
8605
9608
  );
8606
9609
  row = { id };
8607
9610
  }
@@ -8656,7 +9659,7 @@ ${list}`;
8656
9659
 
8657
9660
  // src/mcp/routes.ts
8658
9661
  import { z } from "zod";
8659
- var mcpRoutes = new Hono12();
9662
+ var mcpRoutes = new Hono14();
8660
9663
  async function getUserEmail2(userId) {
8661
9664
  const db = getDb();
8662
9665
  const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
@@ -8722,8 +9725,8 @@ mcpRoutes.all("/", async (c) => {
8722
9725
  });
8723
9726
 
8724
9727
  // src/workflow/run-routes.ts
8725
- import { Hono as Hono13 } from "hono";
8726
- import { v4 as uuid9 } from "uuid";
9728
+ import { Hono as Hono15 } from "hono";
9729
+ import { v4 as uuid11 } from "uuid";
8727
9730
  var RUNNABLE_TYPES2 = ["agent", "skill"];
8728
9731
  var safeJson2 = (s) => {
8729
9732
  if (!s) return null;
@@ -8841,7 +9844,7 @@ async function buildRunBundle(nestId, agentNode) {
8841
9844
  definitions
8842
9845
  };
8843
9846
  }
8844
- var runTriggerRoutes = new Hono13();
9847
+ var runTriggerRoutes = new Hono15();
8845
9848
  async function resolveParentRun2(nestId, parentRunId, childAgent) {
8846
9849
  if (!config.FEATURE_SUBAGENT_RUNS) {
8847
9850
  throw new ValidationError(
@@ -8920,7 +9923,7 @@ runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
8920
9923
  }
8921
9924
  const bundle = await buildRunBundle(nestId, agentNode);
8922
9925
  const triggeredBy = await resolveCallerEmail(c.get("userId"));
8923
- const id = `run_${uuid9()}`;
9926
+ const id = `run_${uuid11()}`;
8924
9927
  await getDb().run(
8925
9928
  `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
8926
9929
  VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)`,
@@ -8949,7 +9952,7 @@ runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
8949
9952
  201
8950
9953
  );
8951
9954
  });
8952
- var runListRoutes = new Hono13();
9955
+ var runListRoutes = new Hono15();
8953
9956
  runListRoutes.get("/", requireWorkflowPlane, async (c) => {
8954
9957
  const nestId = c.req.param("nestId");
8955
9958
  const db = getDb();
@@ -8981,7 +9984,7 @@ runListRoutes.get("/", requireWorkflowPlane, async (c) => {
8981
9984
  }))
8982
9985
  });
8983
9986
  });
8984
- var runDetailRoutes = new Hono13();
9987
+ var runDetailRoutes = new Hono15();
8985
9988
  async function loadRunWithPermission(c, runId, required) {
8986
9989
  const run = await getDb().get("SELECT * FROM runs WHERE id = ?", [
8987
9990
  runId
@@ -9132,8 +10135,8 @@ runDetailRoutes.get("/:id", requireWorkflowPlane, async (c) => {
9132
10135
  });
9133
10136
 
9134
10137
  // src/workflow/schedule-routes.ts
9135
- import { Hono as Hono14 } from "hono";
9136
- import { v4 as uuid10 } from "uuid";
10138
+ import { Hono as Hono16 } from "hono";
10139
+ import { v4 as uuid12 } from "uuid";
9137
10140
  var RUNNABLE_TYPES3 = ["agent", "skill"];
9138
10141
  var MIN_EVERY = 5;
9139
10142
  var MAX_EVERY = 7 * 24 * 60;
@@ -9170,7 +10173,7 @@ function parseEvery(v) {
9170
10173
  }
9171
10174
  return n;
9172
10175
  }
9173
- var scheduleRoutes = new Hono14();
10176
+ var scheduleRoutes = new Hono16();
9174
10177
  scheduleRoutes.get("/", requireWorkflowPlane, async (c) => {
9175
10178
  const nestId = c.req.param("nestId");
9176
10179
  const rows = await getDb().all(
@@ -9211,7 +10214,7 @@ scheduleRoutes.post("/", requireWorkflowPlane, async (c) => {
9211
10214
  );
9212
10215
  }
9213
10216
  const now = (/* @__PURE__ */ new Date()).toISOString();
9214
- const id = `sch_${uuid10()}`;
10217
+ const id = `sch_${uuid12()}`;
9215
10218
  await db.run(
9216
10219
  `INSERT INTO schedules (id, nest_id, agent_node, every_minutes, enabled, last_run_at, max_runs, runs_created, created_by, created_at, updated_at)
9217
10220
  VALUES (?, ?, ?, ?, ?, NULL, ?, 0, ?, ?, ?)`,
@@ -9266,8 +10269,8 @@ scheduleRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
9266
10269
  });
9267
10270
 
9268
10271
  // src/notify/connector-routes.ts
9269
- import { Hono as Hono15 } from "hono";
9270
- import { v4 as uuid11 } from "uuid";
10272
+ import { Hono as Hono17 } from "hono";
10273
+ import { v4 as uuid13 } from "uuid";
9271
10274
  var KNOWN_EVENTS = [
9272
10275
  "review_requested",
9273
10276
  "review_approved",
@@ -9313,7 +10316,7 @@ function parseUrl(v) {
9313
10316
  'url must be a public https:// URL (loopback/private/metadata hosts are refused) or an env reference like "env:SLACK_WEBHOOK"'
9314
10317
  );
9315
10318
  }
9316
- var connectorRoutes = new Hono15();
10319
+ var connectorRoutes = new Hono17();
9317
10320
  connectorRoutes.get("/", async (c) => {
9318
10321
  const nestId = c.req.param("nestId");
9319
10322
  const rows = await getDb().all(
@@ -9332,7 +10335,7 @@ connectorRoutes.post("/", async (c) => {
9332
10335
  const url = parseUrl(body.url);
9333
10336
  const events = parseEvents(body.events);
9334
10337
  const now = (/* @__PURE__ */ new Date()).toISOString();
9335
- const id = `con_${uuid11()}`;
10338
+ const id = `con_${uuid13()}`;
9336
10339
  await getDb().run(
9337
10340
  `INSERT INTO connectors (id, nest_id, channel, url, events, enabled, created_by, created_at, updated_at)
9338
10341
  VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`,
@@ -9375,7 +10378,7 @@ connectorRoutes.delete("/:id", async (c) => {
9375
10378
  });
9376
10379
 
9377
10380
  // src/workflow/hook-routes.ts
9378
- import { Hono as Hono16 } from "hono";
10381
+ import { Hono as Hono18 } from "hono";
9379
10382
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2, randomUUID } from "crypto";
9380
10383
  var PRESETS = ["slack", "teams", "webhook"];
9381
10384
  var RUNNABLE_TYPES4 = ["agent", "skill"];
@@ -9398,7 +10401,7 @@ function toResponse4(c, r, opts = {}) {
9398
10401
  created_at: r.created_at
9399
10402
  };
9400
10403
  }
9401
- var hookNestRoutes = new Hono16();
10404
+ var hookNestRoutes = new Hono18();
9402
10405
  hookNestRoutes.get("/", requireWorkflowPlane, async (c) => {
9403
10406
  const nestId = c.req.param("nestId");
9404
10407
  const agent = c.req.query("agent");
@@ -9472,7 +10475,7 @@ function teamsSignatureValid(secret, authHeader, rawBody) {
9472
10475
  function stripMentions(text) {
9473
10476
  return text.replace(/<@[^>]+>/g, "").replace(/<at>[^<]*<\/at>/gi, "").trim();
9474
10477
  }
9475
- var hookFireRoutes = new Hono16();
10478
+ var hookFireRoutes = new Hono18();
9476
10479
  hookFireRoutes.post("/:token", async (c) => {
9477
10480
  const token = c.req.param("token");
9478
10481
  if (!tryConsume("hook:*", { max: 120, windowMs: 6e4 })) {
@@ -9572,9 +10575,9 @@ hookFireRoutes.post("/:token", async (c) => {
9572
10575
  });
9573
10576
 
9574
10577
  // src/definitions/routes.ts
9575
- import { Hono as Hono17 } from "hono";
9576
- import { v4 as uuid12 } from "uuid";
9577
- var definitionRoutes = new Hono17();
10578
+ import { Hono as Hono19 } from "hono";
10579
+ import { v4 as uuid14 } from "uuid";
10580
+ var definitionRoutes = new Hono19();
9578
10581
  definitionRoutes.get("/", async (c) => {
9579
10582
  const nestId = c.req.param("nestId");
9580
10583
  const db = getDb();
@@ -9653,7 +10656,7 @@ definitionRoutes.post("/", async (c) => {
9653
10656
  await applyUpdate(tx, existing.id);
9654
10657
  targetId = existing.id;
9655
10658
  } else {
9656
- targetId = uuid12();
10659
+ targetId = uuid14();
9657
10660
  wasCreated = true;
9658
10661
  await tx.run(
9659
10662
  `INSERT INTO definitions (id, nest_id, term, definition, linked_tag, defined_by, created_at, updated_at)
@@ -9682,11 +10685,11 @@ definitionRoutes.delete("/:id", async (c) => {
9682
10685
  });
9683
10686
 
9684
10687
  // src/nests/asset-routes.ts
9685
- import { Hono as Hono18 } from "hono";
9686
- import { v4 as uuid13 } from "uuid";
10688
+ import { Hono as Hono20 } from "hono";
10689
+ import { v4 as uuid15 } from "uuid";
9687
10690
  import { mkdir as mkdir2, readFile, writeFile } from "fs/promises";
9688
10691
  import { join as join4 } from "path";
9689
- var assetRoutes = new Hono18();
10692
+ var assetRoutes = new Hono20();
9690
10693
  var CONTENT_TYPES = {
9691
10694
  png: "image/png",
9692
10695
  jpg: "image/jpeg",
@@ -9732,7 +10735,7 @@ assetRoutes.post("/", async (c) => {
9732
10735
  `${isVideo ? "video" : "image"} too large (${(file.size / (1024 * 1024)).toFixed(1)} MB, max ${maxBytes / (1024 * 1024)} MB for ${isVideo ? "video" : "images"})`
9733
10736
  );
9734
10737
  }
9735
- const name = `${uuid13()}.${ext === "jpeg" ? "jpg" : ext}`;
10738
+ const name = `${uuid15()}.${ext === "jpeg" ? "jpg" : ext}`;
9736
10739
  const dir = join4(resolveNestPath(nestId), "assets");
9737
10740
  await mkdir2(dir, { recursive: true });
9738
10741
  await writeFile(join4(dir, name), Buffer.from(await file.arrayBuffer()));
@@ -9773,7 +10776,7 @@ assetRoutes.get("/:file", async (c) => {
9773
10776
  });
9774
10777
 
9775
10778
  // src/mcp/server-routes.ts
9776
- import { Hono as Hono19 } from "hono";
10779
+ import { Hono as Hono21 } from "hono";
9777
10780
  import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
9778
10781
  import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
9779
10782
  import { z as z2 } from "zod";
@@ -10072,7 +11075,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
10072
11075
  }
10073
11076
  return server;
10074
11077
  }
10075
- var serverMcpRoutes = new Hono19();
11078
+ var serverMcpRoutes = new Hono21();
10076
11079
  serverMcpRoutes.all("/", async (c) => {
10077
11080
  const userId = c.get("userId");
10078
11081
  const userEmail = await getUserEmail3(userId);
@@ -10094,7 +11097,7 @@ serverMcpRoutes.all("/", async (c) => {
10094
11097
  await server.server.close();
10095
11098
  }
10096
11099
  });
10097
- var nestIndexRoutes = new Hono19();
11100
+ var nestIndexRoutes = new Hono21();
10098
11101
  nestIndexRoutes.get("/", async (c) => {
10099
11102
  const userId = c.get("userId");
10100
11103
  const nests = await buildNestIndex(userId, c.get("nestScope"));
@@ -10213,14 +11216,14 @@ npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
10213
11216
  through steward review before they become AI-visible.
10214
11217
  `;
10215
11218
  }
10216
- var llmsTxtRoutes = new Hono19();
11219
+ var llmsTxtRoutes = new Hono21();
10217
11220
  llmsTxtRoutes.get("/", (c) => {
10218
11221
  const md = renderLlmsTxt(requestBaseUrl(c.req.url));
10219
11222
  return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
10220
11223
  });
10221
11224
 
10222
11225
  // src/governance/routes.ts
10223
- import { Hono as Hono20 } from "hono";
11226
+ import { Hono as Hono22 } from "hono";
10224
11227
 
10225
11228
  // src/governance/feed-service.ts
10226
11229
  var SNIPPET_CHARS = 200;
@@ -10351,7 +11354,7 @@ async function getNestFeed(params) {
10351
11354
  }
10352
11355
 
10353
11356
  // src/governance/comment-service.ts
10354
- import { v4 as uuid14 } from "uuid";
11357
+ import { v4 as uuid16 } from "uuid";
10355
11358
  async function createComment(params) {
10356
11359
  const db = getDb();
10357
11360
  const body = (params.body ?? "").trim();
@@ -10367,7 +11370,7 @@ async function createComment(params) {
10367
11370
  throw new Error("Parent comment not found on this node");
10368
11371
  }
10369
11372
  }
10370
- const id = uuid14();
11373
+ const id = uuid16();
10371
11374
  await db.run(
10372
11375
  `INSERT INTO comments
10373
11376
  (id, nest_id, node_id, version, anchor_start, anchor_end, anchor_text,
@@ -10652,7 +11655,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
10652
11655
  if (Number.isNaN(n) || n < 1) return def;
10653
11656
  return Math.min(n, max);
10654
11657
  }
10655
- var governanceRoutes = new Hono20();
11658
+ var governanceRoutes = new Hono22();
10656
11659
  governanceRoutes.get("/stewards", async (c) => {
10657
11660
  const nestId = c.req.param("nestId");
10658
11661
  const scope = c.req.query("scope");
@@ -10771,6 +11774,60 @@ governanceRoutes.get("/review-queue", async (c) => {
10771
11774
  }
10772
11775
  return c.json({ ...result, requests });
10773
11776
  });
11777
+ var BULK_REVIEW_LIMIT = 50;
11778
+ governanceRoutes.post("/review-queue/bulk", async (c) => {
11779
+ const nestId = c.req.param("nestId");
11780
+ const body = await c.req.json();
11781
+ const nodeIds = [...new Set(body.nodeIds ?? [])].map(assertSafeNodeId);
11782
+ if (nodeIds.length === 0) {
11783
+ throw new ValidationError("Select at least one document to review");
11784
+ }
11785
+ if (nodeIds.length > BULK_REVIEW_LIMIT) {
11786
+ throw new ValidationError(
11787
+ `Up to ${BULK_REVIEW_LIMIT} documents at a time \u2014 you selected ${nodeIds.length}.`
11788
+ );
11789
+ }
11790
+ if (body.action !== "approve" && body.action !== "reject") {
11791
+ throw new ValidationError("action must be approve or reject");
11792
+ }
11793
+ if (body.action === "reject" && !body.note) {
11794
+ throw new ValidationError("Rejection note is required");
11795
+ }
11796
+ const userEmail = await getUserEmail4(c);
11797
+ const baseUrl = requestBaseUrl(c.req.url);
11798
+ const results = [];
11799
+ for (const nodeId of nodeIds) {
11800
+ try {
11801
+ if (body.action === "approve") {
11802
+ await approve({
11803
+ nestId,
11804
+ nodeId,
11805
+ approvedBy: userEmail,
11806
+ note: body.note,
11807
+ override: false,
11808
+ baseUrl
11809
+ });
11810
+ } else {
11811
+ await reject({
11812
+ nestId,
11813
+ nodeId,
11814
+ rejectedBy: userEmail,
11815
+ note: body.note,
11816
+ baseUrl
11817
+ });
11818
+ }
11819
+ results.push({ nodeId, ok: true });
11820
+ } catch (err) {
11821
+ results.push({ nodeId, ok: false, error: err?.message || "Failed" });
11822
+ }
11823
+ }
11824
+ return c.json({
11825
+ action: body.action,
11826
+ succeeded: results.filter((r) => r.ok).length,
11827
+ failed: results.filter((r) => !r.ok).length,
11828
+ results
11829
+ });
11830
+ });
10774
11831
  function denyUnlessNestManager(c, what) {
10775
11832
  const permission = c.get("nestPermission");
10776
11833
  if (permission === "owner" || permission === "admin") return null;
@@ -10963,7 +12020,7 @@ governanceRoutes.get("/activity", async (c) => {
10963
12020
  const activity = await getActivity({ nestId, limit });
10964
12021
  return c.json({ activity });
10965
12022
  });
10966
- var governanceNodeRoutes = new Hono20();
12023
+ var governanceNodeRoutes = new Hono22();
10967
12024
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
10968
12025
  const nestId = c.req.param("nestId");
10969
12026
  const nodeId = c.req.param("nodeId");
@@ -11361,12 +12418,16 @@ governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
11361
12418
  const nestId = c.req.param("nestId");
11362
12419
  const nodeId = c.req.param("nodeId");
11363
12420
  const userEmail = await getUserEmail4(c);
11364
- const request = await cancelReview({
11365
- nestId,
11366
- nodeId,
11367
- cancelledBy: userEmail
11368
- });
11369
- return c.json({ review: request });
12421
+ try {
12422
+ const request = await cancelReview({
12423
+ nestId,
12424
+ nodeId,
12425
+ cancelledBy: userEmail
12426
+ });
12427
+ return c.json({ review: request });
12428
+ } catch (err) {
12429
+ return c.json({ error: err.message }, 403);
12430
+ }
11370
12431
  });
11371
12432
  async function getUserEmail4(c) {
11372
12433
  const userId = c.get("userId");
@@ -11454,13 +12515,23 @@ var flexAuthWithPromptowlTicket = createMiddleware2(
11454
12515
  async (c, next) => {
11455
12516
  const header = c.req.header("Authorization") || "";
11456
12517
  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
11457
- const secret = config.OFFICIAL_COMMUNITY_SSO_SECRET;
12518
+ const secrets = [
12519
+ config.OFFICIAL_COMMUNITY_SSO_SECRET,
12520
+ config.MCP_SIGNING_SECRET
12521
+ ].filter(Boolean);
11458
12522
  const looksLikeJwt = !!token && !token.startsWith("cnst_") && token.split(".").length === 3;
11459
- if (!looksLikeJwt || !secret) return flexAuthMiddleware(c, next);
11460
- let claims;
11461
- try {
11462
- claims = verifySsoTicket(token, secret);
11463
- } catch {
12523
+ if (!looksLikeJwt || secrets.length === 0) {
12524
+ return flexAuthMiddleware(c, next);
12525
+ }
12526
+ let claims = null;
12527
+ for (const secret of secrets) {
12528
+ try {
12529
+ claims = verifySsoTicket(token, secret);
12530
+ break;
12531
+ } catch {
12532
+ }
12533
+ }
12534
+ if (!claims) {
11464
12535
  return c.json({ error: "Invalid service ticket" }, 401);
11465
12536
  }
11466
12537
  if (claims.scope !== "mcp" || claims.jti) {
@@ -11507,7 +12578,7 @@ async function canApproveInNest(nestId, email) {
11507
12578
  return ok;
11508
12579
  }
11509
12580
  function createApp() {
11510
- const app = new Hono21({ router: new LinearRouter() });
12581
+ const app = new Hono23({ router: new LinearRouter() });
11511
12582
  const corsOrigins = config.CORS_ORIGINS;
11512
12583
  app.use(
11513
12584
  "*",
@@ -11564,6 +12635,9 @@ function createApp() {
11564
12635
  // Presence only, never the value: lets the UI stop prompting for a
11565
12636
  // runner key once the server-wide one exists.
11566
12637
  runner_key_configured: !!process.env.ANTHROPIC_API_KEY,
12638
+ // Presence only: the publish dialog hides the email/invite gates when
12639
+ // this server cannot send mail, because their only way in is a mailed link.
12640
+ email_configured: isEmailConfigured(),
11567
12641
  promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
11568
12642
  // Manual (email/password) sign-in mode (open | invite-only | disabled).
11569
12643
  // The login page reads this to show/hide the credential form, and to
@@ -11707,6 +12781,16 @@ function createApp() {
11707
12781
  oidc_allowed_domains: config.OIDC_ALLOWED_DOMAINS.join(", "),
11708
12782
  oidc_auto_provision: config.OIDC_AUTO_PROVISION,
11709
12783
  oidc_department_tagging: config.OIDC_DEPARTMENT_TAGGING,
12784
+ // Agent SSO / token-exchange. MCP_SIGNING_SECRET is WRITE-ONLY — only its
12785
+ // presence is reported, never the value (same discipline as the OIDC secret).
12786
+ sso_token_exchange_enabled: config.SSO_TOKEN_EXCHANGE_ENABLED,
12787
+ mcp_signing_secret_set: !!config.MCP_SIGNING_SECRET,
12788
+ mcp_token_ttl_seconds: config.MCP_TOKEN_TTL_SECONDS,
12789
+ ms_token_exchange_enabled: config.MS_TOKEN_EXCHANGE_ENABLED,
12790
+ ms_client_id: config.MS_CLIENT_ID,
12791
+ ms_tenant_id: config.MS_TENANT_ID,
12792
+ google_token_exchange_enabled: config.GOOGLE_TOKEN_EXCHANGE_ENABLED,
12793
+ google_client_id: config.GOOGLE_CLIENT_ID,
11710
12794
  // Live readiness (enabled AND issuer+id+secret present) — the SAVED state,
11711
12795
  // i.e. whether the login button is currently live. The Settings UI derives
11712
12796
  // its own "enabled but not configured" warning from the unsaved draft
@@ -11907,6 +12991,67 @@ function createApp() {
11907
12991
  value: body.oidc_department_tagging ? "true" : null
11908
12992
  });
11909
12993
  }
12994
+ if ("sso_token_exchange_enabled" in body) {
12995
+ pending.push({
12996
+ name: "SSO_TOKEN_EXCHANGE_ENABLED",
12997
+ value: body.sso_token_exchange_enabled ? "true" : null
12998
+ });
12999
+ }
13000
+ if ("mcp_signing_secret" in body) {
13001
+ const v = body.mcp_signing_secret;
13002
+ if (v === null || v === "") {
13003
+ pending.push({ name: "MCP_SIGNING_SECRET", value: null });
13004
+ } else if (typeof v !== "string") {
13005
+ addError("mcp_signing_secret", "MCP signing secret must be a string (or empty to clear it).");
13006
+ } else if (v.trim().length < 16) {
13007
+ addError("mcp_signing_secret", "MCP signing secret should be a long random string (16+ characters).");
13008
+ } else {
13009
+ pending.push({ name: "MCP_SIGNING_SECRET", value: v.trim() });
13010
+ }
13011
+ }
13012
+ if ("mcp_token_ttl_seconds" in body) {
13013
+ const n = Number(body.mcp_token_ttl_seconds);
13014
+ if (!Number.isFinite(n) || n < 30 || n > 3600) {
13015
+ addError("mcp_token_ttl_seconds", "Token lifetime must be between 30 and 3600 seconds.");
13016
+ } else {
13017
+ pending.push({ name: "MCP_TOKEN_TTL_SECONDS", value: String(Math.floor(n)) });
13018
+ }
13019
+ }
13020
+ if ("ms_token_exchange_enabled" in body) {
13021
+ pending.push({
13022
+ name: "MS_TOKEN_EXCHANGE_ENABLED",
13023
+ value: body.ms_token_exchange_enabled ? "true" : null
13024
+ });
13025
+ }
13026
+ if ("ms_client_id" in body) {
13027
+ pending.push({ name: "MS_CLIENT_ID", value: String(body.ms_client_id ?? "").trim() || null });
13028
+ }
13029
+ if ("ms_tenant_id" in body) {
13030
+ pending.push({ name: "MS_TENANT_ID", value: String(body.ms_tenant_id ?? "").trim() || null });
13031
+ }
13032
+ if ("google_token_exchange_enabled" in body) {
13033
+ pending.push({
13034
+ name: "GOOGLE_TOKEN_EXCHANGE_ENABLED",
13035
+ value: body.google_token_exchange_enabled ? "true" : null
13036
+ });
13037
+ }
13038
+ if ("google_client_id" in body) {
13039
+ pending.push({ name: "GOOGLE_CLIENT_ID", value: String(body.google_client_id ?? "").trim() || null });
13040
+ }
13041
+ const effAllowedDomains = "oidc_allowed_domains" in body ? String(body.oidc_allowed_domains ?? "").split(",").map((d) => d.trim()).filter(Boolean) : config.OIDC_ALLOWED_DOMAINS;
13042
+ const effAutoProvision = "oidc_auto_provision" in body ? !!body.oidc_auto_provision : config.OIDC_AUTO_PROVISION;
13043
+ if (effAutoProvision && effAllowedDomains.length === 0) {
13044
+ for (const field of [
13045
+ "ms_token_exchange_enabled",
13046
+ "google_token_exchange_enabled"
13047
+ ]) {
13048
+ if (body[field])
13049
+ addError(
13050
+ field,
13051
+ "Set allowed email domains (or turn auto-provision off) before enabling token exchange \u2014 otherwise anyone with an account at that provider can sign themselves up and mint a token."
13052
+ );
13053
+ }
13054
+ }
11910
13055
  const effOidcIssuer = "oidc_issuer" in body ? String(body.oidc_issuer ?? "").trim() : config.OIDC_ISSUER;
11911
13056
  const effOidcClientId = "oidc_client_id" in body ? String(body.oidc_client_id ?? "").trim() : config.OIDC_CLIENT_ID;
11912
13057
  const effOidcSecret = "oidc_client_secret" in body ? String(body.oidc_client_secret ?? "").trim() : config.OIDC_CLIENT_SECRET;
@@ -12336,11 +13481,12 @@ function createApp() {
12336
13481
  app.route("/runs", runDetailRoutes);
12337
13482
  app.route("/hooks", hookFireRoutes);
12338
13483
  app.route("/llms.txt", llmsTxtRoutes);
13484
+ app.route("/p", publishRenderRoutes);
12339
13485
  app.use("/index", flexAuthWithPromptowlTicket);
12340
13486
  app.route("/index", nestIndexRoutes);
12341
13487
  app.use("/mcp", flexAuthWithPromptowlTicket);
12342
13488
  app.route("/mcp", serverMcpRoutes);
12343
- const nestsApp = new Hono21();
13489
+ const nestsApp = new Hono23();
12344
13490
  nestsApp.use("*", async (c, next) => {
12345
13491
  const parts = nestPathParts(c.req.path);
12346
13492
  const ticketEligible = parts.length === 2 && (c.req.method === "POST" && parts[1] === "context" || c.req.method === "GET" && parts[1] === "nodes" || parts[1] === "mcp");
@@ -12431,7 +13577,7 @@ function createApp() {
12431
13577
  }
12432
13578
  let required = "read";
12433
13579
  const path = c.req.path;
12434
- const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review");
13580
+ const isStewardActionPath = path.includes("/approve") || path.includes("/reject") || path.includes("/submit-review") || path.includes("/cancel-review") || /\/review-queue\/bulk$/.test(path);
12435
13581
  const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
12436
13582
  const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
12437
13583
  const isDeletionRequestAction = /\/request-deletion$/.test(path) || /\/deletion-requests\/[^/]+\/withdraw$/.test(path);
@@ -12445,7 +13591,7 @@ function createApp() {
12445
13591
  );
12446
13592
  }
12447
13593
  const resource = parts[1];
12448
- if (resource === "visibility") {
13594
+ if (resource === "visibility" || resource === "reader-mode") {
12449
13595
  required = "admin";
12450
13596
  } else if (resource === "collaborators") {
12451
13597
  required = c.req.method === "GET" || c.req.method === "POST" ? "write" : "admin";
@@ -12533,6 +13679,7 @@ function createApp() {
12533
13679
  nestsApp.route("/:nestId", queryRoutes);
12534
13680
  nestsApp.route("/:nestId", sharingRoutes);
12535
13681
  nestsApp.route("/:nestId/grants", grantRoutes);
13682
+ nestsApp.route("/:nestId/publications", publishRoutes);
12536
13683
  nestsApp.route("/:nestId/teams", nestTeamRoutes);
12537
13684
  nestsApp.route("/:nestId/edge-types", edgeTypeRoutes);
12538
13685
  nestsApp.route("/:nestId/edges", edgeRoutes);
@@ -12853,7 +14000,7 @@ async function repairGraftedVersionChains(db) {
12853
14000
  }
12854
14001
 
12855
14002
  // src/workflow/scheduler.ts
12856
- import { v4 as uuid15 } from "uuid";
14003
+ import { v4 as uuid17 } from "uuid";
12857
14004
  function isDue(s, now) {
12858
14005
  if (!s.last_run_at) return true;
12859
14006
  const last = Date.parse(s.last_run_at);
@@ -12877,21 +14024,21 @@ async function schedulerTick(now = Date.now()) {
12877
14024
  );
12878
14025
  continue;
12879
14026
  }
12880
- const id = `run_${uuid15()}`;
12881
- const nowIso = new Date(now).toISOString();
14027
+ const id = `run_${uuid17()}`;
14028
+ const nowIso2 = new Date(now).toISOString();
12882
14029
  await db.run(
12883
14030
  `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
12884
14031
  VALUES (?, ?, ?, ?, 'running', NULL, ?, NULL, 0)`,
12885
14032
  // triggered_by names the schedule's creator with a schedule: prefix —
12886
14033
  // honest provenance ("this fired on X's schedule, not X clicking").
12887
- [id, s.nest_id, s.agent_node, `schedule:${s.created_by}`, nowIso]
14034
+ [id, s.nest_id, s.agent_node, `schedule:${s.created_by}`, nowIso2]
12888
14035
  );
12889
14036
  const fired = s.runs_created + 1;
12890
14037
  const done = s.max_runs !== null && fired >= s.max_runs;
12891
14038
  await db.run(
12892
14039
  `UPDATE schedules SET last_run_at = ?, runs_created = ?, enabled = ?, updated_at = ?
12893
14040
  WHERE id = ?`,
12894
- [nowIso, fired, done ? 0 : s.enabled, nowIso, s.id]
14041
+ [nowIso2, fired, done ? 0 : s.enabled, nowIso2, s.id]
12895
14042
  );
12896
14043
  created.push(id);
12897
14044
  } catch (err) {