@promptowl/contextnest-community 1.24.0 → 1.26.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 (125) hide show
  1. package/API.md +177 -14
  2. package/CONFIGURATION.md +577 -116
  3. package/README.md +4 -3
  4. package/STEWARDSHIP.md +24 -0
  5. package/dist/{chunk-XUIWAWDO.js → chunk-2QOJE7A5.js} +4 -4
  6. package/dist/{chunk-2TPQTN4Y.js → chunk-DGPTJXIV.js} +47 -15
  7. package/dist/chunk-I5EDEZBQ.js +523 -0
  8. package/dist/{chunk-ZTT4U4NE.js → chunk-IEAWRHMV.js} +99 -12
  9. package/dist/{chunk-YVMSM7LS.js → chunk-LHWOJPFE.js} +7 -0
  10. package/dist/{chunk-5EZOPA47.js → chunk-M6PGYMBD.js} +20 -11
  11. package/dist/{chunk-F66VCBLA.js → chunk-PICUHQEM.js} +51 -2
  12. package/dist/{chunk-I3CSD6CK.js → chunk-RPGFHESR.js} +2 -2
  13. package/dist/{chunk-KIAAEHWL.js → chunk-SWA5M5XX.js} +88 -546
  14. package/dist/{chunk-LA3VTQ22.js → chunk-TRSGPS27.js} +81 -14
  15. package/dist/{engine-S3QBQ7LH.js → engine-QNMFDIBV.js} +4 -3
  16. package/dist/{external-edit-service-6FNFPOJJ.js → external-edit-service-4MEGPT4D.js} +5 -4
  17. package/dist/{grants-service-UT3EQ3R7.js → grants-service-24GLUBY4.js} +3 -3
  18. package/dist/index.js +1739 -583
  19. package/dist/invited-user-AM6A3WGO.js +12 -0
  20. package/dist/{migrations.postgres-AIQ7WSU7.js → migrations.postgres-HGSQ7IA5.js} +16 -2
  21. package/dist/{review-service-HHGOTO6P.js → review-service-2EWN7WWO.js} +8 -7
  22. package/dist/{stewardship-service-4DHNRULG.js → stewardship-service-TDZUC2Q6.js} +7 -4
  23. package/dist/{version-service-KIOQME6U.js → version-service-C3JTZLJH.js} +5 -4
  24. package/dist/web3/assets/ActivityTracePage-DzwWIjjx.js +1 -0
  25. package/dist/web3/assets/{AgentDocsPage-CIiaBqMy.js → AgentDocsPage-ixP2UdAE.js} +1 -1
  26. package/dist/web3/assets/CollaboratorManager-C9VAXZ0h.js +1 -0
  27. package/dist/web3/assets/CollaboratorsTab-tq4ZTexH.js +1 -0
  28. package/dist/web3/assets/DocumentEditor-C2CP2fiE.js +36 -0
  29. package/dist/web3/assets/DocumentsTab-Mh7KxUoB.js +6 -0
  30. package/dist/web3/assets/ExternalEditsTab-B_CWKXZ_.js +1 -0
  31. package/dist/web3/assets/MarkdownEditor-CKh4WSn-.js +653 -0
  32. package/dist/web3/assets/MarkdownEditor-ndtIx5x0.css +1 -0
  33. package/dist/web3/assets/NestPageHeader-Cizo8nUi.js +1 -0
  34. package/dist/web3/assets/NestView-EW-sraEb.js +73 -0
  35. package/dist/web3/assets/OverviewTab-CjidsMX_.js +6 -0
  36. package/dist/web3/assets/PersonCombobox-BKI7dff4.js +1 -0
  37. package/dist/web3/assets/{ReviewActions-OfOdqzCn.js → ReviewActions-CS8JXgXF.js} +2 -2
  38. package/dist/web3/assets/ReviewTab-CwVrXzw5.js +1 -0
  39. package/dist/web3/assets/StewardsTab-C-iP6gnh.js +1 -0
  40. package/dist/web3/assets/SubmitForReviewModal-Biq3m79i.js +1 -0
  41. package/dist/web3/assets/{alert-dialog-DTozKlmV.js → alert-dialog-BvF3NFr7.js} +2 -2
  42. package/dist/web3/assets/{arrow-left-BCKt4CwQ.js → arrow-left-CWIsv-sG.js} +1 -1
  43. package/dist/web3/assets/backlinks-Cqcn7n1J.js +19 -0
  44. package/dist/web3/assets/boxes-ClbgQqG2.js +6 -0
  45. package/dist/web3/assets/{chevron-left-BPwUT9DS.js → chevron-left-CK_ZPFm9.js} +1 -1
  46. package/dist/web3/assets/{circle-check-DI8IBGeo.js → circle-check-Bw_aTXiH.js} +1 -1
  47. package/dist/web3/assets/{circle-x-VKVhC88W.js → circle-x-DkIG68H7.js} +1 -1
  48. package/dist/web3/assets/client-attribution-BraarYaP.js +1 -0
  49. package/dist/web3/assets/{code-xml-CMabYZvH.js → code-xml-BlR7EN6l.js} +1 -1
  50. package/dist/web3/assets/{corner-down-right-BGxwK6wH.js → corner-down-right-DSVxK3tf.js} +1 -1
  51. package/dist/web3/assets/count-skeleton-DToLM74B.js +1 -0
  52. package/dist/web3/assets/{earth-BEp1EKTo.js → earth-BXhVPvCR.js} +1 -1
  53. package/dist/web3/assets/{file-exclamation-point-r8qZGIjP.js → file-exclamation-point-BevGZgtj.js} +1 -1
  54. package/dist/web3/assets/{folder-input-Cmtx1Jhk.js → folder-input-m8UBV4fA.js} +1 -1
  55. package/dist/web3/assets/{index-C1wTSjfP.js → index-BOVYgMvj.js} +1 -1
  56. package/dist/web3/assets/index-DVnaYqj9.css +1 -0
  57. package/dist/web3/assets/index-WYDmEOfO.js +404 -0
  58. package/dist/web3/assets/page-B6pkc9cd.js +2 -0
  59. package/dist/web3/assets/page-BJrFCKXB.js +1 -0
  60. package/dist/web3/assets/page-Bgy9Ng9p.js +1 -0
  61. package/dist/web3/assets/page-C2zoxumw.js +1 -0
  62. package/dist/web3/assets/page-CJWO06dN.js +1 -0
  63. package/dist/web3/assets/page-CptuAPJc.js +6 -0
  64. package/dist/web3/assets/page-DPY01SOj.js +1 -0
  65. package/dist/web3/assets/page-Dea2SblF.js +9 -0
  66. package/dist/web3/assets/page-Deq6IKiN.js +6 -0
  67. package/dist/web3/assets/page-Dfjg8p7Z.js +1 -0
  68. package/dist/web3/assets/{page-ByyVLDVo.js → page-Dh57L0RG.js} +5 -5
  69. package/dist/web3/assets/page-Dwbp4Seu.js +6 -0
  70. package/dist/web3/assets/{page-CHb0F5hV.js → page-DzKwP5Ds.js} +3 -3
  71. package/dist/web3/assets/page-YxjrE5b5.js +45 -0
  72. package/dist/web3/assets/{page-title-ClvvBsIo.js → page-title-DVq4qvMR.js} +1 -1
  73. package/dist/web3/assets/{play-C5YNKpAH.js → play-CXhS6Pgq.js} +1 -1
  74. package/dist/web3/assets/{send-0CHvmluT.js → send-DdfwnIrj.js} +1 -1
  75. package/dist/web3/assets/{settings-CeeF4LEb.js → settings-DKX0Q4gO.js} +1 -1
  76. package/dist/web3/assets/{share-2-CMnOlOt-.js → share-2-CxmXRt58.js} +1 -1
  77. package/dist/web3/assets/{tag-DQ_6J5Gv.js → tag-DeYC_mRv.js} +1 -1
  78. package/dist/web3/assets/{trash-2-HBi-Pslz.js → trash-2-B0HyPTnI.js} +1 -1
  79. package/dist/web3/assets/{user-plus-CvBNcpn4.js → user-plus-Ckm_Isyc.js} +1 -1
  80. package/dist/web3/assets/{x-By7piikG.js → x-mNtADYHc.js} +1 -1
  81. package/dist/web3/assets/zap-D2ifcnYb.js +16 -0
  82. package/dist/web3/index.html +2 -2
  83. package/package.json +3 -2
  84. package/dist/client-KEY4PYJH.js +0 -11
  85. package/dist/keys-CTOGAG3W.js +0 -20
  86. package/dist/web3/assets/ActivityTracePage-BOZHgJ8S.js +0 -1
  87. package/dist/web3/assets/CollaboratorManager-By91wrIr.js +0 -1
  88. package/dist/web3/assets/CollaboratorsTab-D1EE64Fs.js +0 -1
  89. package/dist/web3/assets/DocumentEditor-DrQfrW3d.js +0 -36
  90. package/dist/web3/assets/DocumentsTab-Vba-AzBv.js +0 -6
  91. package/dist/web3/assets/ExternalEditsTab-BSzAQIGM.js +0 -1
  92. package/dist/web3/assets/MarkdownEditor-74N_naNQ.css +0 -1
  93. package/dist/web3/assets/MarkdownEditor-C9zqZjC5.js +0 -643
  94. package/dist/web3/assets/NestPageHeader-CoLUV1Oz.js +0 -1
  95. package/dist/web3/assets/NestView-CTZ55tXD.js +0 -63
  96. package/dist/web3/assets/OverviewTab-Dxi-Hu-N.js +0 -1
  97. package/dist/web3/assets/PersonCombobox-CPOlNs6G.js +0 -1
  98. package/dist/web3/assets/ReasonDialog-DgLxfRWv.js +0 -1
  99. package/dist/web3/assets/ReviewTab-CyX8yxd3.js +0 -1
  100. package/dist/web3/assets/StewardsTab-DBEEfAQI.js +0 -1
  101. package/dist/web3/assets/SubmitForReviewModal-Ci2DdWs0.js +0 -1
  102. package/dist/web3/assets/backlinks-CYd4xENL.js +0 -24
  103. package/dist/web3/assets/card-XG2dP5Xf.js +0 -1
  104. package/dist/web3/assets/count-skeleton-DMbdpscX.js +0 -1
  105. package/dist/web3/assets/dates-BCxbm4_q.js +0 -1
  106. package/dist/web3/assets/index-BM-h3DwI.css +0 -1
  107. package/dist/web3/assets/index-EaX2yql0.js +0 -389
  108. package/dist/web3/assets/page-B3yvQvHy.js +0 -1
  109. package/dist/web3/assets/page-BExMvttJ.js +0 -1
  110. package/dist/web3/assets/page-BUOADv2X.js +0 -1
  111. package/dist/web3/assets/page-BYUFhYWZ.js +0 -1
  112. package/dist/web3/assets/page-Bicf02Cz.js +0 -1
  113. package/dist/web3/assets/page-BsPMDm5d.js +0 -45
  114. package/dist/web3/assets/page-CC0NJi8v.js +0 -1
  115. package/dist/web3/assets/page-CPjWQNKx.js +0 -11
  116. package/dist/web3/assets/page-CQt3ZGbf.js +0 -2
  117. package/dist/web3/assets/page-Cj1NetQ-.js +0 -1
  118. package/dist/web3/assets/page-CtaW65El.js +0 -1
  119. package/dist/web3/assets/page-DGOL9l8i.js +0 -6
  120. package/dist/web3/assets/refresh-cw-BhHzSH9m.js +0 -6
  121. package/dist/web3/assets/scroll-area-dRWncRqa.css +0 -1
  122. package/dist/web3/assets/scroll-area-hz-xtayP.js +0 -1
  123. package/dist/web3/assets/select-DBFTrUmI.js +0 -6
  124. package/dist/web3/assets/triangle-alert-CDy8-7sv.js +0 -6
  125. package/dist/web3/assets/zap-C2T8riBp.js +0 -11
package/dist/index.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  submitForReview,
39
39
  withNodeWriteLock,
40
40
  withdrawDeletionRequest
