@promptowl/contextnest-community 1.18.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 (83) hide show
  1. package/CONFIGURATION.md +83 -1
  2. package/dist/{chunk-QUZXRLUA.js → chunk-3LUUJUWS.js} +2 -2
  3. package/dist/{chunk-FUUJG6XP.js → chunk-7CPP6FIU.js} +256 -40
  4. package/dist/{chunk-OOBZG6BW.js → chunk-EY7WOKJR.js} +3 -3
  5. package/dist/{chunk-HGN45I5P.js → chunk-GVUBZTI3.js} +27 -185
  6. package/dist/{chunk-34UXRZXD.js → chunk-KHMSDTJJ.js} +2 -2
  7. package/dist/{chunk-TQACHVVA.js → chunk-O5DYL7ZG.js} +3 -3
  8. package/dist/{chunk-HYSTFMFG.js → chunk-XCFKS65Y.js} +107 -1
  9. package/dist/{chunk-GUNJTORH.js → chunk-YVMSM7LS.js} +14 -1
  10. package/dist/{client-SW5I6ZBK.js → client-7Z2IVCPW.js} +1 -1
  11. package/dist/{engine-34E3YNX7.js → engine-VZTSDUPB.js} +3 -3
  12. package/dist/{external-edit-service-6Q6SHEOF.js → external-edit-service-3FVK3OD7.js} +4 -4
  13. package/dist/{grants-service-ME7HIVHU.js → grants-service-WC27TRY3.js} +3 -3
  14. package/dist/index.js +2294 -1106
  15. package/dist/{migrations.postgres-5VI4RDTS.js → migrations.postgres-L42OXR7K.js} +47 -1
  16. package/dist/{review-service-H2M5ACPQ.js → review-service-TJRHRMMZ.js} +7 -7
  17. package/dist/{stewardship-service-U4CKL7Y5.js → stewardship-service-HRVD7SIS.js} +4 -4
  18. package/dist/{version-service-JWEN5UUW.js → version-service-5PIFT2OY.js} +4 -4
  19. package/dist/web3/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  20. package/dist/web3/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  21. package/dist/web3/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  22. package/dist/web3/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  23. package/dist/web3/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  24. package/dist/web3/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  25. package/dist/web3/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  26. package/dist/web3/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  27. package/dist/web3/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  28. package/dist/web3/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  29. package/dist/web3/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  30. package/dist/web3/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  31. package/dist/web3/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  32. package/dist/web3/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  33. package/dist/web3/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  34. package/dist/web3/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  35. package/dist/web3/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  36. package/dist/web3/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  37. package/dist/web3/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  38. package/dist/web3/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  39. package/dist/web3/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  40. package/dist/web3/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  41. package/dist/web3/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  42. package/dist/web3/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  43. package/dist/web3/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  44. package/dist/web3/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  45. package/dist/web3/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  46. package/dist/web3/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  47. package/dist/web3/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  48. package/dist/web3/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  49. package/dist/web3/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  50. package/dist/web3/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  51. package/dist/web3/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  52. package/dist/web3/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  53. package/dist/web3/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  54. package/dist/web3/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  55. package/dist/web3/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  56. package/dist/web3/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  57. package/dist/web3/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  58. package/dist/web3/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  59. package/dist/web3/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  60. package/dist/web3/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  61. package/dist/web3/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  62. package/dist/web3/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  63. package/dist/web3/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  64. package/dist/web3/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  65. package/dist/web3/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  66. package/dist/web3/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  67. package/dist/web3/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  68. package/dist/web3/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  69. package/dist/web3/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  70. package/dist/web3/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  71. package/dist/web3/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  72. package/dist/web3/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  73. package/dist/web3/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  74. package/dist/web3/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  75. package/dist/web3/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  76. package/dist/web3/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  77. package/dist/web3/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  78. package/dist/web3/assets/index-D4UG3T04.js +1365 -0
  79. package/dist/web3/assets/index-Dw1UTDIk.css +1 -0
  80. package/dist/web3/index.html +2 -2
  81. package/package.json +7 -2
  82. package/dist/web3/assets/index-DONv5_Ni.css +0 -1
  83. package/dist/web3/assets/index-jSGrnboh.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-HGN45I5P.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-TQACHVVA.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-QUZXRLUA.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-34UXRZXD.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-OOBZG6BW.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,
@@ -174,11 +175,13 @@ import {
174
175
  resolveNestAccess,
175
176
  resolveNestPath,
176
177
  resolveNestPermission,
178
+ resolveNodeUrl,
177
179
  resolveTeamRolesForUser,
178
180
  sendEmailToRecipient,
179
181
  setAllowSelfApprove,
180
182
  setPrimeOnlyReview,
181
183
  setPrimeTags,
184
+ setReaderMode,
182
185
  setStewardshipEnabled,
183
186
  shareTeamWithNest,
184
187
  startLicenseSafetyPoll,
@@ -190,7 +193,7 @@ import {
190
193
  updateMemberRole,
191
194
  validateLicense,
192
195
  verifySmtp
193
- } from "./chunk-FUUJG6XP.js";
196
+ } from "./chunk-7CPP6FIU.js";
194
197
  import {
195
198
  AppError,
196
199
  ConflictError,
@@ -198,15 +201,17 @@ import {
198
201
  LockedError,
199
202
  NotFoundError,
200
203
  UnauthorizedError,
201
- ValidationError
202
- } from "./chunk-GUNJTORH.js";
204
+ ValidationError,
205
+ VaultBusyError,
206
+ isVaultLockTimeout
207
+ } from "./chunk-YVMSM7LS.js";
203
208
  import {
204
209
  config,
205
210
  getDb,
206
211
  initDb,
207
212
  isEmailListish,
208
213
  isEmailish
209
- } from "./chunk-HYSTFMFG.js";
214
+ } from "./chunk-XCFKS65Y.js";
210
215
  import {
211
216
  ANON_EMAIL,
212
217
  ANON_USER_ID,
@@ -220,7 +225,7 @@ import {
220
225
  import { serve } from "@hono/node-server";
221
226
 
222
227
  // src/app.ts
223
- import { Hono as Hono21 } from "hono";
228
+ import { Hono as Hono23 } from "hono";
224
229
  import { LinearRouter } from "hono/router/linear-router";
225
230
  import { createMiddleware as createMiddleware2 } from "hono/factory";
226
231
  import { cors } from "hono/cors";
@@ -250,7 +255,7 @@ function applyArtifactSandboxHeaders(c) {
250
255
 
251
256
  // src/auth/routes.ts
252
257
  import { Hono as Hono2 } from "hono";
253
- import { v4 as uuid2 } from "uuid";
258
+ import { v4 as uuid3 } from "uuid";
254
259
 
255
260
  // src/auth/middleware.ts
256
261
  import { createMiddleware } from "hono/factory";
@@ -472,8 +477,39 @@ function b64urlToBuffer(input) {
472
477
  const pad = input.length % 4 === 0 ? "" : "=".repeat(4 - input.length % 4);
473
478
  return Buffer.from(input.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64");
474
479
  }
480
+ function bufferToB64url(buf) {
481
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
482
+ }
475
483
  var SsoTokenError = class extends Error {
476
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
+ }
477
513
  function verifySsoTicket(token, secret, nowSec = Math.floor(Date.now() / 1e3)) {
478
514
  if (!secret) throw new SsoTokenError("SSO secret not configured");
479
515
  if (!token || typeof token !== "string") {
@@ -553,14 +589,15 @@ var NONCE_COOKIE = "cnst_oidc_nonce";
553
589
  var VERIFIER_COOKIE = "cnst_oidc_verifier";
554
590
  var FLOW_COOKIE_TTL_SECS = 10 * 60;
555
591
  var DISCOVERY_TTL_MS = 5 * 6e4;
556
- var discoveryCache = null;
592
+ var discoveryCache = /* @__PURE__ */ new Map();
557
593
  function isOidcReady() {
558
594
  return config.OIDC_ENABLED && !!config.OIDC_ISSUER && !!config.OIDC_CLIENT_ID && !!config.OIDC_CLIENT_SECRET;
559
595
  }
560
596
  async function discover(issuer) {
561
597
  const now = Date.now();
562
- if (discoveryCache && discoveryCache.issuer === issuer && now - discoveryCache.fetchedAt < DISCOVERY_TTL_MS) {
563
- 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 };
564
601
  }
565
602
  const res = await fetch(`${issuer}/.well-known/openid-configuration`, {
566
603
  headers: { Accept: "application/json" },
@@ -579,12 +616,7 @@ async function discover(issuer) {
579
616
  );
580
617
  }
581
618
  const jwks = createRemoteJWKSet(new URL(doc.jwks_uri));
582
- discoveryCache = {
583
- issuer,
584
- doc,
585
- jwks,
586
- fetchedAt: now
587
- };
619
+ discoveryCache.set(issuer, { doc, jwks, fetchedAt: now });
588
620
  return { doc, jwks };
589
621
  }
590
622
  function base64url(buf) {
@@ -849,6 +881,184 @@ oidcRoutes.get("/logout", async (c) => {
849
881
  return c.redirect(home, 302);
850
882
  });
851
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
+
852
1062
  // src/auth/routes.ts
853
1063
  function isServerAdminEmail(email) {
854
1064
  return isLicenseAdminEmail(email) || isSuperAdmin(email);
@@ -936,8 +1146,8 @@ async function provisionPromptowlUser(c, rawEmail, rawName) {
936
1146
  [meEmail]
937
1147
  );
938
1148
  if (!user) {
939
- const userId = uuid2();
940
- const placeholderHash = await hashPassword(uuid2());
1149
+ const userId = uuid3();
1150
+ const placeholderHash = await hashPassword(uuid3());
941
1151
  await db.run(
942
1152
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
943
1153
  [userId, meEmail, rawName || null, placeholderHash]
@@ -971,6 +1181,7 @@ async function provisionPromptowlUser(c, rawEmail, rawName) {
971
1181
  }
972
1182
  var authRoutes = new Hono2();
973
1183
  authRoutes.route("/oidc", oidcRoutes);
1184
+ authRoutes.post("/token-exchange", handleTokenExchange);
974
1185
  authRoutes.post("/register", async (c) => {
975
1186
  if (config.MANUAL_SIGN_IN !== "open") {
976
1187
  return c.json(
@@ -1009,7 +1220,7 @@ authRoutes.post("/register", async (c) => {
1009
1220
  }
1010
1221
  throw new ValidationError("Email already registered");
1011
1222
  }
1012
- const userId = uuid2();
1223
+ const userId = uuid3();
1013
1224
  const passwordHash = await hashPassword(body.password);
1014
1225
  await db.run(
1015
1226
  "INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)",
@@ -1111,7 +1322,7 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
1111
1322
  });
1112
1323
  if (!check.ok) return c.json({ error: check.error }, check.status);
1113
1324
  const apiKey = generateApiKey();
1114
- const keyId = uuid2();
1325
+ const keyId = uuid3();
1115
1326
  await db.run(
1116
1327
  "INSERT INTO api_keys (id, user_id, key_hash, key_prefix, nest_id, label) VALUES (?, ?, ?, ?, ?, ?)",
1117
1328
  [
@@ -1140,7 +1351,7 @@ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
1140
1351
  if (!check.ok) return c.json({ error: check.error }, check.status);
1141
1352
  const prior = check.key;
1142
1353
  const apiKey = generateApiKey();
1143
- const keyId = uuid2();
1354
+ const keyId = uuid3();
1144
1355
  const nestScope = "nest_id" in body ? body.nest_id || null : prior.nest_id;
1145
1356
  const replaced = await db.transaction(async (tx) => {
1146
1357
  const removed = await tx.run(
@@ -1448,7 +1659,7 @@ authRoutes.post("/admin/reset-password/:userId", async (c) => {
1448
1659
  if (supplied !== void 0) {
1449
1660
  assertValidPassword(supplied);
1450
1661
  }
1451
- const generated = supplied ? null : uuid2().replace(/-/g, "").slice(0, 16);
1662
+ const generated = supplied ? null : uuid3().replace(/-/g, "").slice(0, 16);
1452
1663
  const newPassword = supplied ?? generated;
1453
1664
  const newHash = await hashPassword(newPassword);
1454
1665
  await db.transaction(async (tx) => {
@@ -1559,7 +1770,7 @@ authRoutes.post("/invite", async (c) => {
1559
1770
  409
1560
1771
  );
1561
1772
  }
1562
- const tempPassword = uuid2().replace(/-/g, "").slice(0, 16);
1773
+ const tempPassword = uuid3().replace(/-/g, "").slice(0, 16);
1563
1774
  const passwordHash = await hashPassword(tempPassword);
1564
1775
  let userId;
1565
1776
  if (existing) {
@@ -1569,7 +1780,7 @@ authRoutes.post("/invite", async (c) => {
1569
1780
  userId
1570
1781
  ]);
1571
1782
  } else {
1572
- userId = uuid2();
1783
+ userId = uuid3();
1573
1784
  await db.run(
1574
1785
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
1575
1786
  [userId, email, null, passwordHash]
@@ -1657,16 +1868,15 @@ import {
1657
1868
  normalizeStatus,
1658
1869
  isRejected,
1659
1870
  filterDocuments,
1660
- DocumentNotFoundError
1871
+ DocumentNotFoundError,
1872
+ NODE_TYPES,
1873
+ generateIndexMd
1661
1874
  } from "@promptowl/contextnest-engine";
1662
- import { rename, mkdir } from "fs/promises";
1875
+ import { rename, mkdir, rm, stat } from "fs/promises";
1663
1876
  import { dirname, join } from "path";
1664
1877
 
1665
1878
  // src/governance/safe-publish.ts
1666
- import {
1667
- publishDocument,
1668
- serializeDocument
1669
- } from "@promptowl/contextnest-engine";
1879
+ import { serializeDocument } from "@promptowl/contextnest-engine";
1670
1880
  function serializeNodeSafe(node) {
1671
1881
  return serializeDocument({
1672
1882
  ...node,
@@ -1804,6 +2014,10 @@ function toSafeError(err, context, message) {
1804
2014
  "This document is rejected. Change its status before saving."
1805
2015
  );
1806
2016
  }
2017
+ if (isVaultLockTimeout(err)) {
2018
+ console.error(`${context}:`, err);
2019
+ return new VaultBusyError();
2020
+ }
1807
2021
  console.error(`${context}:`, err);
1808
2022
  return new AppError(500, message);
1809
2023
  }
@@ -1818,6 +2032,12 @@ var RUNNABLE_NODE_TYPES = ["agent", "skill"];
1818
2032
  function isRunnableType(type) {
1819
2033
  return RUNNABLE_NODE_TYPES.includes(type || "");
1820
2034
  }
2035
+ var ACCEPTED_NODE_TYPES = /* @__PURE__ */ new Set([
2036
+ ...NODE_TYPES,
2037
+ ...RUNNABLE_NODE_TYPES,
2038
+ "artifact",
2039
+ "table"
2040
+ ]);
1821
2041
  function toNodeResponse(node) {
1822
2042
  const fm = node.frontmatter;
1823
2043
  const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
@@ -1903,6 +2123,9 @@ function engineScopesFolders() {
1903
2123
  }
1904
2124
  return folderScopeSupported;
1905
2125
  }
2126
+ function isDerivedAnnotationsNode(id) {
2127
+ return id.endsWith("--annotations");
2128
+ }
1906
2129
  async function getNodeForCaller(nestId, selector, userId, userEmail, opts = {}) {
1907
2130
  if (selector.id && !await canReadNode(nestId, selector.id, userId, userEmail)) {
1908
2131
  throw new ForbiddenError(
@@ -1947,12 +2170,12 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
1947
2170
  if (filters.folder !== void 0) assertSafeFolder(filters.folder);
1948
2171
  const { versions: versionManager } = await engineCache.get(nestId);
1949
2172
  const userEmail = await resolveCallerEmail(userId);
1950
- const frontmatterFilters = {
2173
+ const listFilters = {
1951
2174
  ...filters.type ? { type: filters.type } : {},
1952
2175
  ...filters.tag ? { tag: filters.tag } : {},
1953
- ...filters.status ? { status: filters.status } : {},
1954
- includeRetired: true
2176
+ ...filters.status ? { status: filters.status } : {}
1955
2177
  };
2178
+ const frontmatterFilters = { ...listFilters, includeRetired: true };
1956
2179
  let documents;
1957
2180
  if (filters.withContent) {
1958
2181
  const scopeInEngine = filters.folder !== void 0 && engineScopesFolders();
@@ -1960,7 +2183,7 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
1960
2183
  ({ documents } = await engineApi.run(
1961
2184
  "context_list",
1962
2185
  {
1963
- ...frontmatterFilters,
2186
+ ...listFilters,
1964
2187
  include_retired: true,
1965
2188
  ...scopeInEngine ? {
1966
2189
  folder: [scopePrefix, filters.folder].filter(Boolean).join("/"),
@@ -1976,7 +2199,13 @@ async function listNodesForCaller(nestId, userId, filters = {}) {
1976
2199
  nestId,
1977
2200
  filters.folder !== void 0 ? { folder: filters.folder } : {}
1978
2201
  );
1979
- documents = filterDocuments(indexed, frontmatterFilters);
2202
+ documents = filterDocuments(indexed, {
2203
+ ...frontmatterFilters,
2204
+ includeRetired: true
2205
+ });
2206
+ }
2207
+ if (!filters.includeDerived) {
2208
+ documents = documents.filter((d) => !isDerivedAnnotationsNode(d.id));
1980
2209
  }
1981
2210
  const scoped = filters.folder === void 0 ? documents : documents.filter((d) => folderOfId(d.id) === filters.folder);
1982
2211
  const nodes = scoped.map((d) => ({
@@ -2080,10 +2309,37 @@ function withSubtreeTotals(direct) {
2080
2309
  )
2081
2310
  })).sort((a, b) => a.path.localeCompare(b.path));
2082
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
+ }
2083
2338
  async function listNestFolders(nestId, opts = {}) {
2084
2339
  if (opts.folder !== void 0) assertSafeFolder(opts.folder);
2085
2340
  await ensureNodeIndex(nestId);
2086
2341
  const counts = /* @__PURE__ */ new Map();
2342
+ for (const path of await vaultFolderPaths(nestId, opts)) counts.set(path, 0);
2087
2343
  for (const { path, count } of await indexedFolderCounts(nestId)) {
2088
2344
  counts.set(path, count);
2089
2345
  let parent = path;
@@ -2108,7 +2364,9 @@ var RECENT_LIMIT = 3;
2108
2364
  async function nestCensus(nestId, userId) {
2109
2365
  const userEmail = await resolveCallerEmail(userId);
2110
2366
  await ensureNodeIndex(nestId);
2111
- const documents = await readIndexedNodes(nestId);
2367
+ const documents = (await readIndexedNodes(nestId)).filter(
2368
+ (d) => !isDerivedAnnotationsNode(d.id)
2369
+ );
2112
2370
  const visible = await filterAccessible(nestId, userId, userEmail, documents);
2113
2371
  const tags = {};
2114
2372
  const types = {};
@@ -2129,6 +2387,7 @@ async function nestCensus(nestId, userId) {
2129
2387
  const segs = folder.split("/");
2130
2388
  for (let i = 1; i <= segs.length; i++) paths.add(segs.slice(0, i).join("/"));
2131
2389
  }
2390
+ for (const path of await vaultFolderPaths(nestId)) paths.add(path);
2132
2391
  const folders = withSubtreeTotals(
2133
2392
  [...paths].map((path) => ({ path, count: direct.get(path) ?? 0 }))
2134
2393
  );
@@ -2172,7 +2431,10 @@ async function nestCensus(nestId, userId) {
2172
2431
  };
2173
2432
  }
2174
2433
  async function listNodesForCallerByEmail(nestId, userEmail, filters = {}) {
2175
- return listNodesForCaller(nestId, await userIdFromEmail(userEmail), filters);
2434
+ return listNodesForCaller(nestId, await userIdFromEmail(userEmail), {
2435
+ includeDerived: true,
2436
+ ...filters
2437
+ });
2176
2438
  }
2177
2439
  async function actorAutoPublishes(nestId, userEmail) {
2178
2440
  if (!await nestAllowsSelfApprove(nestId)) return false;
@@ -2226,6 +2488,11 @@ async function createNodeOnce(nestId, input, userEmail) {
2226
2488
  if (requestedType === "table" && !config.TYPE_TABLE_ENABLED) {
2227
2489
  throw new ValidationError("Table nodes are disabled on this server");
2228
2490
  }
2491
+ if (!ACCEPTED_NODE_TYPES.has(requestedType)) {
2492
+ throw new ValidationError(
2493
+ `unknown node type "${requestedType}"; allowed: ${[...ACCEPTED_NODE_TYPES].sort().join(", ")}`
2494
+ );
2495
+ }
2229
2496
  if (input.schedule !== void 0 && !isRunnableType(input.type)) {
2230
2497
  throw new ValidationError(
2231
2498
  `schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(
@@ -2632,6 +2899,10 @@ var NODE_KEYED_TABLES = [
2632
2899
  // A live capability token bound to one agent node — deleting the agent must
2633
2900
  // revoke it, or a later node at that id answers the old webhook.
2634
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"],
2635
2906
  ["edges", "from_node"],
2636
2907
  ["edges", "to_node"]
2637
2908
  ];
@@ -2795,6 +3066,7 @@ async function deleteFolder(nestId, folder, userEmail) {
2795
3066
  const docs = await storage.discoverDocuments();
2796
3067
  const targets = docs.map((d) => d.id).filter((id) => isIdUnderFolder(id, rel));
2797
3068
  if (targets.length === 0) {
3069
+ if (await removeVaultFolder(nestId, rel)) return [];
2798
3070
  throw new NotFoundError(`No documents found under folder: ${folder}`);
2799
3071
  }
2800
3072
  const deleted = [];
@@ -2809,13 +3081,35 @@ async function deleteFolder(nestId, folder, userEmail) {
2809
3081
  );
2810
3082
  }
2811
3083
  }
3084
+ await removeVaultFolder(nestId, rel);
2812
3085
  return deleted;
2813
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
+ }
2814
3108
 
2815
3109
  // src/nests/unsynced-service.ts
2816
3110
  import { readdirSync, readFileSync, statSync } from "fs";
2817
3111
  import { join as join2, relative, resolve } from "path";
2818
- import { rm } from "fs/promises";
3112
+ import { rm as rm2 } from "fs/promises";
2819
3113
  var RESERVED = /* @__PURE__ */ new Set(["nests"]);
2820
3114
  function isNestStorageRoot(absPath) {
2821
3115
  return resolve(absPath) === resolve(nestStorageRoot());
@@ -2958,14 +3252,14 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
2958
3252
  if (isNestStorageRoot(src)) {
2959
3253
  throw new ValidationError("Folder is not eligible for sync");
2960
3254
  }
2961
- let stat;
3255
+ let stat2;
2962
3256
  try {
2963
- stat = statSync(src);
3257
+ stat2 = statSync(src);
2964
3258
  } catch {
2965
3259
  console.warn(`[unsynced] source missing: ${src}`);
2966
3260
  throw new NotFoundError(`Folder not found: ${folderName}`);
2967
3261
  }
2968
- if (!stat.isDirectory()) {
3262
+ if (!stat2.isDirectory()) {
2969
3263
  throw new ValidationError(`Not a directory: ${folderName}`);
2970
3264
  }
2971
3265
  const files = collectMarkdownFiles(src, src);
@@ -3000,7 +3294,7 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
3000
3294
  `[unsynced] registered ${documents} document(s) for nest ${nest.id}`
3001
3295
  );
3002
3296
  try {
3003
- await rm(src, { recursive: true, force: true });
3297
+ await rm2(src, { recursive: true, force: true });
3004
3298
  console.log(`[unsynced] removed source folder ${src}`);
3005
3299
  } catch (err) {
3006
3300
  console.error("[unsynced] failed to remove source folder", src, err);
@@ -3014,16 +3308,16 @@ async function deleteUnsyncedFolder(folderName) {
3014
3308
  if (isNestStorageRoot(src)) {
3015
3309
  throw new ValidationError("Folder is not eligible for delete");
3016
3310
  }
3017
- let stat;
3311
+ let stat2;
3018
3312
  try {
3019
- stat = statSync(src);
3313
+ stat2 = statSync(src);
3020
3314
  } catch {
3021
3315
  throw new NotFoundError(`Folder not found: ${folderName}`);
3022
3316
  }
3023
- if (!stat.isDirectory()) {
3317
+ if (!stat2.isDirectory()) {
3024
3318
  throw new ValidationError(`Not a directory: ${folderName}`);
3025
3319
  }
3026
- await rm(src, { recursive: true, force: true });
3320
+ await rm2(src, { recursive: true, force: true });
3027
3321
  console.log(`[unsynced] deleted folder ${src}`);
3028
3322
  }
3029
3323
 
@@ -3402,7 +3696,7 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
3402
3696
  import { Hono as Hono4 } from "hono";
3403
3697
 
3404
3698
  // src/nests/sharing-service.ts
3405
- import { v4 as uuid3 } from "uuid";
3699
+ import { v4 as uuid4 } from "uuid";
3406
3700
  var VALID_PERMISSIONS = ["read", "write", "admin"];
3407
3701
  async function addCollaborator(params) {
3408
3702
  const { nestId } = params;
@@ -3429,10 +3723,10 @@ async function addCollaborator(params) {
3429
3723
  userId = existing.id;
3430
3724
  } else {
3431
3725
  const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
3432
- userId = uuid3();
3726
+ userId = uuid4();
3433
3727
  await db.run(
3434
3728
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
3435
- [userId, email, null, await hashPassword2(uuid3())]
3729
+ [userId, email, null, await hashPassword2(uuid4())]
3436
3730
  );
3437
3731
  }
3438
3732
  }
@@ -3470,7 +3764,7 @@ async function addCollaborator(params) {
3470
3764
  [params.grantedByEmail]
3471
3765
  ))?.id : void 0;
3472
3766
  const granterId = params.grantedByUserId || granterByEmail || ownerRow.user_id;
3473
- const collabId = uuid3();
3767
+ const collabId = uuid4();
3474
3768
  await db.run(
3475
3769
  "INSERT INTO nest_collaborators (id, nest_id, user_id, permission, granted_by) VALUES (?, ?, ?, ?, ?)",
3476
3770
  [collabId, nestId, userId, params.permission, granterId]
@@ -3589,6 +3883,16 @@ sharingRoutes.patch("/visibility", async (c) => {
3589
3883
  if (!body.visibility || !["private", "public"].includes(body.visibility)) {
3590
3884
  throw new ValidationError("visibility must be private or public");
3591
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
+ }
3592
3896
  const db = getDb();
3593
3897
  const nestId = c.req.param("nestId");
3594
3898
  const result = await db.run("UPDATE nests SET visibility = ? WHERE id = ?", [
@@ -3603,10 +3907,55 @@ sharingRoutes.patch("/visibility", async (c) => {
3603
3907
  ]);
3604
3908
  return c.json({ visibility: row.visibility });
3605
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
+ });
3606
3955
 
3607
3956
  // src/nests/grant-routes.ts
3608
3957
  import { Hono as Hono5 } from "hono";
3609
- import { v4 as uuid4 } from "uuid";
3958
+ import { v4 as uuid5 } from "uuid";
3610
3959
  var grantRoutes = new Hono5();
3611
3960
  async function requireManage(c, nestId) {
3612
3961
  if (!await canManageStewards(nestId, c.get("userId"))) {
@@ -3621,10 +3970,10 @@ async function resolveGrantee(email) {
3621
3970
  const existing = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
3622
3971
  if (existing) return existing.id;
3623
3972
  const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
3624
- const id = uuid4();
3973
+ const id = uuid5();
3625
3974
  await db.run(
3626
3975
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
3627
- [id, e, null, await hashPassword2(uuid4())]
3976
+ [id, e, null, await hashPassword2(uuid5())]
3628
3977
  );
3629
3978
  return id;
3630
3979
  }
@@ -3722,1059 +4071,1718 @@ grantRoutes.delete("/:id", async (c) => {
3722
4071
  return c.json({ deleted: true });
3723
4072
  });
3724
4073
 
3725
- // src/nests/nest-team-routes.ts
4074
+ // src/nests/publish-routes.ts
3726
4075
  import { Hono as Hono6 } from "hono";
3727
- var nestTeamRoutes = new Hono6();
3728
- nestTeamRoutes.get("/", async (c) => {
3729
- const nestId = c.req.param("nestId");
3730
- const refs = await listNestTeams(nestId);
3731
- const teams = [];
3732
- for (const ref of refs) {
3733
- const team = await getTeam(ref.teamId).catch(() => null);
3734
- teams.push({
3735
- teamId: ref.teamId,
3736
- teamName: team?.name ?? ref.teamName,
3737
- members: team?.members ?? []
3738
- });
3739
- }
3740
- return c.json({ teams });
3741
- });
3742
- nestTeamRoutes.post("/", async (c) => {
3743
- const body = await c.req.json();
3744
- if (!body.team_id) {
3745
- throw new ValidationError("team_id is required");
3746
- }
3747
- const userId = c.get("userId");
3748
- const teams = await shareTeamWithNest({
3749
- nestId: c.req.param("nestId"),
3750
- teamId: body.team_id,
3751
- callerUserId: userId,
3752
- allowAdmin: await isLicenseAdminUserId(userId),
3753
- baseUrl: requestBaseUrl(c.req.url)
3754
- });
3755
- return c.json({ teams }, 201);
3756
- });
3757
- nestTeamRoutes.delete("/:teamId", async (c) => {
3758
- const teams = await unshareTeamFromNest({
3759
- nestId: c.req.param("nestId"),
3760
- teamId: c.req.param("teamId")
3761
- });
3762
- return c.json({ teams });
3763
- });
3764
-
3765
- // src/governance/team-routes.ts
3766
- import { Hono as Hono7 } from "hono";
3767
4076
 
3768
- // src/governance/promptowl-teams.ts
3769
- var PROMPTOWL_SOURCE = "promptowl";
3770
- var IMPORTABLE_PROMPTOWL_ROLES = ["owner", "editor"];
3771
- function canImportPromptowlTeam(team) {
3772
- return IMPORTABLE_PROMPTOWL_ROLES.includes(
3773
- (team.your_role || "").toLowerCase()
3774
- );
3775
- }
3776
- function mapPromptowlRole(role) {
3777
- switch ((role || "").toLowerCase()) {
3778
- case "owner":
3779
- return "admin";
3780
- case "editor":
3781
- return "editor";
3782
- default:
3783
- return "viewer";
3784
- }
3785
- }
3786
- async function fetchOwnedPromptowlTeams(token) {
3787
- if (!token?.trim()) throw new ValidationError("token is required");
3788
- const promptowlUrl = config.PROMPTOWL_API_URL.replace(/\/$/, "");
3789
- 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) {
3790
4087
  try {
3791
- res = await fetch(`${promptowlUrl}/api/user/teams`, {
3792
- headers: {
3793
- ...config.PROMPTOWL_FETCH_HEADERS,
3794
- Authorization: `Bearer ${token}`
3795
- }
3796
- });
4088
+ const { storage } = await engineCache.get(nestId);
4089
+ await storage.readDocument(nodeId);
4090
+ return true;
3797
4091
  } catch {
3798
- throw new ValidationError("Could not reach PromptOwl. Check this server's network access.");
4092
+ return false;
3799
4093
  }
3800
- if (res.status === 401) {
3801
- throw new UnauthorizedError(
3802
- "PromptOwl rejected this approval. Approve again and retry."
3803
- );
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");
3804
4129
  }
3805
- if (!res.ok) {
3806
- throw new ValidationError(
3807
- `PromptOwl could not list your teams (HTTP ${res.status}).`
3808
- );
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");
3809
4134
  }
3810
- const data = await res.json();
3811
- return Array.isArray(data.teams) ? data.teams : [];
3812
- }
3813
- async function syncPromptowlTeams(params) {
3814
- if (!params.teamIds?.length) {
3815
- 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");
3816
4137
  }
3817
- const owned = await fetchOwnedPromptowlTeams(params.token);
3818
- const byId = new Map(owned.map((t) => [t.id, t]));
3819
- const selected = [];
3820
- for (const id of params.teamIds) {
3821
- const team = byId.get(id);
3822
- if (!team) {
3823
- throw new ValidationError(
3824
- "One of the selected teams isn't available to import. Refresh the list and try again."
3825
- );
3826
- }
3827
- if (!canImportPromptowlTeam(team)) {
3828
- throw new ForbiddenError(
3829
- `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]
3830
4174
  );
3831
4175
  }
3832
- selected.push(team);
3833
4176
  }
3834
- const db = getDb();
3835
- const caller = await db.get("SELECT email FROM users WHERE id = ?", [
3836
- 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
3837
4188
  ]);
3838
- if (!caller?.email) {
3839
- throw new ValidationError("Could not resolve your account.");
3840
- }
3841
- const results = [];
3842
- for (const poTeam of selected) {
3843
- results.push(await syncOne(poTeam, params.callerUserId, caller.email));
3844
- }
3845
- return results;
3846
4189
  }
3847
- async function syncOne(poTeam, callerUserId, callerEmail) {
3848
- const existing = await findTeamByExternalId({
3849
- ownerId: callerUserId,
3850
- source: PROMPTOWL_SOURCE,
3851
- externalId: poTeam.id
3852
- });
3853
- const team = existing ?? await createTeam({
3854
- name: poTeam.name,
3855
- ownerId: callerUserId,
3856
- source: PROMPTOWL_SOURCE,
3857
- externalId: poTeam.id
3858
- });
3859
- if (existing && existing.name !== poTeam.name) {
3860
- await renameTeam({
3861
- teamId: team.id,
3862
- name: poTeam.name,
3863
- callerUserId,
3864
- allowAdmin: false
3865
- });
3866
- }
3867
- const desired = await desiredRoster(poTeam, callerEmail);
3868
- const before = new Map(team.members.map((m) => [m.email, m.role]));
3869
- const after = new Map(desired.map((m) => [m.email, m.role]));
3870
- const added = [...after.keys()].filter((e) => !before.has(e));
3871
- const removed = [...before.keys()].filter((e) => !after.has(e));
3872
- const updated = [...after.keys()].filter(
3873
- (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]
3874
4194
  );
3875
- if (added.length || removed.length || updated.length) {
3876
- await replaceTeamRoster({
3877
- teamId: team.id,
3878
- members: desired,
3879
- callerUserId,
3880
- allowAdmin: false
3881
- });
3882
- }
3883
- return {
3884
- team_id: team.id,
3885
- external_id: poTeam.id,
3886
- name: poTeam.name,
3887
- created: !existing,
3888
- added,
3889
- removed,
3890
- updated
3891
- };
3892
4195
  }
3893
- async function desiredRoster(poTeam, callerEmail) {
3894
- const caller = normalizeEmail(callerEmail);
3895
- const roster = [];
3896
- const seen = /* @__PURE__ */ new Set();
3897
- for (const poMember of poTeam.members || []) {
3898
- if (!poMember?.email) continue;
3899
- const email = normalizeEmail(poMember.email);
3900
- if (email === caller || seen.has(email)) continue;
3901
- seen.add(email);
3902
- const identity = await resolveMemberIdentity({ email });
3903
- roster.push({
3904
- userId: identity.userId,
3905
- email: identity.email,
3906
- role: mapPromptowlRole(poMember.role)
3907
- });
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;
3908
4239
  }
3909
- 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
+ );
3910
4285
  }
3911
4286
 
3912
- // src/governance/team-routes.ts
3913
- var teamRoutes = new Hono7();
3914
- var PROMPTOWL_LIMIT = { max: 10, windowMs: 15 * 6e4 };
3915
- var CONNECT_REQUIRED = "promptowl_connect_required";
3916
- var CONNECT_REQUIRED_STATUS = 428;
3917
- async function withPromptowlToken(c, run) {
3918
- if (!config.PROMPTOWL_TEAMS_ENABLED) {
3919
- return c.json({ error: "Not found" }, 404);
3920
- }
3921
- if (!tryConsume(`po-teams:ip:${clientIp(c)}`, PROMPTOWL_LIMIT)) {
3922
- return c.json({ error: "Too many PromptOwl requests, try again later" }, 429);
3923
- }
3924
- const token = getPromptowlTokenFromRequest(c);
3925
- if (!token) {
3926
- return c.json(
3927
- {
3928
- error: "Sign in with PromptOwl to import teams.",
3929
- code: CONNECT_REQUIRED
3930
- },
3931
- 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."
3932
4293
  );
3933
4294
  }
3934
- try {
3935
- return c.json(await run(token));
3936
- } catch (err) {
3937
- if (err instanceof UnauthorizedError) {
3938
- c.header(
3939
- "Set-Cookie",
3940
- buildPromptowlTokenCookie("", { secure: isSecureRequest(c), clear: true }),
3941
- { append: true }
3942
- );
3943
- return c.json(
3944
- { error: err.message, code: CONNECT_REQUIRED },
3945
- CONNECT_REQUIRED_STATUS
3946
- );
3947
- }
3948
- throw err;
3949
- }
3950
4295
  }
3951
- teamRoutes.get(
3952
- "/promptowl",
3953
- async (c) => withPromptowlToken(c, async (token) => ({
3954
- teams: await fetchOwnedPromptowlTeams(token)
3955
- }))
3956
- );
3957
- teamRoutes.post("/promptowl/sync", async (c) => {
3958
- const body = await c.req.json().catch(() => ({}));
3959
- return withPromptowlToken(c, async (token) => ({
3960
- results: await syncPromptowlTeams({
3961
- callerUserId: c.get("userId"),
3962
- token,
3963
- teamIds: body.team_ids ?? []
3964
- })
3965
- }));
3966
- });
3967
- teamRoutes.post("/", async (c) => {
4296
+ publishRoutes.post("/", async (c) => {
4297
+ const nestId = c.req.param("nestId");
4298
+ await requireManage2(c, nestId);
3968
4299
  const body = await c.req.json();
3969
- const team = await createTeam({
3970
- name: body.name ?? "",
3971
- ownerId: c.get("userId")
3972
- });
3973
- return c.json({ team }, 201);
3974
- });
3975
- teamRoutes.get("/", async (c) => {
3976
- const teams = await listTeamsForUser(c.get("userId"));
3977
- return c.json({ teams });
3978
- });
3979
- teamRoutes.get("/:teamId", async (c) => {
3980
- const userId = c.get("userId");
3981
- const team = await getTeam(c.req.param("teamId"));
3982
- const isMember = team.members.some((m) => m.userId === userId);
3983
- if (team.owner_id !== userId && !isMember && !await isLicenseAdminUserId(userId)) {
3984
- 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
+ );
3985
4305
  }
3986
- return c.json({ team });
3987
- });
3988
- teamRoutes.patch("/:teamId", async (c) => {
3989
- const body = await c.req.json();
3990
- const userId = c.get("userId");
3991
- const team = await renameTeam({
3992
- teamId: c.req.param("teamId"),
3993
- name: body.name ?? "",
3994
- callerUserId: userId,
3995
- 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")
3996
4317
  });
3997
- return c.json({ team });
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);
3998
4331
  });
3999
- teamRoutes.delete("/:teamId", async (c) => {
4000
- const userId = c.get("userId");
4001
- await deleteTeam({
4002
- teamId: c.req.param("teamId"),
4003
- callerUserId: userId,
4004
- 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}` }))
4005
4340
  });
4006
- return c.json({ removed: true });
4007
4341
  });
4008
- teamRoutes.post("/:teamId/members", async (c) => {
4009
- const body = await c.req.json();
4010
- const userId = c.get("userId");
4011
- const team = await addMember({
4012
- teamId: c.req.param("teamId"),
4013
- email: body.email,
4014
- userId: body.user_id,
4015
- role: body.role ?? "",
4016
- callerUserId: userId,
4017
- allowAdmin: await isLicenseAdminUserId(userId)
4018
- });
4019
- return c.json({ team }, 201);
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 });
4020
4349
  });
4021
- teamRoutes.patch("/:teamId/members/:userId", async (c) => {
4022
- const body = await c.req.json();
4023
- const userId = c.get("userId");
4024
- const team = await updateMemberRole({
4025
- teamId: c.req.param("teamId"),
4026
- userId: c.req.param("userId"),
4027
- role: body.role ?? "",
4028
- callerUserId: userId,
4029
- allowAdmin: await isLicenseAdminUserId(userId)
4030
- });
4031
- return c.json({ team });
4032
- });
4033
- teamRoutes.delete("/:teamId/members/:userId", async (c) => {
4034
- const userId = c.get("userId");
4035
- const team = await removeMember({
4036
- teamId: c.req.param("teamId"),
4037
- userId: c.req.param("userId"),
4038
- callerUserId: userId,
4039
- allowAdmin: await isLicenseAdminUserId(userId)
4040
- });
4041
- 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 });
4042
4356
  });
4043
4357
 
4044
- // src/nodes/search.ts
4045
- async function discoverForSearch(storage) {
4046
- 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 } : {} };
4047
4376
  }
4048
- function titleScore(title, query) {
4049
- const t = (title || "").toLowerCase();
4050
- const q = query.trim().toLowerCase();
4051
- if (!q) return 0;
4052
- if (t === q) return 4;
4053
- let midWord = false;
4054
- for (let i = t.indexOf(q); i !== -1; i = t.indexOf(q, i + 1)) {
4055
- if (i === 0) return 3;
4056
- if (!/[a-z0-9]/.test(t[i - 1])) return 2;
4057
- 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 -->";
4058
4383
  }
4059
- if (midWord) return 1;
4060
- const terms = q.split(/\s+/).filter(Boolean);
4061
- if (terms.length > 1 && terms.every((term) => t.includes(term))) return 1;
4062
- 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} -->`;
4063
4389
  }
4064
- function matchDocuments(documents, query) {
4065
- const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
4066
- if (terms.length === 0) return [];
4067
- return documents.filter((node) => {
4068
- const haystack = [
4069
- node.frontmatter.title,
4070
- node.body || "",
4071
- node.frontmatter.type || "",
4072
- ...node.frontmatter.tags || []
4073
- ].join(" ").toLowerCase();
4074
- return terms.every((term) => haystack.includes(term));
4075
- }).sort(
4076
- (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)."
4077
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");
4078
4446
  }
4079
- function bodySnippet(body, query) {
4080
- const flat = body.replace(/\s+/g, " ").trim();
4081
- const terms = (query || "").toLowerCase().split(/\s+/).filter(Boolean);
4082
- const at = terms.map((t) => flat.toLowerCase().indexOf(t)).filter((i) => i >= 0).sort((a, b) => a - b)[0];
4083
- if (at === void 0 || at < 80) return flat.slice(0, 200);
4084
- const start = Math.max(0, at - 60);
4085
- 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
+ }));
4086
4461
  }
4087
- 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
+ }
4088
4471
  return {
4089
- id: node.id,
4090
- title: node.frontmatter.title,
4091
- type: node.frontmatter.type || "document",
4092
- tags: node.frontmatter.tags || [],
4093
- 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)
4094
4483
  };
4095
4484
  }
4096
-
4097
- // src/nodes/routes.ts
4098
- import { Hono as Hono8 } from "hono";
4099
-
4100
- // src/nodes/markdown-export.ts
4101
- import { parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
4102
- function nodeToMarkdown(node) {
4103
- const tags = node.tags ?? [];
4104
- return [
4105
- "---",
4106
- `title: ${JSON.stringify(node.title ?? "")}`,
4107
- `tags: [${tags.map((t) => JSON.stringify(t)).join(", ")}]`,
4108
- `status: ${JSON.stringify(node.status ?? "")}`,
4109
- `id: ${JSON.stringify(node.id)}`,
4110
- "---",
4111
- ""
4112
- ].join("\n") + (node.body ?? "");
4485
+ async function getThreadRow(threadId) {
4486
+ return await getDb().get("SELECT * FROM annotation_threads WHERE id = ?", [threadId]);
4113
4487
  }
4114
- function nodesToMarkdown(nodes) {
4115
- return nodes.map(nodeToMarkdown).join("\n\n---\n\n");
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;
4116
4499
  }
4117
- function isMarkdownFormat(c) {
4118
- return c.req.query("format") === "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));
4119
4506
  }
4120
- function isRawFormat(c) {
4121
- const format = c.req.query("format");
4122
- if (format !== void 0) return format === "raw";
4123
- const accept = c.req.header("Accept") ?? "";
4124
- return accept.split(",").some((part) => part.trim().split(";")[0].trim().toLowerCase() === "text/markdown");
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));
4125
4536
  }
4126
- var RAW_CONTENT_TYPES = {
4127
- artifact: "text/html; charset=utf-8",
4128
- tool: "text/plain; charset=utf-8",
4129
- table: "text/plain; charset=utf-8"
4130
- };
4131
- function rawContentType(nodeType) {
4132
- return RAW_CONTENT_TYPES[nodeType || "document"] ?? "text/markdown; charset=utf-8";
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}`);
4541
+ }
4542
+ return row;
4133
4543
  }
4134
- function frontmatterBlockOf(raw) {
4135
- if (!raw || !raw.startsWith("---")) return "";
4136
- try {
4137
- const body = parseDocument2("frontmatter-split.md", raw, "frontmatter-split").body ?? "";
4138
- if (raw.endsWith(body)) return raw.slice(0, raw.length - body.length);
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));
4555
+ }
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
+ );
4569
+ }
4570
+ return rowToThread(await getThreadRow(threadId));
4571
+ }
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 || "";
4139
4584
  } catch {
4585
+ liveExists = false;
4586
+ }
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
+ }
4597
+ }
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
4646
+ );
4140
4647
  }
4141
- const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
4142
- return m ? `---
4143
- ${m[1]}
4144
- ---
4145
-
4146
- ` : "";
4147
4648
  }
4148
4649
 
4149
- // src/nodes/routes.ts
4150
- var nodeRoutes = new Hono8();
4151
- function nodeAsMarkdown(response, nodeId) {
4152
- return nodeToMarkdown({
4153
- id: nodeId,
4154
- title: response.title,
4155
- tags: response.tags,
4156
- status: response.status,
4157
- body: response.content
4158
- });
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}`;
4159
4677
  }
4160
- function rawNodeBody(c, response, storedRaw) {
4161
- const body = response.content ?? "";
4162
- const payload = c.req.query("frontmatter") === "1" ? frontmatterBlockOf(storedRaw) + body : body;
4163
- c.header("X-Content-Type-Options", "nosniff");
4164
- if ((response.type || "document") === "artifact") {
4165
- applyArtifactSandboxHeaders(c);
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
+ }
4166
4691
  }
4167
- return c.body(payload, 200, {
4168
- "Content-Type": rawContentType(response.type)
4169
- });
4692
+ return null;
4170
4693
  }
4171
- nodeRoutes.get("/", async (c) => {
4172
- const nestId = c.req.param("nestId");
4173
- const userId = c.get("userId");
4174
- const q = c.req.query();
4175
- const limit = q.limit ? parseInt(q.limit, 10) : void 0;
4176
- const nodes = await listNodesForCaller(nestId, userId, {
4177
- ...q.type ? { type: q.type } : {},
4178
- ...q.tag ? { tag: q.tag } : {},
4179
- ...q.status ? { status: q.status } : {},
4180
- // Lets an owner preview what making the nest public would expose.
4181
- ...q.approved_only === "1" || q.approved_only === "true" ? { approvedOnly: true } : {},
4182
- // Ignore junk rather than 400 — a bad ?limit should not fail a listing.
4183
- ...limit !== void 0 && Number.isFinite(limit) && limit > 0 ? { limit } : {},
4184
- // Present (even as "") = one folder level only, for the lazily-expanded
4185
- // document tree. Absent = the whole nest, as every existing caller expects.
4186
- ...q.folder !== void 0 ? { folder: q.folder } : {},
4187
- // Bodies are off by default — the list is served from the DB index, which
4188
- // holds metadata only. Opt back in and the listing crawls the vault for
4189
- // them; kept for any client that was reading `content` off this route.
4190
- ...q.content === "1" || q.content === "true" ? { withContent: true } : {}
4191
- });
4192
- return c.json({ count: nodes.length, nodes });
4193
- });
4194
- nodeRoutes.post("/", async (c) => {
4195
- const body = await c.req.json();
4196
- if (!body.title || !body.content) {
4197
- throw new ValidationError("title and content are required");
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) {
4747
+ try {
4748
+ return (await readArtifact(row.nest_id, row.node_id)).title || row.node_id;
4749
+ } catch {
4750
+ return row.node_id;
4198
4751
  }
4199
- if (body.folder !== void 0 && typeof body.folder !== "string") {
4200
- throw new ValidationError('folder must be a string path like "gtm/deals"');
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);
4201
4764
  }
4202
- const nestId = c.req.param("nestId");
4203
- const authorEmail = await getUserEmail(c);
4204
- const { node } = await createNode(
4205
- nestId,
4206
- {
4207
- title: body.title,
4208
- content: body.content,
4209
- type: body.type,
4210
- tags: body.tags,
4211
- scope: body.scope,
4212
- status: body.status,
4213
- schedule: body.schedule,
4214
- folder: body.folder,
4215
- prime: body.prime
4216
- },
4217
- authorEmail
4218
- );
4219
- const resolved = await resolveStewardsForNode(nestId, node.id);
4220
- return c.json({
4221
- node: toNodeResponse(node),
4222
- stewards: resolved.length > 0 ? resolved.map((r) => ({
4223
- email: r.steward.userEmail,
4224
- role: r.steward.role,
4225
- source: r.source
4226
- })) : void 0
4227
- }, 201);
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
+ });
4776
+ }
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 });
4228
4976
  });
4229
- nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
4230
- const nestId = c.req.param("nestId");
4231
- const nodeId = c.req.param("nodeId");
4232
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-U4CKL7Y5.js");
4233
- const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
4234
- nestId,
4235
- nodeId
4236
- );
4237
- return c.json({
4238
- nodeId,
4239
- stewards: stewards.map((r) => ({
4240
- email: r.steward.userEmail,
4241
- role: r.steward.role,
4242
- scope: r.steward.scope,
4243
- source: r.source,
4244
- priority: r.priority
4245
- })),
4246
- fallbackToOwner,
4247
- ownerEmail
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
4248
5006
  });
5007
+ return c.html(sentPage());
4249
5008
  });
4250
- nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
4251
- const nestId = c.req.param("nestId");
4252
- const nodeId = c.req.param("nodeId");
4253
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-H2M5ACPQ.js");
4254
- const history = await getReviewHistory2(nestId, nodeId);
4255
- return c.json({ reviews: history });
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);
5016
+ }
5017
+ setSessionCookie2(c, slug, await createSession2(row.id, email));
5018
+ return c.redirect(`/p/${slug}`);
4256
5019
  });
4257
- nodeRoutes.post("/:nodeId{.+}/revert", 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) => {
4258
5025
  const nestId = c.req.param("nestId");
4259
- const nodeId = c.req.param("nodeId");
4260
- const { versions: versionManager } = await engineCache.get(nestId);
4261
- const userId = c.get("userId");
4262
- const userEmail = await resolveCallerEmail(userId);
4263
- if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
4264
- return c.json(
4265
- { error: "Access denied \u2014 no steward assignment for this node" },
4266
- 403
4267
- );
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
+ });
4268
5035
  }
4269
- const body = await c.req.json().catch(() => ({}));
4270
- const targetVersion = Number(body.targetVersion);
4271
- if (!Number.isInteger(targetVersion) || targetVersion < 1) {
4272
- throw new ValidationError("targetVersion (a positive integer) is required");
5036
+ return c.json({ teams });
5037
+ });
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");
4273
5042
  }
4274
- let raw;
5043
+ const userId = c.get("userId");
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()
5070
+ );
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;
4275
5086
  try {
4276
- raw = await versionManager.reconstructVersion(nodeId, targetVersion);
5087
+ res = await fetch(`${promptowlUrl}/api/user/teams`, {
5088
+ headers: {
5089
+ ...config.PROMPTOWL_FETCH_HEADERS,
5090
+ Authorization: `Bearer ${token}`
5091
+ }
5092
+ });
4277
5093
  } catch {
4278
- throw new NotFoundError(
4279
- `Version ${targetVersion} not found for ${nodeId}`
4280
- );
5094
+ throw new ValidationError("Could not reach PromptOwl. Check this server's network access.");
4281
5095
  }
4282
- const content = bodyOnly(nodeId, raw);
4283
- const { node, version } = await updateNode(
4284
- nestId,
4285
- nodeId,
4286
- { content, changeNote: `Restored from version ${targetVersion}` },
4287
- userEmail
4288
- );
4289
- await trackEvent("node.revert", { nestId, nodeId, targetVersion });
4290
- return c.json({ ok: true, version, node: toNodeResponse(node) });
4291
- });
4292
- nodeRoutes.post("/:nodeId{.+}/move", async (c) => {
4293
- const nestId = c.req.param("nestId");
4294
- const nodeId = c.req.param("nodeId");
4295
- const userId = c.get("userId");
4296
- const userEmail = await resolveCallerEmail(userId);
4297
- if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
4298
- return c.json(
4299
- { error: "Access denied \u2014 no steward assignment for this node" },
4300
- 403
5096
+ if (res.status === 401) {
5097
+ throw new UnauthorizedError(
5098
+ "PromptOwl rejected this approval. Approve again and retry."
4301
5099
  );
4302
5100
  }
4303
- const body = await c.req.json().catch(() => ({}));
4304
- if (typeof body.folder !== "string") {
4305
- throw new ValidationError('folder (a string; "" for the nest root) is required');
4306
- }
4307
- const { node, previousId } = await moveNode(nestId, nodeId, body.folder, userEmail);
4308
- return c.json({ ok: true, node: toNodeResponse(node), previousId });
4309
- });
4310
- nodeRoutes.get("/:nodeId{.+}/watchers", async (c) => {
4311
- const nestId = c.req.param("nestId");
4312
- const nodeId = c.req.param("nodeId");
4313
- return c.json({ watchers: await listWatchers(nestId, nodeId) });
4314
- });
4315
- nodeRoutes.post("/:nodeId{.+}/watchers", async (c) => {
4316
- const nestId = c.req.param("nestId");
4317
- const nodeId = c.req.param("nodeId");
4318
- const body = await c.req.json().catch(() => ({}));
4319
- const email = typeof body.email === "string" ? body.email.trim() : "";
4320
- if (!email || !email.includes("@")) {
4321
- throw new ValidationError("email is required");
5101
+ if (!res.ok) {
5102
+ throw new ValidationError(
5103
+ `PromptOwl could not list your teams (HTTP ${res.status}).`
5104
+ );
4322
5105
  }
4323
- await addWatcher(nestId, nodeId, email, await getUserEmail(c));
4324
- return c.json({ watchers: await listWatchers(nestId, nodeId) }, 201);
4325
- });
4326
- nodeRoutes.delete("/:nodeId{.+}/watchers", async (c) => {
4327
- const nestId = c.req.param("nestId");
4328
- const nodeId = c.req.param("nodeId");
4329
- const email = c.req.query("email")?.trim() ?? "";
4330
- if (!email) throw new ValidationError("email query param is required");
4331
- if (!await removeWatcher(nestId, nodeId, email)) {
4332
- throw new NotFoundError("Not watching");
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");
4333
5112
  }
4334
- return c.json({ watchers: await listWatchers(nestId, nodeId) });
4335
- });
4336
- nodeRoutes.get("/:nodeId{.+}", async (c) => {
4337
- const nestId = c.req.param("nestId");
4338
- const nodeId = c.req.param("nodeId");
4339
- const { storage, versions: versionManager } = await engineCache.get(nestId);
4340
- const userId = c.get("userId");
4341
- const userEmail = await resolveCallerEmail(userId);
4342
- let node = await getNodeForCaller(
4343
- nestId,
4344
- { id: nodeId },
4345
- userId,
4346
- userEmail,
4347
- { verifyChecksum: true }
4348
- );
4349
- if (node.pendingChange) {
4350
- try {
4351
- await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
4352
- const refreshed = await getPendingChange(nestId, nodeId);
4353
- if (refreshed) node.pendingChange = refreshed;
4354
- } catch (err) {
4355
- console.error("[external-edit] stage-on-read failed:", err);
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
+ );
4356
5122
  }
4357
- try {
4358
- const latest = await loadLatestApprovedNode(nestId, nodeId);
4359
- if (latest) {
4360
- node = { ...latest, pendingChange: node.pendingChange };
4361
- }
4362
- } catch (err) {
4363
- console.error("[external-edit] reconstruct-latest failed:", err);
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
+ );
4364
5127
  }
5128
+ selected.push(team);
4365
5129
  }
4366
- const response = toNodeResponse(node);
4367
- const publicReader = await isPublicReader(nestId, userId);
4368
- if (!publicReader) {
4369
- const people = (await resolveAuthorship(nestId, [
4370
- { id: nodeId, frontmatterAuthor: node.frontmatter?.author ?? null }
4371
- ])).get(nodeId);
4372
- if (people) {
4373
- response.author = people.author;
4374
- response.collaborators = people.collaborators;
4375
- }
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.");
4376
5136
  }
4377
- response.myGrant = await resolveNodeGrant(nestId, userId, nodeId);
4378
- if (publicReader) {
4379
- const approved = await getApprovedVersion(nestId, nodeId);
4380
- if (approved != null) {
4381
- let approvedRaw = "";
4382
- try {
4383
- approvedRaw = await versionManager.reconstructVersion(
4384
- nodeId,
4385
- approved
4386
- );
4387
- response.content = bodyOnly(nodeId, approvedRaw);
4388
- } catch (err) {
4389
- console.error(
4390
- "reconstructVersion failed (public single)",
4391
- nodeId,
4392
- approved,
4393
- err
4394
- );
4395
- response.content = "";
4396
- }
4397
- response.version = approved;
4398
- response.status = "published";
4399
- if (isRawFormat(c)) {
4400
- return rawNodeBody(c, response, approvedRaw);
4401
- }
4402
- if (isMarkdownFormat(c)) {
4403
- return c.body(nodeAsMarkdown(response, nodeId), 200, {
4404
- "Content-Type": "text/markdown; charset=utf-8"
4405
- });
4406
- }
4407
- return c.json({ node: response });
4408
- }
5137
+ const results = [];
5138
+ for (const poTeam of selected) {
5139
+ results.push(await syncOne(poTeam, params.callerUserId, caller.email));
4409
5140
  }
4410
- response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
4411
- if (response.status === "pending_review") {
4412
- const pending = await getPendingReview(nestId, nodeId);
4413
- response.pendingReviewBy = pending?.requestedBy ?? null;
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
+ });
4414
5162
  }
4415
- response.deletionRequest = await getLatestDeletionRequest(
4416
- nestId,
4417
- nodeId
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)
4418
5170
  );
4419
- if (isRawFormat(c)) {
4420
- 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
+ });
4421
5178
  }
4422
- if (isMarkdownFormat(c)) {
4423
- return c.body(nodeAsMarkdown(response, nodeId), 200, {
4424
- "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)
4425
5203
  });
4426
5204
  }
4427
- return c.json({ node: response });
4428
- });
4429
- nodeRoutes.patch("/:nodeId{.+}", async (c) => {
4430
- const nestId = c.req.param("nestId");
4431
- const nodeId = c.req.param("nodeId");
4432
- const body = await c.req.json();
4433
- const baseVersionHeader = c.req.header("X-Base-Version");
4434
- if (baseVersionHeader) {
4435
- const baseVersion = parseInt(baseVersionHeader, 10);
4436
- const conflict = await checkConflict(nestId, nodeId, baseVersion);
4437
- 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
+ );
4438
5239
  return c.json(
4439
- {
4440
- error: "Version conflict",
4441
- your_version: baseVersion,
4442
- current_version: conflict.currentVersion,
4443
- updated_by: conflict.updatedBy,
4444
- updated_at: conflict.updatedAt,
4445
- rejected_content: body.content || body.append || null
4446
- },
4447
- 409
5240
+ { error: err.message, code: CONNECT_REQUIRED },
5241
+ CONNECT_REQUIRED_STATUS
4448
5242
  );
4449
5243
  }
5244
+ throw err;
4450
5245
  }
4451
- const authorEmail = await getUserEmail(c);
4452
- const { node, version: responseVersion } = await updateNode(
4453
- nestId,
4454
- nodeId,
4455
- {
4456
- content: body.content,
4457
- append: body.append,
4458
- tags: body.tags,
4459
- title: body.title,
4460
- status: body.status,
4461
- changeNote: body.changeNote,
4462
- schedule: body.schedule,
4463
- prime: body.prime
4464
- },
4465
- authorEmail
4466
- );
4467
- 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
+ }));
4468
5262
  });