41
- } from "./chunk-5EZOPA47.js";
41
+ } from "./chunk-M6PGYMBD.js";
42
42
  import {
43
43
  canCreateInNest,
44
44
  canManageStewards,
@@ -56,9 +56,10 @@ import {
56
56
  resolveStewardsForNode,
57
57
  resolveStewardsWithFallback,
58
58
  resolveUserRoles,
59
+ stewardDocumentCreator,
59
60
  syncFromConfig,
60
61
  updateSteward
61
- } from "./chunk-2TPQTN4Y.js";
62
+ } from "./chunk-DGPTJXIV.js";
62
63
  import {
63
64
  createGrant,
64
65
  deleteGrant,
@@ -67,21 +68,16 @@ import {
67
68
  listGrants,
68
69
  listUserGrants,
69
70
  resolveNodeGrant
70
- } from "./chunk-I3CSD6CK.js";
71
- import {
72
- generateApiKey,
73
- getKeyPrefix,
74
- hashApiKey,
75
- hashPassword,
76
- parseBearerToken,
77
- verifyKeyRequest,
78
- verifyPassword
79
- } from "./chunk-F66VCBLA.js";
71
+ } from "./chunk-RPGFHESR.js";
80
72
  import {
73
+ attributeRequest,
81
74
  checkConflict,
75
+ clientToolField,
76
+ connectionClient,
82
77
  createVersion,
83
78
  deleteSupersededDrafts,
84
79
  deleteUnsealedVersions,
80
+ deserializeClient,
85
81
  getApprovedVersion,
86
82
  getApprovedVersions,
87
83
  getCurrentVersion,
@@ -91,9 +87,12 @@ import {
91
87
  getTrackedNodeIds,
92
88
  getVersions,
93
89
  listMyDrafts,
90
+ mergeClient,
91
+ parseClientMetadata,
92
+ serializeClient,
94
93
  setApprovedVersion,
95
94
  upsertVersion
96
- } from "./chunk-ZTT4U4NE.js";
95
+ } from "./chunk-IEAWRHMV.js";
97
96
  import {
98
97
  approveExternalEdit,
99
98
  getExternalEditDetail,
@@ -105,7 +104,7 @@ import {
105
104
  scanDocumentForDrift,
106
105
  scanNestForDrift,
107
106
  startDriftScanner
108
- } from "./chunk-XUIWAWDO.js";
107
+ } from "./chunk-2QOJE7A5.js";
109
108
  import {
110
109
  addMember,
111
110
  addWatcher,
@@ -113,10 +112,7 @@ import {
113
112
  assertSafeNodeId,
114
113
  buildDocContext,
115
114
  buildTitleMap,
116
- canApproveWith,
117
- canEditWith,
118
115
  capForArchive,
119
- collabPermToRole,
120
116
  createNest,
121
117
  createTeam,
122
118
  deleteNest,
@@ -139,7 +135,6 @@ import {
139
135
  insertOrIgnore,
140
136
  installLicenseKey,
141
137
  isConfigSuperAdmin,
142
- isEmailConfigured,
143
138
  isLicenseAdminEmail,
144
139
  isLicenseAdminUserId,
145
140
  isNestArchived,
@@ -162,6 +157,7 @@ import {
162
157
  loadServerSettings,
163
158
  markNotificationsRead,
164
159
  nestAllowsSelfApprove,
160
+ nestCreatorIsReviewer,
165
161
  nestName,
166
162
  nestPrimeTags,
167
163
  nestReviewsPrimeOnly,
@@ -177,7 +173,6 @@ import {
177
173
  persistSetting,
178
174
  pinNest,
179
175
  pinnedNestIds,
180
- primaryRole,
181
176
  readIndexedNodes,
182
177
  rebuildNodeIndex,
183
178
  removeMember,
@@ -193,8 +188,8 @@ import {
193
188
  resolveNodeUrl,
194
189
  resolveTeamRolesForUser,
195
190
  restoreNest,
196
- sendEmailToRecipient,
197
191
  setAllowSelfApprove,
192
+ setCreatorIsReviewer,
198
193
  setPrimeOnlyReview,
199
194
  setPrimeTags,
200
195
  setReaderMode,
@@ -209,27 +204,48 @@ import {
209
204
  unpinNest,
210
205
  unshareTeamFromNest,
211
206
  updateMemberRole,
212
- validateLicense,
213
- verifySmtp
214
- } from "./chunk-KIAAEHWL.js";
207
+ validateLicense
208
+ } from "./chunk-SWA5M5XX.js";
215
209
  import {
216
210
  AppError,
217
211
  ConflictError,
218
212
  ForbiddenError,
219
213
  LockedError,
220
214
  NotFoundError,
215
+ PayloadTooLargeError,
221
216
  UnauthorizedError,
222
217
  ValidationError,
223
218
  VaultBusyError,
224
219
  isVaultLockTimeout
225
- } from "./chunk-YVMSM7LS.js";
220
+ } from "./chunk-LHWOJPFE.js";
221
+ import {
222
+ createInvitedUser,
223
+ findOrCreateInvitedUser,
224
+ generateApiKey,
225
+ getKeyPrefix,
226
+ hashApiKey,
227
+ hashPassword,
228
+ parseBearerToken,
229
+ verifyKeyRequest,
230
+ verifyPassword
231
+ } from "./chunk-PICUHQEM.js";
232
+ import {
233
+ canApproveWith,
234
+ canEditWith,
235
+ collabPermToRole,
236
+ isEmailConfigured,
237
+ primaryRole,
238
+ sendEmailToRecipient,
239
+ transportSettings,
240
+ verifyEmailTransport
241
+ } from "./chunk-I5EDEZBQ.js";
226
242
  import {
227
243
  config,
228
244
  getDb,
229
245
  initDb,
230
246
  isEmailListish,
231
247
  isEmailish
232
- } from "./chunk-LA3VTQ22.js";
248
+ } from "./chunk-TRSGPS27.js";
233
249
  import {
234
250
  ANON_EMAIL,
235
251
  ANON_USER_ID,
@@ -243,9 +259,9 @@ import {
243
259
  import { serve } from "@hono/node-server";
244
260
 
245
261
  // src/app.ts
246
- import { Hono as Hono25 } from "hono";
262
+ import { Hono as Hono27 } from "hono";
247
263
  import { LinearRouter } from "hono/router/linear-router";
248
- import { createMiddleware as createMiddleware2 } from "hono/factory";
264
+ import { createMiddleware as createMiddleware3 } from "hono/factory";
249
265
  import { cors } from "hono/cors";
250
266
 
251
267
  // src/shared/artifact-sandbox.ts
@@ -430,7 +446,7 @@ var authMiddleware = createMiddleware(async (c, next) => {
430
446
  if (key) {
431
447
  const keyHash = hashApiKey(key);
432
448
  const record = await db.get(
433
- "SELECT user_id, nest_id FROM api_keys WHERE key_hash = ?",
449
+ "SELECT user_id, nest_id, label FROM api_keys WHERE key_hash = ?",
434
450
  [keyHash]
435
451
  );
436
452
  if (record) {
@@ -440,10 +456,104 @@ var authMiddleware = createMiddleware(async (c, next) => {
440
456
  );
441
457
  c.set("userId", record.user_id);
442
458
  c.set("nestScope", record.nest_id);
459
+ if (record.label) c.set("apiKeyLabel", record.label);
443
460
  return next();
444
461
  }
445
462
  }
446
- return c.json({ error: "Missing or invalid credentials" }, 401);
463
+ if (sessionId) {
464
+ return c.json(
465
+ { error: "Your session has expired \u2014 sign in again.", code: "session_expired" },
466
+ 401
467
+ );
468
+ }
469
+ if (key) {
470
+ return c.json(
471
+ { error: "This API key is invalid or has been revoked \u2014 generate a new one.", code: "invalid_key" },
472
+ 401
473
+ );
474
+ }
475
+ return c.json(
476
+ { error: "Authentication required \u2014 sign in or provide an API key.", code: "missing_credentials" },
477
+ 401
478
+ );
479
+ });
480
+
481
+ // src/auth/nest-scope-guard.ts
482
+ import { createMiddleware as createMiddleware2 } from "hono/factory";
483
+ var NEST_SCOPED_KEY_CODE = "nest_scoped_key";
484
+ var PUBLIC_PATHS = /* @__PURE__ */ new Set([
485
+ "/health",
486
+ "/license/status",
487
+ "/llms.txt",
488
+ "/auth/admin-status"
489
+ ]);
490
+ function segmentMatches(seg, nestId) {
491
+ if (seg === nestId) return true;
492
+ try {
493
+ return decodeURIComponent(seg) === nestId;
494
+ } catch {
495
+ return false;
496
+ }
497
+ }
498
+ function nestKeyVerdict(method, rawPath, scope) {
499
+ const path = rawPath.length > 1 ? rawPath.replace(/\/+$/, "") : rawPath;
500
+ const isGet = method === "GET" || method === "HEAD";
501
+ if (PUBLIC_PATHS.has(path) && isGet) return { allowed: true };
502
+ if (path === "/mcp") return { allowed: true };
503
+ if (path === "/index" && isGet) return { allowed: true };
504
+ if ((path === "/search" || path === "/stats" || path === "/nests") && isGet) {
505
+ return { allowed: true };
506
+ }
507
+ if (path.startsWith("/runs/")) return { allowed: true };
508
+ const m = /^\/nests\/([^/]+)(\/.*)?$/.exec(path);
509
+ if (m) {
510
+ if (segmentMatches(m[1], scope)) return { allowed: true };
511
+ return {
512
+ allowed: false,
513
+ reason: "other_nest",
514
+ mcpTransport: m[2] === "/mcp"
515
+ };
516
+ }
517
+ return { allowed: false, reason: "outside_nest" };
518
+ }
519
+ async function bearerKeyScope(authorization) {
520
+ const key = parseBearerToken(authorization);
521
+ if (!key) return null;
522
+ const row = await getDb().get(
523
+ "SELECT nest_id FROM api_keys WHERE key_hash = ?",
524
+ [hashApiKey(key)]
525
+ );
526
+ return row?.nest_id ?? null;
527
+ }
528
+ var nestScopedKeyGuard = createMiddleware2(async (c, next) => {
529
+ const scope = await bearerKeyScope(c.req.header("Authorization"));
530
+ if (!scope) return next();
531
+ const verdict = nestKeyVerdict(c.req.method, c.req.path, scope);
532
+ if (verdict.allowed) return next();
533
+ const sessionId = getSessionIdFromRequest(c);
534
+ if (sessionId && await resolveSession(sessionId)) return next();
535
+ if (verdict.reason === "other_nest") {
536
+ const error = `API key not authorized for this nest \u2014 this key is scoped to nest "${scope}". Use that nest's MCP URL, or mint a user-level key in the Connect dialog.`;
537
+ if (verdict.mcpTransport) {
538
+ let id = null;
539
+ try {
540
+ id = (await c.req.json())?.id ?? null;
541
+ } catch {
542
+ }
543
+ return c.json(
544
+ { jsonrpc: "2.0", id, error: { code: -32e3, message: error } },
545
+ 403
546
+ );
547
+ }
548
+ return c.json({ error, code: NEST_SCOPED_KEY_CODE }, 403);
549
+ }
550
+ return c.json(
551
+ {
552
+ error: "This API key is scoped to one nest and can't reach account-wide or server-wide routes (API keys, admin, teams, people, your inbox). Use a key for all your nests for those.",
553
+ code: NEST_SCOPED_KEY_CODE
554
+ },
555
+ 403
556
+ );
447
557
  });
448
558
 
449
559
  // src/shared/rate-limit.ts
@@ -1283,7 +1393,7 @@ authRoutes.post("/register", async (c) => {
1283
1393
  if (config.MANUAL_SIGN_IN !== "open") {
1284
1394
  return c.json(
1285
1395
  {
1286
- error: "Self-registration is disabled on this server. If you were invited, use \u201CSet your password\u201D to activate your account. Otherwise ask your admin for an invite."
1396
+ error: "Self-registration is disabled on this server. If you were invited, sign in with the temporary password from your invite email, or continue with PromptOwl using that email. Otherwise ask your admin for an invite."
1287
1397
  },
1288
1398
  403
1289
1399
  );
@@ -1310,7 +1420,7 @@ authRoutes.post("/register", async (c) => {
1310
1420
  if (existing.is_invited === 1) {
1311
1421
  return c.json(
1312
1422
  {
1313
- error: "You've been invited to this server. Ask your admin for your temporary password, then sign in and change it \u2014 you can't set it here."
1423
+ error: "You've been invited to this server. Sign in with the temporary password from your invite email, or continue with PromptOwl using this email. Your admin can reset the password if you don't have it."
1314
1424
  },
1315
1425
  409
1316
1426
  );
@@ -1407,10 +1517,36 @@ authRoutes.post("/logout", async (c) => {
1407
1517
  );
1408
1518
  return c.json({ ok: true });
1409
1519
  });
1520
+ function refuseNestScopedCaller(c) {
1521
+ if (!c.get("nestScope")) return null;
1522
+ return c.json(
1523
+ {
1524
+ error: "This API key is scoped to one nest and can't create, rotate, list or revoke API keys. Use a key for all your nests, or the web app.",
1525
+ code: NEST_SCOPED_KEY_CODE
1526
+ },
1527
+ 403
1528
+ );
1529
+ }
1530
+ async function checkKeyNest(userId, nestId) {
1531
+ if (typeof nestId !== "string" || !nestId.trim()) {
1532
+ return { error: "nest_id must be a nest id.", status: 400 };
1533
+ }
1534
+ const row = await getDb().get("SELECT id FROM nests WHERE id = ?", [nestId]);
1535
+ const reachable = !!row && (config.AUTH_MODE === "open" || await resolveNestPermission(nestId, userId) !== "none");
1536
+ if (!reachable) {
1537
+ return {
1538
+ error: "Nest not found \u2014 you can only scope a key to a nest you have access to.",
1539
+ status: 404
1540
+ };
1541
+ }
1542
+ return null;
1543
+ }
1410
1544
  authRoutes.post("/keys", authMiddleware, async (c) => {
1411
1545
  const body = await c.req.json().catch(
1412
1546
  () => ({})
1413
1547
  );
1548
+ const refused = refuseNestScopedCaller(c);
1549
+ if (refused) return refused;
1414
1550
  const db = getDb();
1415
1551
  const userId = c.get("userId");
1416
1552
  const check = await verifyKeyRequest(db, userId, {
@@ -1418,6 +1554,10 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
1418
1554
  label: body.label
1419
1555
  });
1420
1556
  if (!check.ok) return c.json({ error: check.error }, check.status);
1557
+ if (body.nest_id) {
1558
+ const bad = await checkKeyNest(userId, body.nest_id);
1559
+ if (bad) return c.json({ error: bad.error }, bad.status);
1560
+ }
1421
1561
  const apiKey = generateApiKey();
1422
1562
  const keyId = uuid4();
1423
1563
  await db.run(
@@ -1432,12 +1572,20 @@ authRoutes.post("/keys", authMiddleware, async (c) => {
1432
1572
  ]
1433
1573
  );
1434
1574
  return c.json(
1435
- { id: keyId, api_key: apiKey, key_prefix: getKeyPrefix(apiKey), label: check.label },
1575
+ {
1576
+ id: keyId,
1577
+ api_key: apiKey,
1578
+ key_prefix: getKeyPrefix(apiKey),
1579
+ label: check.label,
1580
+ nest_id: body.nest_id || null
1581
+ },
1436
1582
  201
1437
1583
  );
1438
1584
  });
1439
1585
  authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
1440
1586
  const body = await c.req.json().catch(() => ({}));
1587
+ const refused = refuseNestScopedCaller(c);
1588
+ if (refused) return refused;
1441
1589
  const db = getDb();
1442
1590
  const userId = c.get("userId");
1443
1591
  const check = await verifyKeyRequest(db, userId, {
@@ -1447,6 +1595,10 @@ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
1447
1595
  });
1448
1596
  if (!check.ok) return c.json({ error: check.error }, check.status);
1449
1597
  const prior = check.key;
1598
+ if ("nest_id" in body && body.nest_id) {
1599
+ const bad = await checkKeyNest(userId, body.nest_id);
1600
+ if (bad) return c.json({ error: bad.error }, bad.status);
1601
+ }
1450
1602
  const apiKey = generateApiKey();
1451
1603
  const keyId = uuid4();
1452
1604
  const nestScope = "nest_id" in body ? body.nest_id || null : prior.nest_id;
@@ -1479,10 +1631,13 @@ authRoutes.post("/keys/rotate", authMiddleware, async (c) => {
1479
1631
  id: keyId,
1480
1632
  api_key: apiKey,
1481
1633
  key_prefix: getKeyPrefix(apiKey),
1482
- label: check.label ?? prior.label
1634
+ label: check.label ?? prior.label,
1635
+ nest_id: nestScope
1483
1636
  });
1484
1637
  });
1485
1638
  authRoutes.get("/keys", authMiddleware, async (c) => {
1639
+ const refused = refuseNestScopedCaller(c);
1640
+ if (refused) return refused;
1486
1641
  const db = getDb();
1487
1642
  const userId = c.get("userId");
1488
1643
  const check = await verifyKeyRequest(db, userId, { action: "fetch" });
@@ -1494,6 +1649,8 @@ authRoutes.get("/keys", authMiddleware, async (c) => {
1494
1649
  return c.json({ keys });
1495
1650
  });
1496
1651
  authRoutes.delete("/keys/:keyId", authMiddleware, async (c) => {
1652
+ const refused = refuseNestScopedCaller(c);
1653
+ if (refused) return refused;
1497
1654
  const db = getDb();
1498
1655
  const userId = c.get("userId");
1499
1656
  const check = await verifyKeyRequest(db, userId, {
@@ -1877,47 +2034,30 @@ authRoutes.post("/invite", async (c) => {
1877
2034
  "SELECT id, email, is_invited FROM users WHERE LOWER(email) = ?",
1878
2035
  [email]
1879
2036
  );
1880
- if (existing && existing.is_invited === 0) {
2037
+ if (existing) {
1881
2038
  return c.json(
1882
2039
  {
1883
- error: "A user with this email already exists. Use Reset password to issue them a new password."
2040
+ error: existing.is_invited === 0 ? `${email} is already a member of this server.` : `${email} is already invited but hasn't signed in yet. Use Reset password on their row to issue a new temporary password.`
1884
2041
  },
1885
2042
  409
1886
2043
  );
1887
2044
  }
1888
- const tempPassword = uuid4().replace(/-/g, "").slice(0, 16);
1889
- const passwordHash = await hashPassword(tempPassword);
1890
- let userId;
1891
- if (existing) {
1892
- userId = existing.id;
1893
- await db.run("UPDATE users SET password_hash = ? WHERE id = ?", [
1894
- passwordHash,
1895
- userId
1896
- ]);
1897
- } else {
1898
- userId = uuid4();
1899
- await db.run(
1900
- "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
1901
- [userId, email, null, passwordHash]
1902
- );
1903
- }
1904
- trackEvent("admin.invite", { adminId: callerId, email });
1905
2045
  const inviter = await db.get("SELECT email FROM users WHERE id = ?", [
1906
2046
  callerId
1907
2047
  ]);
1908
- void sendEmailToRecipient(email, {
1909
- status: "invited",
1910
- docTitle: "ContextNest",
1911
- path: "",
1912
- actor: email,
1913
- by: inviter?.email || void 0,
1914
- tempPassword,
1915
- link: requestBaseUrl(c.req.url)
2048
+ const { id: userId, tempPassword } = await createInvitedUser(email, {
2049
+ invitedBy: inviter?.email,
2050
+ baseUrl: requestBaseUrl(c.req.url)
1916
2051
  });
2052
+ trackEvent("admin.invite", { adminId: callerId, email });
1917
2053
  return c.json(
1918
2054
  {
1919
2055
  temporary_password: tempPassword,
1920
2056
  user: { id: userId, email },
2057
+ // PromptOwl sign-in only counts as an option for them when it's open to all.
2058
+ promptowl_sign_in: config.PROMPTOWL_SIGN_IN_GATE === "open",
2059
+ // Email is configured, so the invite email (with this password) went out.
2060
+ emailed: isEmailConfigured(),
1921
2061
  message: "Share this temporary password securely. Your teammate signs in with their email + this password, then can change it and create their own API key."
1922
2062
  },
1923
2063
  201
@@ -1945,7 +2085,12 @@ authRoutes.get("/teammates", async (c) => {
1945
2085
  const teammates = await db.all(
1946
2086
  `SELECT u.id, u.email, u.name, u.is_invited,
1947
2087
  (SELECT COUNT(*) FROM api_keys WHERE user_id = u.id) as key_count,
1948
- (SELECT MAX(last_used_at) FROM api_keys WHERE user_id = u.id) as last_active
2088
+ -- Latest of API-key use and web-session activity (same text format).
2089
+ (SELECT MAX(t) FROM (
2090
+ SELECT last_used_at AS t FROM api_keys WHERE user_id = u.id
2091
+ UNION ALL
2092
+ SELECT last_seen_at FROM sessions WHERE user_id = u.id
2093
+ ) activity) as last_active
1949
2094
  FROM users u
1950
2095
  WHERE u.id != ?
1951
2096
  ORDER BY u.created_at DESC`,
@@ -1986,7 +2131,9 @@ import {
1986
2131
  DocumentNotFoundError,
1987
2132
  NODE_TYPES,
1988
2133
  TAG_PATTERN,
1989
- generateIndexMd
2134
+ generateIndexMd,
2135
+ readPdfBinary,
2136
+ pdfSidecarPath
1990
2137
  } from "@promptowl/contextnest-engine";
1991
2138
  import { rename, mkdir, rm, stat } from "fs/promises";
1992
2139
  import { dirname, join } from "path";
@@ -2566,8 +2713,20 @@ var ACCEPTED_NODE_TYPES = /* @__PURE__ */ new Set([
2566
2713
  ...NODE_TYPES,
2567
2714
  ...RUNNABLE_NODE_TYPES,
2568
2715
  "artifact",
2569
- "table"
2716
+ "table",
2717
+ "pdf"
2570
2718
  ]);
2719
+ var PDF_NODE_TYPE = "pdf";
2720
+ var PDF_UPLOAD_HINT = "upload the file to POST /nests/:nestId/nodes/pdf (the Upload PDF action)";
2721
+ function pdfEngineError(err) {
2722
+ const code = err?.code;
2723
+ const message = err?.message || "The PDF could not be imported.";
2724
+ if (code === "VALIDATION_FAILED" || code === "INVALID_DOCUMENT_ID") {
2725
+ return new ValidationError(message);
2726
+ }
2727
+ if (code === "DOCUMENT_ALREADY_EXISTS") return new ConflictError(message);
2728
+ return err;
2729
+ }
2571
2730
  function assertAcceptedType(type) {
2572
2731
  if (type === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
2573
2732
  throw new ValidationError("Artifact nodes are disabled on this server");
@@ -2581,6 +2740,13 @@ function assertAcceptedType(type) {
2581
2740
  );
2582
2741
  }
2583
2742
  }
2743
+ function assertUpdatableType(storedType) {
2744
+ const type = storedType || "document";
2745
+ if (ACCEPTED_NODE_TYPES.has(type)) return;
2746
+ throw new ValidationError(
2747
+ `node has legacy type "${type}" which this server no longer accepts; include "type": "<accepted type>" in the same PATCH to migrate it (allowed: ${[...ACCEPTED_NODE_TYPES].sort().join(", ")})`
2748
+ );
2749
+ }
2584
2750
  function toNodeResponse(node) {
2585
2751
  const fm = node.frontmatter;
2586
2752
  const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
@@ -2605,7 +2771,8 @@ function toNodeResponse(node) {
2605
2771
  // Same YAML-coercion rule as title above: `schedule: 123` parses as a
2606
2772
  // number — normalize to string at the API boundary.
2607
2773
  schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0,
2608
- prime: documentPrimeFlag(fm.metadata)
2774
+ prime: documentPrimeFlag(fm.metadata),
2775
+ ...fm.type === "pdf" && fm.pdf ? { pdf: fm.pdf } : {}
2609
2776
  };
2610
2777
  }
2611
2778
  async function resolveAuthorship(nestId, nodes) {
@@ -2881,7 +3048,7 @@ async function vaultFolderPaths(nestId, opts = {}) {
2881
3048
  });
2882
3049
  return entries.map(
2883
3050
  (e) => prefix && e.path.startsWith(`${prefix}/`) ? e.path.slice(prefix.length + 1) : e.path
2884
- ).filter(Boolean);
3051
+ ).filter((p) => p && !(!prefix && (p === "assets" || p.startsWith("assets/"))));
2885
3052
  }
2886
3053
  async function listNestFolders(nestId, opts = {}) {
2887
3054
  if (opts.folder !== void 0) assertSafeFolder(opts.folder);
@@ -2998,9 +3165,11 @@ async function assertMayFlagPrime(nestId, userEmail) {
2998
3165
  );
2999
3166
  }
3000
3167
  }
3001
- async function autoPublishReason(nestId, userEmail, doc) {
3002
- if (!await isStewardshipEnabled(nestId)) return "no stewards configured";
3168
+ async function autoPublishReason(nestId, userEmail, doc, opts = {}) {
3169
+ const governed = opts.governed ?? await isStewardshipEnabled(nestId);
3170
+ if (!governed) return "no stewards configured";
3003
3171
  if (await isPrimeDocument(nestId, doc.tags, doc.metadata)) return null;
3172
+ if (opts.creatorReviews) return "creator is the document's reviewer";
3004
3173
  if (await nestReviewsPrimeOnly(nestId)) {
3005
3174
  return "prime-only review, document is not prime";
3006
3175
  }
@@ -3030,8 +3199,14 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
3030
3199
  id = `${prefix}${slug || await generatedSlug(storage, prefix)}`;
3031
3200
  }
3032
3201
  const tags = requireTags(input.tags);
3033
- const requestedType = input.type || "document";
3202
+ const requestedType = input.pdf ? input.type || PDF_NODE_TYPE : input.type || "document";
3034
3203
  assertAcceptedType(requestedType);
3204
+ if (requestedType === PDF_NODE_TYPE && !input.pdf) {
3205
+ throw new ValidationError(`PDF documents can't be created from text \u2014 ${PDF_UPLOAD_HINT}.`);
3206
+ }
3207
+ if (input.pdf && requestedType !== PDF_NODE_TYPE) {
3208
+ throw new ValidationError(`a PDF upload creates a "pdf" node, not "${requestedType}"`);
3209
+ }
3035
3210
  if (input.schedule !== void 0 && !isRunnableType(input.type)) {
3036
3211
  throw new ValidationError(
3037
3212
  `schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(
@@ -3076,42 +3251,85 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
3076
3251
  ...input.schedule ? { schedule: input.schedule } : {},
3077
3252
  ...input.prime === void 0 ? {} : { prime: input.prime }
3078
3253
  };
3079
- const publishReason = await autoPublishReason(nestId, userEmail, {
3080
- tags,
3081
- metadata
3082
- });
3083
- const publishNote = publishReason === null ? void 0 : `Auto-published on create (${publishReason})`;
3084
- let published = publishReason !== null;
3254
+ const governed = await isStewardshipEnabled(nestId);
3255
+ const creatorReviews = opts.creatorReviews !== false && governed && await nestCreatorIsReviewer(nestId);
3256
+ const creatorSteward = creatorReviews ? await stewardDocumentCreator(nestId, id, userEmail) : null;
3257
+ let published = false;
3258
+ let publishNote;
3085
3259
  try {
3086
- await engineApi.run(
3087
- "context_create",
3088
- {
3089
- id,
3090
- title: input.title,
3091
- content: input.content,
3092
- type: requestedType,
3093
- // A skill node is invalid without a skill block, and this surface has
3094
- // no field for one — default the trigger from the title, same as the
3095
- // CLI and mcp-server do.
3096
- ...requestedType === "skill" ? { trigger: `when asked to ${input.title.toLowerCase()}` } : {},
3097
- ...tags.length ? { tags } : {},
3098
- ...input.status ? { status: input.status } : {},
3099
- metadata,
3100
- publish: published,
3101
- ...publishNote ? { note: publishNote } : {}
3102
- },
3103
- await opContext(nestId, userEmail)
3260
+ const publishReason = await autoPublishReason(
3261
+ nestId,
3262
+ userEmail,
3263
+ { tags, metadata },
3264
+ { creatorReviews: creatorSteward?.canApprove === true, governed }
3104
3265
  );
3105
- } catch (err) {
3106
- let onDisk = true;
3266
+ publishNote = publishReason === null ? void 0 : `Auto-published on create (${publishReason})`;
3267
+ published = publishReason !== null;
3107
3268
  try {
3108
- await storage.readDocument(id);
3109
- } catch {
3110
- onDisk = false;
3269
+ if (input.pdf) {
3270
+ try {
3271
+ await engineApi.run(
3272
+ "context_import_pdf",
3273
+ {
3274
+ id,
3275
+ title: input.title,
3276
+ bytes_base64: Buffer.from(input.pdf.bytes).toString("base64"),
3277
+ ...input.pdf.filename ? { filename: input.pdf.filename } : {},
3278
+ ...tags.length ? { tags } : {},
3279
+ publish: published,
3280
+ ...publishNote ? { note: publishNote } : {},
3281
+ ...input.client ? { client: input.client } : {}
3282
+ },
3283
+ await opContext(nestId, userEmail)
3284
+ );
3285
+ } catch (err) {
3286
+ throw pdfEngineError(err);
3287
+ }
3288
+ } else {
3289
+ await engineApi.run(
3290
+ "context_create",
3291
+ {
3292
+ id,
3293
+ title: input.title,
3294
+ content: input.content,
3295
+ type: requestedType,
3296
+ // A skill node is invalid without a skill block, and this surface
3297
+ // has no field for one — default the trigger from the title, same
3298
+ // as the CLI and mcp-server do.
3299
+ ...requestedType === "skill" ? { trigger: `when asked to ${input.title.toLowerCase()}` } : {},
3300
+ ...tags.length ? { tags } : {},
3301
+ ...input.status ? { status: input.status } : {},
3302
+ metadata,
3303
+ publish: published,
3304
+ ...publishNote ? { note: publishNote } : {}
3305
+ },
3306
+ await opContext(nestId, userEmail)
3307
+ );
3308
+ }
3309
+ } catch (err) {
3310
+ let onDisk = true;
3311
+ try {
3312
+ await storage.readDocument(id);
3313
+ } catch {
3314
+ onDisk = false;
3315
+ }
3316
+ if (!onDisk) throw err;
3317
+ console.error("publishDocument failed (node create auto-publish)", err);
3318
+ published = false;
3319
+ }
3320
+ } catch (err) {
3321
+ if (creatorSteward?.insertedId) {
3322
+ try {
3323
+ await removeSteward(creatorSteward.insertedId);
3324
+ } catch (cleanupErr) {
3325
+ console.error(
3326
+ "removeSteward failed while compensating a failed create",
3327
+ id,
3328
+ cleanupErr
3329
+ );
3330
+ }
3111
3331
  }
3112
- if (!onDisk) throw err;
3113
- console.error("publishDocument failed (node create auto-publish)", err);
3114
- published = false;
3332
+ throw err;
3115
3333
  }
3116
3334
  const node = await storage.readDocument(id);
3117
3335
  await syncNodeTags(nestId, id, tags);
@@ -3126,7 +3344,8 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
3126
3344
  author: userEmail,
3127
3345
  status: "published",
3128
3346
  tags,
3129
- changeNote: publishNote
3347
+ changeNote: publishNote,
3348
+ client: input.client
3130
3349
  });
3131
3350
  await setApprovedVersion(nestId, id, savedVersion, userEmail);
3132
3351
  } else {
@@ -3134,10 +3353,12 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
3134
3353
  nestId,
3135
3354
  nodeId: id,
3136
3355
  version: 1,
3137
- content: input.content,
3356
+ // A pdf node's body is what the engine extracted, not anything sent.
3357
+ content: input.pdf ? node.body || "" : input.content,
3138
3358
  author: userEmail,
3139
3359
  status: "draft",
3140
- tags
3360
+ tags,
3361
+ client: input.client
3141
3362
  });
3142
3363
  }
3143
3364
  await trackEvent("node.create", { nestId, nodeId: id });
@@ -3191,7 +3412,7 @@ async function alignOnDiskStatus(nestId, documentId, status) {
3191
3412
  );
3192
3413
  }
3193
3414
  }
3194
- async function registerImportedDocuments(nestId, userEmail) {
3415
+ async function registerImportedDocuments(nestId, userEmail, client) {
3195
3416
  const startedAt = Date.now();
3196
3417
  const tracked = await getTrackedNodeIds(nestId);
3197
3418
  (await engineCache.get(nestId)).invalidateDiscovery();
@@ -3221,7 +3442,8 @@ async function registerImportedDocuments(nestId, userEmail) {
3221
3442
  author: userEmail,
3222
3443
  status: d.status,
3223
3444
  changeNote: d.status === "published" ? "Imported from existing folder" : "Imported from existing folder \u2014 could not be published, kept as a draft",
3224
- tags
3445
+ tags,
3446
+ client
3225
3447
  });
3226
3448
  if (d.status === "published") {
3227
3449
  await setApprovedVersion(nestId, d.id, d.version, userEmail);
@@ -3243,6 +3465,44 @@ async function registerImportedDocuments(nestId, userEmail) {
3243
3465
  await trackEvent("nest.import.documents", { nestId, registered: docs.length });
3244
3466
  return docs.length;
3245
3467
  }
3468
+ async function stagePdfImport(nestId, nodeId, pdf, userEmail, client, priorRaw) {
3469
+ const { storage } = await engineCache.get(nestId);
3470
+ const sidecar = pdfSidecarPath(nodeId);
3471
+ let priorSidecar = null;
3472
+ try {
3473
+ priorSidecar = await storage.readVaultBinary(sidecar);
3474
+ } catch {
3475
+ }
3476
+ const sealedBefore = await storage.maxRecordedVersion(nodeId);
3477
+ const rollback = async () => {
3478
+ if (await storage.maxRecordedVersion(nodeId).catch(() => sealedBefore) > sealedBefore) {
3479
+ return;
3480
+ }
3481
+ try {
3482
+ await storage.writeDocument(nodeId, priorRaw);
3483
+ if (priorSidecar) await storage.writeVaultBinary(sidecar, priorSidecar);
3484
+ await storage.regenerateIndex();
3485
+ } catch (err) {
3486
+ console.error("[pdf] could not roll back a failed PDF replace", nestId, nodeId, err);
3487
+ }
3488
+ };
3489
+ try {
3490
+ const result = await engineApi.run(
3491
+ "context_import_pdf",
3492
+ {
3493
+ id: nodeId,
3494
+ bytes_base64: Buffer.from(pdf.bytes).toString("base64"),
3495
+ ...pdf.filename ? { filename: pdf.filename } : {},
3496
+ publish: false,
3497
+ ...client ? { client } : {}
3498
+ },
3499
+ await opContext(nestId, userEmail)
3500
+ );
3501
+ return { unchanged: result.unchanged === true, rollback };
3502
+ } catch (err) {
3503
+ throw pdfEngineError(err);
3504
+ }
3505
+ }
3246
3506
  async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3247
3507
  return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
3248
3508
  try {
@@ -3255,6 +3515,35 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3255
3515
  } catch {
3256
3516
  throw new NotFoundError(`Node not found: ${nodeId}`);
3257
3517
  }
3518
+ const priorRaw = node.rawContent;
3519
+ let pdfRollback = null;
3520
+ if (patch.type === void 0 || patch.type === node.frontmatter.type) {
3521
+ assertUpdatableType(node.frontmatter.type);
3522
+ }
3523
+ const storedType = node.frontmatter.type || "document";
3524
+ const isPdfNode = storedType === PDF_NODE_TYPE;
3525
+ if (patch.pdf && !isPdfNode) {
3526
+ throw new ValidationError(
3527
+ `${nodeId} is a "${storedType}" node, not a PDF \u2014 a PDF can only replace the PDF of a pdf node.`
3528
+ );
3529
+ }
3530
+ if (patch.type !== void 0 && patch.type !== storedType) {
3531
+ if (isPdfNode || patch.type === PDF_NODE_TYPE) {
3532
+ throw new ValidationError(
3533
+ isPdfNode ? "A PDF document can't be converted to another type \u2014 its body is the text of its PDF." : `A document can't be converted to a PDF \u2014 ${PDF_UPLOAD_HINT}.`
3534
+ );
3535
+ }
3536
+ }
3537
+ if (isPdfNode) {
3538
+ const readOnly = "This is a PDF document: its text is extracted from the PDF and can't be edited directly. Upload a new version of the PDF (Replace PDF) to change it.";
3539
+ if (patch.append) throw new ConflictError(readOnly);
3540
+ if (patch.content !== void 0) {
3541
+ if (patch.content.trim() !== (node.body || "").trim()) {
3542
+ throw new ConflictError(readOnly);
3543
+ }
3544
+ patch = { ...patch, content: void 0 };
3545
+ }
3546
+ }
3258
3547
  const previousBody = node.body || "";
3259
3548
  const opInput = {};
3260
3549
  if (patch.content !== void 0) {
@@ -3350,11 +3639,30 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3350
3639
  if (publishReason === null || isAuthorEditingOwnReview) {
3351
3640
  const currentVersion = await getCurrentVersion(nestId, nodeId);
3352
3641
  const newVersion = await versionManager.nextVersion(nodeId, currentVersion);
3353
- await engineApi.run(
3354
- "context_update",
3355
- { id: nodeId, ...opInput, publish: false, version: newVersion },
3356
- await opContext(nestId, userEmail)
3357
- );
3642
+ if (patch.pdf) {
3643
+ const staged = await stagePdfImport(
3644
+ nestId,
3645
+ nodeId,
3646
+ patch.pdf,
3647
+ userEmail,
3648
+ patch.client,
3649
+ priorRaw
3650
+ );
3651
+ if (staged.unchanged && Object.keys(opInput).length === 0) {
3652
+ return { node, version: node.frontmatter.version || currentVersion || 1 };
3653
+ }
3654
+ if (!staged.unchanged) pdfRollback = staged.rollback;
3655
+ }
3656
+ try {
3657
+ await engineApi.run(
3658
+ "context_update",
3659
+ { id: nodeId, ...opInput, publish: false, version: newVersion },
3660
+ await opContext(nestId, userEmail)
3661
+ );
3662
+ } catch (err) {
3663
+ if (pdfRollback) await pdfRollback();
3664
+ throw err;
3665
+ }
3358
3666
  node = await storage.readDocument(nodeId);
3359
3667
  await syncNodeTags(nestId, nodeId, currentTags);
3360
3668
  await deleteSupersededDrafts(nestId, nodeId, newVersion);
@@ -3366,7 +3674,8 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3366
3674
  author: userEmail,
3367
3675
  status: "draft",
3368
3676
  tags: currentTags,
3369
- changeNote: patch.changeNote
3677
+ changeNote: patch.changeNote,
3678
+ client: patch.client
3370
3679
  });
3371
3680
  if (isAuthorEditingOwnReview) {
3372
3681
  await repointPendingReview({ nestId, nodeId, newVersion });
@@ -3375,6 +3684,20 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3375
3684
  } else {
3376
3685
  const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
3377
3686
  let publishedVersion;
3687
+ if (patch.pdf) {
3688
+ const staged = await stagePdfImport(
3689
+ nestId,
3690
+ nodeId,
3691
+ patch.pdf,
3692
+ userEmail,
3693
+ patch.client,
3694
+ priorRaw
3695
+ );
3696
+ if (staged.unchanged && Object.keys(opInput).length === 0) {
3697
+ return { node, version: node.frontmatter.version || 1 };
3698
+ }
3699
+ if (!staged.unchanged) pdfRollback = staged.rollback;
3700
+ }
3378
3701
  try {
3379
3702
  const result = await engineApi.run(
3380
3703
  "context_update",
@@ -3387,6 +3710,7 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3387
3710
  "publishDocument failed (node patch auto-publish)",
3388
3711
  err
3389
3712
  );
3713
+ if (pdfRollback) await pdfRollback();
3390
3714
  throw err;
3391
3715
  }
3392
3716
  node = await storage.readDocument(nodeId);
@@ -3399,7 +3723,8 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3399
3723
  author: userEmail,
3400
3724
  status: "published",
3401
3725
  tags: currentTags,
3402
- changeNote: publishNote
3726
+ changeNote: publishNote,
3727
+ client: patch.client
3403
3728
  });
3404
3729
  await setApprovedVersion(
3405
3730
  nestId,
@@ -3505,6 +3830,11 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
3505
3830
  } catch {
3506
3831
  throw new NotFoundError(`Node not found: ${nodeId}`);
3507
3832
  }
3833
+ if (node.frontmatter.type === PDF_NODE_TYPE) {
3834
+ throw new ValidationError(
3835
+ "PDF documents can't be moved to another folder yet. Upload the PDF again in the destination folder instead."
3836
+ );
3837
+ }
3508
3838
  await assertNotReviewLocked(nestId, nodeId, userEmail, "moving");
3509
3839
  const leaf = nodeId.split("/").pop();
3510
3840
  const segments = folderToSegments(folder);
@@ -3601,6 +3931,16 @@ async function discardDraft(nestId, nodeId, userEmail) {
3601
3931
  await storage.writeDocument(nodeId, raw);
3602
3932
  await storage.regenerateIndex();
3603
3933
  const restored = parseDocument(`${nodeId}.md`, raw, nodeId);
3934
+ let warning;
3935
+ if (restored.frontmatter.type === PDF_NODE_TYPE) {
3936
+ try {
3937
+ const bytes = await readPdfBinary(storage, nodeId);
3938
+ await storage.writeVaultBinary(pdfSidecarPath(nodeId), bytes);
3939
+ } catch (err) {
3940
+ console.error("[pdf] could not restore the sealed binary on discard", nestId, nodeId, err);
3941
+ warning = `The draft was discarded, but the PDF of version ${sealed} could not be restored (its archived copy is missing or no longer matches). Upload the PDF again, or ask an admin to verify the nest.`;
3942
+ }
3943
+ }
3604
3944
  await syncNodeTags(
3605
3945
  nestId,
3606
3946
  nodeId,
@@ -3608,7 +3948,7 @@ async function discardDraft(nestId, nodeId, userEmail) {
3608
3948
  );
3609
3949
  await deleteUnsealedVersions(nestId, nodeId, sealed);
3610
3950
  await trackEvent("node.discard_draft", { nestId, nodeId, version: sealed });
3611
- return { deleted: false, version: sealed };
3951
+ return { deleted: false, version: sealed, ...warning ? { warning } : {} };
3612
3952
  });
3613
3953
  }
3614
3954
  async function deleteNode(nestId, nodeId, userEmail) {
@@ -3686,6 +4026,7 @@ async function vaultFolderDir(nestId, rel) {
3686
4026
  }
3687
4027
  async function vaultFolderExists(nestId, rel) {
3688
4028
  if (!rel) return false;
4029
+ assertSafeFolder(rel);
3689
4030
  try {
3690
4031
  return (await stat(await vaultFolderDir(nestId, rel))).isDirectory();
3691
4032
  } catch {
@@ -3835,7 +4176,7 @@ function collectMarkdownFiles(dir, root) {
3835
4176
  }
3836
4177
  return out;
3837
4178
  }
3838
- async function syncUnsyncedFolder(userId, folderName, callerEmail) {
4179
+ async function syncUnsyncedFolder(userId, folderName, callerEmail, client) {
3839
4180
  console.log(
3840
4181
  `[unsynced] sync requested folder="${folderName}" user="${callerEmail}"`
3841
4182
  );
@@ -3844,14 +4185,14 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
3844
4185
  if (isNestStorageRoot(src)) {
3845
4186
  throw new ValidationError("Folder is not eligible for sync");
3846
4187
  }
3847
- let stat2;
4188
+ let stat3;
3848
4189
  try {
3849
- stat2 = statSync(src);
4190
+ stat3 = statSync(src);
3850
4191
  } catch {
3851
4192
  console.warn(`[unsynced] source missing: ${src}`);
3852
4193
  throw new NotFoundError(`Folder not found: ${folderName}`);
3853
4194
  }
3854
- if (!stat2.isDirectory()) {
4195
+ if (!stat3.isDirectory()) {
3855
4196
  throw new ValidationError(`Not a directory: ${folderName}`);
3856
4197
  }
3857
4198
  const files = collectMarkdownFiles(src, src);
@@ -3876,7 +4217,7 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
3876
4217
  let documents;
3877
4218
  try {
3878
4219
  await stageImportedFiles(nest.id, files, callerEmail);
3879
- documents = await registerImportedDocuments(nest.id, callerEmail);
4220
+ documents = await registerImportedDocuments(nest.id, callerEmail, client);
3880
4221
  } catch (err) {
3881
4222
  await deleteNest(nest.id).catch(() => {
3882
4223
  });
@@ -3900,19 +4241,226 @@ async function deleteUnsyncedFolder(folderName) {
3900
4241
  if (isNestStorageRoot(src)) {
3901
4242
  throw new ValidationError("Folder is not eligible for delete");
3902
4243
  }
3903
- let stat2;
4244
+ let stat3;
3904
4245
  try {
3905
- stat2 = statSync(src);
4246
+ stat3 = statSync(src);
3906
4247
  } catch {
3907
4248
  throw new NotFoundError(`Folder not found: ${folderName}`);
3908
4249
  }
3909
- if (!stat2.isDirectory()) {
4250
+ if (!stat3.isDirectory()) {
3910
4251
  throw new ValidationError(`Not a directory: ${folderName}`);
3911
4252
  }
3912
4253
  await rm2(src, { recursive: true, force: true });
3913
4254
  console.log(`[unsynced] deleted folder ${src}`);
3914
4255
  }
3915
4256
 
4257
+ // src/shared/branding.ts
4258
+ import { z } from "zod";
4259
+ var HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
4260
+ var IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp)$/i;
4261
+ var ASSET_NAME_RE = /^[0-9a-f-]{36}\.(?:png|jpe?g|gif|webp)$/;
4262
+ var BRANDING_LIMITS = {
4263
+ site_title: 120,
4264
+ banner_headline: 120,
4265
+ banner_subtext: 280,
4266
+ footer_text: 280,
4267
+ footer_link_label: 80,
4268
+ url: 2048
4269
+ };
4270
+ var IMAGE_FIELDS = ["logo_url", "banner_url", "favicon_url"];
4271
+ var COLOR_FIELDS = ["accent_color", "header_bg_color", "page_tint_color"];
4272
+ var CONTROL_RE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/;
4273
+ var optText = (max) => z.string().max(max).refine((s) => !CONTROL_RE.test(s), "must not contain control characters").nullable().optional();
4274
+ var optColor = z.string().regex(HEX_RE, "must be a #rgb or #rrggbb hex color").nullable().optional();
4275
+ var optUrl = z.string().max(BRANDING_LIMITS.url).nullable().optional();
4276
+ var BrandingSchema = z.strictObject({
4277
+ logo_url: optUrl,
4278
+ banner_url: optUrl,
4279
+ banner_headline: optText(BRANDING_LIMITS.banner_headline),
4280
+ banner_subtext: optText(BRANDING_LIMITS.banner_subtext),
4281
+ accent_color: optColor,
4282
+ header_bg_color: optColor,
4283
+ page_tint_color: optColor,
4284
+ site_title: optText(BRANDING_LIMITS.site_title),
4285
+ footer_text: optText(BRANDING_LIMITS.footer_text),
4286
+ footer_link_url: optUrl,
4287
+ footer_link_label: optText(BRANDING_LIMITS.footer_link_label),
4288
+ favicon_url: optUrl,
4289
+ hide_powered_by: z.boolean().optional()
4290
+ });
4291
+ function nestAssetName(url, nestId) {
4292
+ const prefix = `/nests/${nestId}/assets/`;
4293
+ if (!url.startsWith(prefix)) return null;
4294
+ const name = url.slice(prefix.length);
4295
+ return ASSET_NAME_RE.test(name) ? name : null;
4296
+ }
4297
+ function isHttpsImageUrl(url) {
4298
+ if (!/^https:\/\//i.test(url)) return false;
4299
+ let u;
4300
+ try {
4301
+ u = new URL(url);
4302
+ } catch {
4303
+ return false;
4304
+ }
4305
+ if (u.protocol !== "https:" || u.username || u.password || !u.hostname) return false;
4306
+ return IMAGE_EXT_RE.test(u.pathname);
4307
+ }
4308
+ function isHttpsLink(url) {
4309
+ if (!/^https:\/\//i.test(url)) return false;
4310
+ try {
4311
+ const u = new URL(url);
4312
+ return u.protocol === "https:" && !u.username && !u.password && !!u.hostname;
4313
+ } catch {
4314
+ return false;
4315
+ }
4316
+ }
4317
+ function normalizeHex(v) {
4318
+ if (typeof v !== "string" || !HEX_RE.test(v)) return null;
4319
+ let h = v.slice(1).toLowerCase();
4320
+ if (h.length === 3) h = h.split("").map((c) => c + c).join("");
4321
+ return "#" + h;
4322
+ }
4323
+ function compact(b) {
4324
+ const out = {};
4325
+ for (const [k, v] of Object.entries(b)) {
4326
+ if (v === null || v === void 0) continue;
4327
+ if (typeof v === "string") {
4328
+ const t = v.trim();
4329
+ if (t === "") continue;
4330
+ out[k] = t;
4331
+ } else if (v === true) {
4332
+ out[k] = true;
4333
+ }
4334
+ }
4335
+ if (Object.keys(out).length === 0) return null;
4336
+ return out;
4337
+ }
4338
+ var BrandingValidationError = class extends Error {
4339
+ };
4340
+ async function validateBrandingInput(body, nestId, assetExists) {
4341
+ if (body === null || body === void 0) return null;
4342
+ const parsed = BrandingSchema.safeParse(body);
4343
+ if (!parsed.success) {
4344
+ const issue = parsed.error.issues[0];
4345
+ const field2 = issue?.path?.join(".") || "branding";
4346
+ const msg = issue?.code === "unrecognized_keys" ? `unknown branding field(s): ${issue.keys.join(", ")}` : `${field2}: ${issue?.message ?? "invalid"}`;
4347
+ throw new BrandingValidationError(msg);
4348
+ }
4349
+ const b = compact(parsed.data);
4350
+ if (!b) return null;
4351
+ for (const f of COLOR_FIELDS) {
4352
+ if (b[f]) b[f] = normalizeHex(b[f]);
4353
+ }
4354
+ for (const f of IMAGE_FIELDS) {
4355
+ const url = b[f];
4356
+ if (!url) continue;
4357
+ const asset = nestAssetName(url, nestId);
4358
+ if (asset) {
4359
+ if (!await assetExists(asset)) {
4360
+ throw new BrandingValidationError(`${f}: no such asset in this nest`);
4361
+ }
4362
+ continue;
4363
+ }
4364
+ if (!isHttpsImageUrl(url)) {
4365
+ throw new BrandingValidationError(
4366
+ `${f}: must be an image uploaded to this nest or an https:// URL ending in .png, .jpg, .gif or .webp`
4367
+ );
4368
+ }
4369
+ b[f] = new URL(url).href;
4370
+ }
4371
+ if (b.footer_link_url) {
4372
+ if (!isHttpsLink(b.footer_link_url)) {
4373
+ throw new BrandingValidationError("footer_link_url: must be an https:// URL");
4374
+ }
4375
+ b.footer_link_url = new URL(b.footer_link_url).href;
4376
+ }
4377
+ return b;
4378
+ }
4379
+ function parseStoredBranding(raw, nestId) {
4380
+ if (!raw) return null;
4381
+ let data;
4382
+ try {
4383
+ data = JSON.parse(raw);
4384
+ } catch {
4385
+ return null;
4386
+ }
4387
+ const parsed = BrandingSchema.safeParse(data);
4388
+ if (!parsed.success) return null;
4389
+ const b = compact(parsed.data);
4390
+ if (!b) return null;
4391
+ for (const f of IMAGE_FIELDS) {
4392
+ const url = b[f];
4393
+ if (url && !nestAssetName(url, nestId) && !isHttpsImageUrl(url)) delete b[f];
4394
+ }
4395
+ if (b.footer_link_url && !isHttpsLink(b.footer_link_url)) delete b.footer_link_url;
4396
+ const kept = compact(b);
4397
+ if (!kept) return null;
4398
+ return { ...kept, hide_powered_by: kept.hide_powered_by === true };
4399
+ }
4400
+ function rgb(hex) {
4401
+ const h = normalizeHex(hex).slice(1);
4402
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
4403
+ }
4404
+ function toHex([r, g, b]) {
4405
+ return "#" + [r, g, b].map((n) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, "0")).join("");
4406
+ }
4407
+ function relativeLuminance(hex) {
4408
+ const [r, g, b] = rgb(hex).map((v) => {
4409
+ const c = v / 255;
4410
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
4411
+ });
4412
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
4413
+ }
4414
+ function contrastRatio(a, b) {
4415
+ const la = relativeLuminance(a);
4416
+ const lb = relativeLuminance(b);
4417
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
4418
+ }
4419
+ function readableForeground(bg) {
4420
+ return contrastRatio("#000000", bg) >= contrastRatio("#ffffff", bg) ? "#000000" : "#ffffff";
4421
+ }
4422
+ function mixHex(a, b, weight) {
4423
+ const ca = rgb(a);
4424
+ const cb = rgb(b);
4425
+ return toHex([0, 1, 2].map((i) => ca[i] * weight + cb[i] * (1 - weight)));
4426
+ }
4427
+ function ensureContrast(fg, bg, min = 4.5) {
4428
+ const start = normalizeHex(fg);
4429
+ if (contrastRatio(start, bg) >= min) return start;
4430
+ const target = readableForeground(bg);
4431
+ for (let w = 0.05; w <= 1.0001; w += 0.05) {
4432
+ const c = mixHex(target, start, w);
4433
+ if (contrastRatio(c, bg) >= min) return c;
4434
+ }
4435
+ return target;
4436
+ }
4437
+ function publishBrandCssVars(b) {
4438
+ if (!b) return "";
4439
+ const decls = [];
4440
+ const tint = normalizeHex(b.page_tint_color);
4441
+ const page = tint ? mixHex(tint, "#ffffff", 0.1) : "#ffffff";
4442
+ const accent = normalizeHex(b.accent_color);
4443
+ if (accent) {
4444
+ const text = ensureContrast(ensureContrast(accent, page), "#ffffff");
4445
+ decls.push(
4446
+ `--cn-accent:${accent}`,
4447
+ `--cn-accent-fg:${readableForeground(accent)}`,
4448
+ `--cn-accent-text:${text}`
4449
+ );
4450
+ }
4451
+ const header = normalizeHex(b.header_bg_color);
4452
+ if (header) decls.push(`--cn-header-bg:${header}`, `--cn-header-fg:${readableForeground(header)}`);
4453
+ if (tint) decls.push(`--cn-page-bg:${page}`);
4454
+ return decls.length ? `:root{${decls.join(";")}}` : "";
4455
+ }
4456
+ function cssUrl(url) {
4457
+ const safe = url.replace(
4458
+ /["'()\\\s\u0000-\u001f\u007f]/g,
4459
+ (ch) => "%" + ch.charCodeAt(0).toString(16).padStart(2, "0").toUpperCase()
4460
+ );
4461
+ return `url("${safe}")`;
4462
+ }
4463
+
3916
4464
  // src/nests/routes.ts
3917
4465
  async function effectiveAccess(nestId, userId) {
3918
4466
  const access = await resolveNestAccess(nestId, userId);
@@ -3935,6 +4483,11 @@ function isGrantOnly(access) {
3935
4483
  return access.permission === "none" && access.hasGrant;
3936
4484
  }
3937
4485
  var nestRoutes = new Hono3();
4486
+ function withParsedBranding(nest) {
4487
+ if (!nest) return nest;
4488
+ const { branding_json, ...rest } = nest;
4489
+ return { ...rest, branding: parseStoredBranding(branding_json, nest.id) };
4490
+ }
3938
4491
  nestRoutes.get("/", async (c) => {
3939
4492
  const userId = c.get("userId");
3940
4493
  const archivedOnly = c.req.query("archived") === "1";
@@ -3990,14 +4543,20 @@ nestRoutes.get("/", async (c) => {
3990
4543
  pinned: pinned.has(n.id)
3991
4544
  };
3992
4545
  };
4546
+ const nestScope = c.get("nestScope");
3993
4547
  const seen = /* @__PURE__ */ new Set();
3994
4548
  const deduped = [];
3995
4549
  for (const n of [...owned, ...shared, ...publicExtras]) {
4550
+ if (nestScope && n.id !== nestScope) continue;
3996
4551
  if (seen.has(n.id)) continue;
3997
4552
  seen.add(n.id);
3998
4553
  deduped.push(n);
3999
4554
  }
4000
- const out = await Promise.all(deduped.map(annotate));
4555
+ const memberIds = new Set([...owned, ...shared].map((n) => n.id));
4556
+ const out = (await Promise.all(deduped.map(annotate))).map((row) => ({
4557
+ ...row,
4558
+ discovered: !memberIds.has(row.id)
4559
+ }));
4001
4560
  const docRows = await db.all(
4002
4561
  `SELECT nest_id,
4003
4562
  COUNT(DISTINCT CASE
@@ -4053,7 +4612,7 @@ nestRoutes.get("/", async (c) => {
4053
4612
  }
4054
4613
  }
4055
4614
  }
4056
- const withMeta = out.map(
4615
+ const withMeta = out.map((raw) => withParsedBranding(raw)).map(
4057
4616
  (n) => n.grant_only ? {
4058
4617
  ...n,
4059
4618
  document_count: grantedDocsByNest.get(n.id) ?? 0,
@@ -4079,7 +4638,7 @@ nestRoutes.post("/", async (c) => {
4079
4638
  throw new ValidationError("name is required");
4080
4639
  }
4081
4640
  const nest = await createNest(c.get("userId"), body.name, body.description);
4082
- return c.json({ nest }, 201);
4641
+ return c.json({ nest: withParsedBranding(nest) }, 201);
4083
4642
  });
4084
4643
  nestRoutes.post("/:nestId", async (c) => {
4085
4644
  if (c.req.param("nestId") !== "import") {
@@ -4096,7 +4655,14 @@ nestRoutes.post("/:nestId", async (c) => {
4096
4655
  }
4097
4656
  const email2 = await resolveCallerEmail(userId);
4098
4657
  if (body.finalize) {
4099
- return c.json({ nest: nest2, documents: await registerImportedDocuments(target, email2) });
4658
+ return c.json({
4659
+ nest: withParsedBranding(nest2),
4660
+ documents: await registerImportedDocuments(
4661
+ target,
4662
+ email2,
4663
+ attributeRequest(c, body.client)
4664
+ )
4665
+ });
4100
4666
  }
4101
4667
  const registered = await getDb().get(
4102
4668
  "SELECT COUNT(*) AS c FROM node_versions WHERE nest_id = ?",
@@ -4121,10 +4687,14 @@ nestRoutes.post("/:nestId", async (c) => {
4121
4687
  try {
4122
4688
  const { rejected } = await stageImportedFiles(nest.id, files, email);
4123
4689
  if (body.partial) {
4124
- return c.json({ nest, rejected }, 201);
4690
+ return c.json({ nest: withParsedBranding(nest), rejected }, 201);
4125
4691
  }
4126
- const documents = await registerImportedDocuments(nest.id, email);
4127
- return c.json({ nest, documents, rejected }, 201);
4692
+ const documents = await registerImportedDocuments(
4693
+ nest.id,
4694
+ email,
4695
+ attributeRequest(c, body.client)
4696
+ );
4697
+ return c.json({ nest: withParsedBranding(nest), documents, rejected }, 201);
4128
4698
  } catch (err) {
4129
4699
  await deleteNest(nest.id).catch(() => {
4130
4700
  });
@@ -4153,7 +4723,8 @@ nestRoutes.post("/:nestId/sync", async (c) => {
4153
4723
  const result = await syncUnsyncedFolder(
4154
4724
  userId,
4155
4725
  body.name,
4156
- await resolveCallerEmail(userId)
4726
+ await resolveCallerEmail(userId),
4727
+ attributeRequest(c, body.client)
4157
4728
  );
4158
4729
  return c.json(result, 201);
4159
4730
  });
@@ -4189,7 +4760,7 @@ nestRoutes.get("/:nestId", async (c) => {
4189
4760
  const grant_only = roles.length === 0 && isGrantOnly(access);
4190
4761
  const nest = await getNest(nestId);
4191
4762
  return c.json({
4192
- nest,
4763
+ nest: withParsedBranding(nest),
4193
4764
  permission,
4194
4765
  roles: grant_only ? ["viewer"] : roles,
4195
4766
  myStewards,
@@ -4213,7 +4784,7 @@ nestRoutes.patch("/:nestId", async (c) => {
4213
4784
  name: body.name,
4214
4785
  description: body.description
4215
4786
  });
4216
- return c.json({ nest });
4787
+ return c.json({ nest: withParsedBranding(nest) });
4217
4788
  });
4218
4789
  nestRoutes.post("/:nestId/pin", async (c) => {
4219
4790
  const nestId = c.req.param("nestId");
@@ -4275,7 +4846,8 @@ nestRoutes.get("/:nestId/settings", async (c) => {
4275
4846
  stewardship_enabled: await isStewardshipEnabled(nestId),
4276
4847
  allow_self_approve: await nestAllowsSelfApprove(nestId),
4277
4848
  prime_only_review: await nestReviewsPrimeOnly(nestId),
4278
- prime_tags: await nestPrimeTags(nestId)
4849
+ prime_tags: await nestPrimeTags(nestId),
4850
+ creator_is_reviewer: await nestCreatorIsReviewer(nestId)
4279
4851
  });
4280
4852
  });
4281
4853
  nestRoutes.patch("/:nestId/settings", async (c) => {
@@ -4320,11 +4892,15 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
4320
4892
  Array.isArray(body.prime_tags) ? body.prime_tags : [body.prime_tags]
4321
4893
  );
4322
4894
  }
4895
+ if (typeof body.creator_is_reviewer === "boolean") {
4896
+ await setCreatorIsReviewer(nestId, body.creator_is_reviewer);
4897
+ }
4323
4898
  return c.json({
4324
4899
  stewardship_enabled: await isStewardshipEnabled(nestId),
4325
4900
  allow_self_approve: await nestAllowsSelfApprove(nestId),
4326
4901
  prime_only_review: await nestReviewsPrimeOnly(nestId),
4327
4902
  prime_tags: await nestPrimeTags(nestId),
4903
+ creator_is_reviewer: await nestCreatorIsReviewer(nestId),
4328
4904
  wiped
4329
4905
  });
4330
4906
  });
@@ -4351,21 +4927,10 @@ async function addCollaborator(params) {
4351
4927
  if (!isEmailish(params.email)) {
4352
4928
  throw new ValidationError("Enter a valid email address.");
4353
4929
  }
4354
- const email = normalizeEmail(params.email);
4355
- const existing = await db.get(
4356
- "SELECT id FROM users WHERE LOWER(email) = ?",
4357
- [email]
4358
- );
4359
- if (existing) {
4360
- userId = existing.id;
4361
- } else {
4362
- const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
4363
- userId = uuid6();
4364
- await db.run(
4365
- "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
4366
- [userId, email, null, await hashPassword2(uuid6())]
4367
- );
4368
- }
4930
+ userId = (await findOrCreateInvitedUser(params.email, {
4931
+ invitedBy: params.grantedByEmail,
4932
+ baseUrl: params.baseUrl
4933
+ })).id;
4369
4934
  }
4370
4935
  if (!userId) {
4371
4936
  throw new ValidationError("user_id or email is required");
@@ -4424,13 +4989,18 @@ async function addCollaborator(params) {
4424
4989
  // event. Slack/Teams still fire.
4425
4990
  { skipEmail: true }
4426
4991
  );
4427
- const inviteeEmail = params.email || (await db.get("SELECT email FROM users WHERE id = ?", [userId]))?.email;
4992
+ const invitee = await db.get(
4993
+ "SELECT email, is_invited FROM users WHERE id = ?",
4994
+ [userId]
4995
+ );
4996
+ const inviteeEmail = params.email || invitee?.email;
4428
4997
  if (inviteeEmail) {
4998
+ const nestLink = docLink(nestId, void 0, params.baseUrl);
4429
4999
  void sendEmailToRecipient(inviteeEmail, {
4430
5000
  status: "shared",
4431
5001
  docTitle: shareNestName,
4432
5002
  path: shareNestName,
4433
- link: docLink(nestId, void 0, params.baseUrl),
5003
+ link: nestLink && invitee?.is_invited === 1 ? `${nestLink}&invite=${encodeURIComponent(inviteeEmail)}` : nestLink,
4434
5004
  actor: inviteeEmail,
4435
5005
  permission: params.permission,
4436
5006
  by: params.grantedByEmail || void 0
@@ -4539,12 +5109,24 @@ sharingRoutes.patch("/visibility", async (c) => {
4539
5109
  if (!body.visibility || !["private", "org", "public"].includes(body.visibility)) {
4540
5110
  throw new ValidationError("visibility must be private, org or public");
4541
5111
  }
4542
- if (body.visibility === "public" && body.acknowledge_public !== true) {
5112
+ const ack = body.acknowledge_public === true;
5113
+ const shared = config.SHARED_DEPLOYMENT;
5114
+ if (body.visibility === "public" && !ack) {
4543
5115
  return c.json(
4544
5116
  {
4545
5117
  error: "Publishing this nest requires acknowledgement.",
4546
5118
  requires_acknowledgement: true,
4547
- warning: "This makes the nest readable by anyone on the internet, outside your organization."
5119
+ warning: shared ? "This makes the nest readable by anyone on the internet \u2014 including every account on this server, which belongs to other organizations too." : "This makes the nest readable by anyone on the internet, outside your organization."
5120
+ },
5121
+ 409
5122
+ );
5123
+ }
5124
+ if (body.visibility === "org" && shared && !ack) {
5125
+ return c.json(
5126
+ {
5127
+ error: "Opening this nest to the organization requires acknowledgement.",
5128
+ requires_acknowledgement: true,
5129
+ warning: "This server is shared: 'Organization' makes the nest readable by every account on this server, including other organizations \u2014 not just yours."
4548
5130
  },
4549
5131
  409
4550
5132
  );
@@ -4612,10 +5194,78 @@ sharingRoutes.patch("/reader-mode", async (c) => {
4612
5194
  });
4613
5195
  });
4614
5196
 
4615
- // src/nests/grant-routes.ts
5197
+ // src/nests/branding-routes.ts
4616
5198
  import { Hono as Hono5 } from "hono";
4617
- import { v4 as uuid7 } from "uuid";
4618
- var grantRoutes = new Hono5();
5199
+ import { access as fsAccess } from "fs/promises";
5200
+ import { join as join3 } from "path";
5201
+ var brandingRoutes = new Hono5();
5202
+ brandingRoutes.patch("/branding", async (c) => {
5203
+ const userId = c.get("userId");
5204
+ const nestId = c.req.param("nestId");
5205
+ const isServerAdmin = config.AUTH_MODE === "open" || await isServerAdminUserId(userId);
5206
+ if (!isServerAdmin) {
5207
+ const access = await resolveNestAccess(nestId, userId);
5208
+ if (permissionLevel(access.permission) < permissionLevel("admin")) {
5209
+ throw new ForbiddenError(
5210
+ "Only the nest owner, a nest admin, or the server admin can change branding"
5211
+ );
5212
+ }
5213
+ }
5214
+ const db = getDb();
5215
+ if (!await db.get("SELECT id FROM nests WHERE id = ?", [nestId])) {
5216
+ throw new NotFoundError("Nest not found");
5217
+ }
5218
+ const text = await c.req.text();
5219
+ let body = null;
5220
+ if (text.trim() !== "") {
5221
+ try {
5222
+ body = JSON.parse(text);
5223
+ } catch {
5224
+ throw new ValidationError("body must be JSON");
5225
+ }
5226
+ }
5227
+ if (body !== null && (typeof body !== "object" || Array.isArray(body))) {
5228
+ throw new ValidationError("branding must be an object or null");
5229
+ }
5230
+ const assetsDir = join3(resolveNestPath(nestId), "assets");
5231
+ let next;
5232
+ try {
5233
+ next = await validateBrandingInput(body, nestId, async (name) => {
5234
+ try {
5235
+ await fsAccess(join3(assetsDir, name));
5236
+ return true;
5237
+ } catch {
5238
+ return false;
5239
+ }
5240
+ });
5241
+ } catch (err) {
5242
+ if (err instanceof BrandingValidationError) throw new ValidationError(err.message);
5243
+ throw err;
5244
+ }
5245
+ const mentionsHide = body !== null && Object.prototype.hasOwnProperty.call(body, "hide_powered_by");
5246
+ const stored = await db.transaction(async (tx) => {
5247
+ const lock = db.dialect === "postgres" ? " FOR UPDATE" : "";
5248
+ const row = await tx.get(`SELECT branding_json FROM nests WHERE id = ?${lock}`, [nestId]);
5249
+ if (!row) throw new NotFoundError("Nest not found");
5250
+ const wasHidden = parseStoredBranding(row.branding_json, nestId)?.hide_powered_by === true;
5251
+ if (!isServerAdmin) {
5252
+ if (next?.hide_powered_by === true && !wasHidden) {
5253
+ throw new ForbiddenError(
5254
+ 'Only the server admin can hide the "Powered by ContextNest" line'
5255
+ );
5256
+ }
5257
+ if (!mentionsHide && wasHidden) next = { ...next ?? {}, hide_powered_by: true };
5258
+ }
5259
+ const value = next ? JSON.stringify(next) : null;
5260
+ await tx.run("UPDATE nests SET branding_json = ? WHERE id = ?", [value, nestId]);
5261
+ return value;
5262
+ });
5263
+ return c.json({ branding: parseStoredBranding(stored, nestId) });
5264
+ });
5265
+
5266
+ // src/nests/grant-routes.ts
5267
+ import { Hono as Hono6 } from "hono";
5268
+ var grantRoutes = new Hono6();
4619
5269
  async function requireManage(c, nestId, opts = {}) {
4620
5270
  if (config.AUTH_MODE === "open") return;
4621
5271
  const access = await resolveNestAccess(nestId, c.get("userId"));
@@ -4631,17 +5281,8 @@ async function requireManage(c, nestId, opts = {}) {
4631
5281
  }
4632
5282
  }
4633
5283
  async function resolveGrantee(email) {
4634
- const db = getDb();
4635
- const e = normalizeEmail(email);
4636
- const existing = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
4637
- if (existing) return existing.id;
4638
- const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
4639
- const id = uuid7();
4640
- await db.run(
4641
- "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
4642
- [id, e, null, await hashPassword2(uuid7())]
4643
- );
4644
- return id;
5284
+ const { findOrCreateInvitedUser: findOrCreateInvitedUser2 } = await import("./invited-user-AM6A3WGO.js");
5285
+ return (await findOrCreateInvitedUser2(email)).id;
4645
5286
  }
4646
5287
  async function assertGrantAddsAccess(params) {
4647
5288
  const { nestId, email, userId, targetType, target, role } = params;
@@ -4738,10 +5379,10 @@ grantRoutes.delete("/:id", async (c) => {
4738
5379
  });
4739
5380
 
4740
5381
  // src/nests/publish-routes.ts
4741
- import { Hono as Hono6 } from "hono";
5382
+ import { Hono as Hono7 } from "hono";
4742
5383
 
4743
5384
  // src/nests/publish-service.ts
4744
- import { v4 as uuid8 } from "uuid";
5385
+ import { v4 as uuid7 } from "uuid";
4745
5386
  import { createHash as createHash2, randomBytes as randomBytes3 } from "crypto";
4746
5387
  var MODES = ["public", "email", "code", "invite"];
4747
5388
  var LINK_TTL_SECONDS = 60 * 60;
@@ -4810,7 +5451,7 @@ async function createPublish(params) {
4810
5451
  );
4811
5452
  }
4812
5453
  const db = getDb();
4813
- const id = uuid8();
5454
+ const id = uuid7();
4814
5455
  const slug = newSlug();
4815
5456
  const created = nowIso();
4816
5457
  const codeHash = mode === "code" && params.code ? await hashPassword(params.code.trim()) : null;
@@ -4841,7 +5482,7 @@ async function createPublish(params) {
4841
5482
  await db.run(
4842
5483
  `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4843
5484
  VALUES (?, ?, 'invite', ?, NULL, NULL, NULL, ?)`,
4844
- [uuid8(), id, email, created]
5485
+ [uuid7(), id, email, created]
4845
5486
  );
4846
5487
  }
4847
5488
  }
@@ -4896,7 +5537,7 @@ async function mintMagicLink(publishId, email) {
4896
5537
  await getDb().run(
4897
5538
  `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4898
5539
  VALUES (?, ?, 'link', ?, ?, ?, NULL, ?)`,
4899
- [uuid8(), publishId, e, sha256Hex(token), expires, nowIso()]
5540
+ [uuid7(), publishId, e, sha256Hex(token), expires, nowIso()]
4900
5541
  );
4901
5542
  return token;
4902
5543
  }
@@ -4926,7 +5567,7 @@ async function createSession2(publishId, email) {
4926
5567
  await getDb().run(
4927
5568
  `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4928
5569
  VALUES (?, ?, 'session', ?, ?, ?, NULL, ?)`,
4929
- [uuid8(), publishId, email, sha256Hex(token), expires, nowIso()]
5570
+ [uuid7(), publishId, email, sha256Hex(token), expires, nowIso()]
4930
5571
  );
4931
5572
  return token;
4932
5573
  }
@@ -4943,7 +5584,7 @@ async function logView(publishId, email) {
4943
5584
  await getDb().run(
4944
5585
  `INSERT INTO access_grant (id, publish_id, kind, email, token_hash, expires_at, used_at, created_at)
4945
5586
  VALUES (?, ?, 'view', ?, NULL, NULL, NULL, ?)`,
4946
- [uuid8(), publishId, email, nowIso()]
5587
+ [uuid7(), publishId, email, nowIso()]
4947
5588
  );
4948
5589
  }
4949
5590
  async function listAllowlist(publishId) {
@@ -4961,7 +5602,7 @@ async function listViews(publishId) {
4961
5602
  }
4962
5603
 
4963
5604
  // src/nests/publish-routes.ts
4964
- var publishRoutes = new Hono6();
5605
+ var publishRoutes = new Hono7();
4965
5606
  async function requireManage2(c, nestId) {
4966
5607
  if (!await canManageStewards(nestId, c.get("userId"))) {
4967
5608
  throw new ForbiddenError(
@@ -4976,7 +5617,7 @@ publishRoutes.post("/", async (c) => {
4976
5617
  const mode = body.access_mode ?? "public";
4977
5618
  if ((mode === "email" || mode === "invite") && !isEmailConfigured()) {
4978
5619
  throw new ValidationError(
4979
- "Email and invite links need email set up on this server (SMTP_URL and NOTIFY_EMAIL_FROM). Use a public or passcode link instead."
5620
+ "Email and invite links need email set up on this server (SMTP or AWS SES, plus NOTIFY_EMAIL_FROM). Use a public or passcode link instead."
4980
5621
  );
4981
5622
  }
4982
5623
  const row = await createPublish({
@@ -5032,11 +5673,11 @@ publishRoutes.delete("/:id", async (c) => {
5032
5673
  });
5033
5674
 
5034
5675
  // src/nests/publish-render-routes.ts
5035
- import { Hono as Hono7 } from "hono";
5676
+ import { Hono as Hono8 } from "hono";
5036
5677
  import MarkdownIt from "markdown-it";
5037
5678
 
5038
5679
  // src/annotations/service.ts
5039
- import { v4 as uuid9 } from "uuid";
5680
+ import { v4 as uuid8 } from "uuid";
5040
5681
 
5041
5682
  // src/annotations/projection.ts
5042
5683
  var MAX_CONTEXT_CHARS = 250;
@@ -5190,7 +5831,7 @@ async function createThread(nestId, nodeId, input, authorEmail) {
5190
5831
  throw new Error("comment body is required");
5191
5832
  }
5192
5833
  const db = getDb();
5193
- const id = uuid9();
5834
+ const id = uuid8();
5194
5835
  const anchor = clampAnchor(input.anchor);
5195
5836
  const snapshot = input.snapshotVersion ?? await getApprovedVersion(nestId, nodeId) ?? null;
5196
5837
  await db.transaction(async (tx) => {
@@ -5210,7 +5851,7 @@ async function createThread(nestId, nodeId, input, authorEmail) {
5210
5851
  await tx.run(
5211
5852
  `INSERT INTO annotation_comments (id, thread_id, author, body, created_at)
5212
5853
  VALUES (?, ?, ?, ?, ${nowMsExpr(db)})`,
5213
- [uuid9(), id, authorEmail, body]
5854
+ [uuid8(), id, authorEmail, body]
5214
5855
  );
5215
5856
  });
5216
5857
  return rowToThread(await getThreadRow(id));
@@ -5232,7 +5873,7 @@ async function addComment(nestId, nodeId, threadId, authorEmail, body) {
5232
5873
  await db.run(
5233
5874
  `INSERT INTO annotation_comments (id, thread_id, author, body, created_at)
5234
5875
  VALUES (?, ?, ?, ?, ${nowMsExpr(db)})`,
5235
- [uuid9(), threadId, authorEmail, trimmed]
5876
+ [uuid8(), threadId, authorEmail, trimmed]
5236
5877
  );
5237
5878
  return rowToThread(await getThreadRow(threadId));
5238
5879
  }
@@ -5338,7 +5979,7 @@ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
5338
5979
  tags: ["annotations"]
5339
5980
  },
5340
5981
  userEmail,
5341
- { notifyMentions: false }
5982
+ { notifyMentions: false, creatorReviews: false }
5342
5983
  );
5343
5984
  } else {
5344
5985
  throw err;
@@ -5353,7 +5994,9 @@ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
5353
5994
  }
5354
5995
 
5355
5996
  // src/nests/publish-render-routes.ts
5356
- var publishRenderRoutes = new Hono7();
5997
+ import { readFile, stat as stat2 } from "fs/promises";
5998
+ import { join as join4 } from "path";
5999
+ var publishRenderRoutes = new Hono8();
5357
6000
  var SESSION_TTL_SECONDS2 = 24 * 60 * 60;
5358
6001
  var GATE_LIMIT = { max: 10, windowMs: 10 * 6e4 };
5359
6002
  var COMMENT_LIMIT = { max: 5, windowMs: 10 * 6e4 };
@@ -5410,6 +6053,114 @@ function setSessionCookie2(c, slug, token) {
5410
6053
  function esc(s) {
5411
6054
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5412
6055
  }
6056
+ var IMAGE_MIME = {
6057
+ png: "image/png",
6058
+ jpg: "image/jpeg",
6059
+ jpeg: "image/jpeg",
6060
+ gif: "image/gif",
6061
+ webp: "image/webp"
6062
+ };
6063
+ var INLINE_CAP = { logo: 512 * 1024, favicon: 256 * 1024, banner: 2 * 1024 * 1024 };
6064
+ var INLINE_CACHE_MAX_CHARS = 24 * 1024 * 1024;
6065
+ var inlineCache = /* @__PURE__ */ new Map();
6066
+ var inlineCacheChars = 0;
6067
+ function cacheGet(key) {
6068
+ if (!inlineCache.has(key)) return void 0;
6069
+ const v = inlineCache.get(key);
6070
+ inlineCache.delete(key);
6071
+ inlineCache.set(key, v);
6072
+ return v;
6073
+ }
6074
+ function cachePut(key, v) {
6075
+ const size = v?.length ?? 0;
6076
+ if (size > INLINE_CACHE_MAX_CHARS) return;
6077
+ inlineCacheChars -= inlineCache.get(key)?.length ?? 0;
6078
+ inlineCache.set(key, v);
6079
+ inlineCacheChars += size;
6080
+ for (const [k, old] of inlineCache) {
6081
+ if (inlineCacheChars <= INLINE_CACHE_MAX_CHARS && inlineCache.size <= 512) break;
6082
+ inlineCache.delete(k);
6083
+ inlineCacheChars -= old?.length ?? 0;
6084
+ }
6085
+ }
6086
+ async function inlineAsset(nestId, url, cap) {
6087
+ if (!url) return null;
6088
+ const name = nestAssetName(url, nestId);
6089
+ if (!name) return null;
6090
+ const key = `${nestId}/${name}/${cap}`;
6091
+ const hit = cacheGet(key);
6092
+ if (hit !== void 0) return hit;
6093
+ let out = null;
6094
+ try {
6095
+ const path = join4(resolveNestPath(nestId), "assets", name);
6096
+ if ((await stat2(path)).size <= cap) {
6097
+ const mime = IMAGE_MIME[name.split(".").pop().toLowerCase()];
6098
+ if (mime) out = `data:${mime};base64,${(await readFile(path)).toString("base64")}`;
6099
+ }
6100
+ } catch {
6101
+ return null;
6102
+ }
6103
+ cachePut(key, out);
6104
+ return out;
6105
+ }
6106
+ async function loadBrand(row) {
6107
+ let b = null;
6108
+ try {
6109
+ const r = await getDb().get("SELECT branding_json FROM nests WHERE id = ?", [row.nest_id]);
6110
+ b = parseStoredBranding(r?.branding_json, row.nest_id);
6111
+ } catch {
6112
+ b = null;
6113
+ }
6114
+ if (!b) return null;
6115
+ const [logo, banner, favicon] = await Promise.all([
6116
+ inlineAsset(row.nest_id, b.logo_url, INLINE_CAP.logo),
6117
+ inlineAsset(row.nest_id, b.banner_url, INLINE_CAP.banner),
6118
+ inlineAsset(row.nest_id, b.favicon_url, INLINE_CAP.favicon)
6119
+ ]);
6120
+ return { b, logo, banner, favicon };
6121
+ }
6122
+ var BRAND_CSS = `
6123
+ .cn-brand{display:flex;align-items:center;gap:12px;padding:12px 24px;background:var(--cn-header-bg,#fff);color:var(--cn-header-fg,#1c1917);border-bottom:1px solid rgba(0,0,0,.08);font:600 16px/1.3 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
6124
+ .cn-brand img{height:32px;width:auto;max-width:160px;object-fit:contain}
6125
+ .cn-brand span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
6126
+ .cn-hero{position:relative;min-height:120px;max-height:220px;height:28vw;display:flex;flex-direction:column;justify-content:flex-end;padding:20px 24px;box-sizing:border-box;background:var(--cn-accent,#142a80) center/cover no-repeat;color:var(--cn-accent-fg,#fff);overflow:hidden}
6127
+ .cn-hero.has-img{color:#fff}
6128
+ .cn-hero.has-img::before{content:"";position:absolute;inset:0;background:linear-gradient(to top,rgba(0,0,0,.62),rgba(0,0,0,.08))}
6129
+ .cn-hero *{position:relative;margin:0;max-width:820px}
6130
+ .cn-hero strong{display:block;font:700 clamp(20px,4vw,30px)/1.2 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
6131
+ .cn-hero p{margin-top:6px;font-size:15px;opacity:.92}
6132
+ .cn-foot{max-width:820px;margin:0 auto;padding:20px 24px 40px;border-top:1px solid #e7e5e4;color:#78716c;font-size:13px;display:flex;flex-wrap:wrap;gap:6px 16px}
6133
+ .cn-foot a{color:var(--cn-accent-text,#142a80)}
6134
+ `;
6135
+ function brandHead(brand, extraCss = "") {
6136
+ if (!brand) return "";
6137
+ const icon = brand.favicon ? `<link rel="icon" href="${esc(brand.favicon)}">` : "";
6138
+ return `<style>${publishBrandCssVars(brand.b)}${BRAND_CSS}${extraCss}</style>${icon}`;
6139
+ }
6140
+ function brandBar(brand) {
6141
+ if (!brand) return "";
6142
+ const title = brand.b.site_title ? `<span>${esc(brand.b.site_title)}</span>` : "";
6143
+ const logo = brand.logo ? `<img src="${esc(brand.logo)}" alt="">` : "";
6144
+ if (!title && !logo) return "";
6145
+ return `<header class="cn-brand">${logo}${title}</header>`;
6146
+ }
6147
+ function brandHero(brand) {
6148
+ if (!brand) return "";
6149
+ const { banner_headline: h, banner_subtext: sub } = brand.b;
6150
+ if (!brand.banner && !h) return "";
6151
+ const style = brand.banner ? ` style="background-image:${esc(cssUrl(brand.banner))}"` : "";
6152
+ const cls = brand.banner ? "cn-hero has-img" : "cn-hero";
6153
+ return `<div class="${cls}"${style}>${h ? `<strong>${esc(h)}</strong>` : ""}${sub ? `<p>${esc(sub)}</p>` : ""}</div>`;
6154
+ }
6155
+ function brandFooter(brand) {
6156
+ if (!brand) return "";
6157
+ const { footer_text: text, footer_link_url: href, footer_link_label: label } = brand.b;
6158
+ const parts = [];
6159
+ if (text) parts.push(`<span>${esc(text)}</span>`);
6160
+ if (href) parts.push(`<a href="${esc(href)}" rel="noopener noreferrer" target="_blank">${esc(label || href)}</a>`);
6161
+ if (!brand.b.hide_powered_by) parts.push(`<span>Secured by PromptOwl \xB7 ContextNest</span>`);
6162
+ return parts.length ? `<footer class="cn-foot">${parts.join("")}</footer>` : "";
6163
+ }
5413
6164
  var NO_DOWNLOAD_CSS = `<style>
5414
6165
  @media print{html,body{display:none !important}}
5415
6166
  html,body{-webkit-user-select:none;-ms-user-select:none;user-select:none}
@@ -5481,7 +6232,10 @@ async function renderPublished(c, row, viewerEmail) {
5481
6232
  }
5482
6233
  const title = esc(artifact.title || row.node_id);
5483
6234
  const heading = /^\s*#\s/.test(body) ? "" : `<h1>${title}</h1>`;
5484
- 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>`;
6235
+ const brand = await loadBrand(row);
6236
+ const docTitle = brand?.b.site_title ? `${title} \xB7 ${esc(brand.b.site_title)}` : title;
6237
+ const brandCss = brand ? `body{background:var(--cn-page-bg,#fff)}h1,h2,h3,h4,a{color:var(--cn-accent-text,#142a80)}` : "";
6238
+ const shell = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${docTitle}</title><style>${PROSE_CSS}</style>${brandHead(brand, brandCss)}</head><body>${brandBar(brand)}${brandHero(brand)}<main>${heading}${markdown.render(body)}</main>${brandFooter(brand)}${overlays}</body></html>`;
5485
6239
  return c.body(shell, 200, { "Content-Type": "text/html; charset=utf-8" });
5486
6240
  }
5487
6241
  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>`;
@@ -5502,9 +6256,29 @@ var GATE_EYE_SCRIPT = `<script>
5502
6256
  });
5503
6257
  })();
5504
6258
  </script>`;
5505
- function gatePage(row, message) {
6259
+ var GATE_BRAND_CSS = `
6260
+ body{background:var(--cn-page-bg,#f4f4f5)}
6261
+ .card{box-sizing:border-box;overflow:hidden}
6262
+ h1{color:var(--cn-accent-text,#142a80)}
6263
+ button[type=submit]{background:var(--cn-accent,#142a80);color:var(--cn-accent-fg,#fff)}
6264
+ .cn-brand{margin:-32px -34px 20px;border-radius:14px 14px 0 0;padding:14px 20px}
6265
+ .cn-gate-foot{font-size:12px;color:#a8a29e;margin:10px 0 0;text-align:center}
6266
+ .cn-gate-foot a{color:var(--cn-accent-text,#142a80)}
6267
+ `;
6268
+ function gateBrandTop(brand) {
6269
+ return brandBar(brand);
6270
+ }
6271
+ function gateBrandFoot(brand) {
6272
+ if (!brand) return "";
6273
+ const { footer_text: text, footer_link_url: href, footer_link_label: label } = brand.b;
6274
+ const parts = [];
6275
+ if (text) parts.push(esc(text));
6276
+ if (href) parts.push(`<a href="${esc(href)}" rel="noopener noreferrer" target="_blank">${esc(label || href)}</a>`);
6277
+ return parts.length ? `<p class="cn-gate-foot">${parts.join(" \xB7 ")}</p>` : "";
6278
+ }
6279
+ function gatePage(row, message, brand = null) {
5506
6280
  const wantsCode = row.access_mode === "code";
5507
- const field = wantsCode ? `<label>Access code<span class="wrap">
6281
+ const field2 = wantsCode ? `<label>Access code<span class="wrap">
5508
6282
  <input id="cn-code" name="code" type="password" autocomplete="off" required autofocus>
5509
6283
  <button type="button" id="cn-eye" class="eye" aria-label="Show code" aria-pressed="false">${EYE_SVG}</button>
5510
6284
  </span></label>` : `<label>Your email<input name="email" type="email" autocomplete="email" required autofocus></label>`;
@@ -5527,10 +6301,10 @@ function gatePage(row, message) {
5527
6301
  button.eye svg{width:18px;height:18px;display:block}
5528
6302
  .err{color:#be123c;font-weight:600}
5529
6303
  .brand{font-size:12px;color:#a8a29e;margin:18px 0 0;text-align:center}
5530
- </style></head><body><form class="card" method="POST" action="/p/${esc(row.slug)}/gate">
5531
- <h1>Protected document</h1><p>${esc(note)}</p>${err}${field}
6304
+ </style>${brandHead(brand, GATE_BRAND_CSS)}</head><body><form class="card" method="POST" action="/p/${esc(row.slug)}/gate">
6305
+ ${gateBrandTop(brand)}<h1>Protected document</h1><p>${esc(note)}</p>${err}${field2}
5532
6306
  <button type="submit">${cta}</button>
5533
- <p class="brand">Secured by PromptOwl \xB7 ContextNest</p>
6307
+ ${brand?.b.hide_powered_by ? "" : `<p class="brand">Secured by PromptOwl \xB7 ContextNest</p>`}${gateBrandFoot(brand)}
5534
6308
  </form>${wantsCode ? GATE_EYE_SCRIPT : ""}</body></html>`;
5535
6309
  }
5536
6310
  function unavailablePage() {
@@ -5549,7 +6323,7 @@ function unavailablePage() {
5549
6323
  function sentPage() {
5550
6324
  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>`;
5551
6325
  }
5552
- function readerShell(row, title, viewerEmail) {
6326
+ function readerShell(row, title, viewerEmail, brand = null) {
5553
6327
  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>`;
5554
6328
  return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(title)}</title><style>
5555
6329
  html,body{margin:0;height:100%}
@@ -5570,7 +6344,7 @@ function readerShell(row, title, viewerEmail) {
5570
6344
  button[disabled]{opacity:.6;cursor:default}
5571
6345
  .msg{font-weight:600;margin:0 0 14px;font-size:14px}
5572
6346
  .msg.err{color:#be123c}.msg.ok{color:#15803d}
5573
- </style></head><body>
6347
+ </style>${brand ? `<style>${publishBrandCssVars(brand.b)}#cn-open,#cn-send{background:var(--cn-accent,#142a80);color:var(--cn-accent-fg,#fff)}.card h2{color:var(--cn-accent-text,#142a80)}</style>` : ""}</head><body>
5574
6348
  <iframe id="cn-frame" src="/p/${esc(row.slug)}/view" title="${esc(title)}"></iframe>
5575
6349
  <button id="cn-open" type="button">Leave a comment</button>
5576
6350
  <div id="cn-back"><div class="card" role="dialog" aria-modal="true" aria-label="Leave a comment">
@@ -5641,12 +6415,12 @@ publishRenderRoutes.get("/:slug", async (c) => {
5641
6415
  const row = await getPublishBySlug(slug);
5642
6416
  if (!isLivePublish(row)) return c.html(unavailablePage(), 404);
5643
6417
  const viewer = await resolveViewer(c, row);
5644
- if (!viewer) return c.html(gatePage(row));
6418
+ if (!viewer) return c.html(gatePage(row, void 0, await loadBrand(row)));
5645
6419
  if (row.comments_enabled !== 1) return renderPublished(c, row, viewer.email);
5646
6420
  const title = await publishedTitle(row);
5647
6421
  c.header("Content-Security-Policy", SHELL_CSP);
5648
6422
  c.header("Referrer-Policy", "no-referrer");
5649
- return c.html(readerShell(row, title, viewer.email));
6423
+ return c.html(readerShell(row, title, viewer.email, await loadBrand(row)));
5650
6424
  });
5651
6425
  publishRenderRoutes.get("/:slug/view", async (c) => {
5652
6426
  const slug = c.req.param("slug");
@@ -5684,20 +6458,20 @@ publishRenderRoutes.post("/:slug/gate", async (c) => {
5684
6458
  const row = await getPublishBySlug(slug);
5685
6459
  if (!isLivePublish(row)) return c.html(unavailablePage(), 404);
5686
6460
  if (!tryConsume(`publish-gate:${slug}:${clientIp(c)}`, GATE_LIMIT)) {
5687
- return c.html(gatePage(row, "Too many attempts. Try again in a few minutes."), 429);
6461
+ return c.html(gatePage(row, "Too many attempts. Try again in a few minutes.", await loadBrand(row)), 429);
5688
6462
  }
5689
6463
  if (row.access_mode === "public") return c.redirect(`/p/${slug}`);
5690
6464
  const form = await c.req.parseBody();
5691
6465
  if (row.access_mode === "code") {
5692
6466
  if (!await verifyCode(row, String(form.code ?? ""))) {
5693
- return c.html(gatePage(row, "That code is not correct."), 401);
6467
+ return c.html(gatePage(row, "That code is not correct.", await loadBrand(row)), 401);
5694
6468
  }
5695
6469
  setSessionCookie2(c, slug, await createSession2(row.id, null));
5696
6470
  return c.redirect(`/p/${slug}`);
5697
6471
  }
5698
6472
  const email = normalizeEmail(String(form.email ?? ""));
5699
6473
  if (!isEmailish(email)) {
5700
- return c.html(gatePage(row, "Enter a valid email address."), 400);
6474
+ return c.html(gatePage(row, "Enter a valid email address.", await loadBrand(row)), 400);
5701
6475
  }
5702
6476
  const allowed = row.access_mode !== "invite" || await isAllowlisted(row.id, email);
5703
6477
  const linkToken = await mintMagicLink(row.id, email);
@@ -5717,15 +6491,15 @@ publishRenderRoutes.get("/:slug/verify", async (c) => {
5717
6491
  if (!isLivePublish(row)) return c.html(unavailablePage(), 404);
5718
6492
  const email = await consumeMagicLink(row.id, c.req.query("token") ?? "");
5719
6493
  if (!email) {
5720
- return c.html(gatePage(row, "That link is invalid or has expired. Request a new one."), 401);
6494
+ return c.html(gatePage(row, "That link is invalid or has expired. Request a new one.", await loadBrand(row)), 401);
5721
6495
  }
5722
6496
  setSessionCookie2(c, slug, await createSession2(row.id, email));
5723
6497
  return c.redirect(`/p/${slug}`);
5724
6498
  });
5725
6499
 
5726
6500
  // src/nests/nest-team-routes.ts
5727
- import { Hono as Hono8 } from "hono";
5728
- var nestTeamRoutes = new Hono8();
6501
+ import { Hono as Hono9 } from "hono";
6502
+ var nestTeamRoutes = new Hono9();
5729
6503
  nestTeamRoutes.get("/", async (c) => {
5730
6504
  const nestId = c.req.param("nestId");
5731
6505
  const refs = await listNestTeams(nestId);
@@ -5764,8 +6538,8 @@ nestTeamRoutes.delete("/:teamId", async (c) => {
5764
6538
  });
5765
6539
 
5766
6540
  // src/nests/people-routes.ts
5767
- import { Hono as Hono9 } from "hono";
5768
- var peopleRoutes = new Hono9();
6541
+ import { Hono as Hono10 } from "hono";
6542
+ var peopleRoutes = new Hono10();
5769
6543
  peopleRoutes.get("/suggest", async (c) => {
5770
6544
  const userId = c.get("userId");
5771
6545
  const q = c.req.query("q") ?? "";
@@ -5775,7 +6549,7 @@ peopleRoutes.get("/suggest", async (c) => {
5775
6549
  });
5776
6550
 
5777
6551
  // src/governance/team-routes.ts
5778
- import { Hono as Hono10 } from "hono";
6552
+ import { Hono as Hono11 } from "hono";
5779
6553
 
5780
6554
  // src/governance/promptowl-teams.ts
5781
6555
  var PROMPTOWL_SOURCE = "promptowl";
@@ -5922,7 +6696,7 @@ async function desiredRoster(poTeam, callerEmail) {
5922
6696
  }
5923
6697
 
5924
6698
  // src/governance/team-routes.ts
5925
- var teamRoutes = new Hono10();
6699
+ var teamRoutes = new Hono11();
5926
6700
  var PROMPTOWL_LIMIT = { max: 10, windowMs: 15 * 6e4 };
5927
6701
  var CONNECT_REQUIRED = "promptowl_connect_required";
5928
6702
  var CONNECT_REQUIRED_STATUS = 428;
@@ -6107,7 +6881,8 @@ function toSearchHit(node, query) {
6107
6881
  }
6108
6882
 
6109
6883
  // src/nodes/routes.ts
6110
- import { Hono as Hono11 } from "hono";
6884
+ import { Hono as Hono12 } from "hono";
6885
+ import { parseDocument as parseDocument3, readPdfBinary as readPdfBinary2 } from "@promptowl/contextnest-engine";
6111
6886
 
6112
6887
  // src/nodes/markdown-export.ts
6113
6888
  import { parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
@@ -6159,7 +6934,7 @@ ${m[1]}
6159
6934
  }
6160
6935
 
6161
6936
  // src/nodes/routes.ts
6162
- var nodeRoutes = new Hono11();
6937
+ var nodeRoutes = new Hono12();
6163
6938
  function nodeAsMarkdown(response, nodeId) {
6164
6939
  return nodeToMarkdown({
6165
6940
  id: nodeId,
@@ -6224,7 +6999,8 @@ nodeRoutes.post("/", async (c) => {
6224
6999
  status: body.status,
6225
7000
  schedule: body.schedule,
6226
7001
  folder: body.folder,
6227
- prime: body.prime
7002
+ prime: body.prime,
7003
+ client: attributeRequest(c, body.client)
6228
7004
  },
6229
7005
  authorEmail
6230
7006
  );
@@ -6241,7 +7017,7 @@ nodeRoutes.post("/", async (c) => {
6241
7017
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
6242
7018
  const nestId = c.req.param("nestId");
6243
7019
  const nodeId = c.req.param("nodeId");
6244
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-4DHNRULG.js");
7020
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-TDZUC2Q6.js");
6245
7021
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
6246
7022
  nestId,
6247
7023
  nodeId
@@ -6262,7 +7038,7 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
6262
7038
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
6263
7039
  const nestId = c.req.param("nestId");
6264
7040
  const nodeId = c.req.param("nodeId");
6265
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-HHGOTO6P.js");
7041
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-2EWN7WWO.js");
6266
7042
  const history = await getReviewHistory2(nestId, nodeId);
6267
7043
  return c.json({ reviews: history });
6268
7044
  });
@@ -6283,7 +7059,7 @@ nodeRoutes.post("/:nodeId{.+}/discard", async (c) => {
6283
7059
  nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
6284
7060
  const nestId = c.req.param("nestId");
6285
7061
  const nodeId = c.req.param("nodeId");
6286
- const { versions: versionManager } = await engineCache.get(nestId);
7062
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
6287
7063
  const userId = c.get("userId");
6288
7064
  const userEmail = await resolveCallerEmail(userId);
6289
7065
  if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
@@ -6305,11 +7081,26 @@ nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
6305
7081
  `Version ${targetVersion} not found for ${nodeId}`
6306
7082
  );
6307
7083
  }
7084
+ let restoredPdf = null;
7085
+ if (pdfMetaOf(nodeId, raw)) {
7086
+ try {
7087
+ restoredPdf = await readPdfBinary2(storage, nodeId, {
7088
+ version: targetVersion
7089
+ });
7090
+ } catch (err) {
7091
+ console.error("[pdf] revert could not read the version's binary", nestId, nodeId, targetVersion, err);
7092
+ throw new NotFoundError(`The PDF of version ${targetVersion} is not available to restore.`);
7093
+ }
7094
+ }
6308
7095
  const content = bodyOnly(nodeId, raw);
6309
7096
  const { node, version } = await updateNode(
6310
7097
  nestId,
6311
7098
  nodeId,
6312
- { content, changeNote: `Restored from version ${targetVersion}` },
7099
+ {
7100
+ ...restoredPdf ? { pdf: { bytes: new Uint8Array(restoredPdf) } } : { content },
7101
+ changeNote: `Restored from version ${targetVersion}`,
7102
+ client: attributeRequest(c, body.client)
7103
+ },
6313
7104
  userEmail
6314
7105
  );
6315
7106
  await trackEvent("node.revert", { nestId, nodeId, targetVersion });
@@ -6412,6 +7203,10 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
6412
7203
  approved
6413
7204
  );
6414
7205
  response.content = bodyOnly(nodeId, approvedRaw);
7206
+ if (response.type === "pdf") {
7207
+ const approvedPdf = pdfMetaOf(nodeId, approvedRaw);
7208
+ if (approvedPdf) response.pdf = approvedPdf;
7209
+ }
6415
7210
  } catch (err) {
6416
7211
  console.error(
6417
7212
  "reconstructVersion failed (public single)",
@@ -6494,6 +7289,7 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
6494
7289
  changeNote: body.changeNote,
6495
7290
  schedule: body.schedule,
6496
7291
  prime: body.prime,
7292
+ client: attributeRequest(c, body.client),
6497
7293
  type: body.type
6498
7294
  },
6499
7295
  authorEmail
@@ -6506,6 +7302,13 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
6506
7302
  await deleteNode(nestId, nodeId, await getUserEmail(c));
6507
7303
  return c.json({ deleted: true });
6508
7304
  });
7305
+ function pdfMetaOf(nodeId, raw) {
7306
+ try {
7307
+ return parseDocument3(`${nodeId}.md`, raw, nodeId).frontmatter.pdf;
7308
+ } catch {
7309
+ return void 0;
7310
+ }
7311
+ }
6509
7312
  async function getUserEmail(c) {
6510
7313
  const userId = c.get("userId");
6511
7314
  const db = getDb();
@@ -6514,13 +7317,13 @@ async function getUserEmail(c) {
6514
7317
  }
6515
7318
 
6516
7319
  // src/annotations/routes.ts
6517
- import { Hono as Hono12 } from "hono";
7320
+ import { Hono as Hono13 } from "hono";
6518
7321
 
6519
7322
  // src/annotations/types.ts
6520
7323
  var ARTIFACT_NODE_TYPE = "artifact";
6521
7324
 
6522
7325
  // src/annotations/routes.ts
6523
- var annotationRoutes = new Hono12();
7326
+ var annotationRoutes = new Hono13();
6524
7327
  function getNodeId(c) {
6525
7328
  const raw = c.req.param("nodeId");
6526
7329
  try {
@@ -6648,7 +7451,7 @@ annotationRoutes.get("/:nodeId{.+}/hosted", async (c) => {
6648
7451
  });
6649
7452
 
6650
7453
  // src/nodes/query-routes.ts
6651
- import { Hono as Hono13 } from "hono";
7454
+ import { Hono as Hono14 } from "hono";
6652
7455
 
6653
7456
  // src/tables/service.ts
6654
7457
  function splitCsvLine(line) {
@@ -7108,7 +7911,7 @@ async function resolveExportBody(nestId, nodeId, workingBody) {
7108
7911
 
7109
7912
  // src/nests/bundle-service.ts
7110
7913
  import { readdirSync as readdirSync2, readFileSync as readFileSync2, statSync as statSync2 } from "fs";
7111
- import { join as join3, sep } from "path";
7914
+ import { join as join5, sep } from "path";
7112
7915
  import JSZip from "jszip";
7113
7916
  import { serializeDocument as serializeDocument2 } from "@promptowl/contextnest-engine";
7114
7917
  function walkFiles(root) {
@@ -7121,7 +7924,7 @@ function walkFiles(root) {
7121
7924
  }
7122
7925
  for (const rel of entries) {
7123
7926
  try {
7124
- if (statSync2(join3(root, rel)).isFile()) out.push(rel.split(sep).join("/"));
7927
+ if (statSync2(join5(root, rel)).isFile()) out.push(rel.split(sep).join("/"));
7125
7928
  } catch {
7126
7929
  }
7127
7930
  }
@@ -7162,13 +7965,17 @@ async function exportNestBundle(nestId, includeDrafts) {
7162
7965
  const segs = rel.split("/");
7163
7966
  const keep = segs.includes(".versions") || rel === ".context/config.yaml" || rel === "CONTEXT.md";
7164
7967
  if (!keep) continue;
7165
- zip.file(rel, readFileSync2(join3(nestPath, rel)));
7968
+ zip.file(rel, readFileSync2(join5(nestPath, rel)));
7166
7969
  }
7167
7970
  trackEvent("nest.export.bundle", { nestId, documents: count, includeDrafts });
7168
7971
  return zip.generateAsync({ type: "arraybuffer" });
7169
7972
  }
7170
7973
 
7171
7974
  // src/nodes/graph-service.ts
7975
+ import {
7976
+ contextLinkTarget,
7977
+ extractContextLinks
7978
+ } from "@promptowl/contextnest-engine";
7172
7979
  var MAX_GRAPH_NODES = 150;
7173
7980
  var WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
7174
7981
  function bareSlug(idOrTarget) {
@@ -7186,6 +7993,15 @@ function extractWikiTargets(body) {
7186
7993
  }
7187
7994
  return out;
7188
7995
  }
7996
+ function extractContextTargets(body) {
7997
+ const out = /* @__PURE__ */ new Set();
7998
+ if (!body) return [];
7999
+ for (const uri of extractContextLinks(body)) {
8000
+ const t = contextLinkTarget(uri);
8001
+ if (t) out.add(t);
8002
+ }
8003
+ return [...out];
8004
+ }
7189
8005
  async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
7190
8006
  const { storage } = await engineCache.get(nestId);
7191
8007
  const docs = (await storage.discoverDocuments()).filter(
@@ -7220,6 +8036,15 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
7220
8036
  allLinks.push({ source: d.id, target: targetId });
7221
8037
  }
7222
8038
  }
8039
+ for (const d of accessible) {
8040
+ for (const targetId of extractContextTargets(d.body || "")) {
8041
+ if (!idSet.has(targetId) || targetId === d.id) continue;
8042
+ const key = `${d.id}->${targetId}`;
8043
+ if (seen.has(key)) continue;
8044
+ seen.add(key);
8045
+ allLinks.push({ source: d.id, target: targetId });
8046
+ }
8047
+ }
7223
8048
  const tagCounts = /* @__PURE__ */ new Map();
7224
8049
  const tagToDocs = /* @__PURE__ */ new Map();
7225
8050
  for (const n of allNodes) {
@@ -7231,6 +8056,17 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
7231
8056
  }
7232
8057
  }
7233
8058
  const tags = [...tagCounts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
8059
+ for (const docIds of tagToDocs.values()) {
8060
+ const ids = [...docIds].sort();
8061
+ for (let i = 0; i < ids.length; i++) {
8062
+ for (let j = i + 1; j < ids.length; j++) {
8063
+ const key = `${ids[i]}->${ids[j]}`;
8064
+ if (seen.has(key) || seen.has(`${ids[j]}->${ids[i]}`)) continue;
8065
+ seen.add(key);
8066
+ allLinks.push({ source: ids[i], target: ids[j] });
8067
+ }
8068
+ }
8069
+ }
7234
8070
  const exposeStewards = await isStewardshipEnabled(nestId) && !await isPublicReader(nestId, userId);
7235
8071
  const stewardRows = exposeStewards ? (await listStewards({ nestId })).filter(
7236
8072
  // Drop document-scoped rows whose node the caller can't see.
@@ -7321,7 +8157,7 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
7321
8157
  }
7322
8158
 
7323
8159
  // src/nodes/query-routes.ts
7324
- var queryRoutes = new Hono13();
8160
+ var queryRoutes = new Hono14();
7325
8161
  queryRoutes.get("/changes", async (c) => {
7326
8162
  const nestId = c.req.param("nestId");
7327
8163
  const userId = c.get("userId");
@@ -7525,7 +8361,10 @@ queryRoutes.post("/context", async (c) => {
7525
8361
  hopsUsed = result.hopsUsed;
7526
8362
  nodesTraversed = result.nodesTraversed;
7527
8363
  }
7528
- if (titleMatches.length > 0 && hops > 0) {
8364
+ const hopSeeds = [
8365
+ ...new Map([...documents, ...titleMatches].map((d) => [d.id, d])).values()
8366
+ ];
8367
+ if (hopSeeds.length > 0 && hops > 0) {
7529
8368
  const byTitle = new Map(
7530
8369
  allDocs.map((d) => [
7531
8370
  String(d.frontmatter.title || "").toLowerCase(),
@@ -7543,9 +8382,13 @@ queryRoutes.post("/context", async (c) => {
7543
8382
  const t = byId.get(target) ?? byTitle.get(target.toLowerCase());
7544
8383
  if (t && t.id !== d.id) link(d.id, t.id);
7545
8384
  }
8385
+ for (const target of extractContextTargets(d.body || "")) {
8386
+ const t = byId.get(target);
8387
+ if (t && t.id !== d.id) link(d.id, t.id);
8388
+ }
7546
8389
  }
7547
- const seen = new Set(titleMatches.map((d) => d.id));
7548
- let frontier = titleMatches.map((d) => d.id);
8390
+ const seen = new Set(hopSeeds.map((d) => d.id));
8391
+ let frontier = hopSeeds.map((d) => d.id);
7549
8392
  for (let depth = 0; depth < hops && frontier.length; depth++) {
7550
8393
  const next = [];
7551
8394
  for (const id of frontier) {
@@ -7879,7 +8722,8 @@ queryRoutes.post("/publish", async (c) => {
7879
8722
  content: doc.content,
7880
8723
  type: doc.type,
7881
8724
  tags: doc.tags,
7882
- scope: doc.scope
8725
+ scope: doc.scope,
8726
+ folder: doc.folder
7883
8727
  },
7884
8728
  userEmail
7885
8729
  );
@@ -7911,10 +8755,13 @@ queryRoutes.post("/publish", async (c) => {
7911
8755
  });
7912
8756
 
7913
8757
  // src/mcp/routes.ts
7914
- import { Hono as Hono15 } from "hono";
8758
+ import { Hono as Hono16 } from "hono";
7915
8759
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7916
8760
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
7917
8761
 
8762
+ // src/mcp/tools.ts
8763
+ import { parseDocument as parseDocument4 } from "@promptowl/contextnest-engine";
8764
+
7918
8765
  // src/telemetry/trace-log.ts
7919
8766
  var PRUNE_EVERY = 500;
7920
8767
  var insertsSincePrune = 0;
@@ -7923,8 +8770,8 @@ async function logTraceEvent(e) {
7923
8770
  const db = getDb();
7924
8771
  await db.run(
7925
8772
  `INSERT INTO api_events
7926
- (ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms)
7927
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
8773
+ (ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms, client_json)
8774
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
7928
8775
  [
7929
8776
  (/* @__PURE__ */ new Date()).toISOString(),
7930
8777
  e.kind,
@@ -7935,7 +8782,8 @@ async function logTraceEvent(e) {
7935
8782
  e.userId ?? null,
7936
8783
  e.userEmail ?? null,
7937
8784
  e.status ?? null,
7938
- e.durationMs ?? null
8785
+ e.durationMs ?? null,
8786
+ serializeClient(e.client)
7939
8787
  ]
7940
8788
  );
7941
8789
  if (++insertsSincePrune >= PRUNE_EVERY) {
@@ -7984,17 +8832,23 @@ async function listTraceEvents(filters = {}) {
7984
8832
  LIMIT ? OFFSET ?`,
7985
8833
  [...args, limit, offset]
7986
8834
  );
7987
- return { events, total };
8835
+ return {
8836
+ events: events.map(({ client_json, ...e }) => {
8837
+ const client = deserializeClient(client_json);
8838
+ return client ? { ...e, client } : e;
8839
+ }),
8840
+ total
8841
+ };
7988
8842
  } catch {
7989
8843
  return { events: [], total: 0 };
7990
8844
  }
7991
8845
  }
7992
8846
 
7993
8847
  // src/mcp/workflow-tools.ts
7994
- import { v4 as uuid12 } from "uuid";
8848
+ import { v4 as uuid11 } from "uuid";
7995
8849
 
7996
8850
  // src/workflow/run-service.ts
7997
- import { v4 as uuid10 } from "uuid";
8851
+ import { v4 as uuid9 } from "uuid";
7998
8852
  var RUNNABLE_TYPES = ["agent", "skill"];
7999
8853
  async function flowSubgraph(nestId, agentNode) {
8000
8854
  const db = getDb();
@@ -8124,7 +8978,7 @@ async function triggerRun(opts) {
8124
8978
  }
8125
8979
  const edges = await flowSubgraph(nestId, agentNode);
8126
8980
  const definitions = await referencedDefinitions(nestId, edges);
8127
- const id = `run_${uuid10()}`;
8981
+ const id = `run_${uuid9()}`;
8128
8982
  const db = getDb();
8129
8983
  await db.transaction(async (tx) => {
8130
8984
  if (parentRunId) {
@@ -8310,8 +9164,8 @@ async function listRuns(nestId, opts) {
8310
9164
  }
8311
9165
 
8312
9166
  // src/workflow/edge-routes.ts
8313
- import { Hono as Hono14 } from "hono";
8314
- import { v4 as uuid11 } from "uuid";
9167
+ import { Hono as Hono15 } from "hono";
9168
+ import { v4 as uuid10 } from "uuid";
8315
9169
  function toResponse(e, t) {
8316
9170
  return {
8317
9171
  id: e.id,
@@ -8429,7 +9283,7 @@ async function assertNoDuplicateEdge(db, nestId, fromNode, toNode, type, exclude
8429
9283
  );
8430
9284
  }
8431
9285
  }
8432
- var edgeRoutes = new Hono14();
9286
+ var edgeRoutes = new Hono15();
8433
9287
  edgeRoutes.get("/", requireWorkflowPlane, async (c) => {
8434
9288
  const nestId = c.req.param("nestId");
8435
9289
  const db = getDb();
@@ -8497,7 +9351,7 @@ edgeRoutes.post("/", requireWorkflowPlane, async (c) => {
8497
9351
  );
8498
9352
  const metadata = body.metadata === void 0 || body.metadata === null ? null : JSON.stringify(body.metadata);
8499
9353
  const now = (/* @__PURE__ */ new Date()).toISOString();
8500
- const id = uuid11();
9354
+ const id = uuid10();
8501
9355
  await db.transaction(async (tx) => {
8502
9356
  await assertNoDuplicateEdge(tx, nestId, fromNode, toNode, type);
8503
9357
  if (type.is_flow) {
@@ -8898,7 +9752,7 @@ async function createEdge(ctx, args) {
8898
9752
  );
8899
9753
  const metadata = args.metadata === void 0 || args.metadata === null ? null : JSON.stringify(args.metadata);
8900
9754
  const now = (/* @__PURE__ */ new Date()).toISOString();
8901
- const id = uuid12();
9755
+ const id = uuid11();
8902
9756
  await db.transaction(async (tx) => {
8903
9757
  await assertNoDuplicateEdge(tx, ctx.nestId, fromNode, toNode, type);
8904
9758
  if (type.is_flow) {
@@ -8962,7 +9816,7 @@ async function upsertEdgeType(ctx, args) {
8962
9816
  `INSERT INTO edge_types
8963
9817
  (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
8964
9818
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
8965
- [uuid12(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
9819
+ [uuid11(), ctx.nestId, name, args.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
8966
9820
  );
8967
9821
  }
8968
9822
  });
@@ -9032,6 +9886,19 @@ function summarizeNode(node, body) {
9032
9886
  body: body ?? void 0
9033
9887
  };
9034
9888
  }
9889
+ function pdfSourceLine(node, body) {
9890
+ if ((node.frontmatter.type || "document") !== "pdf") return null;
9891
+ let pdf = node.frontmatter.pdf;
9892
+ if (body && body.startsWith("---")) {
9893
+ try {
9894
+ pdf = parseDocument4(`${node.id}.md`, body, node.id).frontmatter.pdf ?? pdf;
9895
+ } catch {
9896
+ }
9897
+ }
9898
+ if (!pdf) return "**Source file:** PDF";
9899
+ const pages = `${pdf.pages} page${pdf.pages === 1 ? "" : "s"}`;
9900
+ return `**Source file:** PDF, ${pages}${pdf.text_layer ? "" : ", no text layer"}`;
9901
+ }
9035
9902
  var TOOL_DEFINITIONS = [
9036
9903
  {
9037
9904
  name: "context_init",
@@ -9262,7 +10129,7 @@ var TOOL_DEFINITIONS = [
9262
10129
  tags: {
9263
10130
  type: "array",
9264
10131
  items: { type: "string" },
9265
- description: "Tags to add"
10132
+ description: "The node's full tag set \u2014 REPLACES the existing tags (same as the engine catalog). To add one, send the current tags plus the new one; to remove one, send the set without it."
9266
10133
  },
9267
10134
  status: {
9268
10135
  type: "string",
@@ -9431,10 +10298,14 @@ var TOOL_DEFINITIONS = [
9431
10298
  type: "object",
9432
10299
  properties: {
9433
10300
  title: { type: "string", description: "Title of the node to submit" },
10301
+ id: {
10302
+ type: "string",
10303
+ description: 'Node id / path (e.g. "nodes/gtm/deals/win-loss-log"). Use instead of title when two nodes share a title or the title is unknown.'
10304
+ },
9434
10305
  note: { type: "string", description: "Optional note for the reviewer" },
9435
10306
  priority: { type: "string", description: "Priority: low, normal (default), high, urgent" }
9436
- },
9437
- required: ["title"]
10307
+ }
10308
+ // One of id or title — see context_update.
9438
10309
  }
9439
10310
  },
9440
10311
  {
@@ -9572,8 +10443,30 @@ async function resolveLlmBody(ctx, node, gate) {
9572
10443
  async function handleToolCall(toolName, args, ctx) {
9573
10444
  const started = Date.now();
9574
10445
  let status = 200;
10446
+ let client;
10447
+ try {
10448
+ client = mergeClient(ctx.client, parseClientMetadata(args.client));
10449
+ } catch (err) {
10450
+ void logTraceEvent({
10451
+ kind: "mcp",
10452
+ tool: toolName,
10453
+ nestId: ctx.nestId,
10454
+ userId: ctx.userId,
10455
+ userEmail: ctx.userEmail,
10456
+ status: 400,
10457
+ durationMs: Date.now() - started
10458
+ });
10459
+ return toolError(err.message, "VALIDATION_FAILED");
10460
+ }
10461
+ const attributed = { ...ctx, client };
10462
+ if ((toolName === "context_create" || toolName === "context_update") && typeof args.content === "string" && /^\s*---\r?\n[\w-]+:/.test(args.content)) {
10463
+ return toolError(
10464
+ "`content` is the markdown body only \u2014 it starts with a YAML frontmatter block, which would be stored as body text. Drop the `---` block and pass tags/status as parameters; the server owns the frontmatter.",
10465
+ "VALIDATION_FAILED"
10466
+ );
10467
+ }
9575
10468
  try {
9576
- return await runTool(toolName, args, ctx);
10469
+ return await runTool(toolName, args, attributed);
9577
10470
  } catch (err) {
9578
10471
  if (isVaultLockTimeout(err) || err instanceof VaultBusyError) {
9579
10472
  status = 503;
@@ -9592,7 +10485,10 @@ async function handleToolCall(toolName, args, ctx) {
9592
10485
  userId: ctx.userId,
9593
10486
  userEmail: ctx.userEmail,
9594
10487
  status,
9595
- durationMs: Date.now() - started
10488
+ durationMs: Date.now() - started,
10489
+ // Reads record attribution HERE — the activity trace is this edition's
10490
+ // §9.2 access trace, and a read seals no version to carry it.
10491
+ client
9596
10492
  });
9597
10493
  }
9598
10494
  }
@@ -9787,7 +10683,8 @@ ${list}`,
9787
10683
  const meta = [
9788
10684
  `**Title:** ${node.frontmatter.title}`,
9789
10685
  `**Type:** ${node.frontmatter.type || "document"}`,
9790
- node.frontmatter.tags?.length ? `**Tags:** ${node.frontmatter.tags.join(" ")}` : null
10686
+ node.frontmatter.tags?.length ? `**Tags:** ${node.frontmatter.tags.join(" ")}` : null,
10687
+ pdfSourceLine(node, body)
9791
10688
  ].filter(Boolean).join("\n");
9792
10689
  return {
9793
10690
  text: `${meta}
@@ -10050,7 +10947,8 @@ ${sections}`;
10050
10947
  type: args.type,
10051
10948
  tags: args.tags,
10052
10949
  scope: args.scope,
10053
- folder: args.folder
10950
+ folder: args.folder,
10951
+ client: ctx.client
10054
10952
  },
10055
10953
  userEmail
10056
10954
  );
@@ -10078,16 +10976,16 @@ ${sections}`;
10078
10976
  "UNAUTHORIZED_ACTION"
10079
10977
  );
10080
10978
  }
10081
- const mergedTags = args.tags ? [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...args.tags])] : void 0;
10082
10979
  const { node: updated, version } = await updateNode(
10083
10980
  nestId,
10084
10981
  node.id,
10085
10982
  {
10086
10983
  content: args.content,
10087
10984
  append: args.append,
10088
- tags: mergedTags,
10985
+ tags: args.tags,
10089
10986
  status: args.status,
10090
- changeNote: args.changeNote
10987
+ changeNote: args.changeNote,
10988
+ client: ctx.client
10091
10989
  },
10092
10990
  userEmail
10093
10991
  );
@@ -10140,10 +11038,11 @@ Use context_request_deletion to ask a nest owner/admin to delete it, with a reas
10140
11038
  args.folder,
10141
11039
  userEmail
10142
11040
  );
11041
+ const data = { id: moved.id, previous_id: previousId, title: moved.frontmatter.title };
10143
11042
  if (previousId === moved.id) {
10144
- return `**${moved.frontmatter.title}** is already at \`${moved.id}\` \u2014 nothing to move.`;
11043
+ return { text: `**${moved.frontmatter.title}** is already at \`${moved.id}\` \u2014 nothing to move.`, data };
10145
11044
  }
10146
- return `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}\``;
11045
+ return { text: `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}\``, data };
10147
11046
  }
10148
11047
  // ─── Governance Tool Handlers ──────────────────────────────────────
10149
11048
  case "context_stewards": {
@@ -10386,7 +11285,8 @@ Submit one with \`context_submit_review\`.`;
10386
11285
  requestedBy: ctx.userEmail,
10387
11286
  note: args.note,
10388
11287
  priority: args.priority,
10389
- baseUrl: ctx.baseUrl
11288
+ baseUrl: ctx.baseUrl,
11289
+ client: ctx.client
10390
11290
  });
10391
11291
  const resolved2 = await resolveStewardsForNode(
10392
11292
  ctx.nestId,
@@ -10437,7 +11337,8 @@ ${resolved2.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` :
10437
11337
  nodeId: node.id,
10438
11338
  approvedBy: ctx.userEmail,
10439
11339
  note: args.note,
10440
- baseUrl: ctx.baseUrl
11340
+ baseUrl: ctx.baseUrl,
11341
+ client: ctx.client
10441
11342
  });
10442
11343
  return `Approved "${args.title}" v${request.version}. This version is now live for AI queries.${args.note ? `
10443
11344
  Note: ${args.note}` : ""}`;
@@ -10478,7 +11379,9 @@ Reason: ${args.note}`;
10478
11379
  };
10479
11380
  }
10480
11381
  const list = allVersions.map(
10481
- (v) => `- **v${v.version}** ${v.status}${v.version === approved ? " (AI-active)" : ""} \u2014 by ${v.editedBy} at ${v.editedAt}${v.changeNote ? ` "${v.changeNote}"` : ""}`
11382
+ (v) => `- **v${v.version}** ${v.status}${v.version === approved ? " (AI-active)" : ""} \u2014 by ${v.editedBy} at ${v.editedAt}${v.changeNote ? ` "${v.changeNote}"` : ""}${// §9.4: which agent wrote it. In the prose as well as the
11383
+ // structured payload — a chat client reads only this half.
11384
+ v.client?.agent ? ` [via ${v.client.agent}]` : ""}`
10482
11385
  ).join("\n");
10483
11386
  return {
10484
11387
  text: `# Version History: ${label}
@@ -10502,7 +11405,11 @@ ${list}`,
10502
11405
  status: v.status,
10503
11406
  edited_by: v.editedBy,
10504
11407
  edited_at: v.editedAt,
10505
- note: v.changeNote
11408
+ note: v.changeNote,
11409
+ // §9.4: which agent wrote this version, in which session. Omitted
11410
+ // (not `{}`) when the write carried no attribution — "not
11411
+ // attributed" and "attributed to nobody" are different claims.
11412
+ ...v.client ? { client: v.client } : {}
10506
11413
  }))
10507
11414
  }
10508
11415
  };
@@ -10563,22 +11470,12 @@ ${list}`,
10563
11470
  if (!target) return "target is required (a node id or folder prefix).";
10564
11471
  if (!["read", "write"].includes(role)) return "role must be read or write.";
10565
11472
  try {
10566
- const { createGrant: createGrant2 } = await import("./grants-service-UT3EQ3R7.js");
10567
- const db = (await import("./client-KEY4PYJH.js")).getDb();
11473
+ const { createGrant: createGrant2 } = await import("./grants-service-24GLUBY4.js");
10568
11474
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
10569
11475
  const e = normalizeEmail2(String(args.email || ""));
10570
11476
  if (!e) return "email is required.";
10571
- let row = await db.get("SELECT id FROM users WHERE LOWER(email) = ?", [e]);
10572
- if (!row) {
10573
- const { hashPassword: hashPassword2 } = await import("./keys-CTOGAG3W.js");
10574
- const { v4: uuid20 } = await import("uuid");
10575
- const id = uuid20();
10576
- await db.run(
10577
- "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
10578
- [id, e, null, await hashPassword2(uuid20())]
10579
- );
10580
- row = { id };
10581
- }
11477
+ const { findOrCreateInvitedUser: findOrCreateInvitedUser2 } = await import("./invited-user-AM6A3WGO.js");
11478
+ const row = await findOrCreateInvitedUser2(e, { invitedBy: ctx.userEmail });
10582
11479
  await createGrant2({
10583
11480
  nestId: ctx.nestId,
10584
11481
  targetType,
@@ -10613,7 +11510,8 @@ ${list}`;
10613
11510
  const { nest, documents } = await syncUnsyncedFolder(
10614
11511
  userId,
10615
11512
  args.name,
10616
- userEmail
11513
+ userEmail,
11514
+ ctx.client
10617
11515
  );
10618
11516
  return `Synced folder "${args.name}" \u2192 nest **${nest.name}** (${nest.id}) with ${documents} document(s).`;
10619
11517
  } catch (err) {
@@ -10629,14 +11527,14 @@ ${list}`;
10629
11527
  }
10630
11528
 
10631
11529
  // src/mcp/routes.ts
10632
- import { z } from "zod";
10633
- var mcpRoutes = new Hono15();
11530
+ import { z as z2 } from "zod";
11531
+ var mcpRoutes = new Hono16();
10634
11532
  async function getUserEmail2(userId) {
10635
11533
  const db = getDb();
10636
11534
  const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
10637
11535
  return user?.email || "anonymous@localhost";
10638
11536
  }
10639
- function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
11537
+ function createMcpServerForNest(nestId, userId, userEmail, baseUrl, c) {
10640
11538
  const server = new McpServer(
10641
11539
  { name: `contextnest-${nestId}`, version: "1.0.0" },
10642
11540
  { capabilities: { tools: {} } }
@@ -10647,16 +11545,18 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
10647
11545
  const required = tool.inputSchema.required || [];
10648
11546
  const shape = {};
10649
11547
  for (const [key, def] of Object.entries(props)) {
10650
- let field;
10651
- if (def.type === "string") field = z.string();
10652
- else if (def.type === "number") field = z.number();
10653
- else if (def.type === "array") field = z.array(z.string());
10654
- else field = z.any();
10655
- if (!required.includes(key)) field = field.optional();
10656
- shape[key] = field;
10657
- }
11548
+ let field2;
11549
+ if (def.type === "string") field2 = z2.string();
11550
+ else if (def.type === "number") field2 = z2.number();
11551
+ else if (def.type === "array") field2 = z2.array(z2.string());
11552
+ else field2 = z2.any();
11553
+ if (!required.includes(key)) field2 = field2.optional();
11554
+ shape[key] = field2;
11555
+ }
11556
+ Object.assign(shape, clientToolField);
10658
11557
  server.tool(tool.name, tool.description, shape, async (args) => {
10659
11558
  const engine = await engineCache.get(nestId);
11559
+ const client = attributeRequest(c, void 0, connectionClient(server));
10660
11560
  const out = await handleToolCall(tool.name, args, {
10661
11561
  storage: engine.storage,
10662
11562
  queryEngine: engine.query,
@@ -10664,7 +11564,8 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
10664
11564
  nestId,
10665
11565
  userId,
10666
11566
  userEmail,
10667
- baseUrl
11567
+ baseUrl,
11568
+ client
10668
11569
  });
10669
11570
  return toCallToolResult(out);
10670
11571
  });
@@ -10679,7 +11580,8 @@ mcpRoutes.all("/", async (c) => {
10679
11580
  nestId,
10680
11581
  userId,
10681
11582
  userEmail,
10682
- requestBaseUrl(c.req.url)
11583
+ requestBaseUrl(c.req.url),
11584
+ c
10683
11585
  );
10684
11586
  const transport = new WebStandardStreamableHTTPServerTransport({
10685
11587
  sessionIdGenerator: void 0,
@@ -10696,8 +11598,8 @@ mcpRoutes.all("/", async (c) => {
10696
11598
  });
10697
11599
 
10698
11600
  // src/workflow/run-routes.ts
10699
- import { Hono as Hono16 } from "hono";
10700
- import { v4 as uuid13 } from "uuid";
11601
+ import { Hono as Hono17 } from "hono";
11602
+ import { v4 as uuid12 } from "uuid";
10701
11603
  var RUNNABLE_TYPES2 = ["agent", "skill"];
10702
11604
  var safeJson2 = (s) => {
10703
11605
  if (!s) return null;
@@ -10815,7 +11717,7 @@ async function buildRunBundle(nestId, agentNode) {
10815
11717
  definitions
10816
11718
  };
10817
11719
  }
10818
- var runTriggerRoutes = new Hono16();
11720
+ var runTriggerRoutes = new Hono17();
10819
11721
  async function resolveParentRun2(nestId, parentRunId, childAgent) {
10820
11722
  if (!config.FEATURE_SUBAGENT_RUNS) {
10821
11723
  throw new ValidationError(
@@ -10894,7 +11796,7 @@ runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
10894
11796
  }
10895
11797
  const bundle = await buildRunBundle(nestId, agentNode);
10896
11798
  const triggeredBy = await resolveCallerEmail(c.get("userId"));
10897
- const id = `run_${uuid13()}`;
11799
+ const id = `run_${uuid12()}`;
10898
11800
  await getDb().run(
10899
11801
  `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)
10900
11802
  VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)`,
@@ -10923,7 +11825,7 @@ runTriggerRoutes.post("/:agentNode{.+}", requireWorkflowPlane, async (c) => {
10923
11825
  201
10924
11826
  );
10925
11827
  });
10926
- var runListRoutes = new Hono16();
11828
+ var runListRoutes = new Hono17();
10927
11829
  runListRoutes.get("/", requireWorkflowPlane, async (c) => {
10928
11830
  const nestId = c.req.param("nestId");
10929
11831
  const db = getDb();
@@ -10955,7 +11857,7 @@ runListRoutes.get("/", requireWorkflowPlane, async (c) => {
10955
11857
  }))
10956
11858
  });
10957
11859
  });
10958
- var runDetailRoutes = new Hono16();
11860
+ var runDetailRoutes = new Hono17();
10959
11861
  async function loadRunWithPermission(c, runId, required) {
10960
11862
  const run = await getDb().get("SELECT * FROM runs WHERE id = ?", [
10961
11863
  runId
@@ -11106,8 +12008,8 @@ runDetailRoutes.get("/:id", requireWorkflowPlane, async (c) => {
11106
12008
  });
11107
12009
 
11108
12010
  // src/workflow/schedule-routes.ts
11109
- import { Hono as Hono17 } from "hono";
11110
- import { v4 as uuid14 } from "uuid";
12011
+ import { Hono as Hono18 } from "hono";
12012
+ import { v4 as uuid13 } from "uuid";
11111
12013
  var RUNNABLE_TYPES3 = ["agent", "skill"];
11112
12014
  var MIN_EVERY = 5;
11113
12015
  var MAX_EVERY = 7 * 24 * 60;
@@ -11144,7 +12046,7 @@ function parseEvery(v) {
11144
12046
  }
11145
12047
  return n;
11146
12048
  }
11147
- var scheduleRoutes = new Hono17();
12049
+ var scheduleRoutes = new Hono18();
11148
12050
  scheduleRoutes.get("/", requireWorkflowPlane, async (c) => {
11149
12051
  const nestId = c.req.param("nestId");
11150
12052
  const rows = await getDb().all(
@@ -11185,7 +12087,7 @@ scheduleRoutes.post("/", requireWorkflowPlane, async (c) => {
11185
12087
  );
11186
12088
  }
11187
12089
  const now = (/* @__PURE__ */ new Date()).toISOString();
11188
- const id = `sch_${uuid14()}`;
12090
+ const id = `sch_${uuid13()}`;
11189
12091
  await db.run(
11190
12092
  `INSERT INTO schedules (id, nest_id, agent_node, every_minutes, enabled, last_run_at, max_runs, runs_created, created_by, created_at, updated_at)
11191
12093
  VALUES (?, ?, ?, ?, ?, NULL, ?, 0, ?, ?, ?)`,
@@ -11240,8 +12142,8 @@ scheduleRoutes.delete("/:id", requireWorkflowPlane, async (c) => {
11240
12142
  });
11241
12143
 
11242
12144
  // src/notify/connector-routes.ts
11243
- import { Hono as Hono18 } from "hono";
11244
- import { v4 as uuid15 } from "uuid";
12145
+ import { Hono as Hono19 } from "hono";
12146
+ import { v4 as uuid14 } from "uuid";
11245
12147
  var KNOWN_EVENTS = [
11246
12148
  "review_requested",
11247
12149
  "review_approved",
@@ -11288,7 +12190,7 @@ function parseUrl(v) {
11288
12190
  'url must be a public https:// URL (loopback/private/metadata hosts are refused) or an env reference like "env:SLACK_WEBHOOK"'
11289
12191
  );
11290
12192
  }
11291
- var connectorRoutes = new Hono18();
12193
+ var connectorRoutes = new Hono19();
11292
12194
  connectorRoutes.get("/", async (c) => {
11293
12195
  const nestId = c.req.param("nestId");
11294
12196
  const rows = await getDb().all(
@@ -11307,7 +12209,7 @@ connectorRoutes.post("/", async (c) => {
11307
12209
  const url = parseUrl(body.url);
11308
12210
  const events = parseEvents(body.events);
11309
12211
  const now = (/* @__PURE__ */ new Date()).toISOString();
11310
- const id = `con_${uuid15()}`;
12212
+ const id = `con_${uuid14()}`;
11311
12213
  await getDb().run(
11312
12214
  `INSERT INTO connectors (id, nest_id, channel, url, events, enabled, created_by, created_at, updated_at)
11313
12215
  VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`,
@@ -11350,7 +12252,7 @@ connectorRoutes.delete("/:id", async (c) => {
11350
12252
  });
11351
12253
 
11352
12254
  // src/workflow/hook-routes.ts
11353
- import { Hono as Hono19 } from "hono";
12255
+ import { Hono as Hono20 } from "hono";
11354
12256
  import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2, randomUUID } from "crypto";
11355
12257
  var PRESETS = ["slack", "teams", "webhook"];
11356
12258
  var RUNNABLE_TYPES4 = ["agent", "skill"];
@@ -11373,7 +12275,7 @@ function toResponse4(c, r, opts = {}) {
11373
12275
  created_at: r.created_at
11374
12276
  };
11375
12277
  }
11376
- var hookNestRoutes = new Hono19();
12278
+ var hookNestRoutes = new Hono20();
11377
12279
  hookNestRoutes.get("/", requireWorkflowPlane, async (c) => {
11378
12280
  const nestId = c.req.param("nestId");
11379
12281
  const agent = c.req.query("agent");
@@ -11447,7 +12349,7 @@ function teamsSignatureValid(secret, authHeader, rawBody) {
11447
12349
  function stripMentions(text) {
11448
12350
  return text.replace(/<@[^>]+>/g, "").replace(/<at>[^<]*<\/at>/gi, "").trim();
11449
12351
  }
11450
- var hookFireRoutes = new Hono19();
12352
+ var hookFireRoutes = new Hono20();
11451
12353
  hookFireRoutes.post("/:token", async (c) => {
11452
12354
  const token = c.req.param("token");
11453
12355
  if (!tryConsume("hook:*", { max: 120, windowMs: 6e4 })) {
@@ -11549,9 +12451,9 @@ hookFireRoutes.post("/:token", async (c) => {
11549
12451
  });
11550
12452
 
11551
12453
  // src/definitions/routes.ts
11552
- import { Hono as Hono20 } from "hono";
11553
- import { v4 as uuid16 } from "uuid";
11554
- var definitionRoutes = new Hono20();
12454
+ import { Hono as Hono21 } from "hono";
12455
+ import { v4 as uuid15 } from "uuid";
12456
+ var definitionRoutes = new Hono21();
11555
12457
  definitionRoutes.get("/", async (c) => {
11556
12458
  const nestId = c.req.param("nestId");
11557
12459
  const db = getDb();
@@ -11630,7 +12532,7 @@ definitionRoutes.post("/", async (c) => {
11630
12532
  await applyUpdate(tx, existing.id);
11631
12533
  targetId = existing.id;
11632
12534
  } else {
11633
- targetId = uuid16();
12535
+ targetId = uuid15();
11634
12536
  wasCreated = true;
11635
12537
  await tx.run(
11636
12538
  `INSERT INTO definitions (id, nest_id, term, definition, linked_tag, defined_by, created_at, updated_at)
@@ -11659,11 +12561,11 @@ definitionRoutes.delete("/:id", async (c) => {
11659
12561
  });
11660
12562
 
11661
12563
  // src/nests/asset-routes.ts
11662
- import { Hono as Hono21 } from "hono";
11663
- import { v4 as uuid17 } from "uuid";
11664
- import { mkdir as mkdir2, readFile, writeFile } from "fs/promises";
11665
- import { join as join4 } from "path";
11666
- var assetRoutes = new Hono21();
12564
+ import { Hono as Hono22 } from "hono";
12565
+ import { v4 as uuid16 } from "uuid";
12566
+ import { mkdir as mkdir2, readFile as readFile2, writeFile } from "fs/promises";
12567
+ import { join as join6 } from "path";
12568
+ var assetRoutes = new Hono22();
11667
12569
  var CONTENT_TYPES = {
11668
12570
  png: "image/png",
11669
12571
  jpg: "image/jpeg",
@@ -11709,10 +12611,10 @@ assetRoutes.post("/", async (c) => {
11709
12611
  `${isVideo ? "video" : "image"} too large (${(file.size / (1024 * 1024)).toFixed(1)} MB, max ${maxBytes / (1024 * 1024)} MB for ${isVideo ? "video" : "images"})`
11710
12612
  );
11711
12613
  }
11712
- const name = `${uuid17()}.${ext === "jpeg" ? "jpg" : ext}`;
11713
- const dir = join4(resolveNestPath(nestId), "assets");
12614
+ const name = `${uuid16()}.${ext === "jpeg" ? "jpg" : ext}`;
12615
+ const dir = join6(resolveNestPath(nestId), "assets");
11714
12616
  await mkdir2(dir, { recursive: true });
11715
- await writeFile(join4(dir, name), Buffer.from(await file.arrayBuffer()));
12617
+ await writeFile(join6(dir, name), Buffer.from(await file.arrayBuffer()));
11716
12618
  const url = `/nests/${nestId}/assets/${name}`;
11717
12619
  return c.json(
11718
12620
  {
@@ -11734,7 +12636,7 @@ assetRoutes.get("/:file", async (c) => {
11734
12636
  }
11735
12637
  let bytes;
11736
12638
  try {
11737
- bytes = await readFile(join4(resolveNestPath(nestId), "assets", name));
12639
+ bytes = await readFile2(join6(resolveNestPath(nestId), "assets", name));
11738
12640
  } catch {
11739
12641
  throw new NotFoundError("Asset not found");
11740
12642
  }
@@ -11749,8 +12651,185 @@ assetRoutes.get("/:file", async (c) => {
11749
12651
  });
11750
12652
  });
11751
12653
 
12654
+ // src/nodes/pdf-routes.ts
12655
+ import { Hono as Hono23 } from "hono";
12656
+ import { bodyLimit } from "hono/body-limit";
12657
+ import { createHash as createHash3 } from "crypto";
12658
+ import { isPdf, readPdfBinary as readPdfBinary3 } from "@promptowl/contextnest-engine";
12659
+ var pdfNodeRoutes = new Hono23();
12660
+ var PDF_UPLOAD_PATH = /^\/nests\/[^/]+\/nodes\/pdf\/?$/;
12661
+ function pdfMaxRequestBytes() {
12662
+ return config.PDF_MAX_BYTES + 1024 * 1024;
12663
+ }
12664
+ function tooLarge(bytes) {
12665
+ return new PayloadTooLargeError(
12666
+ `PDF too large (${(bytes / (1024 * 1024)).toFixed(1)} MB, max ${config.PDF_MAX_BYTES / (1024 * 1024)} MB)`
12667
+ );
12668
+ }
12669
+ function field(body, name) {
12670
+ const v = body[name];
12671
+ const one = Array.isArray(v) ? v[0] : v;
12672
+ return typeof one === "string" ? one.trim() : "";
12673
+ }
12674
+ function tagsField(body) {
12675
+ const v = body["tags"];
12676
+ if (v === void 0) return void 0;
12677
+ const raw = (Array.isArray(v) ? v : [v]).filter((x) => typeof x === "string");
12678
+ const tags = raw.flatMap((s) => s.split(",")).map((t) => t.trim().replace(/^#/, "")).filter(Boolean);
12679
+ return tags;
12680
+ }
12681
+ function titleFromFilename(name) {
12682
+ return name.split(/[/\\]/).pop().replace(/\.pdf$/i, "").replace(/[_]+/g, " ").trim().slice(0, 200);
12683
+ }
12684
+ pdfNodeRoutes.post(
12685
+ "/pdf",
12686
+ (c, next) => bodyLimit({
12687
+ maxSize: pdfMaxRequestBytes(),
12688
+ onError: (c2) => c2.json(
12689
+ {
12690
+ error: `PDF too large (max ${config.PDF_MAX_BYTES / (1024 * 1024)} MB)`
12691
+ },
12692
+ 413
12693
+ )
12694
+ })(c, next),
12695
+ async (c) => {
12696
+ const nestId = c.req.param("nestId");
12697
+ const lenStr = c.req.header("Content-Length");
12698
+ if (lenStr) {
12699
+ const len = parseInt(lenStr, 10);
12700
+ if (Number.isFinite(len) && len > pdfMaxRequestBytes()) throw tooLarge(len);
12701
+ }
12702
+ const body = await c.req.parseBody({ all: true });
12703
+ const fileField = Array.isArray(body["file"]) ? body["file"][0] : body["file"];
12704
+ if (!(fileField instanceof File)) {
12705
+ throw new ValidationError('multipart field "file" (a PDF) is required');
12706
+ }
12707
+ const file = fileField;
12708
+ if (file.size > config.PDF_MAX_BYTES) throw tooLarge(file.size);
12709
+ const bytes = new Uint8Array(await file.arrayBuffer());
12710
+ if (!isPdf(bytes)) {
12711
+ throw new ValidationError("That file is not a PDF (it does not start with %PDF-).");
12712
+ }
12713
+ const userEmail = await resolveCallerEmail(c.get("userId"));
12714
+ const title = field(body, "title");
12715
+ const tags = tagsField(body);
12716
+ const client = attributeRequest(c, void 0);
12717
+ const pdf = { bytes, filename: file.name || void 0 };
12718
+ const targetId = field(body, "nodeId");
12719
+ if (targetId) {
12720
+ assertSafeNodeId(targetId);
12721
+ const changeNote = field(body, "changeNote") || "Replaced the PDF";
12722
+ const { node: node2, version: version2 } = await updateNode(
12723
+ nestId,
12724
+ targetId,
12725
+ {
12726
+ pdf,
12727
+ ...title ? { title } : {},
12728
+ ...tags ? { tags } : {},
12729
+ changeNote,
12730
+ client
12731
+ },
12732
+ userEmail
12733
+ );
12734
+ return c.json({ node: toNodeResponse(node2), version: version2 });
12735
+ }
12736
+ const folder = field(body, "folder");
12737
+ const { node, version } = await createNode(
12738
+ nestId,
12739
+ {
12740
+ title: title || titleFromFilename(file.name || "") || "Untitled PDF",
12741
+ content: "",
12742
+ type: PDF_NODE_TYPE,
12743
+ ...tags ? { tags } : {},
12744
+ ...folder ? { folder } : {},
12745
+ client,
12746
+ pdf
12747
+ },
12748
+ userEmail
12749
+ );
12750
+ const resolved = await resolveStewardsForNode(nestId, node.id);
12751
+ return c.json(
12752
+ {
12753
+ node: toNodeResponse(node),
12754
+ version,
12755
+ stewards: resolved.length > 0 ? resolved.map((r) => ({
12756
+ email: r.steward.userEmail,
12757
+ role: r.steward.role,
12758
+ source: r.source
12759
+ })) : void 0
12760
+ },
12761
+ 201
12762
+ );
12763
+ }
12764
+ );
12765
+ pdfNodeRoutes.get("/:nodeId{.+}/pdf", async (c, next) => {
12766
+ const nestId = c.req.param("nestId");
12767
+ const nodeId = c.req.param("nodeId");
12768
+ const userId = c.get("userId");
12769
+ const userEmail = await resolveCallerEmail(userId);
12770
+ let node;
12771
+ try {
12772
+ node = await getNodeForCaller(nestId, { id: nodeId }, userId, userEmail);
12773
+ } catch {
12774
+ return next();
12775
+ }
12776
+ if (node.frontmatter.type !== PDF_NODE_TYPE) return next();
12777
+ const requested = c.req.query("version");
12778
+ if (requested !== void 0 && !/^[1-9]\d*$/.test(requested)) {
12779
+ throw new ValidationError("version must be a positive integer");
12780
+ }
12781
+ const approvedOnly = c.req.query("approved_only") === "1" || c.req.query("approved_only") === "true";
12782
+ let version;
12783
+ if (approvedOnly || await isPublicReader(nestId, userId)) {
12784
+ const approved = await getApprovedVersion(nestId, nodeId);
12785
+ if (approved == null) {
12786
+ throw new NotFoundError("This document has no approved version yet.");
12787
+ }
12788
+ version = approved;
12789
+ } else if (requested !== void 0) {
12790
+ version = Number(requested);
12791
+ }
12792
+ const { storage } = await engineCache.get(nestId);
12793
+ let bytes;
12794
+ try {
12795
+ bytes = await readPdfBinary3(
12796
+ storage,
12797
+ nodeId,
12798
+ version !== void 0 ? { version } : {}
12799
+ );
12800
+ } catch (err) {
12801
+ const code = err?.code;
12802
+ if (code === "INTEGRITY_ERROR") {
12803
+ console.error("[pdf] integrity failure serving", nestId, nodeId, version, err);
12804
+ throw new AppError(
12805
+ 500,
12806
+ "This PDF no longer matches the version history's record of it, so it is not being served. Ask an admin to verify the nest."
12807
+ );
12808
+ }
12809
+ throw new NotFoundError(
12810
+ version !== void 0 ? `Version ${version} of this PDF was not found.` : "This document's PDF was not found."
12811
+ );
12812
+ }
12813
+ const etag = `"sha256:${createHash3("sha256").update(bytes).digest("hex")}"`;
12814
+ const leaf = (nodeId.split("/").pop() || "document").replace(/[^A-Za-z0-9._-]/g, "-");
12815
+ const disposition = c.req.query("download") === "1" ? "attachment" : "inline";
12816
+ const headers = {
12817
+ "Content-Type": "application/pdf",
12818
+ "Content-Disposition": `${disposition}; filename="${leaf}.pdf"`,
12819
+ "Content-Security-Policy": "sandbox",
12820
+ "X-Content-Type-Options": "nosniff",
12821
+ "Cache-Control": "private, no-cache",
12822
+ ETag: etag
12823
+ };
12824
+ const ifNoneMatch = c.req.header("If-None-Match");
12825
+ if (ifNoneMatch && ifNoneMatch.split(",").map((t) => t.trim()).includes(etag)) {
12826
+ return c.body(null, 304, headers);
12827
+ }
12828
+ return c.body(new Uint8Array(bytes), 200, headers);
12829
+ });
12830
+
11752
12831
  // src/auth/community-sites-routes.ts
11753
- import { Hono as Hono22 } from "hono";
12832
+ import { Hono as Hono24 } from "hono";
11754
12833
  var serverAdminAllowed = async (c) => config.AUTH_MODE === "open" || await isServerAdminUserId(c.get("userId"));
11755
12834
  var isHttpUrl = (v) => /^https?:\/\/\S+$/i.test(v);
11756
12835
  var serializeSite = (site) => ({
@@ -11762,7 +12841,7 @@ var serializeSite = (site) => ({
11762
12841
  created_at: site.created_at,
11763
12842
  updated_at: site.updated_at
11764
12843
  });
11765
- var communitySiteRoutes = new Hono22();
12844
+ var communitySiteRoutes = new Hono24();
11766
12845
  communitySiteRoutes.get("/", async (c) => {
11767
12846
  if (!await serverAdminAllowed(c))
11768
12847
  return c.json({ error: "Only the server admin can view this." }, 403);
@@ -11850,10 +12929,10 @@ communitySiteRoutes.delete("/:id", async (c) => {
11850
12929
  });
11851
12930
 
11852
12931
  // src/mcp/server-routes.ts
11853
- import { Hono as Hono23 } from "hono";
12932
+ import { Hono as Hono25 } from "hono";
11854
12933
  import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
11855
12934
  import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
11856
- import { z as z2 } from "zod";
12935
+ import { z as z3 } from "zod";
11857
12936
  var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set([
11858
12937
  "nest_create",
11859
12938
  "context_create",
@@ -11973,116 +13052,39 @@ var NEST_ARG_OPTIONAL = {
11973
13052
  description: "Target nest \u2014 id or name. Omit to search/list across ALL accessible nests."
11974
13053
  }
11975
13054
  };
13055
+ var SERVER_NEST_TOOLS = [
13056
+ // Fan-out reads — `nest` optional: omit it to span every accessible nest.
13057
+ ...FANOUT_READ_TOOLS,
13058
+ // Targeted reads and writes — `nest` required.
13059
+ "context_get",
13060
+ "context_resolve",
13061
+ "context_comments",
13062
+ "context_versions",
13063
+ "context_create",
13064
+ "context_update",
13065
+ "context_delete",
13066
+ "context_move",
13067
+ "context_submit_review"
13068
+ ];
13069
+ function withNestArg(tool) {
13070
+ const fanout = FANOUT_READ_TOOLS.has(tool.name);
13071
+ const schema = tool.inputSchema;
13072
+ return {
13073
+ name: tool.name,
13074
+ description: fanout ? `${tool.description} On this server-level endpoint, omit \`nest\` to run it across ALL accessible nests (each hit names its nest).` : `${tool.description} On this server-level endpoint, \`nest\` (id, name or slug) is required.`,
13075
+ inputSchema: {
13076
+ ...schema,
13077
+ properties: { ...fanout ? NEST_ARG_OPTIONAL : NEST_ARG, ...schema.properties },
13078
+ required: [...fanout ? [] : ["nest"], ...schema.required ?? []]
13079
+ }
13080
+ };
13081
+ }
11976
13082
  var SERVER_TOOL_DEFINITIONS = [
11977
13083
  {
11978
13084
  name: "nest_index",
11979
13085
  description: "Grounded index of every nest these credentials can read: name, id, permission, document count. Call this FIRST.",
11980
13086
  inputSchema: { type: "object", properties: {} }
11981
13087
  },
11982
- // ─── Fan-out reads (nest optional) ─────────────────────────────────────
11983
- {
11984
- name: "context_query",
11985
- description: "Run a structured selector query. Omit `nest` to query ALL accessible nests. Selector: #tag, type:X, [[Title]], scope:X, +AND, |OR, -NOT.",
11986
- inputSchema: {
11987
- type: "object",
11988
- properties: {
11989
- ...NEST_ARG_OPTIONAL,
11990
- query: { type: "string", description: "Selector query" },
11991
- hops: {
11992
- type: "number",
11993
- description: "Graph traversal depth from the matches (default: 2)"
11994
- }
11995
- },
11996
- required: ["query"]
11997
- }
11998
- },
11999
- {
12000
- name: "context_search",
12001
- description: "Full-text keyword search. Omit `nest` to search ALL accessible nests. Returns count and first `limit` hits.",
12002
- inputSchema: {
12003
- type: "object",
12004
- properties: {
12005
- ...NEST_ARG_OPTIONAL,
12006
- query: { type: "string", description: "Search terms" },
12007
- limit: {
12008
- type: "number",
12009
- description: "Max hits to return (default 20, max 200)"
12010
- }
12011
- },
12012
- required: ["query"]
12013
- }
12014
- },
12015
- {
12016
- name: "context_list",
12017
- description: "Browse contents with optional type, tag, or limit filters. Omit `nest` to list ALL accessible nests.",
12018
- inputSchema: {
12019
- type: "object",
12020
- properties: {
12021
- ...NEST_ARG_OPTIONAL,
12022
- type: { type: "string", description: "Filter by node type" },
12023
- tag: { type: "string", description: "Filter by tag" },
12024
- limit: { type: "number", description: "Max nodes to return" }
12025
- }
12026
- }
12027
- },
12028
- {
12029
- name: "context_overview",
12030
- description: "Map of nests: node count, types, tags, title+snippet per node. Omit `nest` for ALL accessible nests.",
12031
- inputSchema: {
12032
- type: "object",
12033
- properties: { ...NEST_ARG_OPTIONAL }
12034
- }
12035
- },
12036
- // ─── Targeted reads (nest required) ────────────────────────────────────
12037
- {
12038
- name: "context_get",
12039
- description: "Get the FULL content of a node in a nest by title or id.",
12040
- inputSchema: {
12041
- type: "object",
12042
- properties: {
12043
- ...NEST_ARG,
12044
- title: { type: "string", description: "Title of the node" },
12045
- id: { type: "string", description: "ID of the node" }
12046
- },
12047
- required: ["nest"]
12048
- }
12049
- },
12050
- {
12051
- name: "context_resolve",
12052
- description: "Full deterministic context resolution against one nest \u2014 selector + hops + token budget, complete node bodies.",
12053
- inputSchema: {
12054
- type: "object",
12055
- properties: {
12056
- ...NEST_ARG,
12057
- selector: { type: "string", description: "Selector query string" },
12058
- max_tokens: {
12059
- type: "number",
12060
- description: "Approximate token budget (default: 8000)"
12061
- },
12062
- hops: {
12063
- type: "number",
12064
- description: "Graph traversal depth from the matches (default: 2)"
12065
- }
12066
- },
12067
- required: ["nest", "selector"]
12068
- }
12069
- },
12070
- {
12071
- // Read-only, so it belongs on this endpoint: an agent reading a nest's
12072
- // documents across nests also needs the feedback left on them. Writing a
12073
- // comment stays on the per-nest endpoint with the other write tools.
12074
- name: "context_comments",
12075
- description: "Get the review comments/annotations on a document in a nest \u2014 anchored quotes, threaded replies, and open/resolved status. Use to pull human feedback into context before acting on a document.",
12076
- inputSchema: {
12077
- type: "object",
12078
- properties: {
12079
- nest: { type: "string", description: "Nest name or id (see nest_index)" },
12080
- title: { type: "string", description: "Title of the node" },
12081
- id: { type: "string", description: "ID of the node" }
12082
- },
12083
- required: ["nest"]
12084
- }
12085
- },
12086
13088
  {
12087
13089
  name: "nest_create",
12088
13090
  description: "Create a new nest owned by the caller. Returns the new nest's id \u2014 target it with the other tools afterwards.",
@@ -12095,93 +13097,18 @@ var SERVER_TOOL_DEFINITIONS = [
12095
13097
  required: ["name"]
12096
13098
  }
12097
13099
  },
12098
- // ─── Writes (nest required) ────────────────────────────────────────────
12099
- {
12100
- name: "context_create",
12101
- description: "Create a new document in a nest. Needs write permission. A `folder` path creates the folder implicitly.",
12102
- inputSchema: {
12103
- type: "object",
12104
- properties: {
12105
- ...NEST_ARG,
12106
- title: { type: "string", description: "Descriptive title" },
12107
- content: { type: "string", description: "Markdown content body" },
12108
- type: { type: "string", description: "Node type (default: document)" },
12109
- tags: { type: "array", items: { type: "string" }, description: "Tags" },
12110
- scope: { type: "string", description: "Visibility scope" },
12111
- folder: {
12112
- type: "string",
12113
- description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
12114
- }
12115
- },
12116
- required: ["nest", "title", "content"]
12117
- }
12118
- },
12119
- {
12120
- name: "context_update",
12121
- description: "Update an existing node by title or id. Needs write permission. Use `content` to replace, `append` to add.",
12122
- inputSchema: {
12123
- type: "object",
12124
- properties: {
12125
- ...NEST_ARG,
12126
- title: { type: "string", description: "Title of node to update" },
12127
- id: { type: "string", description: "Node id (use when title is ambiguous)" },
12128
- content: { type: "string", description: "New content (replaces)" },
12129
- append: { type: "string", description: "Content to append" },
12130
- tags: { type: "array", items: { type: "string" }, description: "Tags to add" },
12131
- status: { type: "string", description: "New status: draft|pending_review|approved|published" },
12132
- changeNote: { type: "string", description: "Why this changed \u2014 recorded in history" }
12133
- },
12134
- required: ["nest"]
12135
- }
12136
- },
12137
- {
12138
- name: "context_delete",
12139
- description: "Delete a node by title or id. Needs write permission. Irreversible.",
12140
- inputSchema: {
12141
- type: "object",
12142
- properties: {
12143
- ...NEST_ARG,
12144
- title: { type: "string", description: "Title of node to delete" },
12145
- id: { type: "string", description: "Node id (use when title is ambiguous)" }
12146
- },
12147
- required: ["nest"]
12148
- }
12149
- },
12150
- {
12151
- name: "context_move",
12152
- description: "Move a node to a different folder. The id changes; history and links follow. Needs write permission.",
12153
- inputSchema: {
12154
- type: "object",
12155
- properties: {
12156
- ...NEST_ARG,
12157
- title: { type: "string", description: "Title of node to move" },
12158
- id: { type: "string", description: "Node id (use when title is ambiguous)" },
12159
- folder: { type: "string", description: 'Destination folder (e.g. "gtm/deals"); "" for root' }
12160
- },
12161
- required: ["nest", "folder"]
12162
- }
12163
- },
12164
- {
12165
- name: "context_submit_review",
12166
- description: "Submit a node for steward review. Stewards will be notified. Use on governed nests.",
12167
- inputSchema: {
12168
- type: "object",
12169
- properties: {
12170
- ...NEST_ARG,
12171
- title: { type: "string", description: "Title of node to submit" },
12172
- id: { type: "string", description: "Node id (use when title is ambiguous)" },
12173
- note: { type: "string", description: "Note for the reviewer" },
12174
- priority: { type: "string", description: "Priority: low, normal, high, urgent" }
12175
- },
12176
- required: ["nest"]
12177
- }
12178
- }
13100
+ ...SERVER_NEST_TOOLS.map((name) => {
13101
+ const tool = TOOL_DEFINITIONS.find((t) => t.name === name);
13102
+ if (!tool) throw new Error(`server-level tool ${name} has no per-nest definition`);
13103
+ return withNestArg(tool);
13104
+ })
12179
13105
  ];
12180
- async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
13106
+ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl, client) {
12181
13107
  if (toolName === "nest_index") {
12182
13108
  const nests = await buildNestIndex(userId, nestScope);
12183
- if (!nests.length) return "No nests accessible with these credentials.";
12184
- return renderNestIndexMarkdown(nests, baseUrl);
13109
+ const data = { nests };
13110
+ if (!nests.length) return { text: "No nests accessible with these credentials.", data };
13111
+ return { text: renderNestIndexMarkdown(nests, baseUrl), data };
12185
13112
  }
12186
13113
  if (SERVER_WRITE_TOOLS.has(toolName)) {
12187
13114
  if (isSuspended()) {
@@ -12204,7 +13131,7 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
12204
13131
  );
12205
13132
  return `Created nest "${nest2.name}" (id: ${nest2.id}). Add documents with context_create, retrieve with the other tools.`;
12206
13133
  } catch (err) {
12207
- if (err instanceof ValidationError) return err.message;
13134
+ if (err instanceof ValidationError || err instanceof ForbiddenError) return err.message;
12208
13135
  throw err;
12209
13136
  }
12210
13137
  }
@@ -12213,9 +13140,6 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
12213
13140
  const nests = await buildNestIndex(userId, nestScope);
12214
13141
  if (!nests.length) return "No nests accessible with these credentials.";
12215
13142
  const nonEmpty = nests.filter((n) => (n.document_count ?? 0) > 0);
12216
- if (!nonEmpty.length) {
12217
- return "All accessible nests are empty. Use context_create to add documents first.";
12218
- }
12219
13143
  const results = await Promise.allSettled(
12220
13144
  nonEmpty.map(async (n) => {
12221
13145
  const engine2 = await engineCache.get(n.id);
@@ -12226,25 +13150,66 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
12226
13150
  nestId: n.id,
12227
13151
  userId,
12228
13152
  userEmail,
12229
- baseUrl
13153
+ baseUrl,
13154
+ // Same attribution the single-nest branch below passes (§9.4). A
13155
+ // fan-out read is still one call per nest on the trace, and each row
13156
+ // has to name the caller — without this the derived agent/session is
13157
+ // dropped and only an explicit `client` in the args survives.
13158
+ client
12230
13159
  });
12231
13160
  return { nest: n, result };
12232
13161
  })
12233
13162
  );
12234
13163
  const parts = [];
13164
+ let firstError;
13165
+ let firstRejection;
13166
+ const tag = (n, items) => Array.isArray(items) ? items.map((it) => ({ ...it, nest: { id: n.id, name: n.name } })) : [];
13167
+ const perNestDocs = [];
13168
+ const perNestHits = [];
13169
+ let count = 0;
13170
+ let nodesTraversed = 0;
13171
+ let hopsUsed = 0;
13172
+ let mode;
12235
13173
  for (const r of results) {
12236
- if (r.status === "fulfilled") {
12237
- const { nest: n, result } = r.value;
12238
- const text = typeof result === "string" ? result : result.text;
12239
- parts.push(`## ${n.name} (${n.id})
13174
+ if (r.status !== "fulfilled") {
13175
+ firstRejection ??= r;
13176
+ continue;
13177
+ }
13178
+ const { nest: n, result } = r.value;
13179
+ if (typeof result !== "string" && result.isError) {
13180
+ firstError ??= result;
13181
+ continue;
13182
+ }
13183
+ parts.push(`## ${n.name} (${n.id})
12240
13184
 
12241
- ${text}`);
13185
+ ${typeof result === "string" ? result : result.text}`);
13186
+ const d = typeof result === "string" ? void 0 : result.data;
13187
+ if (!d) continue;
13188
+ perNestDocs.push(tag(n, d.documents));
13189
+ perNestHits.push(tag(n, d.results));
13190
+ count += d.count ?? 0;
13191
+ nodesTraversed += d.traversal?.nodes_traversed ?? 0;
13192
+ hopsUsed = Math.max(hopsUsed, d.traversal?.hops_used ?? 0);
13193
+ mode ??= d.traversal?.mode;
13194
+ }
13195
+ const interleave = (lists) => {
13196
+ const out = [];
13197
+ for (let i = 0; lists.some((l) => i < l.length); i++) {
13198
+ for (const l of lists) if (i < l.length) out.push(l[i]);
12242
13199
  }
13200
+ return out;
13201
+ };
13202
+ if (!parts.length && firstError) return firstError;
13203
+ if (!parts.length && firstRejection) throw firstRejection.reason;
13204
+ const documents = interleave(perNestDocs);
13205
+ const listLimit = typeof args.limit === "number" && args.limit >= 1 ? Math.floor(args.limit) : 50;
13206
+ const data = toolName === "context_search" ? { results: interleave(perNestHits).slice(0, normalizeSearchLimit(args.limit)), count } : toolName === "context_query" ? { documents, traversal: { mode: mode ?? "selector", hops_used: hopsUsed, nodes_traversed: nodesTraversed } } : { documents: documents.slice(0, listLimit) };
13207
+ const reply = (text) => toolName === "context_overview" ? text : { text, data };
13208
+ if (!nonEmpty.length) {
13209
+ return reply("All accessible nests are empty. Use context_create to add documents first.");
12243
13210
  }
12244
- if (!parts.length) {
12245
- return "No results found across any accessible nests.";
12246
- }
12247
- return parts.join("\n\n---\n\n");
13211
+ if (!parts.length) return reply("No results found across any accessible nests.");
13212
+ return reply(parts.join("\n\n---\n\n"));
12248
13213
  }
12249
13214
  const inaccessible = `Nest not found or not accessible: "${nestArg}". Use nest_index to see what these credentials can read.`;
12250
13215
  if (!nestArg) {
@@ -12262,10 +13227,11 @@ ${text}`);
12262
13227
  nestId: nest.id,
12263
13228
  userId,
12264
13229
  userEmail,
12265
- baseUrl
13230
+ baseUrl,
13231
+ client
12266
13232
  });
12267
13233
  }
12268
- function createServerMcp(userId, userEmail, nestScope, baseUrl) {
13234
+ function createServerMcp(userId, userEmail, nestScope, baseUrl, c) {
12269
13235
  const server = new McpServer2(
12270
13236
  { name: "contextnest-server", version: "1.0.0" },
12271
13237
  { capabilities: { tools: {} } }
@@ -12275,14 +13241,15 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
12275
13241
  const required = tool.inputSchema.required || [];
12276
13242
  const shape = {};
12277
13243
  for (const [key, def] of Object.entries(props)) {
12278
- let field;
12279
- if (def.type === "string") field = z2.string();
12280
- else if (def.type === "number") field = z2.number();
12281
- else if (def.type === "array") field = z2.array(z2.string());
12282
- else field = z2.any();
12283
- if (!required.includes(key)) field = field.optional();
12284
- shape[key] = field;
12285
- }
13244
+ let field2;
13245
+ if (def.type === "string") field2 = z3.string();
13246
+ else if (def.type === "number") field2 = z3.number();
13247
+ else if (def.type === "array") field2 = z3.array(z3.string());
13248
+ else field2 = z3.any();
13249
+ if (!required.includes(key)) field2 = field2.optional();
13250
+ shape[key] = field2;
13251
+ }
13252
+ Object.assign(shape, clientToolField);
12286
13253
  server.tool(tool.name, tool.description, shape, async (args) => {
12287
13254
  const out = await handleServerToolCall(
12288
13255
  tool.name,
@@ -12290,14 +13257,15 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
12290
13257
  userId,
12291
13258
  userEmail,
12292
13259
  nestScope,
12293
- baseUrl
13260
+ baseUrl,
13261
+ attributeRequest(c, void 0, connectionClient(server))
12294
13262
  );
12295
13263
  return toCallToolResult(out);
12296
13264
  });
12297
13265
  }
12298
13266
  return server;
12299
13267
  }
12300
- var serverMcpRoutes = new Hono23();
13268
+ var serverMcpRoutes = new Hono25();
12301
13269
  serverMcpRoutes.all("/", async (c) => {
12302
13270
  const userId = c.get("userId");
12303
13271
  const userEmail = await getUserEmail3(userId);
@@ -12305,7 +13273,8 @@ serverMcpRoutes.all("/", async (c) => {
12305
13273
  userId,
12306
13274
  userEmail,
12307
13275
  c.get("nestScope"),
12308
- requestBaseUrl(c.req.url)
13276
+ requestBaseUrl(c.req.url),
13277
+ c
12309
13278
  );
12310
13279
  const transport = new WebStandardStreamableHTTPServerTransport2({
12311
13280
  sessionIdGenerator: void 0,
@@ -12319,7 +13288,7 @@ serverMcpRoutes.all("/", async (c) => {
12319
13288
  await server.server.close();
12320
13289
  }
12321
13290
  });
12322
- var nestIndexRoutes = new Hono23();
13291
+ var nestIndexRoutes = new Hono25();
12323
13292
  nestIndexRoutes.get("/", async (c) => {
12324
13293
  const userId = c.get("userId");
12325
13294
  const nests = await buildNestIndex(userId, c.get("nestScope"));
@@ -12413,6 +13382,20 @@ Write endpoints (need write permission):
12413
13382
  - \`GET ${B}/me/drafts\` \u2014 your documents never submitted for review
12414
13383
  - \`GET ${B}/me/notifications\` \u2014 watcher and review notifications
12415
13384
 
13385
+ ## Say who you are: \`client\`
13386
+
13387
+ Every write body and every MCP tool call takes an optional
13388
+ \`client\` object \u2014 \`{ "agent": "claude-code", "session_id": "s-9f2" }\`, plus any
13389
+ custom scalar keys. A write that seals a version records it on that version
13390
+ (\`GET /nodes/<id>/versions\` and \`context_versions\` serve it back); every call
13391
+ records it on this server's activity trace, which is how a READ gets attributed.
13392
+
13393
+ Send it. Nothing requires it and no call is refused for omitting it, but a vault
13394
+ serving several agents cannot otherwise tell which of you wrote what. It is a
13395
+ label, never an identity claim: it is not authenticated, grants nothing, and is
13396
+ never part of a hash. Bounded \u2014 scalar values up to 512 chars, at most 16 custom
13397
+ keys beyond \`agent\` and \`session_id\`.
13398
+
12416
13399
  ## MCP (Model Context Protocol)
12417
13400
 
12418
13401
  Streamable HTTP, stateless. Two endpoints:
@@ -12421,7 +13404,7 @@ Streamable HTTP, stateless. Two endpoints:
12421
13404
  Tools: \`nest_index\` (call first), then:
12422
13405
  - Fan-out reads (omit \`nest\` to search/list ALL nests): \`context_search\`,
12423
13406
  \`context_list\`, \`context_query\`, \`context_overview\`
12424
- - Targeted reads (\`nest\` required): \`context_get\`, \`context_resolve\`, \`context_comments\`
13407
+ - Targeted reads (\`nest\` required): \`context_get\`, \`context_resolve\`, \`context_comments\`, \`context_versions\`
12425
13408
  - Writes (\`nest\` required): \`nest_create\`, \`context_create\`, \`context_update\`,
12426
13409
  \`context_delete\`, \`context_move\`, \`context_submit_review\`
12427
13410
  - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, the full toolset. Same names as the
@@ -12458,9 +13441,11 @@ on the command line.
12458
13441
  export CONTEXTNEST_API_KEY=<api-key>
12459
13442
  # every nest these credentials can read, through the server-level endpoint
12460
13443
  ctx vault add contextnest --url ${B}/mcp --bearer-env CONTEXTNEST_API_KEY
12461
- # or one nest
13444
+ ctx vault list # lists contextnest/<nest> for each nest
13445
+ ctx search "pricing" --vault contextnest # reads span every nest
13446
+ ctx update nodes/x --tags a,b --vault contextnest/<nest> # a write names one nest
13447
+ # or pin one nest by its own endpoint
12462
13448
  ctx vault add mynest --url ${B}/nests/<nest-id>/mcp --bearer-env CONTEXTNEST_API_KEY
12463
- ctx query "#gtm" --vault mynest
12464
13449
  \`\`\`
12465
13450
 
12466
13451
  Servers in "open" auth mode: drop \`--bearer-env\`. Pushing a local folder up
@@ -12479,14 +13464,14 @@ is \`ctx push --server ${B} --nest <nest-id>\` with the same env var set.
12479
13464
  through steward review before they become AI-visible.
12480
13465
  `;
12481
13466
  }
12482
- var llmsTxtRoutes = new Hono23();
13467
+ var llmsTxtRoutes = new Hono25();
12483
13468
  llmsTxtRoutes.get("/", (c) => {
12484
13469
  const md = renderLlmsTxt(requestBaseUrl(c.req.url));
12485
13470
  return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
12486
13471
  });
12487
13472
 
12488
13473
  // src/governance/routes.ts
12489
- import { Hono as Hono24 } from "hono";
13474
+ import { Hono as Hono26 } from "hono";
12490
13475
 
12491
13476
  // src/governance/feed-service.ts
12492
13477
  var SNIPPET_CHARS = 200;
@@ -12617,7 +13602,7 @@ async function getNestFeed(params) {
12617
13602
  }
12618
13603
 
12619
13604
  // src/governance/comment-service.ts
12620
- import { v4 as uuid18 } from "uuid";
13605
+ import { v4 as uuid17 } from "uuid";
12621
13606
  async function createComment(params) {
12622
13607
  const db = getDb();
12623
13608
  const body = (params.body ?? "").trim();
@@ -12633,7 +13618,7 @@ async function createComment(params) {
12633
13618
  throw new Error("Parent comment not found on this node");
12634
13619
  }
12635
13620
  }
12636
- const id = uuid18();
13621
+ const id = uuid17();
12637
13622
  await db.run(
12638
13623
  `INSERT INTO comments
12639
13624
  (id, nest_id, node_id, version, anchor_start, anchor_end, anchor_text,
@@ -12833,7 +13818,7 @@ function rowToComment(row) {
12833
13818
 
12834
13819
  // src/governance/stewards-parser.ts
12835
13820
  import { readFileSync as readFileSync3, existsSync } from "fs";
12836
- import { join as join5 } from "path";
13821
+ import { join as join7 } from "path";
12837
13822
  function parseStewardsYaml(content) {
12838
13823
  const result = { version: 1 };
12839
13824
  const lines = content.split("\n");
@@ -12899,9 +13884,9 @@ function parseEntry(str) {
12899
13884
  function loadStewardsConfig(nestId) {
12900
13885
  const nestPath = resolveNestPath(nestId);
12901
13886
  const candidates = [
12902
- join5(nestPath, "stewards.yaml"),
12903
- join5(nestPath, "stewards.yml"),
12904
- join5(nestPath, ".context", "stewards.yaml")
13887
+ join7(nestPath, "stewards.yaml"),
13888
+ join7(nestPath, "stewards.yml"),
13889
+ join7(nestPath, ".context", "stewards.yaml")
12905
13890
  ];
12906
13891
  for (const candidatePath of candidates) {
12907
13892
  if (existsSync(candidatePath)) {
@@ -12918,7 +13903,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
12918
13903
  if (Number.isNaN(n) || n < 1) return def;
12919
13904
  return Math.min(n, max);
12920
13905
  }
12921
- var governanceRoutes = new Hono24();
13906
+ var governanceRoutes = new Hono26();
12922
13907
  governanceRoutes.get("/stewards", async (c) => {
12923
13908
  const nestId = c.req.param("nestId");
12924
13909
  const scope = c.req.query("scope");
@@ -13058,6 +14043,7 @@ governanceRoutes.post("/review-queue/bulk", async (c) => {
13058
14043
  }
13059
14044
  const userEmail = await getUserEmail4(c);
13060
14045
  const baseUrl = requestBaseUrl(c.req.url);
14046
+ const client = attributeRequest(c, body.client);
13061
14047
  const results = [];
13062
14048
  for (const nodeId of nodeIds) {
13063
14049
  try {
@@ -13068,7 +14054,8 @@ governanceRoutes.post("/review-queue/bulk", async (c) => {
13068
14054
  approvedBy: userEmail,
13069
14055
  note: body.note,
13070
14056
  override: false,
13071
- baseUrl
14057
+ baseUrl,
14058
+ client
13072
14059
  });
13073
14060
  } else {
13074
14061
  await reject({
@@ -13160,7 +14147,7 @@ governanceRoutes.post("/request-deletion", async (c) => {
13160
14147
  if (body.targetType === "folder") {
13161
14148
  const { storage } = await engineCache.get(nestId);
13162
14149
  const docs = await storage.discoverDocuments();
13163
- if (!docs.some((d) => isIdUnderFolder(d.id, target))) {
14150
+ if (!docs.some((d) => isIdUnderFolder(d.id, target)) && !await vaultFolderExists(nestId, target)) {
13164
14151
  return c.json({ error: `Folder not found: ${target}` }, 404);
13165
14152
  }
13166
14153
  }
@@ -13286,7 +14273,7 @@ governanceRoutes.get("/activity", async (c) => {
13286
14273
  const activity = await getActivity({ nestId, limit });
13287
14274
  return c.json({ activity });
13288
14275
  });
13289
- var governanceNodeRoutes = new Hono24();
14276
+ var governanceNodeRoutes = new Hono26();
13290
14277
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
13291
14278
  const nestId = c.req.param("nestId");
13292
14279
  const nodeId = c.req.param("nodeId");
@@ -13492,7 +14479,10 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
13492
14479
  requestedBy: userEmail,
13493
14480
  note: body.note,
13494
14481
  priority: body.priority,
13495
- baseUrl: requestBaseUrl(c.req.url)
14482
+ baseUrl: requestBaseUrl(c.req.url),
14483
+ // Submitting seals the draft into the engine's append-only chain, so the
14484
+ // block lands on that entry as well as on this request's trace row.
14485
+ client: attributeRequest(c, body.client)
13496
14486
  });
13497
14487
  } catch (err) {
13498
14488
  const msg = err instanceof Error ? err.message : String(err);
@@ -13626,7 +14616,8 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
13626
14616
  approvedBy: userEmail,
13627
14617
  note: body.note,
13628
14618
  override: body.override && isAdmin,
13629
- baseUrl: requestBaseUrl(c.req.url)
14619
+ baseUrl: requestBaseUrl(c.req.url),
14620
+ client: attributeRequest(c, body.client)
13630
14621
  });
13631
14622
  return c.json({ review: request });
13632
14623
  } catch (err) {
@@ -13641,6 +14632,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
13641
14632
  throw new ValidationError("Rejection note is required");
13642
14633
  }
13643
14634
  const userEmail = await getUserEmail4(c);
14635
+ attributeRequest(c, body.client);
13644
14636
  try {
13645
14637
  const request = await reject({
13646
14638
  nestId,
@@ -13753,26 +14745,52 @@ function posthogKeyError(value) {
13753
14745
  return null;
13754
14746
  }
13755
14747
 
14748
+ // src/shared/public-base-url.ts
14749
+ var UNSAFE = /["'`\\$;|&<>(){}\s]/;
14750
+ function publicBaseUrlError(value) {
14751
+ const v = value.trim();
14752
+ if (!v) return null;
14753
+ let parsed;
14754
+ try {
14755
+ parsed = new URL(v);
14756
+ } catch {
14757
+ return "Public base URL must be a full http(s) URL, e.g. https://nest.example.com";
14758
+ }
14759
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
14760
+ return "Public base URL must use http or https, e.g. https://nest.example.com";
14761
+ if (!parsed.hostname)
14762
+ return "Public base URL must include a hostname, e.g. https://nest.example.com";
14763
+ if (UNSAFE.test(v))
14764
+ return "Public base URL must not contain spaces, quotes or shell characters (\" ' ` \\ $ ; | & < > ( ) { }) \u2014 it is pasted into terminal commands and config files.";
14765
+ return null;
14766
+ }
14767
+
13756
14768
  // src/app.ts
13757
14769
  import { serveStatic } from "@hono/node-server/serve-static";
13758
14770
  import { fileURLToPath } from "url";
13759
- import { dirname as dirname2, join as join6, relative as relative2 } from "path";
14771
+ import { dirname as dirname2, join as join8, relative as relative2 } from "path";
13760
14772
  import { existsSync as existsSync2, readFileSync as readFileSync4 } from "fs";
13761
14773
  var HERE = dirname2(fileURLToPath(import.meta.url));
13762
14774
  var SERVICE_VERSION = (() => {
13763
14775
  try {
13764
- return JSON.parse(readFileSync4(join6(HERE, "..", "package.json"), "utf8")).version;
14776
+ return JSON.parse(readFileSync4(join8(HERE, "..", "package.json"), "utf8")).version;
13765
14777
  } catch {
13766
14778
  return "unknown";
13767
14779
  }
13768
14780
  })();
13769
14781
  var UI_DIR_CANDIDATES = [
13770
- join6(HERE, "web3"),
13771
- join6(process.cwd(), "dist", "web3")
14782
+ join8(HERE, "web3"),
14783
+ join8(process.cwd(), "dist", "web3")
13772
14784
  ];
13773
14785
  var UI_DIR_ABS = UI_DIR_CANDIDATES.find((p) => existsSync2(p)) || UI_DIR_CANDIDATES[0];
13774
14786
  var UI_DIR_REL = relative2(process.cwd(), UI_DIR_ABS) || ".";
13775
- var openModeMiddleware = createMiddleware2(async (c, next) => {
14787
+ var isDocumentNavigation = (c) => c.req.header("Sec-Fetch-Mode") === "navigate" || (c.req.header("Accept") || "").includes("text/html");
14788
+ var serveIndexShell = serveStatic({
14789
+ root: UI_DIR_REL,
14790
+ path: "index.html",
14791
+ onFound: (_p, c) => c.header("Cache-Control", "no-cache")
14792
+ });
14793
+ var openModeMiddleware = createMiddleware3(async (c, next) => {
13776
14794
  const anonId = await ensureAnonymousUser();
13777
14795
  c.set("userId", anonId);
13778
14796
  c.set("nestScope", null);
@@ -13784,7 +14802,7 @@ function isPublicReadEligiblePath(method, path) {
13784
14802
  if (/^\/nests\/[^/]+\/stewards/.test(path)) return false;
13785
14803
  return !/\/(collaborators|visibility|settings|mcp)/.test(path);
13786
14804
  }
13787
- var flexAuthMiddleware = createMiddleware2(async (c, next) => {
14805
+ var flexAuthMiddleware = createMiddleware3(async (c, next) => {
13788
14806
  const hasBearer = c.req.header("Authorization")?.startsWith("Bearer cnst_");
13789
14807
  const hasCookie = !!c.req.header("Cookie")?.includes("cnst_session=");
13790
14808
  if (hasBearer || hasCookie) {
@@ -13802,12 +14820,15 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
13802
14820
  c.set("nestScope", null);
13803
14821
  return next();
13804
14822
  }
13805
- return c.json({ error: "Missing or invalid credentials" }, 401);
14823
+ return c.json(
14824
+ { error: "Authentication required \u2014 sign in or provide an API key.", code: "missing_credentials" },
14825
+ 401
14826
+ );
13806
14827
  });
13807
14828
  function nestPathParts(path) {
13808
14829
  return path.replace(/^\/nests\//, "").split("/").filter(Boolean);
13809
14830
  }
13810
- var flexAuthWithPromptowlTicket = createMiddleware2(
14831
+ var flexAuthWithPromptowlTicket = createMiddleware3(
13811
14832
  async (c, next) => {
13812
14833
  const header = c.req.header("Authorization") || "";
13813
14834
  const token = header.startsWith("Bearer ") ? header.slice("Bearer ".length) : "";
@@ -13820,14 +14841,24 @@ var flexAuthWithPromptowlTicket = createMiddleware2(
13820
14841
  return flexAuthMiddleware(c, next);
13821
14842
  }
13822
14843
  let claims = null;
14844
+ let expired = false;
13823
14845
  for (const secret of secrets) {
13824
14846
  try {
13825
14847
  claims = verifySsoTicket(token, secret);
13826
14848
  break;
13827
- } catch {
14849
+ } catch (err) {
14850
+ if (err instanceof SsoTokenError && err.message === "Ticket expired") {
14851
+ expired = true;
14852
+ }
13828
14853
  }
13829
14854
  }
13830
14855
  if (!claims) {
14856
+ if (expired) {
14857
+ return c.json(
14858
+ { error: "Service ticket expired \u2014 reconnect to refresh.", code: "ticket_expired" },
14859
+ 401
14860
+ );
14861
+ }
13831
14862
  return c.json({ error: "Invalid service ticket" }, 401);
13832
14863
  }
13833
14864
  if (claims.scope !== "mcp" || claims.jti) {
@@ -13874,7 +14905,7 @@ async function canApproveInNest(nestId, email) {
13874
14905
  return ok;
13875
14906
  }
13876
14907
  function createApp() {
13877
- const app = new Hono25({ router: new LinearRouter() });
14908
+ const app = new Hono27({ router: new LinearRouter() });
13878
14909
  const corsOrigins = config.CORS_ORIGINS;
13879
14910
  app.use(
13880
14911
  "*",
@@ -13892,7 +14923,8 @@ function createApp() {
13892
14923
  const lenStr = c.req.header("Content-Length");
13893
14924
  if (lenStr) {
13894
14925
  const len = parseInt(lenStr, 10);
13895
- const cap = c.req.method === "POST" && ASSET_UPLOAD_PATH.test(c.req.path) ? Math.max(config.MAX_BODY_BYTES, ASSET_MAX_REQUEST_BYTES) : config.MAX_BODY_BYTES;
14926
+ const isPost = c.req.method === "POST";
14927
+ const cap = isPost && ASSET_UPLOAD_PATH.test(c.req.path) ? Math.max(config.MAX_BODY_BYTES, ASSET_MAX_REQUEST_BYTES) : isPost && PDF_UPLOAD_PATH.test(c.req.path) ? Math.max(config.MAX_BODY_BYTES, pdfMaxRequestBytes()) : config.MAX_BODY_BYTES;
13896
14928
  if (Number.isFinite(len) && len > cap) {
13897
14929
  return c.json(
13898
14930
  {
@@ -13917,9 +14949,13 @@ function createApp() {
13917
14949
  nestId: /^\/nests\/([^/]+)/.exec(path)?.[1] ?? null,
13918
14950
  userId: c.get("userId") ?? null,
13919
14951
  status: c.res.status,
13920
- durationMs: Date.now() - started
14952
+ durationMs: Date.now() - started,
14953
+ // Set by whichever handler validated the request's `client` block
14954
+ // (§9.4). Absent on a request that sent none.
14955
+ client: c.get("clientMetadata")
13921
14956
  });
13922
14957
  });
14958
+ app.use("*", nestScopedKeyGuard);
13923
14959
  app.get("/health", (c) => {
13924
14960
  const oidcReady = isOidcReady();
13925
14961
  return c.json({
@@ -13934,6 +14970,9 @@ function createApp() {
13934
14970
  // Presence only: the publish dialog hides the email/invite gates when
13935
14971
  // this server cannot send mail, because their only way in is a mailed link.
13936
14972
  email_configured: isEmailConfigured(),
14973
+ // Who may create nests (admins | everyone) — the Nests page hides the
14974
+ // create buttons from non-admins when it's admins-only.
14975
+ nest_creation: config.NEST_CREATION,
13937
14976
  // Product analytics for the SPA. The PostHog project key is a public
13938
14977
  // client key, but it only ships while usage telemetry is on — one switch
13939
14978
  // covers everything this server sends outward.
@@ -13962,6 +15001,11 @@ function createApp() {
13962
15001
  // API would refuse it — existing nodes of the type stay readable.
13963
15002
  type_artifact_enabled: config.TYPE_ARTIFACT_ENABLED,
13964
15003
  type_table_enabled: config.TYPE_TABLE_ENABLED,
15004
+ // This server hosts several organizations (PromptOwl-hosted) rather than
15005
+ // one. The visibility picker words 'Organization' and 'Public' by it and
15006
+ // confirms before 'org'; the server enforces the same in
15007
+ // PATCH /nests/:id/visibility.
15008
+ shared_deployment: config.SHARED_DEPLOYMENT,
13965
15009
  // Sub-agent runs gate its own recursion surface separately (env-only,
13966
15010
  // must clear a security review — NOT a UI toggle). Exposed read-only so
13967
15011
  // the Runs page shows the "spawn sub-agent" option only when it's live.
@@ -14084,7 +15128,9 @@ function createApp() {
14084
15128
  oidc_allowed_domains: config.OIDC_ALLOWED_DOMAINS.join(", "),
14085
15129
  oidc_auto_provision: config.OIDC_AUTO_PROVISION,
14086
15130
  oidc_department_tagging: config.OIDC_DEPARTMENT_TAGGING,
15131
+ nest_creation: config.NEST_CREATION,
14087
15132
  people_suggest_all_users: config.PEOPLE_SUGGEST_ALL_USERS,
15133
+ shared_deployment: config.SHARED_DEPLOYMENT,
14088
15134
  // Agent SSO / token-exchange. MCP_SIGNING_SECRET is WRITE-ONLY — only its
14089
15135
  // presence is reported, never the value (same discipline as the OIDC secret).
14090
15136
  sso_token_exchange_enabled: config.SSO_TOKEN_EXCHANGE_ENABLED,
@@ -14104,7 +15150,12 @@ function createApp() {
14104
15150
  anthropic_api_key_masked: process.env.ANTHROPIC_API_KEY ? `\u2022\u2022\u2022\u2022${process.env.ANTHROPIC_API_KEY.slice(-4)}` : null,
14105
15151
  slack_webhook_url: config.SLACK_WEBHOOK_URL ?? "",
14106
15152
  msteams_webhook_url: config.MSTEAMS_WEBHOOK_URL ?? "",
15153
+ email_provider: config.EMAIL_PROVIDER,
14107
15154
  smtp_url: config.SMTP_URL ?? "",
15155
+ ses_region: config.SES_REGION ?? "",
15156
+ ses_access_key_id: config.SES_ACCESS_KEY_ID ?? "",
15157
+ // Write-only, like the OIDC secret — presence only, never the value.
15158
+ ses_secret_access_key_set: !!config.SES_SECRET_ACCESS_KEY,
14108
15159
  notify_email_from: config.NOTIFY_EMAIL_FROM ?? "",
14109
15160
  notify_email_to: config.NOTIFY_EMAIL_TO ?? ""
14110
15161
  });
@@ -14137,8 +15188,8 @@ function createApp() {
14137
15188
  return c.json({ error: "Invalid JSON body" }, 400);
14138
15189
  }
14139
15190
  const fieldErrors = {};
14140
- const addError = (field, message) => {
14141
- fieldErrors[field] = message;
15191
+ const addError = (field2, message) => {
15192
+ fieldErrors[field2] = message;
14142
15193
  };
14143
15194
  const pending = [];
14144
15195
  if ("promptowl_sign_in_gate" in body) {
@@ -14159,7 +15210,9 @@ function createApp() {
14159
15210
  }
14160
15211
  if ("public_base_url" in body) {
14161
15212
  const v = String(body.public_base_url ?? "").trim().replace(/\/$/, "");
14162
- pending.push({ name: "PUBLIC_BASE_URL", value: v || null });
15213
+ const err = v ? publicBaseUrlError(v) : null;
15214
+ if (err) addError("public_base_url", err);
15215
+ else pending.push({ name: "PUBLIC_BASE_URL", value: v || null });
14163
15216
  }
14164
15217
  if ("telemetry_enabled" in body) {
14165
15218
  pending.push({ name: "TELEMETRY_ENABLED", value: body.telemetry_enabled ? "true" : "false" });
@@ -14307,12 +15360,24 @@ function createApp() {
14307
15360
  value: body.oidc_department_tagging ? "true" : null
14308
15361
  });
14309
15362
  }
15363
+ if ("nest_creation" in body) {
15364
+ const v = String(body.nest_creation ?? "").trim().toLowerCase();
15365
+ if (v !== "admins" && v !== "everyone")
15366
+ addError("nest_creation", "Nest creation must be admins or everyone.");
15367
+ else pending.push({ name: "NEST_CREATION", value: v });
15368
+ }
14310
15369
  if ("people_suggest_all_users" in body) {
14311
15370
  pending.push({
14312
15371
  name: "PEOPLE_SUGGEST_ALL_USERS",
14313
15372
  value: body.people_suggest_all_users ? "true" : null
14314
15373
  });
14315
15374
  }
15375
+ if ("shared_deployment" in body) {
15376
+ pending.push({
15377
+ name: "SHARED_DEPLOYMENT",
15378
+ value: body.shared_deployment ? "true" : null
15379
+ });
15380
+ }
14316
15381
  if ("sso_token_exchange_enabled" in body) {
14317
15382
  pending.push({
14318
15383
  name: "SSO_TOKEN_EXCHANGE_ENABLED",
@@ -14363,13 +15428,13 @@ function createApp() {
14363
15428
  const effAllowedDomains = "oidc_allowed_domains" in body ? String(body.oidc_allowed_domains ?? "").split(",").map((d) => d.trim()).filter(Boolean) : config.OIDC_ALLOWED_DOMAINS;
14364
15429
  const effAutoProvision = "oidc_auto_provision" in body ? !!body.oidc_auto_provision : config.OIDC_AUTO_PROVISION;
14365
15430
  if (effAutoProvision && effAllowedDomains.length === 0) {
14366
- for (const field of [
15431
+ for (const field2 of [
14367
15432
  "ms_token_exchange_enabled",
14368
15433
  "google_token_exchange_enabled"
14369
15434
  ]) {
14370
- if (body[field])
15435
+ if (body[field2])
14371
15436
  addError(
14372
- field,
15437
+ field2,
14373
15438
  "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."
14374
15439
  );
14375
15440
  }
@@ -14410,6 +15475,50 @@ function createApp() {
14410
15475
  addError("smtp_url", "SMTP connection URL must start with smtp:// or smtps:// (or leave it empty to turn email notifications off).");
14411
15476
  else pending.push({ name: "SMTP_URL", value: v || null });
14412
15477
  }
15478
+ if ("email_provider" in body) {
15479
+ const v = String(body.email_provider ?? "").trim().toLowerCase();
15480
+ if (v !== "smtp" && v !== "ses")
15481
+ addError("email_provider", "Email provider must be SMTP or AWS SES.");
15482
+ else pending.push({ name: "EMAIL_PROVIDER", value: v });
15483
+ }
15484
+ if ("ses_region" in body) {
15485
+ const v = String(body.ses_region ?? "").trim();
15486
+ if (v && !/^[a-z]{2}(-[a-z]+)+-\d+$/.test(v))
15487
+ addError("ses_region", "AWS region must look like us-east-1.");
15488
+ else pending.push({ name: "SES_REGION", value: v || null });
15489
+ }
15490
+ if ("ses_access_key_id" in body) {
15491
+ const v = String(body.ses_access_key_id ?? "").trim();
15492
+ pending.push({ name: "SES_ACCESS_KEY_ID", value: v || null });
15493
+ }
15494
+ if ("ses_secret_access_key" in body) {
15495
+ const v = body.ses_secret_access_key;
15496
+ if (v !== null && typeof v !== "string")
15497
+ addError("ses_secret_access_key", "AWS secret access key must be a string (or empty to clear it).");
15498
+ else pending.push({ name: "SES_SECRET_ACCESS_KEY", value: v?.trim() || null });
15499
+ }
15500
+ const provider = String(body.email_provider ?? "").trim().toLowerCase();
15501
+ if (provider === "smtp" || provider === "ses") {
15502
+ const stale = provider === "ses" ? ["SMTP_URL"] : ["SES_REGION", "SES_ACCESS_KEY_ID", "SES_SECRET_ACCESS_KEY"];
15503
+ for (const name of stale) {
15504
+ const i = pending.findIndex((p) => p.name === name);
15505
+ if (i >= 0) pending.splice(i, 1);
15506
+ pending.push({ name, value: null });
15507
+ }
15508
+ }
15509
+ if ("ses_access_key_id" in body || "ses_secret_access_key" in body) {
15510
+ const next = (name, current) => {
15511
+ const p = pending.find((x) => x.name === name);
15512
+ return p ? p.value : current;
15513
+ };
15514
+ const hasId = !!next("SES_ACCESS_KEY_ID", config.SES_ACCESS_KEY_ID);
15515
+ const hasSecret = !!next("SES_SECRET_ACCESS_KEY", config.SES_SECRET_ACCESS_KEY);
15516
+ if (hasId !== hasSecret)
15517
+ addError(
15518
+ "ses_access_key_id",
15519
+ "Set both the AWS access key ID and secret access key, or leave both empty to use the server's IAM role."
15520
+ );
15521
+ }
14413
15522
  if ("notify_email_from" in body) {
14414
15523
  const v = String(body.notify_email_from ?? "").trim();
14415
15524
  if (v && v.includes(","))
@@ -14447,15 +15556,18 @@ function createApp() {
14447
15556
  { error: errorMessages.join("; "), fieldErrors, settings: currentServerSettings() },
14448
15557
  400
14449
15558
  );
14450
- const smtpChange = pending.find((p) => p.name === "SMTP_URL");
14451
- if (smtpChange && smtpChange.value) {
14452
- const result = await verifySmtp(smtpChange.value);
15559
+ const over = Object.fromEntries(pending.map((p) => [p.name, p.value]));
15560
+ const nextEmail = transportSettings(over);
15561
+ const emailFields = nextEmail?.provider === "ses" ? ["EMAIL_PROVIDER", "SES_REGION", "SES_ACCESS_KEY_ID", "SES_SECRET_ACCESS_KEY"] : ["EMAIL_PROVIDER", "SMTP_URL"];
15562
+ if (nextEmail && emailFields.some((k) => k in over)) {
15563
+ const result = await verifyEmailTransport(nextEmail);
14453
15564
  if (!result.ok) {
14454
- const message = `SMTP connection failed: ${result.error}`;
15565
+ const ses = nextEmail.provider === "ses";
15566
+ const message = `${ses ? "AWS SES" : "SMTP"} connection failed: ${result.error}`;
14455
15567
  return c.json(
14456
15568
  {
14457
15569
  error: message,
14458
- fieldErrors: { smtp_url: message },
15570
+ fieldErrors: { [ses ? "ses_region" : "smtp_url"]: message },
14459
15571
  settings: currentServerSettings()
14460
15572
  },
14461
15573
  400
@@ -14614,6 +15726,10 @@ function createApp() {
14614
15726
  app.use("/admin/community-sites", flexAuthMiddleware);
14615
15727
  app.use("/admin/community-sites/*", flexAuthMiddleware);
14616
15728
  app.route("/admin/community-sites", communitySiteRoutes);
15729
+ app.get("/teams", async (c, next) => {
15730
+ if (isDocumentNavigation(c)) return serveIndexShell(c, next);
15731
+ return next();
15732
+ });
14617
15733
  app.use("/teams/*", flexAuthMiddleware);
14618
15734
  app.use("/teams", flexAuthMiddleware);
14619
15735
  app.route("/teams", teamRoutes);
@@ -14667,7 +15783,7 @@ function createApp() {
14667
15783
  const others = requests.filter((r) => r.requestedBy !== me);
14668
15784
  const nestApprove = new Map(
14669
15785
  await Promise.all(
14670
- [...new Set(others.map((r) => r.nestId))].map(
15786
+ [...nestName2.keys()].map(
14671
15787
  async (nestId) => [
14672
15788
  nestId,
14673
15789
  await canApproveInNest(nestId, me)
@@ -14697,13 +15813,33 @@ function createApp() {
14697
15813
  );
14698
15814
  waiting.sort(byNewest);
14699
15815
  const notifications = await listNotifications(me, { unreadOnly: true, limit: 50 });
15816
+ const approvableNestIds = [...nestName2.keys()].filter(
15817
+ (id) => nestApprove.get(id)
15818
+ );
15819
+ const restNestIds = [...nestName2.keys()].filter(
15820
+ (id) => !nestApprove.get(id)
15821
+ );
15822
+ const [{ total: approvableReviewTotal }, { total: myReviewsElsewhereTotal }] = await Promise.all([
15823
+ getReviewQueue({ status: "pending", nestIds: approvableNestIds, limit: 1 }),
15824
+ getReviewQueue({
15825
+ status: "pending",
15826
+ nestIds: restNestIds,
15827
+ requestedByEmail: me,
15828
+ limit: 1
15829
+ })
15830
+ ]);
15831
+ const reviewTotal = approvableReviewTotal + myReviewsElsewhereTotal;
15832
+ const deletionsShown = items.filter((i) => i.type === "deletion").length + waiting.filter((i) => i.type === "deletion").length;
14700
15833
  return c.json({
14701
15834
  items,
14702
15835
  waiting,
14703
15836
  notifications,
14704
- // total = all pending across visible nests (both buckets); has_more drives
14705
- // the "Load more" control on the client.
14706
- pagination: { total, limit, offset, has_more: offset + requests.length < total }
15837
+ pagination: {
15838
+ total: reviewTotal + deletionsShown,
15839
+ limit,
15840
+ offset,
15841
+ has_more: offset + requests.length < total
15842
+ }
14707
15843
  });
14708
15844
  });
14709
15845
  app.get("/me/drafts", async (c) => {
@@ -14752,12 +15888,29 @@ function createApp() {
14752
15888
  await markNotificationsRead(me, body.ids === "all" ? "all" : body.ids ?? []);
14753
15889
  return c.json({ ok: true });
14754
15890
  });
15891
+ app.post("/me/nests/:nestId/leave", async (c) => {
15892
+ const nestId = c.req.param("nestId");
15893
+ const me = await resolveCallerEmail(c.get("userId"));
15894
+ const db = getDb();
15895
+ const info = await db.run(
15896
+ "DELETE FROM nest_collaborators WHERE nest_id = ? AND user_id = ?",
15897
+ [nestId, c.get("userId")]
15898
+ );
15899
+ await db.run(
15900
+ `UPDATE notifications SET read_at = ? WHERE LOWER(user_email) = LOWER(?) AND nest_id = ? AND kind = 'nest_invite' AND read_at IS NULL`,
15901
+ [(/* @__PURE__ */ new Date()).toISOString(), me, nestId]
15902
+ );
15903
+ return c.json({ left: info.changes > 0 });
15904
+ });
14755
15905
  app.use("/stats", flexAuthMiddleware);
14756
15906
  app.get("/stats", async (c) => {
14757
15907
  const db = getDb();
14758
15908
  const userId = c.get("userId");
14759
15909
  const userEmail = await resolveCallerEmail(userId);
14760
- const visibleNests = await listVisibleNests(userId);
15910
+ const nestScope = c.get("nestScope");
15911
+ const visibleNests = (await listVisibleNests(userId)).filter(
15912
+ (n) => !nestScope || n.id === nestScope
15913
+ );
14761
15914
  let documents = 0;
14762
15915
  for (const nest of visibleNests) {
14763
15916
  try {
@@ -14785,8 +15938,12 @@ function createApp() {
14785
15938
  const limit = Math.min(Math.max(Number(c.req.query("limit")) || 20, 1), 50);
14786
15939
  const userId = c.get("userId");
14787
15940
  const userEmail = await resolveCallerEmail(userId);
15941
+ const nestScope = c.get("nestScope");
15942
+ const searchable = (await listVisibleNests(userId, { readable: true })).filter(
15943
+ (n) => !nestScope || n.id === nestScope
15944
+ );
14788
15945
  const perNest = await Promise.all(
14789
- (await listVisibleNests(userId, { readable: true })).map(async (nest) => {
15946
+ searchable.map(async (nest) => {
14790
15947
  try {
14791
15948
  const { storage } = await engineCache.get(nest.id);
14792
15949
  const matches = matchDocuments(await discoverForSearch(storage), q);
@@ -14819,7 +15976,7 @@ function createApp() {
14819
15976
  app.route("/index", nestIndexRoutes);
14820
15977
  app.use("/mcp", flexAuthWithPromptowlTicket);
14821
15978
  app.route("/mcp", serverMcpRoutes);
14822
- const nestsApp = new Hono25();
15979
+ const nestsApp = new Hono27();
14823
15980
  nestsApp.use("*", async (c, next) => {
14824
15981
  const parts = nestPathParts(c.req.path);
14825
15982
  const ticketEligible = parts.length === 2 && (c.req.method === "POST" && parts[1] === "context" || c.req.method === "GET" && parts[1] === "nodes" || parts[1] === "mcp");
@@ -14949,7 +16106,7 @@ function createApp() {
14949
16106
  );
14950
16107
  }
14951
16108
  const resource = parts[1];
14952
- if (resource === "visibility" || resource === "reader-mode") {
16109
+ if (resource === "visibility" || resource === "reader-mode" || resource === "branding") {
14953
16110
  required = "admin";
14954
16111
  } else if (resource === "collaborators") {
14955
16112
  required = c.req.method === "GET" || c.req.method === "POST" ? "write" : "admin";
@@ -14976,7 +16133,9 @@ function createApp() {
14976
16133
  stewardEditorBypass = resolved.some(
14977
16134
  (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor"
14978
16135
  );
14979
- } else if (parts.length === 2 && c.req.method === "POST") {
16136
+ } else if (c.req.method === "POST" && // POST /nodes (create) and POST /nodes/pdf (PDF upload — a create, or
16137
+ // a new version of a pdf node) share the nest-scope editor rule.
16138
+ (parts.length === 2 || parts.length === 3 && parts[2] === "pdf")) {
14980
16139
  const resolved = await resolveStewardsForNode(nestId, "");
14981
16140
  stewardEditorBypass = resolved.some(
14982
16141
  (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor" && r.steward.scope === "nest"
@@ -15038,11 +16197,13 @@ function createApp() {
15038
16197
  });
15039
16198
  nestsApp.route("/", nestRoutes);
15040
16199
  nestsApp.route("/:nestId", governanceRoutes);
16200
+ nestsApp.route("/:nestId/nodes", pdfNodeRoutes);
15041
16201
  nestsApp.route("/:nestId/nodes", annotationRoutes);
15042
16202
  nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
15043
16203
  nestsApp.route("/:nestId/nodes", nodeRoutes);
15044
16204
  nestsApp.route("/:nestId", queryRoutes);
15045
16205
  nestsApp.route("/:nestId", sharingRoutes);
16206
+ nestsApp.route("/:nestId", brandingRoutes);
15046
16207
  nestsApp.route("/:nestId/grants", grantRoutes);
15047
16208
  nestsApp.route("/:nestId/publications", publishRoutes);
15048
16209
  nestsApp.route("/:nestId/teams", nestTeamRoutes);
@@ -15066,18 +16227,13 @@ function createApp() {
15066
16227
  onFound: (_p, c) => c.header("Cache-Control", "public, max-age=31536000, immutable")
15067
16228
  })
15068
16229
  );
15069
- const isDocumentNavigation = (c) => c.req.header("Sec-Fetch-Mode") === "navigate" || (c.req.header("Accept") || "").includes("text/html");
15070
16230
  app.get(
15071
16231
  "*",
15072
16232
  async (c, next) => {
15073
16233
  if (isDocumentNavigation(c)) return next();
15074
16234
  return c.json({ error: "Not found" }, 404);
15075
16235
  },
15076
- serveStatic({
15077
- root: UI_DIR_REL,
15078
- path: "index.html",
15079
- onFound: (_p, c) => c.header("Cache-Control", "no-cache")
15080
- })
16236
+ serveIndexShell
15081
16237
  );
15082
16238
  app.notFound((c) => c.json({ error: "Not found" }, 404));
15083
16239
  app.onError((err, c) => {
@@ -15365,7 +16521,7 @@ async function repairGraftedVersionChains(db) {
15365
16521
  }
15366
16522
 
15367
16523
  // src/workflow/scheduler.ts
15368
- import { v4 as uuid19 } from "uuid";
16524
+ import { v4 as uuid18 } from "uuid";
15369
16525
  function isDue(s, now) {
15370
16526
  if (!s.last_run_at) return true;
15371
16527
  const last = Date.parse(s.last_run_at);
@@ -15393,7 +16549,7 @@ async function schedulerTick(now = Date.now()) {
15393
16549
  );
15394
16550
  continue;
15395
16551
  }
15396
- const id = `run_${uuid19()}`;
16552
+ const id = `run_${uuid18()}`;
15397
16553
  const nowIso2 = new Date(now).toISOString();
15398
16554
  await db.run(
15399
16555
  `INSERT INTO runs (id, nest_id, agent_node, triggered_by, status, inputs, started_at, parent_run_id, depth)