4469
- nodeRoutes.delete("/:nodeId{.+}", async (c) => {
4470
- const nestId = c.req.param("nestId");
4471
- const nodeId = c.req.param("nodeId");
4472
- await deleteNode(nestId, nodeId, await getUserEmail(c));
4473
- 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);
4474
5270
  });
4475
- 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) => {
4476
5276
  const userId = c.get("userId");
4477
- const db = getDb();
4478
- const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
4479
- return user?.email || "anonymous@localhost";
4480
- }
4481
-
4482
- // src/annotations/routes.ts
4483
- import { Hono as Hono9 } from "hono";
4484
-
4485
- // src/annotations/service.ts
4486
- import { v4 as uuid5 } from "uuid";
4487
-
4488
- // src/annotations/projection.ts
4489
- var MAX_CONTEXT_CHARS = 250;
4490
- var MAX_QUOTE_CHARS = 1e3;
4491
- function clampAnchor(raw) {
4492
- if (!raw) return null;
4493
- const quote = (raw.quote ?? "").trim().slice(0, MAX_QUOTE_CHARS);
4494
- if (!quote) return null;
4495
- const before = (raw.before ?? "").slice(-MAX_CONTEXT_CHARS);
4496
- const after = (raw.after ?? "").slice(0, MAX_CONTEXT_CHARS);
4497
- const line = typeof raw.line === "number" && Number.isFinite(raw.line) && raw.line > 0 ? Math.floor(raw.line) : void 0;
4498
- return { quote, before, after, ...line !== void 0 ? { line } : {} };
4499
- }
4500
- function oneLine(s) {
4501
- return s.replace(/\s+/g, " ").replace(/--+>/g, "-\u2192").trim();
4502
- }
4503
- function renderAnchor(anchor) {
4504
- if (!anchor) {
4505
- return "<!-- anchor: whole-artifact -->";
4506
- }
4507
- const linePart = anchor.line !== void 0 ? `line ${anchor.line} \xB7 ` : "";
4508
- const context = `\u2026${oneLine(anchor.before)}\u3008${oneLine(anchor.quote)}\u3009${oneLine(
4509
- anchor.after
4510
- )}\u2026`;
4511
- return `<!-- anchor: ${linePart}quote "${oneLine(anchor.quote)}" \xB7 context ${context} -->`;
4512
- }
4513
- function statusHeading(thread) {
4514
- if (thread.status === "resolved") {
4515
- const who = thread.resolvedBy ? `${thread.resolvedBy}, ` : "";
4516
- const when = thread.resolvedAt ? `${thread.resolvedAt}` : "";
4517
- const meta = who || when ? ` (${who}${when})` : "";
4518
- return `\u2705 RESOLVED${meta}`;
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.");
4519
5281
  }
4520
- return "\u{1F7E0} OPEN";
4521
- }
4522
- function snapshotLabel(v) {
4523
- return v == null ? "Unpinned" : `Snapshot v${v}`;
4524
- }
4525
- function orderThreads(threads) {
4526
- return [...threads].sort((a, b) => {
4527
- if (a.status !== b.status) return a.status === "open" ? -1 : 1;
4528
- return a.createdAt.localeCompare(b.createdAt);
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)
4529
5292
  });
4530
- }
4531
- function projectThreadsToMarkdown(artifactTitle, threads) {
4532
- const lines = [];
4533
- lines.push(`# ${artifactTitle} \u2014 Annotations`);
4534
- lines.push("");
4535
- lines.push(
4536
- "> 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)."
4537
- );
4538
- lines.push("");
4539
- if (threads.length === 0) {
4540
- lines.push("_No annotations yet._");
4541
- lines.push("");
4542
- return lines.join("\n");
4543
- }
4544
- const buckets2 = /* @__PURE__ */ new Map();
4545
- for (const t of threads) {
4546
- const key = t.snapshotVersion ?? null;
4547
- (buckets2.get(key) ?? buckets2.set(key, []).get(key)).push(t);
4548
- }
4549
- const keys = [...buckets2.keys()].sort((a, b) => {
4550
- if (a == null) return 1;
4551
- if (b == null) return -1;
4552
- return b - a;
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)
4553
5301
  });
4554
- for (const key of keys) {
4555
- lines.push(`## ${snapshotLabel(key)}`);
4556
- lines.push("");
4557
- for (const thread of orderThreads(buckets2.get(key))) {
4558
- lines.push(`### Thread \xB7 ${statusHeading(thread)}`);
4559
- lines.push(renderAnchor(thread.anchor));
4560
- for (const comment of thread.comments) {
4561
- lines.push(
4562
- `- **${comment.author}** \xB7 ${comment.createdAt} \u2014 ${comment.body}`
4563
- );
4564
- }
4565
- lines.push("");
4566
- }
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
+ });
5339
+
5340
+ // src/nodes/search.ts
5341
+ async function discoverForSearch(storage) {
5342
+ return storage.discoverDocuments({ includeRetired: true });
5343
+ }
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;
4567
5354
  }
4568
- return lines.join("\n");
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;
4569
5359
  }
4570
-
4571
- // src/annotations/service.ts
4572
- async function loadComments(threadId) {
4573
- const db = getDb();
4574
- const rows = await db.all(
4575
- "SELECT id, author, body, created_at FROM annotation_comments WHERE thread_id = ? ORDER BY created_at ASC, id ASC",
4576
- [threadId]
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)
4577
5373
  );
4578
- return rows.map((r) => ({
4579
- id: r.id,
4580
- author: r.author,
4581
- body: r.body,
4582
- createdAt: r.created_at
4583
- }));
4584
5374
  }
4585
- async function rowToThread(row) {
4586
- let anchor = null;
4587
- if (row.anchor_json) {
4588
- try {
4589
- anchor = JSON.parse(row.anchor_json);
4590
- } catch {
4591
- anchor = null;
4592
- }
4593
- }
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)}`;
5382
+ }
5383
+ function toSearchHit(node, query) {
4594
5384
  return {
4595
- id: row.id,
4596
- nestId: row.nest_id,
4597
- nodeId: row.node_id,
4598
- snapshotVersion: row.snapshot_version,
4599
- anchor,
4600
- status: row.status,
4601
- createdBy: row.created_by,
4602
- createdAt: row.created_at,
4603
- resolvedBy: row.resolved_by,
4604
- resolvedAt: row.resolved_at,
4605
- 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)
4606
5390
  };
4607
5391
  }
4608
- async function getThreadRow(threadId) {
4609
- 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 ?? "");
4610
5409
  }
4611
- async function countThreadsByNode(nestId) {
4612
- const rows = await getDb().all(
4613
- `SELECT node_id, CAST(COUNT(*) AS INTEGER) AS count, MAX(created_at) AS last_at
4614
- FROM annotation_threads
4615
- WHERE nest_id = ?
4616
- GROUP BY node_id`,
4617
- [nestId]
4618
- );
4619
- const out = {};
4620
- for (const r of rows) out[r.node_id] = { count: r.count, lastAt: r.last_at };
4621
- return out;
5410
+ function nodesToMarkdown(nodes) {
5411
+ return nodes.map(nodeToMarkdown).join("\n\n---\n\n");
4622
5412
  }
4623
- async function listThreads(nestId, nodeId) {
4624
- const rows = await getDb().all(
4625
- "SELECT * FROM annotation_threads WHERE nest_id = ? AND node_id = ? ORDER BY created_at ASC, id ASC",
4626
- [nestId, nodeId]
4627
- );
4628
- return Promise.all(rows.map(rowToThread));
5413
+ function isMarkdownFormat(c) {
5414
+ return c.req.query("format") === "markdown";
4629
5415
  }
4630
- async function createThread(nestId, nodeId, input, authorEmail) {
4631
- const body = (input.body ?? "").trim();
4632
- if (!body) {
4633
- 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 {
4634
5436
  }
4635
- const db = getDb();
4636
- const id = uuid5();
4637
- const anchor = clampAnchor(input.anchor);
4638
- const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
4639
- await db.transaction(async (tx) => {
4640
- await tx.run(
4641
- `INSERT INTO annotation_threads
4642
- (id, nest_id, node_id, snapshot_version, anchor_json, status, created_by)
4643
- VALUES (?, ?, ?, ?, ?, 'open', ?)`,
4644
- [
4645
- id,
4646
- nestId,
4647
- nodeId,
4648
- snapshot,
4649
- anchor ? JSON.stringify(anchor) : null,
4650
- authorEmail
4651
- ]
4652
- );
4653
- await tx.run(
4654
- "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4655
- [uuid5(), id, authorEmail, body]
4656
- );
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
4657
5454
  });
4658
- return rowToThread(await getThreadRow(id));
4659
5455
  }
4660
- async function getScopedThreadRow(threadId, nestId, nodeId) {
4661
- const row = await getThreadRow(threadId);
4662
- if (!row || row.nest_id !== nestId || row.node_id !== nodeId) {
4663
- 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);
4664
5462
  }
4665
- return row;
5463
+ return c.body(payload, 200, {
5464
+ "Content-Type": rawContentType(response.type)
5465
+ });
4666
5466
  }
4667
- async function addComment(nestId, nodeId, threadId, authorEmail, body) {
4668
- const trimmed = (body ?? "").trim();
4669
- if (!trimmed) {
4670
- 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");
4671
5494
  }
4672
- await getScopedThreadRow(threadId, nestId, nodeId);
4673
- await getDb().run(
4674
- "INSERT INTO annotation_comments (id, thread_id, author, body) VALUES (?, ?, ?, ?)",
4675
- [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
4676
5514
  );
4677
- return rowToThread(await getThreadRow(threadId));
4678
- }
4679
- async function setThreadStatus(nestId, nodeId, threadId, status, byEmail) {
4680
- await getScopedThreadRow(threadId, nestId, nodeId);
4681
- const db = getDb();
4682
- if (status === "resolved") {
4683
- await db.run(
4684
- `UPDATE annotation_threads SET status = 'resolved', resolved_by = ?, resolved_at = ${nowExpr(db)} WHERE id = ?`,
4685
- [byEmail, threadId]
4686
- );
4687
- } else {
4688
- await db.run(
4689
- "UPDATE annotation_threads SET status = 'open', resolved_by = NULL, resolved_at = NULL WHERE id = ?",
4690
- [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
4691
5563
  );
4692
5564
  }
4693
- return rowToThread(await getThreadRow(threadId));
4694
- }
4695
- async function readArtifact(nestId, nodeId, version) {
4696
- const { storage, versions: versionManager } = await engineCache.get(nestId);
4697
- const target = version ?? await getApprovedVersion(nestId, nodeId) ?? null;
4698
- let title = nodeId;
4699
- let type = null;
4700
- let liveHtml = "";
4701
- 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;
4702
5571
  try {
4703
- const node = await storage.readDocument(nodeId);
4704
- title = node.frontmatter?.title || nodeId;
4705
- type = node.frontmatter?.type || null;
4706
- liveHtml = node.body || "";
5572
+ raw = await versionManager.reconstructVersion(nodeId, targetVersion);
4707
5573
  } catch {
4708
- liveExists = false;
5574
+ throw new NotFoundError(
5575
+ `Version ${targetVersion} not found for ${nodeId}`
5576
+ );
4709
5577
  }
4710
- if (target != null) {
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
+ );
5598
+ }
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');
5602
+ }
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) {
4711
5646
  try {
4712
- const raw = await versionManager.reconstructVersion(nodeId, target);
4713
- return { title, html: bodyOnly(nodeId, raw), version: target, type };
5647
+ await scanDocumentForDrift(nestId, nodeId, userEmail || "system:read");
5648
+ const refreshed = await getPendingChange(nestId, nodeId);
5649
+ if (refreshed) node.pendingChange = refreshed;
4714
5650
  } catch (err) {
4715
- console.warn(
4716
- `[annotations] reconstruct v${target} failed for ${nestId}/${nodeId}; falling back to live body:`,
4717
- err
4718
- );
5651
+ console.error("[external-edit] stage-on-read failed:", err);
5652
+ }
5653
+ try {
5654
+ const latest = await loadLatestApprovedNode(nestId, nodeId);
5655
+ if (latest) {
5656
+ node = { ...latest, pendingChange: node.pendingChange };
5657
+ }
5658
+ } catch (err) {
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
5682
+ );
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
+ });
5702
+ }
5703
+ return c.json({ node: response });
4719
5704
  }
4720
5705
  }
4721
- if (!liveExists) {
4722
- throw new NotFoundError(`Artifact not found: ${nodeId}`);
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;
4723
5710
  }
4724
- return { title, html: liveHtml, version: target, type };
4725
- }
4726
- function derivedAnnotationsId(sourceNodeId) {
4727
- const base = sourceNodeId.replace(/^nodes\//, "");
4728
- return `nodes/${base}--annotations`;
4729
- }
4730
- async function syncAnnotationsNode(nestId, nodeId, userEmail) {
4731
- try {
4732
- let title = nodeId;
4733
- let type = null;
4734
- try {
4735
- const { storage } = await engineCache.get(nestId);
4736
- const node = await storage.readDocument(nodeId);
4737
- title = node.frontmatter?.title || nodeId;
4738
- type = node.frontmatter?.type || "document";
4739
- } catch {
4740
- }
4741
- if (type !== null && type !== "artifact") return;
4742
- const threads = await listThreads(nestId, nodeId);
4743
- const derivedTitle = `${title.slice(0, 100)} \u2014 Annotations`;
4744
- const markdown = projectThreadsToMarkdown(title, threads);
4745
- const derivedId = derivedAnnotationsId(nodeId);
4746
- try {
4747
- await updateNode(nestId, derivedId, { content: markdown }, userEmail);
4748
- } catch (err) {
4749
- if (err instanceof NotFoundError) {
4750
- await createNode(
4751
- nestId,
4752
- {
4753
- id: derivedId,
4754
- title: derivedTitle,
4755
- content: markdown,
4756
- type: "document",
4757
- tags: ["annotations"]
4758
- },
4759
- userEmail
4760
- );
4761
- } else {
4762
- throw err;
4763
- }
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
+ );
4764
5745
  }
4765
- } catch (err) {
4766
- console.warn(
4767
- `[annotations] failed to sync derived node for ${nestId}/${nodeId}:`,
4768
- err
4769
- );
4770
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";
4771
5776
  }
4772
5777
 
5778
+ // src/annotations/routes.ts
5779
+ import { Hono as Hono11 } from "hono";
5780
+
4773
5781
  // src/annotations/types.ts
4774
5782
  var ARTIFACT_NODE_TYPE = "artifact";
4775
5783
 
4776
5784
  // src/annotations/routes.ts
4777
- var annotationRoutes = new Hono9();
5785
+ var annotationRoutes = new Hono11();
4778
5786
  function getNodeId(c) {
4779
5787
  const raw = c.req.param("nodeId");
4780
5788
  try {
@@ -4888,8 +5896,7 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
4888
5896
  });
4889
5897
 
4890
5898
  // src/nodes/query-routes.ts
4891
- import { Hono as Hono10 } from "hono";
4892
- import { serializeDocument as serializeDocument3 } from "@promptowl/contextnest-engine";
5899
+ import { Hono as Hono12 } from "hono";
4893
5900
 
4894
5901
  // src/tables/service.ts
4895
5902
  function splitCsvLine(line) {
@@ -5390,7 +6397,9 @@ function extractWikiTargets(body) {
5390
6397
  }
5391
6398
  async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
5392
6399
  const { storage } = await engineCache.get(nestId);
5393
- const docs = await storage.discoverDocuments();
6400
+ const docs = (await storage.discoverDocuments()).filter(
6401
+ (d) => !isDerivedAnnotationsNode(d.id)
6402
+ );
5394
6403
  const accessible = await filterAccessible(nestId, userId, userEmail, docs);
5395
6404
  const idSet = new Set(accessible.map((d) => d.id));
5396
6405
  const titleToId = /* @__PURE__ */ new Map();
@@ -5521,7 +6530,7 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
5521
6530
  }
5522
6531
 
5523
6532
  // src/nodes/query-routes.ts
5524
- var queryRoutes = new Hono10();
6533
+ var queryRoutes = new Hono12();
5525
6534
  queryRoutes.get("/changes", async (c) => {
5526
6535
  const nestId = c.req.param("nestId");
5527
6536
  const userId = c.get("userId");
@@ -5649,11 +6658,15 @@ queryRoutes.get("/graph", async (c) => {
5649
6658
  function approxTokens(text) {
5650
6659
  return Math.ceil(text.length / 4);
5651
6660
  }
5652
- function formatContextBlock(node) {
6661
+ function formatContextBlock(node, url) {
5653
6662
  const tags = (node.frontmatter.tags || []).join(" ");
5654
- const header = tags ? `# ${node.frontmatter.title}
6663
+ const meta = [
6664
+ tags ? `_tags: ${tags}_` : null,
6665
+ url ? `_source: ${url}_` : null
6666
+ ].filter(Boolean);
6667
+ const header = meta.length ? `# ${node.frontmatter.title}
5655
6668
 
5656
- _tags: ${tags}_
6669
+ ${meta.join("\n")}
5657
6670
 
5658
6671
  ` : `# ${node.frontmatter.title}
5659
6672
 
@@ -5666,6 +6679,7 @@ queryRoutes.post("/context", async (c) => {
5666
6679
  throw new ValidationError("prompt or selector is required");
5667
6680
  }
5668
6681
  const nestId = c.req.param("nestId");
6682
+ const baseUrl = requestBaseUrl(c.req.url);
5669
6683
  const { query: queryEngine, storage } = await engineCache.get(nestId);
5670
6684
  const maxTokens = Math.max(50, body.max_tokens ?? 4e3);
5671
6685
  const hops = body.hops ?? 2;
@@ -5774,20 +6788,21 @@ queryRoutes.post("/context", async (c) => {
5774
6788
  const readable = await Promise.all(
5775
6789
  accessible.map(async (doc) => ({
5776
6790
  ...doc,
5777
- body: await resolveReadableBody(nestId, doc.id, userId, doc.body || "")
6791
+ body: await resolveReadableBody(nestId, doc.id, userId, doc.body || ""),
6792
+ url: resolveNodeUrl(doc, { nestId, baseUrl })
5778
6793
  }))
5779
6794
  );
5780
6795
  const included = [];
5781
6796
  let tokenCount = 0;
5782
6797
  for (const doc of readable) {
5783
- const block = formatContextBlock(doc);
6798
+ const block = formatContextBlock(doc, doc.url);
5784
6799
  const blockTokens = approxTokens(block);
5785
6800
  if (tokenCount + blockTokens > maxTokens && included.length > 0) break;
5786
6801
  included.push(doc);
5787
6802
  tokenCount += blockTokens;
5788
6803
  }
5789
6804
  const truncatedByBudget = accessible.length - included.length;
5790
- const contextMarkdown = included.map(formatContextBlock).join("\n\n---\n\n");
6805
+ const contextMarkdown = included.map((n) => formatContextBlock(n, n.url)).join("\n\n---\n\n");
5791
6806
  trackEvent("context.resolve", {
5792
6807
  nestId,
5793
6808
  selector_from_prompt: !!compileDetail,
@@ -5800,6 +6815,7 @@ queryRoutes.post("/context", async (c) => {
5800
6815
  nodes: included.map((n) => ({
5801
6816
  id: n.id,
5802
6817
  title: n.frontmatter.title,
6818
+ url: n.url,
5803
6819
  tags: n.frontmatter.tags || [],
5804
6820
  type: n.frontmatter.type || "document"
5805
6821
  })),
@@ -5867,7 +6883,9 @@ queryRoutes.get("/search", async (c) => {
5867
6883
  }
5868
6884
  const nestId = c.req.param("nestId");
5869
6885
  const { storage } = await engineCache.get(nestId);
5870
- const matches = matchDocuments(await discoverForSearch(storage), q);
6886
+ const matches = matchDocuments(await discoverForSearch(storage), q).filter(
6887
+ (n) => !isDerivedAnnotationsNode(n.id)
6888
+ );
5871
6889
  const userId = c.get("userId");
5872
6890
  const userEmail = await resolveCallerEmail(userId);
5873
6891
  const accessible = await filterAccessible(nestId, userId, userEmail, matches);
@@ -5879,7 +6897,9 @@ queryRoutes.get("/search", async (c) => {
5879
6897
  });
5880
6898
  queryRoutes.get("/overview", async (c) => {
5881
6899
  const { storage } = await engineCache.get(c.req.param("nestId"));
5882
- const documents = await storage.discoverDocuments();
6900
+ const documents = (await storage.discoverDocuments()).filter(
6901
+ (d) => !isDerivedAnnotationsNode(d.id)
6902
+ );
5883
6903
  const types = {};
5884
6904
  const tags = {};
5885
6905
  for (const node of documents) {
@@ -5914,6 +6934,14 @@ queryRoutes.get("/folders", async (c) => {
5914
6934
  });
5915
6935
  return c.json({ folders });
5916
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
+ });
5917
6945
  queryRoutes.post("/reindex", async (c) => {
5918
6946
  if (permissionLevel(c.get("nestPermission")) < permissionLevel("write")) {
5919
6947
  throw new ForbiddenError("Reindexing a nest requires write access");
@@ -6013,55 +7041,58 @@ queryRoutes.post("/publish", async (c) => {
6013
7041
  }
6014
7042
  }
6015
7043
  const nestId = c.req.param("nestId");
6016
- const { storage } = await engineCache.get(nestId);
7044
+ const userEmail = await resolveCallerEmail(c.get("userId"));
6017
7045
  const created = [];
7046
+ const skipped = [];
7047
+ const failed = [];
6018
7048
  if (body.context_md) {
7049
+ const { storage } = await engineCache.get(nestId);
6019
7050
  await storage.writeContextMd(body.context_md);
7051
+ await storage.regenerateIndex();
6020
7052
  }
6021
7053
  for (const doc of body.documents || []) {
6022
7054
  if (!doc.title || !doc.content) continue;
6023
- const slug = doc.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
6024
- const id = `nodes/${slug}`;
6025
- const now = (/* @__PURE__ */ new Date()).toISOString();
6026
- const tags = doc.tags?.map((t) => t.startsWith("#") ? t : `#${t}`) || [];
6027
- const node = {
6028
- id,
6029
- filePath: "",
6030
- frontmatter: {
7055
+ try {
7056
+ const result = await createNode(
7057
+ nestId,
7058
+ {
7059
+ title: doc.title,
7060
+ content: doc.content,
7061
+ type: doc.type,
7062
+ tags: doc.tags,
7063
+ scope: doc.scope
7064
+ },
7065
+ userEmail
7066
+ );
7067
+ created.push(result.node.id);
7068
+ } catch (err) {
7069
+ if (err instanceof ConflictError) {
7070
+ skipped.push(doc.title);
7071
+ continue;
7072
+ }
7073
+ if (isVaultLockTimeout(err) || err instanceof VaultBusyError) throw err;
7074
+ if (!(err instanceof AppError)) console.error("bulk publish row failed", err);
7075
+ failed.push({
6031
7076
  title: doc.title,
6032
- type: doc.type || "document",
6033
- tags,
6034
- status: "draft",
6035
- version: 1,
6036
- created_at: now,
6037
- updated_at: now,
6038
- metadata: {
6039
- owners: ["*"],
6040
- scope: doc.scope || "team"
6041
- }
6042
- },
6043
- body: doc.content,
6044
- rawContent: ""
6045
- };
6046
- const serialized = serializeDocument3(node);
6047
- await storage.writeDocument(id, serialized);
6048
- await syncNodeTags(nestId, id, tags);
6049
- created.push(id);
7077
+ error: err instanceof AppError ? err.message : "Could not create this document"
7078
+ });
7079
+ }
6050
7080
  }
6051
- if (created.length > 0 || body.context_md) await storage.regenerateIndex();
6052
7081
  trackEvent("nest.publish", { nestId, count: created.length });
6053
7082
  return c.json(
6054
7083
  {
6055
7084
  published: created.length,
6056
7085
  context_md_updated: !!body.context_md,
6057
- node_ids: created
7086
+ node_ids: created,
7087
+ skipped,
7088
+ failed
6058
7089
  },
6059
7090
  201
6060
7091
  );
6061
7092
  });
6062
7093
 
6063
7094
  // src/mcp/routes.ts
6064
- import { Hono as Hono12 } from "hono";
7095
+ import { Hono as Hono14 } from "hono";
6065
7096
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6066
7097
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
6067
7098
 
@@ -6141,10 +7172,10 @@ async function listTraceEvents(filters = {}) {
6141
7172
  }
6142
7173
 
6143
7174
  // src/mcp/workflow-tools.ts
6144
- import { v4 as uuid8 } from "uuid";
7175
+ import { v4 as uuid10 } from "uuid";
6145
7176
 
6146
7177
  // src/workflow/run-service.ts
6147
- import { v4 as uuid6 } from "uuid";
7178
+ import { v4 as uuid8 } from "uuid";
6148
7179
  var RUNNABLE_TYPES = ["agent", "skill"];
6149
7180
  async function flowSubgraph(nestId, agentNode) {
6150
7181
  const db = getDb();
@@ -6274,7 +7305,7 @@ async function triggerRun(opts) {
6274
7305
  }
6275
7306
  const edges = await flowSubgraph(nestId, agentNode);
6276
7307
  const definitions = await referencedDefinitions(nestId, edges);
6277
- const id = `run_${uuid6()}`;
7308
+ const id = `run_${uuid8()}`;
6278
7309
  const db = getDb();
6279
7310
  await db.transaction(async (tx) => {
6280
7311
  if (parentRunId) {
@@ -6460,8 +7491,8 @@ async function listRuns(nestId, opts) {
6460
7491
  }
6461
7492
 
6462
7493
  // src/workflow/edge-routes.ts
6463
- import { Hono as Hono11 } from "hono";
6464
- import { v4 as uuid7 } from "uuid";
7494
+ import { Hono as Hono13 } from "hono";
7495
+ import { v4 as uuid9 } from "uuid";
6465
7496
  function toResponse(e, t) {
6466
7497
  return {
6467
7498
  id: e.id,
@@ -6575,7 +7606,7 @@ async function assertNoDuplicateEdge(db, nestId, fromNode, toNode, type, exclude
6575
7606
  );
6576
7607
  }
6577
7608
  }
6578
- var edgeRoutes = new Hono11();
7609
+ var edgeRoutes = new Hono13();
6579
7610
  edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
6580
7611
  const nestId = c.req.param("nestId");
6581
7612
  const db = getDb();
@@ -6643,7 +7674,7 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
6643
7674
  );
6644
7675
  const metadata = body.metadata === void 0 || body.metadata === null ? null : JSON.stringify(body.metadata);
6645
7676
  const now = (/* @__PURE__ */ new Date()).toISOString();
6646
- const id = uuid7();
7677
+ const id = uuid9();
6647
7678
  await db.transaction(async (tx) => {
6648
7679
  await assertNoDuplicateEdge(tx, nestId, fromNode, toNode, type);
6649
7680
  if (type.is_flow) {
@@ -7044,7 +8075,7 @@ async function createEdge(ctx, args) {
7044
8075
  );
7045
8076
  const metadata = args.metadata === void 0 || args.metadata === null ? null : JSON.stringify(args.metadata);
7046
8077
  const now = (/* @__PURE__ */ new Date()).toISOString();
7047
- const id = uuid8();
8078
+ const id = uuid10();
7048
8079
  await db.transaction(async (tx) => {
7049
8080
  await assertNoDuplicateEdge(tx, ctx.nestId, fromNode, toNode, type);
7050
8081
  if (type.is_flow) {
@@ -7108,7 +8139,7 @@ async function upsertEdgeType(ctx, args) {
7108
8139
  `INSERT INTO edge_types
7109
8140
  (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
7110
8141
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
7111
- [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]
7112
8143
  );
7113
8144
  }
7114
8145
  });
@@ -7664,6 +8695,13 @@ async function handleToolCall(toolName, args, ctx) {
7664
8695
  try {
7665
8696
  return await runTool(toolName, args, ctx);
7666
8697
  } catch (err) {
8698
+ if (isVaultLockTimeout(err) || err instanceof VaultBusyError) {
8699
+ status = 503;
8700
+ return toolError(
8701
+ "The vault is busy with another write. Retry in a few seconds.",
8702
+ "VAULT_BUSY"
8703
+ );
8704
+ }
7667
8705
  status = 500;
7668
8706
  throw err;
7669
8707
  } finally {
@@ -8554,19 +9592,19 @@ ${list}`,
8554
9592
  if (!target) return "target is required (a node id or folder prefix).";
8555
9593
  if (!["read", "write"].includes(role)) return "role must be read or write.";
8556
9594
  try {
8557
- const { createGrant: createGrant2 } = await import("./grants-service-ME7HIVHU.js");
8558
- 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();
8559
9597
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
8560
9598
  const e = normalizeEmail2(String(args.email || ""));
8561
9599
  if (!e) return "email is required.";
8562
9600
  let row = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
8563
9601
  if (!row) {
8564
9602
  const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
8565
- const { v4: uuid16 } = await import("uuid");
8566
- const id = uuid16();
9603
+ const { v4: uuid18 } = await import("uuid");
9604
+ const id = uuid18();
8567
9605
  await db.run(
8568
9606
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
8569
- [id, e, null, await hashPassword2(uuid16())]
9607
+ [id, e, null, await hashPassword2(uuid18())]
8570
9608
  );
8571
9609
  row = { id };
8572
9610
  }
@@ -8621,7 +9659,7 @@ ${list}`;
8621
9659
 
8622
9660
  // src/mcp/routes.ts
8623
9661
  import { z } from "zod";
8624
- var mcpRoutes = new Hono12();
9662
+ var mcpRoutes = new Hono14();
8625
9663
  async function getUserEmail2(userId) {
8626
9664
  const db = getDb();
8627
9665
  const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
@@ -8687,8 +9725,8 @@ mcpRoutes.all("/", async (c) => {
8687
9725
  });
8688
9726
 
8689
9727
  // src/workflow/run-routes.ts
8690
- import { Hono as Hono13 } from "hono";
8691
- import { v4 as uuid9 } from "uuid";
9728
+ import { Hono as Hono15 } from "hono";
9729
+ import { v4 as uuid11 } from "uuid";
8692
9730
  var RUNNABLE_TYPES2 = ["agent", "skill"];
8693
9731
  var safeJson2 = (s) => {
8694
9732
  if (!s) return null;
@@ -8806,7 +9844,7 @@ async function buildRunBundle(nestId, agentNode) {
8806
9844
  definitions
8807
9845
  };
8808
9846
  }
8809
- var runTriggerRoutes = new Hono13();
9847
+ var runTriggerRoutes = new Hono15();
8810
9848
  async function resolveParentRun2(nestId, parentRunId, childAgent) {
8811
9849
  if (!config.FEATURE_SUBAGENT_RUNS) {
8812
9850
  throw new ValidationError(
@@ -8885,7 +9923,7 @@ runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
8885
9923
  }
8886
9924
  const bundle = await buildRunBundle(nestId, agentNode);
8887
9925
  const triggeredBy = await resolveCallerEmail(c.get("userId"));
8888
- const id = `run_${uuid9()}`;
9926
+ const id = `run_${uuid11()}`;
8889
9927
  await getDb().run(
8890
9928
  `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
8891
9929
  VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)`,
@@ -8914,7 +9952,7 @@ runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
8914
9952
  201
8915
9953
  );
8916
9954
  });
8917
- var runListRoutes = new Hono13();
9955
+ var runListRoutes = new Hono15();
8918
9956
  runListRoutes.get("/", requireWorkflowPlane, async (c) => {
8919
9957
  const nestId = c.req.param("nestId");
8920
9958
  const db = getDb();
@@ -8946,7 +9984,7 @@ runListRoutes.get("/", requireWorkflowPlane, async (c) => {
8946
9984
  }))
8947
9985
  });
8948
9986
  });
8949
- var runDetailRoutes = new Hono13();
9987
+ var runDetailRoutes = new Hono15();
8950
9988
  async function loadRunWithPermission(c, runId, required) {
8951
9989
  const run = await getDb().get("SELECT * FROM runs WHERE id = ?", [
8952
9990
  runId
@@ -9097,8 +10135,8 @@ runDetailRoutes.get("/:id", requireWorkflowPlane, async (c) => {
9097
10135
  });
9098
10136
 
9099
10137
  // src/workflow/schedule-routes.ts
9100
- import { Hono as Hono14 } from "hono";
9101
- import { v4 as uuid10 } from "uuid";
10138
+ import { Hono as Hono16 } from "hono";
10139
+ import { v4 as uuid12 } from "uuid";
9102
10140
  var RUNNABLE_TYPES3 = ["agent", "skill"];
9103
10141
  var MIN_EVERY = 5;
9104
10142
  var MAX_EVERY = 7 * 24 * 60;
@@ -9135,7 +10173,7 @@ function parseEvery(v) {
9135
10173
  }
9136
10174
  return n;
9137
10175
  }
9138
- var scheduleRoutes = new Hono14();
10176
+ var scheduleRoutes = new Hono16();
9139
10177
  scheduleRoutes.get("/", requireWorkflowPlane, async (c) => {
9140
10178
  const nestId = c.req.param("nestId");
9141
10179
  const rows = await getDb().all(
@@ -9176,7 +10214,7 @@ scheduleRoutes.post("/", requireWorkflowPlane, async (c) => {
9176
10214
  );
9177
10215
  }
9178
10216
  const now = (/* @__PURE__ */ new Date()).toISOString();
9179
- const id = `sch_${uuid10()}`;
10217
+ const id = `sch_${uuid12()}`;
9180
10218
  await db.run(
9181
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)
9182
10220
  VALUES (?, ?, ?, ?, ?, NULL, ?, 0, ?, ?, ?)`,
@@ -9231,8 +10269,8 @@ scheduleRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
9231
10269
  });
9232
10270
 
9233
10271
  // src/notify/connector-routes.ts
9234
- import { Hono as Hono15 } from "hono";
9235
- import { v4 as uuid11 } from "uuid";
10272
+ import { Hono as Hono17 } from "hono";
10273
+ import { v4 as uuid13 } from "uuid";
9236
10274
  var KNOWN_EVENTS = [
9237
10275
  "review_requested",
9238
10276
  "review_approved",
@@ -9278,7 +10316,7 @@ function parseUrl(v) {
9278
10316
  'url must be a public https:// URL (loopback/private/metadata hosts are refused) or an env reference like "env:SLACK_WEBHOOK"'
9279
10317
  );
9280
10318
  }
9281
- var connectorRoutes = new Hono15();
10319
+ var connectorRoutes = new Hono17();
9282
10320
  connectorRoutes.get("/", async (c) => {
9283
10321
  const nestId = c.req.param("nestId");
9284
10322
  const rows = await getDb().all(
@@ -9297,7 +10335,7 @@ connectorRoutes.post("/", async (c) => {
9297
10335
  const url = parseUrl(body.url);
9298
10336
  const events = parseEvents(body.events);
9299
10337
  const now = (/* @__PURE__ */ new Date()).toISOString();
9300
- const id = `con_${uuid11()}`;
10338
+ const id = `con_${uuid13()}`;
9301
10339
  await getDb().run(
9302
10340
  `INSERT INTO connectors (id, nest_id, channel, url, events, enabled, created_by, created_at, updated_at)
9303
10341
  VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`,
@@ -9340,7 +10378,7 @@ connectorRoutes.delete("/:id", async (c) => {
9340
10378
  });
9341
10379
 
9342
10380
  // src/workflow/hook-routes.ts
9343
- import { Hono as Hono16 } from "hono";
10381
+ import { Hono as Hono18 } from "hono";
9344
10382
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2, randomUUID } from "crypto";
9345
10383
  var PRESETS = ["slack", "teams", "webhook"];
9346
10384
  var RUNNABLE_TYPES4 = ["agent", "skill"];
@@ -9363,7 +10401,7 @@ function toResponse4(c, r, opts = {}) {
9363
10401
  created_at: r.created_at
9364
10402
  };
9365
10403
  }
9366
- var hookNestRoutes = new Hono16();
10404
+ var hookNestRoutes = new Hono18();
9367
10405
  hookNestRoutes.get("/", requireWorkflowPlane, async (c) => {
9368
10406
  const nestId = c.req.param("nestId");
9369
10407
  const agent = c.req.query("agent");
@@ -9437,7 +10475,7 @@ function teamsSignatureValid(secret, authHeader, rawBody) {
9437
10475
  function stripMentions(text) {
9438
10476
  return text.replace(/<@[^>]+>/g, "").replace(/<at>[^<]*<\/at>/gi, "").trim();
9439
10477
  }
9440
- var hookFireRoutes = new Hono16();
10478
+ var hookFireRoutes = new Hono18();
9441
10479
  hookFireRoutes.post("/:token", async (c) => {
9442
10480
  const token = c.req.param("token");
9443
10481
  if (!tryConsume("hook:*", { max: 120, windowMs: 6e4 })) {
@@ -9537,9 +10575,9 @@ hookFireRoutes.post("/:token", async (c) => {
9537
10575
  });
9538
10576
 
9539
10577
  // src/definitions/routes.ts
9540
- import { Hono as Hono17 } from "hono";
9541
- import { v4 as uuid12 } from "uuid";
9542
- var definitionRoutes = new Hono17();
10578
+ import { Hono as Hono19 } from "hono";
10579
+ import { v4 as uuid14 } from "uuid";
10580
+ var definitionRoutes = new Hono19();
9543
10581
  definitionRoutes.get("/", async (c) => {
9544
10582
  const nestId = c.req.param("nestId");
9545
10583
  const db = getDb();
@@ -9618,7 +10656,7 @@ definitionRoutes.post("/", async (c) => {
9618
10656
  await applyUpdate(tx, existing.id);
9619
10657
  targetId = existing.id;
9620
10658
  } else {
9621
- targetId = uuid12();
10659
+ targetId = uuid14();
9622
10660
  wasCreated = true;
9623
10661
  await tx.run(
9624
10662
  `INSERT INTO definitions (id, nest_id, term, definition, linked_tag, defined_by, created_at, updated_at)
@@ -9647,11 +10685,11 @@ definitionRoutes.delete("/:id", async (c) => {
9647
10685
  });
9648
10686
 
9649
10687
  // src/nests/asset-routes.ts
9650
- import { Hono as Hono18 } from "hono";
9651
- import { v4 as uuid13 } from "uuid";
10688
+ import { Hono as Hono20 } from "hono";
10689
+ import { v4 as uuid15 } from "uuid";
9652
10690
  import { mkdir as mkdir2, readFile, writeFile } from "fs/promises";
9653
10691
  import { join as join4 } from "path";
9654
- var assetRoutes = new Hono18();
10692
+ var assetRoutes = new Hono20();
9655
10693
  var CONTENT_TYPES = {
9656
10694
  png: "image/png",
9657
10695
  jpg: "image/jpeg",
@@ -9697,7 +10735,7 @@ assetRoutes.post("/", async (c) => {
9697
10735
  `${isVideo ? "video" : "image"} too large (${(file.size / (1024 * 1024)).toFixed(1)} MB, max ${maxBytes / (1024 * 1024)} MB for ${isVideo ? "video" : "images"})`
9698
10736
  );
9699
10737
  }
9700
- const name = `${uuid13()}.${ext === "jpeg" ? "jpg" : ext}`;
10738
+ const name = `${uuid15()}.${ext === "jpeg" ? "jpg" : ext}`;
9701
10739
  const dir = join4(resolveNestPath(nestId), "assets");
9702
10740
  await mkdir2(dir, { recursive: true });
9703
10741
  await writeFile(join4(dir, name), Buffer.from(await file.arrayBuffer()));
@@ -9738,7 +10776,7 @@ assetRoutes.get("/:file", async (c) => {
9738
10776
  });
9739
10777
 
9740
10778
  // src/mcp/server-routes.ts
9741
- import { Hono as Hono19 } from "hono";
10779
+ import { Hono as Hono21 } from "hono";
9742
10780
  import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
9743
10781
  import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
9744
10782
  import { z as z2 } from "zod";
@@ -10037,7 +11075,7 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
10037
11075
  }
10038
11076
  return server;
10039
11077
  }
10040
- var serverMcpRoutes = new Hono19();
11078
+ var serverMcpRoutes = new Hono21();
10041
11079
  serverMcpRoutes.all("/", async (c) => {
10042
11080
  const userId = c.get("userId");
10043
11081
  const userEmail = await getUserEmail3(userId);
@@ -10059,7 +11097,7 @@ serverMcpRoutes.all("/", async (c) => {
10059
11097
  await server.server.close();
10060
11098
  }
10061
11099
  });
10062
- var nestIndexRoutes = new Hono19();
11100
+ var nestIndexRoutes = new Hono21();
10063
11101
  nestIndexRoutes.get("/", async (c) => {
10064
11102
  const userId = c.get("userId");
10065
11103
  const nests = await buildNestIndex(userId, c.get("nestScope"));
@@ -10178,14 +11216,14 @@ npx -y mcp-remote ${B}/mcp --header "Authorization: Bearer <api-key>"
10178
11216
  through steward review before they become AI-visible.
10179
11217
  `;
10180
11218
  }
10181
- var llmsTxtRoutes = new Hono19();
11219
+ var llmsTxtRoutes = new Hono21();
10182
11220
  llmsTxtRoutes.get("/", (c) => {
10183
11221
  const md = renderLlmsTxt(requestBaseUrl(c.req.url));
10184
11222
  return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
10185
11223
  });
10186
11224
 
10187
11225
  // src/governance/routes.ts
10188
- import { Hono as Hono20 } from "hono";
11226
+ import { Hono as Hono22 } from "hono";
10189
11227
 
10190
11228
  // src/governance/feed-service.ts
10191
11229
  var SNIPPET_CHARS = 200;
@@ -10316,7 +11354,7 @@ async function getNestFeed(params) {
10316
11354
  }
10317
11355
 
10318
11356
  // src/governance/comment-service.ts
10319
- import { v4 as uuid14 } from "uuid";
11357
+ import { v4 as uuid16 } from "uuid";
10320
11358
  async function createComment(params) {
10321
11359
  const db = getDb();
10322
11360
  const body = (params.body ?? "").trim();
@@ -10332,7 +11370,7 @@ async function createComment(params) {
10332
11370
  throw new Error("Parent comment not found on this node");
10333
11371
  }
10334
11372
  }
10335
- const id = uuid14();
11373
+ const id = uuid16();
10336
11374
  await db.run(
10337
11375
  `INSERT INTO comments
10338
11376
  (id, nest_id, node_id, version, anchor_start, anchor_end, anchor_text,
@@ -10617,7 +11655,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
10617
11655
  if (Number.isNaN(n) || n < 1) return def;
10618
11656
  return Math.min(n, max);
10619
11657
  }
10620
- var governanceRoutes = new Hono20();
11658
+ var governanceRoutes = new Hono22();
10621
11659
  governanceRoutes.get("/stewards", async (c) => {
10622
11660
  const nestId = c.req.param("nestId");
10623
11661
  const scope = c.req.query("scope");
@@ -10736,6 +11774,60 @@ governanceRoutes.get("/review-queue", async (c) => {
10736
11774
  }
10737
11775
  return c.json({ ...result, requests });
10738
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
+ });
10739
11831
  function denyUnlessNestManager(c, what) {
10740
11832
  const permission = c.get("nestPermission");
10741
11833
  if (permission === "owner" || permission === "admin") return null;
@@ -10928,7 +12020,7 @@ governanceRoutes.get("/activity", async (c) => {
10928
12020
  const activity = await getActivity({ nestId, limit });
10929
12021
  return c.json({ activity });
10930
12022
  });
10931
- var governanceNodeRoutes = new Hono20();
12023
+ var governanceNodeRoutes = new Hono22();
10932
12024
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
10933
12025
  const nestId = c.req.param("nestId");
10934
12026
  const nodeId = c.req.param("nodeId");
@@ -11326,12 +12418,16 @@ governanceNodeRoutes.post("/:nodeId{.+}/cancel-review", async (c) => {
11326
12418
  const nestId = c.req.param("nestId");
11327
12419
  const nodeId = c.req.param("nodeId");
11328
12420
  const userEmail = await getUserEmail4(c);
11329
- const request = await cancelReview({
11330
- nestId,
11331
- nodeId,
11332
- cancelledBy: userEmail
11333
- });
11334
- 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
+ }
11335
12431
  });
11336
12432
  async function getUserEmail4(c) {
11337
12433
  const userId = c.get("userId");
@@ -11419,13 +12515,23 @@ var flexAuthWithPromptowlTicket = createMiddleware2(
11419
12515
  async (c, next) => {
11420
12516
  const header = c.req.header("Authorization") || "";
11421
12517
  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
11422
- const secret = config.OFFICIAL_COMMUNITY_SSO_SECRET;
12518
+ const secrets = [
12519
+ config.OFFICIAL_COMMUNITY_SSO_SECRET,
12520
+ config.MCP_SIGNING_SECRET
12521
+ ].filter(Boolean);
11423
12522
  const looksLikeJwt = !!token && !token.startsWith("cnst_") && token.split(".").length === 3;
11424
- if (!looksLikeJwt || !secret) return flexAuthMiddleware(c, next);
11425
- let claims;
11426
- try {
11427
- claims = verifySsoTicket(token, secret);
11428
- } 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) {
11429
12535
  return c.json({ error: "Invalid service ticket" }, 401);
11430
12536
  }
11431
12537
  if (claims.scope !== "mcp" || claims.jti) {
@@ -11472,7 +12578,7 @@ async function canApproveInNest(nestId, email) {
11472
12578
  return ok;
11473
12579
  }
11474
12580
  function createApp() {
11475
- const app = new Hono21({ router: new LinearRouter() });
12581
+ const app = new Hono23({ router: new LinearRouter() });
11476
12582
  const corsOrigins = config.CORS_ORIGINS;
11477
12583
  app.use(
11478
12584
  "*",
@@ -11529,6 +12635,9 @@ function createApp() {
11529
12635
  // Presence only, never the value: lets the UI stop prompting for a
11530
12636
  // runner key once the server-wide one exists.
11531
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(),
11532
12641
  promptowl_sign_in_gate: config.PROMPTOWL_SIGN_IN_GATE,
11533
12642
  // Manual (email/password) sign-in mode (open | invite-only | disabled).
11534
12643
  // The login page reads this to show/hide the credential form, and to
@@ -11672,6 +12781,16 @@ function createApp() {
11672
12781
  oidc_allowed_domains: config.OIDC_ALLOWED_DOMAINS.join(", "),
11673
12782
  oidc_auto_provision: config.OIDC_AUTO_PROVISION,
11674
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,
11675
12794
  // Live readiness (enabled AND issuer+id+secret present) — the SAVED state,
11676
12795
  // i.e. whether the login button is currently live. The Settings UI derives
11677
12796
  // its own "enabled but not configured" warning from the unsaved draft
@@ -11872,6 +12991,67 @@ function createApp() {
11872
12991
  value: body.oidc_department_tagging ? "true" : null
11873
12992
  });
11874
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
+ }
11875
13055
  const effOidcIssuer = "oidc_issuer" in body ? String(body.oidc_issuer ?? "").trim() : config.OIDC_ISSUER;
11876
13056
  const effOidcClientId = "oidc_client_id" in body ? String(body.oidc_client_id ?? "").trim() : config.OIDC_CLIENT_ID;
11877
13057
  const effOidcSecret = "oidc_client_secret" in body ? String(body.oidc_client_secret ?? "").trim() : config.OIDC_CLIENT_SECRET;
@@ -12301,11 +13481,12 @@ function createApp() {
12301
13481
  app.route("/runs", runDetailRoutes);
12302
13482
  app.route("/hooks", hookFireRoutes);
12303
13483
  app.route("/llms.txt", llmsTxtRoutes);
13484
+ app.route("/p", publishRenderRoutes);
12304
13485
  app.use("/index", flexAuthWithPromptowlTicket);
12305
13486
  app.route("/index", nestIndexRoutes);
12306
13487
  app.use("/mcp", flexAuthWithPromptowlTicket);
12307
13488
  app.route("/mcp", serverMcpRoutes);
12308
- const nestsApp = new Hono21();
13489
+ const nestsApp = new Hono23();
12309
13490
  nestsApp.use("*", async (c, next) => {
12310
13491
  const parts = nestPathParts(c.req.path);
12311
13492
  const ticketEligible = parts.length === 2 && (c.req.method === "POST" && parts[1] === "context" || c.req.method === "GET" && parts[1] === "nodes" || parts[1] === "mcp");
@@ -12396,7 +13577,7 @@ function createApp() {
12396
13577
  }
12397
13578
  let required = "read";
12398
13579
  const path = c.req.path;
12399
- 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);
12400
13581
  const isAnnotationAction = /\/annotations$/.test(path) || /\/annotations\/[^/]+\/(comments|resolve|reopen)$/.test(path);
12401
13582
  const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
12402
13583
  const isDeletionRequestAction = /\/request-deletion$/.test(path) || /\/deletion-requests\/[^/]+\/withdraw$/.test(path);
@@ -12410,7 +13591,7 @@ function createApp() {
12410
13591
  );
12411
13592
  }
12412
13593
  const resource = parts[1];
12413
- if (resource === "visibility") {
13594
+ if (resource === "visibility" || resource === "reader-mode") {
12414
13595
  required = "admin";
12415
13596
  } else if (resource === "collaborators") {
12416
13597
  required = c.req.method === "GET" || c.req.method === "POST" ? "write" : "admin";
@@ -12498,6 +13679,7 @@ function createApp() {
12498
13679
  nestsApp.route("/:nestId", queryRoutes);
12499
13680
  nestsApp.route("/:nestId", sharingRoutes);
12500
13681
  nestsApp.route("/:nestId/grants", grantRoutes);
13682
+ nestsApp.route("/:nestId/publications", publishRoutes);
12501
13683
  nestsApp.route("/:nestId/teams", nestTeamRoutes);
12502
13684
  nestsApp.route("/:nestId/edge-types", edgeTypeRoutes);
12503
13685
  nestsApp.route("/:nestId/edges", edgeRoutes);
@@ -12534,6 +13716,12 @@ function createApp() {
12534
13716
  );
12535
13717
  app.notFound((c) => c.json({ error: "Not found" }, 404));
12536
13718
  app.onError((err, c) => {
13719
+ if (isVaultLockTimeout(err) || err instanceof VaultBusyError) {
13720
+ const busy = err instanceof VaultBusyError ? err : new VaultBusyError();
13721
+ return c.json({ error: busy.message }, 503, {
13722
+ "Retry-After": String(busy.retryAfterSeconds)
13723
+ });
13724
+ }
12537
13725
  if (err instanceof AppError) {
12538
13726
  return c.json({ error: err.message }, err.statusCode);
12539
13727
  }
@@ -12812,7 +14000,7 @@ async function repairGraftedVersionChains(db) {
12812
14000
  }
12813
14001
 
12814
14002
  // src/workflow/scheduler.ts
12815
- import { v4 as uuid15 } from "uuid";
14003
+ import { v4 as uuid17 } from "uuid";
12816
14004
  function isDue(s, now) {
12817
14005
  if (!s.last_run_at) return true;
12818
14006
  const last = Date.parse(s.last_run_at);
@@ -12836,21 +14024,21 @@ async function schedulerTick(now = Date.now()) {
12836
14024
  );
12837
14025
  continue;
12838
14026
  }
12839
- const id = `run_${uuid15()}`;
12840
- const nowIso = new Date(now).toISOString();
14027
+ const id = `run_${uuid17()}`;
14028
+ const nowIso2 = new Date(now).toISOString();
12841
14029
  await db.run(
12842
14030
  `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
12843
14031
  VALUES (?, ?, ?, ?, 'running', NULL, ?, NULL, 0)`,
12844
14032
  // triggered_by names the schedule's creator with a schedule: prefix —
12845
14033
  // honest provenance ("this fired on X's schedule, not X clicking").
12846
- [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]
12847
14035
  );
12848
14036
  const fired = s.runs_created + 1;
12849
14037
  const done = s.max_runs !== null && fired >= s.max_runs;
12850
14038
  await db.run(
12851
14039
  `UPDATE schedules SET last_run_at = ?, runs_created = ?, enabled = ?, updated_at = ?
12852
14040
  WHERE id = ?`,
12853
- [nowIso, fired, done ? 0 : s.enabled, nowIso, s.id]
14041
+ [nowIso2, fired, done ? 0 : s.enabled, nowIso2, s.id]
12854
14042
  );
12855
14043
  created.push(id);
12856
14044
  } catch (err) {