@promptowl/contextnest-community 1.24.0 → 1.25.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 (102) hide show
  1. package/API.md +109 -6
  2. package/CONFIGURATION.md +7 -2
  3. package/README.md +1 -1
  4. package/STEWARDSHIP.md +24 -0
  5. package/dist/{chunk-2TPQTN4Y.js → chunk-2KHM5C7V.js} +37 -3
  6. package/dist/{chunk-XUIWAWDO.js → chunk-32SZCH36.js} +4 -4
  7. package/dist/{chunk-KIAAEHWL.js → chunk-7FROMOZM.js} +23 -5
  8. package/dist/{chunk-5EZOPA47.js → chunk-DTLMBF4W.js} +10 -8
  9. package/dist/{chunk-LA3VTQ22.js → chunk-JDAOA5KP.js} +43 -1
  10. package/dist/{chunk-I3CSD6CK.js → chunk-KPGNZLZL.js} +2 -2
  11. package/dist/{chunk-YVMSM7LS.js → chunk-LHWOJPFE.js} +7 -0
  12. package/dist/{chunk-ZTT4U4NE.js → chunk-TI7HMP64.js} +99 -12
  13. package/dist/{client-KEY4PYJH.js → client-D44ZHDTJ.js} +1 -1
  14. package/dist/{engine-S3QBQ7LH.js → engine-TER6FDBP.js} +3 -3
  15. package/dist/{external-edit-service-6FNFPOJJ.js → external-edit-service-CAGENHDJ.js} +4 -4
  16. package/dist/{grants-service-UT3EQ3R7.js → grants-service-2OTFS7EH.js} +3 -3
  17. package/dist/index.js +863 -355
  18. package/dist/{migrations.postgres-AIQ7WSU7.js → migrations.postgres-23VJJJJX.js} +12 -2
  19. package/dist/{review-service-HHGOTO6P.js → review-service-4G2XUFS6.js} +7 -7
  20. package/dist/{stewardship-service-4DHNRULG.js → stewardship-service-S22V2JKG.js} +6 -4
  21. package/dist/{version-service-KIOQME6U.js → version-service-X4FAG2OZ.js} +4 -4
  22. package/dist/web3/assets/ActivityTracePage-D2iIbEph.js +1 -0
  23. package/dist/web3/assets/{AgentDocsPage-CIiaBqMy.js → AgentDocsPage-Dkhgb1hk.js} +1 -1
  24. package/dist/web3/assets/CollaboratorManager-Bo81D--Q.js +1 -0
  25. package/dist/web3/assets/CollaboratorsTab-DRCitQuX.js +1 -0
  26. package/dist/web3/assets/DocumentEditor-DV2WHYbj.js +36 -0
  27. package/dist/web3/assets/{DocumentsTab-Vba-AzBv.js → DocumentsTab-BUxBCN8o.js} +2 -2
  28. package/dist/web3/assets/{ExternalEditsTab-BSzAQIGM.js → ExternalEditsTab-kIBimZyE.js} +1 -1
  29. package/dist/web3/assets/MarkdownEditor-CsVusP2d.css +1 -0
  30. package/dist/web3/assets/MarkdownEditor-Dfk5uIE5.js +648 -0
  31. package/dist/web3/assets/{NestPageHeader-CoLUV1Oz.js → NestPageHeader-yOK-OIYZ.js} +1 -1
  32. package/dist/web3/assets/NestView-DwIagxwJ.js +68 -0
  33. package/dist/web3/assets/{OverviewTab-Dxi-Hu-N.js → OverviewTab-DFOFMVyE.js} +1 -1
  34. package/dist/web3/assets/{PersonCombobox-CPOlNs6G.js → PersonCombobox-D350WlrJ.js} +1 -1
  35. package/dist/web3/assets/{ReasonDialog-DgLxfRWv.js → ReasonDialog-CN3ECAnQ.js} +1 -1
  36. package/dist/web3/assets/{ReviewActions-OfOdqzCn.js → ReviewActions-BXDTDPp1.js} +1 -1
  37. package/dist/web3/assets/{ReviewTab-CyX8yxd3.js → ReviewTab-CZewAYiz.js} +1 -1
  38. package/dist/web3/assets/StewardsTab-AagNWcWS.js +1 -0
  39. package/dist/web3/assets/{SubmitForReviewModal-Ci2DdWs0.js → SubmitForReviewModal-BOZJxwRN.js} +1 -1
  40. package/dist/web3/assets/{alert-dialog-DTozKlmV.js → alert-dialog-4SiLKlz8.js} +1 -1
  41. package/dist/web3/assets/{arrow-left-BCKt4CwQ.js → arrow-left-BAgIocLn.js} +1 -1
  42. package/dist/web3/assets/backlinks-Cb8Uf8mw.js +24 -0
  43. package/dist/web3/assets/{card-XG2dP5Xf.js → card-UDyMRcps.js} +1 -1
  44. package/dist/web3/assets/{chevron-left-BPwUT9DS.js → chevron-left-XR4ReJ9Z.js} +1 -1
  45. package/dist/web3/assets/{circle-check-DI8IBGeo.js → circle-check-CSkZUEFK.js} +1 -1
  46. package/dist/web3/assets/{circle-x-VKVhC88W.js → circle-x-in2o3aHU.js} +1 -1
  47. package/dist/web3/assets/client-attribution-BraarYaP.js +1 -0
  48. package/dist/web3/assets/{code-xml-CMabYZvH.js → code-xml-pPQgdctI.js} +1 -1
  49. package/dist/web3/assets/{corner-down-right-BGxwK6wH.js → corner-down-right-CuxZWscX.js} +1 -1
  50. package/dist/web3/assets/{count-skeleton-DMbdpscX.js → count-skeleton-DBWCDhRy.js} +1 -1
  51. package/dist/web3/assets/{earth-BEp1EKTo.js → earth-CXOcThqn.js} +1 -1
  52. package/dist/web3/assets/{file-exclamation-point-r8qZGIjP.js → file-exclamation-point-CSphs1VD.js} +1 -1
  53. package/dist/web3/assets/{folder-input-Cmtx1Jhk.js → folder-input-BAwK9tFL.js} +1 -1
  54. package/dist/web3/assets/{index-C1wTSjfP.js → index-A0_ymcqL.js} +1 -1
  55. package/dist/web3/assets/index-B5hLclEq.js +389 -0
  56. package/dist/web3/assets/index-DrUAtoQM.css +1 -0
  57. package/dist/web3/assets/page-BHD4beun.js +1 -0
  58. package/dist/web3/assets/{page-CPjWQNKx.js → page-BSYUXYmO.js} +1 -1
  59. package/dist/web3/assets/page-BYc2DIux.js +45 -0
  60. package/dist/web3/assets/{page-Bicf02Cz.js → page-BfibLg8e.js} +1 -1
  61. package/dist/web3/assets/page-CGe8MtUu.js +9 -0
  62. package/dist/web3/assets/{page-CQt3ZGbf.js → page-CIgPJmG6.js} +1 -1
  63. package/dist/web3/assets/{page-CHb0F5hV.js → page-CUjuSs5Q.js} +1 -1
  64. package/dist/web3/assets/{page-BYUFhYWZ.js → page-Ck31nx9b.js} +1 -1
  65. package/dist/web3/assets/{page-BExMvttJ.js → page-Cpdj_11Y.js} +1 -1
  66. package/dist/web3/assets/{page-CtaW65El.js → page-D0sN3GO6.js} +1 -1
  67. package/dist/web3/assets/page-DEpH91X2.js +1 -0
  68. package/dist/web3/assets/{page-CC0NJi8v.js → page-l8-B7CGt.js} +1 -1
  69. package/dist/web3/assets/{page-DGOL9l8i.js → page-mnSHmk6A.js} +1 -1
  70. package/dist/web3/assets/{page-title-ClvvBsIo.js → page-title-BkqlUA0j.js} +1 -1
  71. package/dist/web3/assets/{page-ByyVLDVo.js → page-xzYqkBUP.js} +5 -5
  72. package/dist/web3/assets/{play-C5YNKpAH.js → play-B8xE9j5f.js} +1 -1
  73. package/dist/web3/assets/{refresh-cw-BhHzSH9m.js → refresh-cw-CsCtmWwX.js} +1 -1
  74. package/dist/web3/assets/{scroll-area-hz-xtayP.js → scroll-area-CbcH6yrZ.js} +1 -1
  75. package/dist/web3/assets/{select-DBFTrUmI.js → select-DrL0mpRN.js} +2 -2
  76. package/dist/web3/assets/{send-0CHvmluT.js → send-CvAgOUPG.js} +1 -1
  77. package/dist/web3/assets/{settings-CeeF4LEb.js → settings-EY4A_DYv.js} +1 -1
  78. package/dist/web3/assets/{share-2-CMnOlOt-.js → share-2-vGmZUl90.js} +1 -1
  79. package/dist/web3/assets/{tag-DQ_6J5Gv.js → tag-Br_y1f00.js} +1 -1
  80. package/dist/web3/assets/{trash-2-HBi-Pslz.js → trash-2-B4hRXo-p.js} +1 -1
  81. package/dist/web3/assets/{triangle-alert-CDy8-7sv.js → triangle-alert-CsYGyfGZ.js} +1 -1
  82. package/dist/web3/assets/{user-plus-CvBNcpn4.js → user-plus-4CTYEkd3.js} +1 -1
  83. package/dist/web3/assets/{x-By7piikG.js → x-qxt1WrUi.js} +1 -1
  84. package/dist/web3/assets/zap-BcsKR0ef.js +16 -0
  85. package/dist/web3/index.html +2 -2
  86. package/package.json +2 -2
  87. package/dist/web3/assets/ActivityTracePage-BOZHgJ8S.js +0 -1
  88. package/dist/web3/assets/CollaboratorManager-By91wrIr.js +0 -1
  89. package/dist/web3/assets/CollaboratorsTab-D1EE64Fs.js +0 -1
  90. package/dist/web3/assets/DocumentEditor-DrQfrW3d.js +0 -36
  91. package/dist/web3/assets/MarkdownEditor-74N_naNQ.css +0 -1
  92. package/dist/web3/assets/MarkdownEditor-C9zqZjC5.js +0 -643
  93. package/dist/web3/assets/NestView-CTZ55tXD.js +0 -63
  94. package/dist/web3/assets/StewardsTab-DBEEfAQI.js +0 -1
  95. package/dist/web3/assets/backlinks-CYd4xENL.js +0 -24
  96. package/dist/web3/assets/index-BM-h3DwI.css +0 -1
  97. package/dist/web3/assets/index-EaX2yql0.js +0 -389
  98. package/dist/web3/assets/page-B3yvQvHy.js +0 -1
  99. package/dist/web3/assets/page-BUOADv2X.js +0 -1
  100. package/dist/web3/assets/page-BsPMDm5d.js +0 -45
  101. package/dist/web3/assets/page-Cj1NetQ-.js +0 -1
  102. 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-DTLMBF4W.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-2KHM5C7V.js";
62
63
  import {
63
64
  createGrant,
64
65
  deleteGrant,
@@ -67,7 +68,7 @@ import {
67
68
  listGrants,
68
69
  listUserGrants,
69
70
  resolveNodeGrant
70
- } from "./chunk-I3CSD6CK.js";
71
+ } from "./chunk-KPGNZLZL.js";
71
72
  import {
72
73
  generateApiKey,
73
74
  getKeyPrefix,
@@ -78,10 +79,14 @@ import {
78
79
  verifyPassword
79
80
  } from "./chunk-F66VCBLA.js";
80
81
  import {
82
+ attributeRequest,
81
83
  checkConflict,
84
+ clientToolField,
85
+ connectionClient,
82
86
  createVersion,
83
87
  deleteSupersededDrafts,
84
88
  deleteUnsealedVersions,
89
+ deserializeClient,
85
90
  getApprovedVersion,
86
91
  getApprovedVersions,
87
92
  getCurrentVersion,
@@ -91,9 +96,12 @@ import {
91
96
  getTrackedNodeIds,
92
97
  getVersions,
93
98
  listMyDrafts,
99
+ mergeClient,
100
+ parseClientMetadata,
101
+ serializeClient,
94
102
  setApprovedVersion,
95
103
  upsertVersion
96
- } from "./chunk-ZTT4U4NE.js";
104
+ } from "./chunk-TI7HMP64.js";
97
105
  import {
98
106
  approveExternalEdit,
99
107
  getExternalEditDetail,
@@ -105,7 +113,7 @@ import {
105
113
  scanDocumentForDrift,
106
114
  scanNestForDrift,
107
115
  startDriftScanner
108
- } from "./chunk-XUIWAWDO.js";
116
+ } from "./chunk-32SZCH36.js";
109
117
  import {
110
118
  addMember,
111
119
  addWatcher,
@@ -162,6 +170,7 @@ import {
162
170
  loadServerSettings,
163
171
  markNotificationsRead,
164
172
  nestAllowsSelfApprove,
173
+ nestCreatorIsReviewer,
165
174
  nestName,
166
175
  nestPrimeTags,
167
176
  nestReviewsPrimeOnly,
@@ -195,6 +204,7 @@ import {
195
204
  restoreNest,
196
205
  sendEmailToRecipient,
197
206
  setAllowSelfApprove,
207
+ setCreatorIsReviewer,
198
208
  setPrimeOnlyReview,
199
209
  setPrimeTags,
200
210
  setReaderMode,
@@ -211,25 +221,26 @@ import {
211
221
  updateMemberRole,
212
222
  validateLicense,
213
223
  verifySmtp
214
- } from "./chunk-KIAAEHWL.js";
224
+ } from "./chunk-7FROMOZM.js";
215
225
  import {
216
226
  AppError,
217
227
  ConflictError,
218
228
  ForbiddenError,
219
229
  LockedError,
220
230
  NotFoundError,
231
+ PayloadTooLargeError,
221
232
  UnauthorizedError,
222
233
  ValidationError,
223
234
  VaultBusyError,
224
235
  isVaultLockTimeout
225
- } from "./chunk-YVMSM7LS.js";
236
+ } from "./chunk-LHWOJPFE.js";
226
237
  import {
227
238
  config,
228
239
  getDb,
229
240
  initDb,
230
241
  isEmailListish,
231
242
  isEmailish
232
- } from "./chunk-LA3VTQ22.js";
243
+ } from "./chunk-JDAOA5KP.js";
233
244
  import {
234
245
  ANON_EMAIL,
235
246
  ANON_USER_ID,
@@ -243,7 +254,7 @@ import {
243
254
  import { serve } from "@hono/node-server";
244
255
 
245
256
  // src/app.ts
246
- import { Hono as Hono25 } from "hono";
257
+ import { Hono as Hono26 } from "hono";
247
258
  import { LinearRouter } from "hono/router/linear-router";
248
259
  import { createMiddleware as createMiddleware2 } from "hono/factory";
249
260
  import { cors } from "hono/cors";
@@ -430,7 +441,7 @@ var authMiddleware = createMiddleware(async (c, next) => {
430
441
  if (key) {
431
442
  const keyHash = hashApiKey(key);
432
443
  const record = await db.get(
433
- "SELECT user_id, nest_id FROM api_keys WHERE key_hash = ?",
444
+ "SELECT user_id, nest_id, label FROM api_keys WHERE key_hash = ?",
434
445
  [keyHash]
435
446
  );
436
447
  if (record) {
@@ -440,10 +451,26 @@ var authMiddleware = createMiddleware(async (c, next) => {
440
451
  );
441
452
  c.set("userId", record.user_id);
442
453
  c.set("nestScope", record.nest_id);
454
+ if (record.label) c.set("apiKeyLabel", record.label);
443
455
  return next();
444
456
  }
445
457
  }
446
- return c.json({ error: "Missing or invalid credentials" }, 401);
458
+ if (sessionId) {
459
+ return c.json(
460
+ { error: "Your session has expired \u2014 sign in again.", code: "session_expired" },
461
+ 401
462
+ );
463
+ }
464
+ if (key) {
465
+ return c.json(
466
+ { error: "This API key is invalid or has been revoked \u2014 generate a new one.", code: "invalid_key" },
467
+ 401
468
+ );
469
+ }
470
+ return c.json(
471
+ { error: "Authentication required \u2014 sign in or provide an API key.", code: "missing_credentials" },
472
+ 401
473
+ );
447
474
  });
448
475
 
449
476
  // src/shared/rate-limit.ts
@@ -1986,7 +2013,9 @@ import {
1986
2013
  DocumentNotFoundError,
1987
2014
  NODE_TYPES,
1988
2015
  TAG_PATTERN,
1989
- generateIndexMd
2016
+ generateIndexMd,
2017
+ readPdfBinary,
2018
+ pdfSidecarPath
1990
2019
  } from "@promptowl/contextnest-engine";
1991
2020
  import { rename, mkdir, rm, stat } from "fs/promises";
1992
2021
  import { dirname, join } from "path";
@@ -2566,8 +2595,20 @@ var ACCEPTED_NODE_TYPES = /* @__PURE__ */ new Set([
2566
2595
  ...NODE_TYPES,
2567
2596
  ...RUNNABLE_NODE_TYPES,
2568
2597
  "artifact",
2569
- "table"
2598
+ "table",
2599
+ "pdf"
2570
2600
  ]);
2601
+ var PDF_NODE_TYPE = "pdf";
2602
+ var PDF_UPLOAD_HINT = "upload the file to POST /nests/:nestId/nodes/pdf (the Upload PDF action)";
2603
+ function pdfEngineError(err) {
2604
+ const code = err?.code;
2605
+ const message = err?.message || "The PDF could not be imported.";
2606
+ if (code === "VALIDATION_FAILED" || code === "INVALID_DOCUMENT_ID") {
2607
+ return new ValidationError(message);
2608
+ }
2609
+ if (code === "DOCUMENT_ALREADY_EXISTS") return new ConflictError(message);
2610
+ return err;
2611
+ }
2571
2612
  function assertAcceptedType(type) {
2572
2613
  if (type === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
2573
2614
  throw new ValidationError("Artifact nodes are disabled on this server");
@@ -2581,6 +2622,13 @@ function assertAcceptedType(type) {
2581
2622
  );
2582
2623
  }
2583
2624
  }
2625
+ function assertUpdatableType(storedType) {
2626
+ const type = storedType || "document";
2627
+ if (ACCEPTED_NODE_TYPES.has(type)) return;
2628
+ throw new ValidationError(
2629
+ `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(", ")})`
2630
+ );
2631
+ }
2584
2632
  function toNodeResponse(node) {
2585
2633
  const fm = node.frontmatter;
2586
2634
  const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
@@ -2605,7 +2653,8 @@ function toNodeResponse(node) {
2605
2653
  // Same YAML-coercion rule as title above: `schedule: 123` parses as a
2606
2654
  // number — normalize to string at the API boundary.
2607
2655
  schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0,
2608
- prime: documentPrimeFlag(fm.metadata)
2656
+ prime: documentPrimeFlag(fm.metadata),
2657
+ ...fm.type === "pdf" && fm.pdf ? { pdf: fm.pdf } : {}
2609
2658
  };
2610
2659
  }
2611
2660
  async function resolveAuthorship(nestId, nodes) {
@@ -2998,9 +3047,11 @@ async function assertMayFlagPrime(nestId, userEmail) {
2998
3047
  );
2999
3048
  }
3000
3049
  }
3001
- async function autoPublishReason(nestId, userEmail, doc) {
3002
- if (!await isStewardshipEnabled(nestId)) return "no stewards configured";
3050
+ async function autoPublishReason(nestId, userEmail, doc, opts = {}) {
3051
+ const governed = opts.governed ?? await isStewardshipEnabled(nestId);
3052
+ if (!governed) return "no stewards configured";
3003
3053
  if (await isPrimeDocument(nestId, doc.tags, doc.metadata)) return null;
3054
+ if (opts.creatorReviews) return "creator is the document's reviewer";
3004
3055
  if (await nestReviewsPrimeOnly(nestId)) {
3005
3056
  return "prime-only review, document is not prime";
3006
3057
  }
@@ -3030,8 +3081,14 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
3030
3081
  id = `${prefix}${slug || await generatedSlug(storage, prefix)}`;
3031
3082
  }
3032
3083
  const tags = requireTags(input.tags);
3033
- const requestedType = input.type || "document";
3084
+ const requestedType = input.pdf ? input.type || PDF_NODE_TYPE : input.type || "document";
3034
3085
  assertAcceptedType(requestedType);
3086
+ if (requestedType === PDF_NODE_TYPE && !input.pdf) {
3087
+ throw new ValidationError(`PDF documents can't be created from text \u2014 ${PDF_UPLOAD_HINT}.`);
3088
+ }
3089
+ if (input.pdf && requestedType !== PDF_NODE_TYPE) {
3090
+ throw new ValidationError(`a PDF upload creates a "pdf" node, not "${requestedType}"`);
3091
+ }
3035
3092
  if (input.schedule !== void 0 && !isRunnableType(input.type)) {
3036
3093
  throw new ValidationError(
3037
3094
  `schedule is only valid for runnable node types (${RUNNABLE_NODE_TYPES.join(
@@ -3076,42 +3133,85 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
3076
3133
  ...input.schedule ? { schedule: input.schedule } : {},
3077
3134
  ...input.prime === void 0 ? {} : { prime: input.prime }
3078
3135
  };
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;
3136
+ const governed = await isStewardshipEnabled(nestId);
3137
+ const creatorReviews = opts.creatorReviews !== false && governed && await nestCreatorIsReviewer(nestId);
3138
+ const creatorSteward = creatorReviews ? await stewardDocumentCreator(nestId, id, userEmail) : null;
3139
+ let published = false;
3140
+ let publishNote;
3085
3141
  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)
3142
+ const publishReason = await autoPublishReason(
3143
+ nestId,
3144
+ userEmail,
3145
+ { tags, metadata },
3146
+ { creatorReviews: creatorSteward?.canApprove === true, governed }
3104
3147
  );
3105
- } catch (err) {
3106
- let onDisk = true;
3148
+ publishNote = publishReason === null ? void 0 : `Auto-published on create (${publishReason})`;
3149
+ published = publishReason !== null;
3107
3150
  try {
3108
- await storage.readDocument(id);
3109
- } catch {
3110
- onDisk = false;
3151
+ if (input.pdf) {
3152
+ try {
3153
+ await engineApi.run(
3154
+ "context_import_pdf",
3155
+ {
3156
+ id,
3157
+ title: input.title,
3158
+ bytes_base64: Buffer.from(input.pdf.bytes).toString("base64"),
3159
+ ...input.pdf.filename ? { filename: input.pdf.filename } : {},
3160
+ ...tags.length ? { tags } : {},
3161
+ publish: published,
3162
+ ...publishNote ? { note: publishNote } : {},
3163
+ ...input.client ? { client: input.client } : {}
3164
+ },
3165
+ await opContext(nestId, userEmail)
3166
+ );
3167
+ } catch (err) {
3168
+ throw pdfEngineError(err);
3169
+ }
3170
+ } else {
3171
+ await engineApi.run(
3172
+ "context_create",
3173
+ {
3174
+ id,
3175
+ title: input.title,
3176
+ content: input.content,
3177
+ type: requestedType,
3178
+ // A skill node is invalid without a skill block, and this surface
3179
+ // has no field for one — default the trigger from the title, same
3180
+ // as the CLI and mcp-server do.
3181
+ ...requestedType === "skill" ? { trigger: `when asked to ${input.title.toLowerCase()}` } : {},
3182
+ ...tags.length ? { tags } : {},
3183
+ ...input.status ? { status: input.status } : {},
3184
+ metadata,
3185
+ publish: published,
3186
+ ...publishNote ? { note: publishNote } : {}
3187
+ },
3188
+ await opContext(nestId, userEmail)
3189
+ );
3190
+ }
3191
+ } catch (err) {
3192
+ let onDisk = true;
3193
+ try {
3194
+ await storage.readDocument(id);
3195
+ } catch {
3196
+ onDisk = false;
3197
+ }
3198
+ if (!onDisk) throw err;
3199
+ console.error("publishDocument failed (node create auto-publish)", err);
3200
+ published = false;
3201
+ }
3202
+ } catch (err) {
3203
+ if (creatorSteward?.insertedId) {
3204
+ try {
3205
+ await removeSteward(creatorSteward.insertedId);
3206
+ } catch (cleanupErr) {
3207
+ console.error(
3208
+ "removeSteward failed while compensating a failed create",
3209
+ id,
3210
+ cleanupErr
3211
+ );
3212
+ }
3111
3213
  }
3112
- if (!onDisk) throw err;
3113
- console.error("publishDocument failed (node create auto-publish)", err);
3114
- published = false;
3214
+ throw err;
3115
3215
  }
3116
3216
  const node = await storage.readDocument(id);
3117
3217
  await syncNodeTags(nestId, id, tags);
@@ -3126,7 +3226,8 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
3126
3226
  author: userEmail,
3127
3227
  status: "published",
3128
3228
  tags,
3129
- changeNote: publishNote
3229
+ changeNote: publishNote,
3230
+ client: input.client
3130
3231
  });
3131
3232
  await setApprovedVersion(nestId, id, savedVersion, userEmail);
3132
3233
  } else {
@@ -3134,10 +3235,12 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
3134
3235
  nestId,
3135
3236
  nodeId: id,
3136
3237
  version: 1,
3137
- content: input.content,
3238
+ // A pdf node's body is what the engine extracted, not anything sent.
3239
+ content: input.pdf ? node.body || "" : input.content,
3138
3240
  author: userEmail,
3139
3241
  status: "draft",
3140
- tags
3242
+ tags,
3243
+ client: input.client
3141
3244
  });
3142
3245
  }
3143
3246
  await trackEvent("node.create", { nestId, nodeId: id });
@@ -3191,7 +3294,7 @@ async function alignOnDiskStatus(nestId, documentId, status) {
3191
3294
  );
3192
3295
  }
3193
3296
  }
3194
- async function registerImportedDocuments(nestId, userEmail) {
3297
+ async function registerImportedDocuments(nestId, userEmail, client) {
3195
3298
  const startedAt = Date.now();
3196
3299
  const tracked = await getTrackedNodeIds(nestId);
3197
3300
  (await engineCache.get(nestId)).invalidateDiscovery();
@@ -3221,7 +3324,8 @@ async function registerImportedDocuments(nestId, userEmail) {
3221
3324
  author: userEmail,
3222
3325
  status: d.status,
3223
3326
  changeNote: d.status === "published" ? "Imported from existing folder" : "Imported from existing folder \u2014 could not be published, kept as a draft",
3224
- tags
3327
+ tags,
3328
+ client
3225
3329
  });
3226
3330
  if (d.status === "published") {
3227
3331
  await setApprovedVersion(nestId, d.id, d.version, userEmail);
@@ -3243,6 +3347,44 @@ async function registerImportedDocuments(nestId, userEmail) {
3243
3347
  await trackEvent("nest.import.documents", { nestId, registered: docs.length });
3244
3348
  return docs.length;
3245
3349
  }
3350
+ async function stagePdfImport(nestId, nodeId, pdf, userEmail, client, priorRaw) {
3351
+ const { storage } = await engineCache.get(nestId);
3352
+ const sidecar = pdfSidecarPath(nodeId);
3353
+ let priorSidecar = null;
3354
+ try {
3355
+ priorSidecar = await storage.readVaultBinary(sidecar);
3356
+ } catch {
3357
+ }
3358
+ const sealedBefore = await storage.maxRecordedVersion(nodeId);
3359
+ const rollback = async () => {
3360
+ if (await storage.maxRecordedVersion(nodeId).catch(() => sealedBefore) > sealedBefore) {
3361
+ return;
3362
+ }
3363
+ try {
3364
+ await storage.writeDocument(nodeId, priorRaw);
3365
+ if (priorSidecar) await storage.writeVaultBinary(sidecar, priorSidecar);
3366
+ await storage.regenerateIndex();
3367
+ } catch (err) {
3368
+ console.error("[pdf] could not roll back a failed PDF replace", nestId, nodeId, err);
3369
+ }
3370
+ };
3371
+ try {
3372
+ const result = await engineApi.run(
3373
+ "context_import_pdf",
3374
+ {
3375
+ id: nodeId,
3376
+ bytes_base64: Buffer.from(pdf.bytes).toString("base64"),
3377
+ ...pdf.filename ? { filename: pdf.filename } : {},
3378
+ publish: false,
3379
+ ...client ? { client } : {}
3380
+ },
3381
+ await opContext(nestId, userEmail)
3382
+ );
3383
+ return { unchanged: result.unchanged === true, rollback };
3384
+ } catch (err) {
3385
+ throw pdfEngineError(err);
3386
+ }
3387
+ }
3246
3388
  async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3247
3389
  return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
3248
3390
  try {
@@ -3255,6 +3397,35 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3255
3397
  } catch {
3256
3398
  throw new NotFoundError(`Node not found: ${nodeId}`);
3257
3399
  }
3400
+ const priorRaw = node.rawContent;
3401
+ let pdfRollback = null;
3402
+ if (patch.type === void 0 || patch.type === node.frontmatter.type) {
3403
+ assertUpdatableType(node.frontmatter.type);
3404
+ }
3405
+ const storedType = node.frontmatter.type || "document";
3406
+ const isPdfNode = storedType === PDF_NODE_TYPE;
3407
+ if (patch.pdf && !isPdfNode) {
3408
+ throw new ValidationError(
3409
+ `${nodeId} is a "${storedType}" node, not a PDF \u2014 a PDF can only replace the PDF of a pdf node.`
3410
+ );
3411
+ }
3412
+ if (patch.type !== void 0 && patch.type !== storedType) {
3413
+ if (isPdfNode || patch.type === PDF_NODE_TYPE) {
3414
+ throw new ValidationError(
3415
+ 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}.`
3416
+ );
3417
+ }
3418
+ }
3419
+ if (isPdfNode) {
3420
+ 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.";
3421
+ if (patch.append) throw new ConflictError(readOnly);
3422
+ if (patch.content !== void 0) {
3423
+ if (patch.content.trim() !== (node.body || "").trim()) {
3424
+ throw new ConflictError(readOnly);
3425
+ }
3426
+ patch = { ...patch, content: void 0 };
3427
+ }
3428
+ }
3258
3429
  const previousBody = node.body || "";
3259
3430
  const opInput = {};
3260
3431
  if (patch.content !== void 0) {
@@ -3350,11 +3521,30 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3350
3521
  if (publishReason === null || isAuthorEditingOwnReview) {
3351
3522
  const currentVersion = await getCurrentVersion(nestId, nodeId);
3352
3523
  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
- );
3524
+ if (patch.pdf) {
3525
+ const staged = await stagePdfImport(
3526
+ nestId,
3527
+ nodeId,
3528
+ patch.pdf,
3529
+ userEmail,
3530
+ patch.client,
3531
+ priorRaw
3532
+ );
3533
+ if (staged.unchanged && Object.keys(opInput).length === 0) {
3534
+ return { node, version: node.frontmatter.version || currentVersion || 1 };
3535
+ }
3536
+ if (!staged.unchanged) pdfRollback = staged.rollback;
3537
+ }
3538
+ try {
3539
+ await engineApi.run(
3540
+ "context_update",
3541
+ { id: nodeId, ...opInput, publish: false, version: newVersion },
3542
+ await opContext(nestId, userEmail)
3543
+ );
3544
+ } catch (err) {
3545
+ if (pdfRollback) await pdfRollback();
3546
+ throw err;
3547
+ }
3358
3548
  node = await storage.readDocument(nodeId);
3359
3549
  await syncNodeTags(nestId, nodeId, currentTags);
3360
3550
  await deleteSupersededDrafts(nestId, nodeId, newVersion);
@@ -3366,7 +3556,8 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3366
3556
  author: userEmail,
3367
3557
  status: "draft",
3368
3558
  tags: currentTags,
3369
- changeNote: patch.changeNote
3559
+ changeNote: patch.changeNote,
3560
+ client: patch.client
3370
3561
  });
3371
3562
  if (isAuthorEditingOwnReview) {
3372
3563
  await repointPendingReview({ nestId, nodeId, newVersion });
@@ -3375,6 +3566,20 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3375
3566
  } else {
3376
3567
  const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
3377
3568
  let publishedVersion;
3569
+ if (patch.pdf) {
3570
+ const staged = await stagePdfImport(
3571
+ nestId,
3572
+ nodeId,
3573
+ patch.pdf,
3574
+ userEmail,
3575
+ patch.client,
3576
+ priorRaw
3577
+ );
3578
+ if (staged.unchanged && Object.keys(opInput).length === 0) {
3579
+ return { node, version: node.frontmatter.version || 1 };
3580
+ }
3581
+ if (!staged.unchanged) pdfRollback = staged.rollback;
3582
+ }
3378
3583
  try {
3379
3584
  const result = await engineApi.run(
3380
3585
  "context_update",
@@ -3387,6 +3592,7 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3387
3592
  "publishDocument failed (node patch auto-publish)",
3388
3593
  err
3389
3594
  );
3595
+ if (pdfRollback) await pdfRollback();
3390
3596
  throw err;
3391
3597
  }
3392
3598
  node = await storage.readDocument(nodeId);
@@ -3399,7 +3605,8 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3399
3605
  author: userEmail,
3400
3606
  status: "published",
3401
3607
  tags: currentTags,
3402
- changeNote: publishNote
3608
+ changeNote: publishNote,
3609
+ client: patch.client
3403
3610
  });
3404
3611
  await setApprovedVersion(
3405
3612
  nestId,
@@ -3505,6 +3712,11 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
3505
3712
  } catch {
3506
3713
  throw new NotFoundError(`Node not found: ${nodeId}`);
3507
3714
  }
3715
+ if (node.frontmatter.type === PDF_NODE_TYPE) {
3716
+ throw new ValidationError(
3717
+ "PDF documents can't be moved to another folder yet. Upload the PDF again in the destination folder instead."
3718
+ );
3719
+ }
3508
3720
  await assertNotReviewLocked(nestId, nodeId, userEmail, "moving");
3509
3721
  const leaf = nodeId.split("/").pop();
3510
3722
  const segments = folderToSegments(folder);
@@ -3601,6 +3813,16 @@ async function discardDraft(nestId, nodeId, userEmail) {
3601
3813
  await storage.writeDocument(nodeId, raw);
3602
3814
  await storage.regenerateIndex();
3603
3815
  const restored = parseDocument(`${nodeId}.md`, raw, nodeId);
3816
+ let warning;
3817
+ if (restored.frontmatter.type === PDF_NODE_TYPE) {
3818
+ try {
3819
+ const bytes = await readPdfBinary(storage, nodeId);
3820
+ await storage.writeVaultBinary(pdfSidecarPath(nodeId), bytes);
3821
+ } catch (err) {
3822
+ console.error("[pdf] could not restore the sealed binary on discard", nestId, nodeId, err);
3823
+ 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.`;
3824
+ }
3825
+ }
3604
3826
  await syncNodeTags(
3605
3827
  nestId,
3606
3828
  nodeId,
@@ -3608,7 +3830,7 @@ async function discardDraft(nestId, nodeId, userEmail) {
3608
3830
  );
3609
3831
  await deleteUnsealedVersions(nestId, nodeId, sealed);
3610
3832
  await trackEvent("node.discard_draft", { nestId, nodeId, version: sealed });
3611
- return { deleted: false, version: sealed };
3833
+ return { deleted: false, version: sealed, ...warning ? { warning } : {} };
3612
3834
  });
3613
3835
  }
3614
3836
  async function deleteNode(nestId, nodeId, userEmail) {
@@ -3835,7 +4057,7 @@ function collectMarkdownFiles(dir, root) {
3835
4057
  }
3836
4058
  return out;
3837
4059
  }
3838
- async function syncUnsyncedFolder(userId, folderName, callerEmail) {
4060
+ async function syncUnsyncedFolder(userId, folderName, callerEmail, client) {
3839
4061
  console.log(
3840
4062
  `[unsynced] sync requested folder="${folderName}" user="${callerEmail}"`
3841
4063
  );
@@ -3876,7 +4098,7 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
3876
4098
  let documents;
3877
4099
  try {
3878
4100
  await stageImportedFiles(nest.id, files, callerEmail);
3879
- documents = await registerImportedDocuments(nest.id, callerEmail);
4101
+ documents = await registerImportedDocuments(nest.id, callerEmail, client);
3880
4102
  } catch (err) {
3881
4103
  await deleteNest(nest.id).catch(() => {
3882
4104
  });
@@ -3997,7 +4219,11 @@ nestRoutes.get("/", async (c) => {
3997
4219
  seen.add(n.id);
3998
4220
  deduped.push(n);
3999
4221
  }
4000
- const out = await Promise.all(deduped.map(annotate));
4222
+ const memberIds = new Set([...owned, ...shared].map((n) => n.id));
4223
+ const out = (await Promise.all(deduped.map(annotate))).map((row) => ({
4224
+ ...row,
4225
+ discovered: !memberIds.has(row.id)
4226
+ }));
4001
4227
  const docRows = await db.all(
4002
4228
  `SELECT nest_id,
4003
4229
  COUNT(DISTINCT CASE
@@ -4096,7 +4322,14 @@ nestRoutes.post("/:nestId", async (c) => {
4096
4322
  }
4097
4323
  const email2 = await resolveCallerEmail(userId);
4098
4324
  if (body.finalize) {
4099
- return c.json({ nest: nest2, documents: await registerImportedDocuments(target, email2) });
4325
+ return c.json({
4326
+ nest: nest2,
4327
+ documents: await registerImportedDocuments(
4328
+ target,
4329
+ email2,
4330
+ attributeRequest(c, body.client)
4331
+ )
4332
+ });
4100
4333
  }
4101
4334
  const registered = await getDb().get(
4102
4335
  "SELECT COUNT(*) AS c FROM node_versions WHERE nest_id = ?",
@@ -4123,7 +4356,11 @@ nestRoutes.post("/:nestId", async (c) => {
4123
4356
  if (body.partial) {
4124
4357
  return c.json({ nest, rejected }, 201);
4125
4358
  }
4126
- const documents = await registerImportedDocuments(nest.id, email);
4359
+ const documents = await registerImportedDocuments(
4360
+ nest.id,
4361
+ email,
4362
+ attributeRequest(c, body.client)
4363
+ );
4127
4364
  return c.json({ nest, documents, rejected }, 201);
4128
4365
  } catch (err) {
4129
4366
  await deleteNest(nest.id).catch(() => {
@@ -4153,7 +4390,8 @@ nestRoutes.post("/:nestId/sync", async (c) => {
4153
4390
  const result = await syncUnsyncedFolder(
4154
4391
  userId,
4155
4392
  body.name,
4156
- await resolveCallerEmail(userId)
4393
+ await resolveCallerEmail(userId),
4394
+ attributeRequest(c, body.client)
4157
4395
  );
4158
4396
  return c.json(result, 201);
4159
4397
  });
@@ -4275,7 +4513,8 @@ nestRoutes.get("/:nestId/settings", async (c) => {
4275
4513
  stewardship_enabled: await isStewardshipEnabled(nestId),
4276
4514
  allow_self_approve: await nestAllowsSelfApprove(nestId),
4277
4515
  prime_only_review: await nestReviewsPrimeOnly(nestId),
4278
- prime_tags: await nestPrimeTags(nestId)
4516
+ prime_tags: await nestPrimeTags(nestId),
4517
+ creator_is_reviewer: await nestCreatorIsReviewer(nestId)
4279
4518
  });
4280
4519
  });
4281
4520
  nestRoutes.patch("/:nestId/settings", async (c) => {
@@ -4320,11 +4559,15 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
4320
4559
  Array.isArray(body.prime_tags) ? body.prime_tags : [body.prime_tags]
4321
4560
  );
4322
4561
  }
4562
+ if (typeof body.creator_is_reviewer === "boolean") {
4563
+ await setCreatorIsReviewer(nestId, body.creator_is_reviewer);
4564
+ }
4323
4565
  return c.json({
4324
4566
  stewardship_enabled: await isStewardshipEnabled(nestId),
4325
4567
  allow_self_approve: await nestAllowsSelfApprove(nestId),
4326
4568
  prime_only_review: await nestReviewsPrimeOnly(nestId),
4327
4569
  prime_tags: await nestPrimeTags(nestId),
4570
+ creator_is_reviewer: await nestCreatorIsReviewer(nestId),
4328
4571
  wiped
4329
4572
  });
4330
4573
  });
@@ -4539,12 +4782,24 @@ sharingRoutes.patch("/visibility", async (c) => {
4539
4782
  if (!body.visibility || !["private", "org", "public"].includes(body.visibility)) {
4540
4783
  throw new ValidationError("visibility must be private, org or public");
4541
4784
  }
4542
- if (body.visibility === "public" && body.acknowledge_public !== true) {
4785
+ const ack = body.acknowledge_public === true;
4786
+ const shared = config.SHARED_DEPLOYMENT;
4787
+ if (body.visibility === "public" && !ack) {
4543
4788
  return c.json(
4544
4789
  {
4545
4790
  error: "Publishing this nest requires acknowledgement.",
4546
4791
  requires_acknowledgement: true,
4547
- warning: "This makes the nest readable by anyone on the internet, outside your organization."
4792
+ 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."
4793
+ },
4794
+ 409
4795
+ );
4796
+ }
4797
+ if (body.visibility === "org" && shared && !ack) {
4798
+ return c.json(
4799
+ {
4800
+ error: "Opening this nest to the organization requires acknowledgement.",
4801
+ requires_acknowledgement: true,
4802
+ warning: "This server is shared: 'Organization' makes the nest readable by every account on this server, including other organizations \u2014 not just yours."
4548
4803
  },
4549
4804
  409
4550
4805
  );
@@ -5338,7 +5593,7 @@ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
5338
5593
  tags: ["annotations"]
5339
5594
  },
5340
5595
  userEmail,
5341
- { notifyMentions: false }
5596
+ { notifyMentions: false, creatorReviews: false }
5342
5597
  );
5343
5598
  } else {
5344
5599
  throw err;
@@ -5504,7 +5759,7 @@ var GATE_EYE_SCRIPT = `<script>
5504
5759
  </script>`;
5505
5760
  function gatePage(row, message) {
5506
5761
  const wantsCode = row.access_mode === "code";
5507
- const field = wantsCode ? `<label>Access code<span class="wrap">
5762
+ const field2 = wantsCode ? `<label>Access code<span class="wrap">
5508
5763
  <input id="cn-code" name="code" type="password" autocomplete="off" required autofocus>
5509
5764
  <button type="button" id="cn-eye" class="eye" aria-label="Show code" aria-pressed="false">${EYE_SVG}</button>
5510
5765
  </span></label>` : `<label>Your email<input name="email" type="email" autocomplete="email" required autofocus></label>`;
@@ -5528,7 +5783,7 @@ function gatePage(row, message) {
5528
5783
  .err{color:#be123c;font-weight:600}
5529
5784
  .brand{font-size:12px;color:#a8a29e;margin:18px 0 0;text-align:center}
5530
5785
  </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}
5786
+ <h1>Protected document</h1><p>${esc(note)}</p>${err}${field2}
5532
5787
  <button type="submit">${cta}</button>
5533
5788
  <p class="brand">Secured by PromptOwl \xB7 ContextNest</p>
5534
5789
  </form>${wantsCode ? GATE_EYE_SCRIPT : ""}</body></html>`;
@@ -6108,6 +6363,7 @@ function toSearchHit(node, query) {
6108
6363
 
6109
6364
  // src/nodes/routes.ts
6110
6365
  import { Hono as Hono11 } from "hono";
6366
+ import { parseDocument as parseDocument3, readPdfBinary as readPdfBinary2 } from "@promptowl/contextnest-engine";
6111
6367
 
6112
6368
  // src/nodes/markdown-export.ts
6113
6369
  import { parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
@@ -6224,7 +6480,8 @@ nodeRoutes.post("/", async (c) => {
6224
6480
  status: body.status,
6225
6481
  schedule: body.schedule,
6226
6482
  folder: body.folder,
6227
- prime: body.prime
6483
+ prime: body.prime,
6484
+ client: attributeRequest(c, body.client)
6228
6485
  },
6229
6486
  authorEmail
6230
6487
  );
@@ -6241,7 +6498,7 @@ nodeRoutes.post("/", async (c) => {
6241
6498
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
6242
6499
  const nestId = c.req.param("nestId");
6243
6500
  const nodeId = c.req.param("nodeId");
6244
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-4DHNRULG.js");
6501
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-S22V2JKG.js");
6245
6502
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
6246
6503
  nestId,
6247
6504
  nodeId
@@ -6262,7 +6519,7 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
6262
6519
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
6263
6520
  const nestId = c.req.param("nestId");
6264
6521
  const nodeId = c.req.param("nodeId");
6265
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-HHGOTO6P.js");
6522
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-4G2XUFS6.js");
6266
6523
  const history = await getReviewHistory2(nestId, nodeId);
6267
6524
  return c.json({ reviews: history });
6268
6525
  });
@@ -6283,7 +6540,7 @@ nodeRoutes.post("/:nodeId{.+}/discard", async (c) => {
6283
6540
  nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
6284
6541
  const nestId = c.req.param("nestId");
6285
6542
  const nodeId = c.req.param("nodeId");
6286
- const { versions: versionManager } = await engineCache.get(nestId);
6543
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
6287
6544
  const userId = c.get("userId");
6288
6545
  const userEmail = await resolveCallerEmail(userId);
6289
6546
  if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
@@ -6305,11 +6562,26 @@ nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
6305
6562
  `Version ${targetVersion} not found for ${nodeId}`
6306
6563
  );
6307
6564
  }
6565
+ let restoredPdf = null;
6566
+ if (pdfMetaOf(nodeId, raw)) {
6567
+ try {
6568
+ restoredPdf = await readPdfBinary2(storage, nodeId, {
6569
+ version: targetVersion
6570
+ });
6571
+ } catch (err) {
6572
+ console.error("[pdf] revert could not read the version's binary", nestId, nodeId, targetVersion, err);
6573
+ throw new NotFoundError(`The PDF of version ${targetVersion} is not available to restore.`);
6574
+ }
6575
+ }
6308
6576
  const content = bodyOnly(nodeId, raw);
6309
6577
  const { node, version } = await updateNode(
6310
6578
  nestId,
6311
6579
  nodeId,
6312
- { content, changeNote: `Restored from version ${targetVersion}` },
6580
+ {
6581
+ ...restoredPdf ? { pdf: { bytes: new Uint8Array(restoredPdf) } } : { content },
6582
+ changeNote: `Restored from version ${targetVersion}`,
6583
+ client: attributeRequest(c, body.client)
6584
+ },
6313
6585
  userEmail
6314
6586
  );
6315
6587
  await trackEvent("node.revert", { nestId, nodeId, targetVersion });
@@ -6412,6 +6684,10 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
6412
6684
  approved
6413
6685
  );
6414
6686
  response.content = bodyOnly(nodeId, approvedRaw);
6687
+ if (response.type === "pdf") {
6688
+ const approvedPdf = pdfMetaOf(nodeId, approvedRaw);
6689
+ if (approvedPdf) response.pdf = approvedPdf;
6690
+ }
6415
6691
  } catch (err) {
6416
6692
  console.error(
6417
6693
  "reconstructVersion failed (public single)",
@@ -6494,6 +6770,7 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
6494
6770
  changeNote: body.changeNote,
6495
6771
  schedule: body.schedule,
6496
6772
  prime: body.prime,
6773
+ client: attributeRequest(c, body.client),
6497
6774
  type: body.type
6498
6775
  },
6499
6776
  authorEmail
@@ -6506,6 +6783,13 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
6506
6783
  await deleteNode(nestId, nodeId, await getUserEmail(c));
6507
6784
  return c.json({ deleted: true });
6508
6785
  });
6786
+ function pdfMetaOf(nodeId, raw) {
6787
+ try {
6788
+ return parseDocument3(`${nodeId}.md`, raw, nodeId).frontmatter.pdf;
6789
+ } catch {
6790
+ return void 0;
6791
+ }
6792
+ }
6509
6793
  async function getUserEmail(c) {
6510
6794
  const userId = c.get("userId");
6511
6795
  const db = getDb();
@@ -7231,6 +7515,17 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
7231
7515
  }
7232
7516
  }
7233
7517
  const tags = [...tagCounts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count);
7518
+ for (const docIds of tagToDocs.values()) {
7519
+ const ids = [...docIds].sort();
7520
+ for (let i = 0; i < ids.length; i++) {
7521
+ for (let j = i + 1; j < ids.length; j++) {
7522
+ const key = `${ids[i]}->${ids[j]}`;
7523
+ if (seen.has(key) || seen.has(`${ids[j]}->${ids[i]}`)) continue;
7524
+ seen.add(key);
7525
+ allLinks.push({ source: ids[i], target: ids[j] });
7526
+ }
7527
+ }
7528
+ }
7234
7529
  const exposeStewards = await isStewardshipEnabled(nestId) && !await isPublicReader(nestId, userId);
7235
7530
  const stewardRows = exposeStewards ? (await listStewards({ nestId })).filter(
7236
7531
  // Drop document-scoped rows whose node the caller can't see.
@@ -7879,7 +8174,8 @@ queryRoutes.post("/publish", async (c) => {
7879
8174
  content: doc.content,
7880
8175
  type: doc.type,
7881
8176
  tags: doc.tags,
7882
- scope: doc.scope
8177
+ scope: doc.scope,
8178
+ folder: doc.folder
7883
8179
  },
7884
8180
  userEmail
7885
8181
  );
@@ -7915,6 +8211,9 @@ import { Hono as Hono15 } from "hono";
7915
8211
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7916
8212
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
7917
8213
 
8214
+ // src/mcp/tools.ts
8215
+ import { parseDocument as parseDocument4 } from "@promptowl/contextnest-engine";
8216
+
7918
8217
  // src/telemetry/trace-log.ts
7919
8218
  var PRUNE_EVERY = 500;
7920
8219
  var insertsSincePrune = 0;
@@ -7923,8 +8222,8 @@ async function logTraceEvent(e) {
7923
8222
  const db = getDb();
7924
8223
  await db.run(
7925
8224
  `INSERT INTO api_events
7926
- (ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms)
7927
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
8225
+ (ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms, client_json)
8226
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
7928
8227
  [
7929
8228
  (/* @__PURE__ */ new Date()).toISOString(),
7930
8229
  e.kind,
@@ -7935,7 +8234,8 @@ async function logTraceEvent(e) {
7935
8234
  e.userId ?? null,
7936
8235
  e.userEmail ?? null,
7937
8236
  e.status ?? null,
7938
- e.durationMs ?? null
8237
+ e.durationMs ?? null,
8238
+ serializeClient(e.client)
7939
8239
  ]
7940
8240
  );
7941
8241
  if (++insertsSincePrune >= PRUNE_EVERY) {
@@ -7984,7 +8284,13 @@ async function listTraceEvents(filters = {}) {
7984
8284
  LIMIT ? OFFSET ?`,
7985
8285
  [...args, limit, offset]
7986
8286
  );
7987
- return { events, total };
8287
+ return {
8288
+ events: events.map(({ client_json, ...e }) => {
8289
+ const client = deserializeClient(client_json);
8290
+ return client ? { ...e, client } : e;
8291
+ }),
8292
+ total
8293
+ };
7988
8294
  } catch {
7989
8295
  return { events: [], total: 0 };
7990
8296
  }
@@ -9032,6 +9338,19 @@ function summarizeNode(node, body) {
9032
9338
  body: body ?? void 0
9033
9339
  };
9034
9340
  }
9341
+ function pdfSourceLine(node, body) {
9342
+ if ((node.frontmatter.type || "document") !== "pdf") return null;
9343
+ let pdf = node.frontmatter.pdf;
9344
+ if (body && body.startsWith("---")) {
9345
+ try {
9346
+ pdf = parseDocument4(`${node.id}.md`, body, node.id).frontmatter.pdf ?? pdf;
9347
+ } catch {
9348
+ }
9349
+ }
9350
+ if (!pdf) return "**Source file:** PDF";
9351
+ const pages = `${pdf.pages} page${pdf.pages === 1 ? "" : "s"}`;
9352
+ return `**Source file:** PDF, ${pages}${pdf.text_layer ? "" : ", no text layer"}`;
9353
+ }
9035
9354
  var TOOL_DEFINITIONS = [
9036
9355
  {
9037
9356
  name: "context_init",
@@ -9262,7 +9581,7 @@ var TOOL_DEFINITIONS = [
9262
9581
  tags: {
9263
9582
  type: "array",
9264
9583
  items: { type: "string" },
9265
- description: "Tags to add"
9584
+ 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
9585
  },
9267
9586
  status: {
9268
9587
  type: "string",
@@ -9431,10 +9750,14 @@ var TOOL_DEFINITIONS = [
9431
9750
  type: "object",
9432
9751
  properties: {
9433
9752
  title: { type: "string", description: "Title of the node to submit" },
9753
+ id: {
9754
+ type: "string",
9755
+ 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.'
9756
+ },
9434
9757
  note: { type: "string", description: "Optional note for the reviewer" },
9435
9758
  priority: { type: "string", description: "Priority: low, normal (default), high, urgent" }
9436
- },
9437
- required: ["title"]
9759
+ }
9760
+ // One of id or title — see context_update.
9438
9761
  }
9439
9762
  },
9440
9763
  {
@@ -9572,8 +9895,30 @@ async function resolveLlmBody(ctx, node, gate) {
9572
9895
  async function handleToolCall(toolName, args, ctx) {
9573
9896
  const started = Date.now();
9574
9897
  let status = 200;
9898
+ let client;
9899
+ try {
9900
+ client = mergeClient(ctx.client, parseClientMetadata(args.client));
9901
+ } catch (err) {
9902
+ void logTraceEvent({
9903
+ kind: "mcp",
9904
+ tool: toolName,
9905
+ nestId: ctx.nestId,
9906
+ userId: ctx.userId,
9907
+ userEmail: ctx.userEmail,
9908
+ status: 400,
9909
+ durationMs: Date.now() - started
9910
+ });
9911
+ return toolError(err.message, "VALIDATION_FAILED");
9912
+ }
9913
+ const attributed = { ...ctx, client };
9914
+ if ((toolName === "context_create" || toolName === "context_update") && typeof args.content === "string" && /^\s*---\r?\n[\w-]+:/.test(args.content)) {
9915
+ return toolError(
9916
+ "`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.",
9917
+ "VALIDATION_FAILED"
9918
+ );
9919
+ }
9575
9920
  try {
9576
- return await runTool(toolName, args, ctx);
9921
+ return await runTool(toolName, args, attributed);
9577
9922
  } catch (err) {
9578
9923
  if (isVaultLockTimeout(err) || err instanceof VaultBusyError) {
9579
9924
  status = 503;
@@ -9592,7 +9937,10 @@ async function handleToolCall(toolName, args, ctx) {
9592
9937
  userId: ctx.userId,
9593
9938
  userEmail: ctx.userEmail,
9594
9939
  status,
9595
- durationMs: Date.now() - started
9940
+ durationMs: Date.now() - started,
9941
+ // Reads record attribution HERE — the activity trace is this edition's
9942
+ // §9.2 access trace, and a read seals no version to carry it.
9943
+ client
9596
9944
  });
9597
9945
  }
9598
9946
  }
@@ -9787,7 +10135,8 @@ ${list}`,
9787
10135
  const meta = [
9788
10136
  `**Title:** ${node.frontmatter.title}`,
9789
10137
  `**Type:** ${node.frontmatter.type || "document"}`,
9790
- node.frontmatter.tags?.length ? `**Tags:** ${node.frontmatter.tags.join(" ")}` : null
10138
+ node.frontmatter.tags?.length ? `**Tags:** ${node.frontmatter.tags.join(" ")}` : null,
10139
+ pdfSourceLine(node, body)
9791
10140
  ].filter(Boolean).join("\n");
9792
10141
  return {
9793
10142
  text: `${meta}
@@ -10050,7 +10399,8 @@ ${sections}`;
10050
10399
  type: args.type,
10051
10400
  tags: args.tags,
10052
10401
  scope: args.scope,
10053
- folder: args.folder
10402
+ folder: args.folder,
10403
+ client: ctx.client
10054
10404
  },
10055
10405
  userEmail
10056
10406
  );
@@ -10078,16 +10428,16 @@ ${sections}`;
10078
10428
  "UNAUTHORIZED_ACTION"
10079
10429
  );
10080
10430
  }
10081
- const mergedTags = args.tags ? [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...args.tags])] : void 0;
10082
10431
  const { node: updated, version } = await updateNode(
10083
10432
  nestId,
10084
10433
  node.id,
10085
10434
  {
10086
10435
  content: args.content,
10087
10436
  append: args.append,
10088
- tags: mergedTags,
10437
+ tags: args.tags,
10089
10438
  status: args.status,
10090
- changeNote: args.changeNote
10439
+ changeNote: args.changeNote,
10440
+ client: ctx.client
10091
10441
  },
10092
10442
  userEmail
10093
10443
  );
@@ -10140,10 +10490,11 @@ Use context_request_deletion to ask a nest owner/admin to delete it, with a reas
10140
10490
  args.folder,
10141
10491
  userEmail
10142
10492
  );
10493
+ const data = { id: moved.id, previous_id: previousId, title: moved.frontmatter.title };
10143
10494
  if (previousId === moved.id) {
10144
- return `**${moved.frontmatter.title}** is already at \`${moved.id}\` \u2014 nothing to move.`;
10495
+ return { text: `**${moved.frontmatter.title}** is already at \`${moved.id}\` \u2014 nothing to move.`, data };
10145
10496
  }
10146
- return `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}\``;
10497
+ return { text: `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}\``, data };
10147
10498
  }
10148
10499
  // ─── Governance Tool Handlers ──────────────────────────────────────
10149
10500
  case "context_stewards": {
@@ -10386,7 +10737,8 @@ Submit one with \`context_submit_review\`.`;
10386
10737
  requestedBy: ctx.userEmail,
10387
10738
  note: args.note,
10388
10739
  priority: args.priority,
10389
- baseUrl: ctx.baseUrl
10740
+ baseUrl: ctx.baseUrl,
10741
+ client: ctx.client
10390
10742
  });
10391
10743
  const resolved2 = await resolveStewardsForNode(
10392
10744
  ctx.nestId,
@@ -10437,7 +10789,8 @@ ${resolved2.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` :
10437
10789
  nodeId: node.id,
10438
10790
  approvedBy: ctx.userEmail,
10439
10791
  note: args.note,
10440
- baseUrl: ctx.baseUrl
10792
+ baseUrl: ctx.baseUrl,
10793
+ client: ctx.client
10441
10794
  });
10442
10795
  return `Approved "${args.title}" v${request.version}. This version is now live for AI queries.${args.note ? `
10443
10796
  Note: ${args.note}` : ""}`;
@@ -10478,7 +10831,9 @@ Reason: ${args.note}`;
10478
10831
  };
10479
10832
  }
10480
10833
  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}"` : ""}`
10834
+ (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
10835
+ // structured payload — a chat client reads only this half.
10836
+ v.client?.agent ? ` [via ${v.client.agent}]` : ""}`
10482
10837
  ).join("\n");
10483
10838
  return {
10484
10839
  text: `# Version History: ${label}
@@ -10502,7 +10857,11 @@ ${list}`,
10502
10857
  status: v.status,
10503
10858
  edited_by: v.editedBy,
10504
10859
  edited_at: v.editedAt,
10505
- note: v.changeNote
10860
+ note: v.changeNote,
10861
+ // §9.4: which agent wrote this version, in which session. Omitted
10862
+ // (not `{}`) when the write carried no attribution — "not
10863
+ // attributed" and "attributed to nobody" are different claims.
10864
+ ...v.client ? { client: v.client } : {}
10506
10865
  }))
10507
10866
  }
10508
10867
  };
@@ -10563,8 +10922,8 @@ ${list}`,
10563
10922
  if (!target) return "target is required (a node id or folder prefix).";
10564
10923
  if (!["read", "write"].includes(role)) return "role must be read or write.";
10565
10924
  try {
10566
- const { createGrant: createGrant2 } = await import("./grants-service-UT3EQ3R7.js");
10567
- const db = (await import("./client-KEY4PYJH.js")).getDb();
10925
+ const { createGrant: createGrant2 } = await import("./grants-service-2OTFS7EH.js");
10926
+ const db = (await import("./client-D44ZHDTJ.js")).getDb();
10568
10927
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
10569
10928
  const e = normalizeEmail2(String(args.email || ""));
10570
10929
  if (!e) return "email is required.";
@@ -10613,7 +10972,8 @@ ${list}`;
10613
10972
  const { nest, documents } = await syncUnsyncedFolder(
10614
10973
  userId,
10615
10974
  args.name,
10616
- userEmail
10975
+ userEmail,
10976
+ ctx.client
10617
10977
  );
10618
10978
  return `Synced folder "${args.name}" \u2192 nest **${nest.name}** (${nest.id}) with ${documents} document(s).`;
10619
10979
  } catch (err) {
@@ -10636,7 +10996,7 @@ async function getUserEmail2(userId) {
10636
10996
  const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
10637
10997
  return user?.email || "anonymous@localhost";
10638
10998
  }
10639
- function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
10999
+ function createMcpServerForNest(nestId, userId, userEmail, baseUrl, c) {
10640
11000
  const server = new McpServer(
10641
11001
  { name: `contextnest-${nestId}`, version: "1.0.0" },
10642
11002
  { capabilities: { tools: {} } }
@@ -10647,16 +11007,18 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
10647
11007
  const required = tool.inputSchema.required || [];
10648
11008
  const shape = {};
10649
11009
  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
- }
11010
+ let field2;
11011
+ if (def.type === "string") field2 = z.string();
11012
+ else if (def.type === "number") field2 = z.number();
11013
+ else if (def.type === "array") field2 = z.array(z.string());
11014
+ else field2 = z.any();
11015
+ if (!required.includes(key)) field2 = field2.optional();
11016
+ shape[key] = field2;
11017
+ }
11018
+ Object.assign(shape, clientToolField);
10658
11019
  server.tool(tool.name, tool.description, shape, async (args) => {
10659
11020
  const engine = await engineCache.get(nestId);
11021
+ const client = attributeRequest(c, void 0, connectionClient(server));
10660
11022
  const out = await handleToolCall(tool.name, args, {
10661
11023
  storage: engine.storage,
10662
11024
  queryEngine: engine.query,
@@ -10664,7 +11026,8 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
10664
11026
  nestId,
10665
11027
  userId,
10666
11028
  userEmail,
10667
- baseUrl
11029
+ baseUrl,
11030
+ client
10668
11031
  });
10669
11032
  return toCallToolResult(out);
10670
11033
  });
@@ -10679,7 +11042,8 @@ mcpRoutes.all("/", async (c) => {
10679
11042
  nestId,
10680
11043
  userId,
10681
11044
  userEmail,
10682
- requestBaseUrl(c.req.url)
11045
+ requestBaseUrl(c.req.url),
11046
+ c
10683
11047
  );
10684
11048
  const transport = new WebStandardStreamableHTTPServerTransport({
10685
11049
  sessionIdGenerator: void 0,
@@ -11749,8 +12113,185 @@ assetRoutes.get("/:file", async (c) => {
11749
12113
  });
11750
12114
  });
11751
12115
 
11752
- // src/auth/community-sites-routes.ts
12116
+ // src/nodes/pdf-routes.ts
11753
12117
  import { Hono as Hono22 } from "hono";
12118
+ import { bodyLimit } from "hono/body-limit";
12119
+ import { createHash as createHash3 } from "crypto";
12120
+ import { isPdf, readPdfBinary as readPdfBinary3 } from "@promptowl/contextnest-engine";
12121
+ var pdfNodeRoutes = new Hono22();
12122
+ var PDF_UPLOAD_PATH = /^\/nests\/[^/]+\/nodes\/pdf\/?$/;
12123
+ function pdfMaxRequestBytes() {
12124
+ return config.PDF_MAX_BYTES + 1024 * 1024;
12125
+ }
12126
+ function tooLarge(bytes) {
12127
+ return new PayloadTooLargeError(
12128
+ `PDF too large (${(bytes / (1024 * 1024)).toFixed(1)} MB, max ${config.PDF_MAX_BYTES / (1024 * 1024)} MB)`
12129
+ );
12130
+ }
12131
+ function field(body, name) {
12132
+ const v = body[name];
12133
+ const one = Array.isArray(v) ? v[0] : v;
12134
+ return typeof one === "string" ? one.trim() : "";
12135
+ }
12136
+ function tagsField(body) {
12137
+ const v = body["tags"];
12138
+ if (v === void 0) return void 0;
12139
+ const raw = (Array.isArray(v) ? v : [v]).filter((x) => typeof x === "string");
12140
+ const tags = raw.flatMap((s) => s.split(",")).map((t) => t.trim().replace(/^#/, "")).filter(Boolean);
12141
+ return tags;
12142
+ }
12143
+ function titleFromFilename(name) {
12144
+ return name.split(/[/\\]/).pop().replace(/\.pdf$/i, "").replace(/[_]+/g, " ").trim().slice(0, 200);
12145
+ }
12146
+ pdfNodeRoutes.post(
12147
+ "/pdf",
12148
+ (c, next) => bodyLimit({
12149
+ maxSize: pdfMaxRequestBytes(),
12150
+ onError: (c2) => c2.json(
12151
+ {
12152
+ error: `PDF too large (max ${config.PDF_MAX_BYTES / (1024 * 1024)} MB)`
12153
+ },
12154
+ 413
12155
+ )
12156
+ })(c, next),
12157
+ async (c) => {
12158
+ const nestId = c.req.param("nestId");
12159
+ const lenStr = c.req.header("Content-Length");
12160
+ if (lenStr) {
12161
+ const len = parseInt(lenStr, 10);
12162
+ if (Number.isFinite(len) && len > pdfMaxRequestBytes()) throw tooLarge(len);
12163
+ }
12164
+ const body = await c.req.parseBody({ all: true });
12165
+ const fileField = Array.isArray(body["file"]) ? body["file"][0] : body["file"];
12166
+ if (!(fileField instanceof File)) {
12167
+ throw new ValidationError('multipart field "file" (a PDF) is required');
12168
+ }
12169
+ const file = fileField;
12170
+ if (file.size > config.PDF_MAX_BYTES) throw tooLarge(file.size);
12171
+ const bytes = new Uint8Array(await file.arrayBuffer());
12172
+ if (!isPdf(bytes)) {
12173
+ throw new ValidationError("That file is not a PDF (it does not start with %PDF-).");
12174
+ }
12175
+ const userEmail = await resolveCallerEmail(c.get("userId"));
12176
+ const title = field(body, "title");
12177
+ const tags = tagsField(body);
12178
+ const client = attributeRequest(c, void 0);
12179
+ const pdf = { bytes, filename: file.name || void 0 };
12180
+ const targetId = field(body, "nodeId");
12181
+ if (targetId) {
12182
+ assertSafeNodeId(targetId);
12183
+ const changeNote = field(body, "changeNote") || "Replaced the PDF";
12184
+ const { node: node2, version: version2 } = await updateNode(
12185
+ nestId,
12186
+ targetId,
12187
+ {
12188
+ pdf,
12189
+ ...title ? { title } : {},
12190
+ ...tags ? { tags } : {},
12191
+ changeNote,
12192
+ client
12193
+ },
12194
+ userEmail
12195
+ );
12196
+ return c.json({ node: toNodeResponse(node2), version: version2 });
12197
+ }
12198
+ const folder = field(body, "folder");
12199
+ const { node, version } = await createNode(
12200
+ nestId,
12201
+ {
12202
+ title: title || titleFromFilename(file.name || "") || "Untitled PDF",
12203
+ content: "",
12204
+ type: PDF_NODE_TYPE,
12205
+ ...tags ? { tags } : {},
12206
+ ...folder ? { folder } : {},
12207
+ client,
12208
+ pdf
12209
+ },
12210
+ userEmail
12211
+ );
12212
+ const resolved = await resolveStewardsForNode(nestId, node.id);
12213
+ return c.json(
12214
+ {
12215
+ node: toNodeResponse(node),
12216
+ version,
12217
+ stewards: resolved.length > 0 ? resolved.map((r) => ({
12218
+ email: r.steward.userEmail,
12219
+ role: r.steward.role,
12220
+ source: r.source
12221
+ })) : void 0
12222
+ },
12223
+ 201
12224
+ );
12225
+ }
12226
+ );
12227
+ pdfNodeRoutes.get("/:nodeId{.+}/pdf", async (c, next) => {
12228
+ const nestId = c.req.param("nestId");
12229
+ const nodeId = c.req.param("nodeId");
12230
+ const userId = c.get("userId");
12231
+ const userEmail = await resolveCallerEmail(userId);
12232
+ let node;
12233
+ try {
12234
+ node = await getNodeForCaller(nestId, { id: nodeId }, userId, userEmail);
12235
+ } catch {
12236
+ return next();
12237
+ }
12238
+ if (node.frontmatter.type !== PDF_NODE_TYPE) return next();
12239
+ const requested = c.req.query("version");
12240
+ if (requested !== void 0 && !/^[1-9]\d*$/.test(requested)) {
12241
+ throw new ValidationError("version must be a positive integer");
12242
+ }
12243
+ const approvedOnly = c.req.query("approved_only") === "1" || c.req.query("approved_only") === "true";
12244
+ let version;
12245
+ if (approvedOnly || await isPublicReader(nestId, userId)) {
12246
+ const approved = await getApprovedVersion(nestId, nodeId);
12247
+ if (approved == null) {
12248
+ throw new NotFoundError("This document has no approved version yet.");
12249
+ }
12250
+ version = approved;
12251
+ } else if (requested !== void 0) {
12252
+ version = Number(requested);
12253
+ }
12254
+ const { storage } = await engineCache.get(nestId);
12255
+ let bytes;
12256
+ try {
12257
+ bytes = await readPdfBinary3(
12258
+ storage,
12259
+ nodeId,
12260
+ version !== void 0 ? { version } : {}
12261
+ );
12262
+ } catch (err) {
12263
+ const code = err?.code;
12264
+ if (code === "INTEGRITY_ERROR") {
12265
+ console.error("[pdf] integrity failure serving", nestId, nodeId, version, err);
12266
+ throw new AppError(
12267
+ 500,
12268
+ "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."
12269
+ );
12270
+ }
12271
+ throw new NotFoundError(
12272
+ version !== void 0 ? `Version ${version} of this PDF was not found.` : "This document's PDF was not found."
12273
+ );
12274
+ }
12275
+ const etag = `"sha256:${createHash3("sha256").update(bytes).digest("hex")}"`;
12276
+ const leaf = (nodeId.split("/").pop() || "document").replace(/[^A-Za-z0-9._-]/g, "-");
12277
+ const disposition = c.req.query("download") === "1" ? "attachment" : "inline";
12278
+ const headers = {
12279
+ "Content-Type": "application/pdf",
12280
+ "Content-Disposition": `${disposition}; filename="${leaf}.pdf"`,
12281
+ "Content-Security-Policy": "sandbox",
12282
+ "X-Content-Type-Options": "nosniff",
12283
+ "Cache-Control": "private, no-cache",
12284
+ ETag: etag
12285
+ };
12286
+ const ifNoneMatch = c.req.header("If-None-Match");
12287
+ if (ifNoneMatch && ifNoneMatch.split(",").map((t) => t.trim()).includes(etag)) {
12288
+ return c.body(null, 304, headers);
12289
+ }
12290
+ return c.body(new Uint8Array(bytes), 200, headers);
12291
+ });
12292
+
12293
+ // src/auth/community-sites-routes.ts
12294
+ import { Hono as Hono23 } from "hono";
11754
12295
  var serverAdminAllowed = async (c) => config.AUTH_MODE === "open" || await isServerAdminUserId(c.get("userId"));
11755
12296
  var isHttpUrl = (v) => /^https?:\/\/\S+$/i.test(v);
11756
12297
  var serializeSite = (site) => ({
@@ -11762,7 +12303,7 @@ var serializeSite = (site) => ({
11762
12303
  created_at: site.created_at,
11763
12304
  updated_at: site.updated_at
11764
12305
  });
11765
- var communitySiteRoutes = new Hono22();
12306
+ var communitySiteRoutes = new Hono23();
11766
12307
  communitySiteRoutes.get("/", async (c) => {
11767
12308
  if (!await serverAdminAllowed(c))
11768
12309
  return c.json({ error: "Only the server admin can view this." }, 403);
@@ -11850,7 +12391,7 @@ communitySiteRoutes.delete("/:id", async (c) => {
11850
12391
  });
11851
12392
 
11852
12393
  // src/mcp/server-routes.ts
11853
- import { Hono as Hono23 } from "hono";
12394
+ import { Hono as Hono24 } from "hono";
11854
12395
  import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
11855
12396
  import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
11856
12397
  import { z as z2 } from "zod";
@@ -11973,116 +12514,39 @@ var NEST_ARG_OPTIONAL = {
11973
12514
  description: "Target nest \u2014 id or name. Omit to search/list across ALL accessible nests."
11974
12515
  }
11975
12516
  };
12517
+ var SERVER_NEST_TOOLS = [
12518
+ // Fan-out reads — `nest` optional: omit it to span every accessible nest.
12519
+ ...FANOUT_READ_TOOLS,
12520
+ // Targeted reads and writes — `nest` required.
12521
+ "context_get",
12522
+ "context_resolve",
12523
+ "context_comments",
12524
+ "context_versions",
12525
+ "context_create",
12526
+ "context_update",
12527
+ "context_delete",
12528
+ "context_move",
12529
+ "context_submit_review"
12530
+ ];
12531
+ function withNestArg(tool) {
12532
+ const fanout = FANOUT_READ_TOOLS.has(tool.name);
12533
+ const schema = tool.inputSchema;
12534
+ return {
12535
+ name: tool.name,
12536
+ 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.`,
12537
+ inputSchema: {
12538
+ ...schema,
12539
+ properties: { ...fanout ? NEST_ARG_OPTIONAL : NEST_ARG, ...schema.properties },
12540
+ required: [...fanout ? [] : ["nest"], ...schema.required ?? []]
12541
+ }
12542
+ };
12543
+ }
11976
12544
  var SERVER_TOOL_DEFINITIONS = [
11977
12545
  {
11978
12546
  name: "nest_index",
11979
12547
  description: "Grounded index of every nest these credentials can read: name, id, permission, document count. Call this FIRST.",
11980
12548
  inputSchema: { type: "object", properties: {} }
11981
12549
  },
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
12550
  {
12087
12551
  name: "nest_create",
12088
12552
  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 +12559,18 @@ var SERVER_TOOL_DEFINITIONS = [
12095
12559
  required: ["name"]
12096
12560
  }
12097
12561
  },
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
- }
12562
+ ...SERVER_NEST_TOOLS.map((name) => {
12563
+ const tool = TOOL_DEFINITIONS.find((t) => t.name === name);
12564
+ if (!tool) throw new Error(`server-level tool ${name} has no per-nest definition`);
12565
+ return withNestArg(tool);
12566
+ })
12179
12567
  ];
12180
- async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
12568
+ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl, client) {
12181
12569
  if (toolName === "nest_index") {
12182
12570
  const nests = await buildNestIndex(userId, nestScope);
12183
- if (!nests.length) return "No nests accessible with these credentials.";
12184
- return renderNestIndexMarkdown(nests, baseUrl);
12571
+ const data = { nests };
12572
+ if (!nests.length) return { text: "No nests accessible with these credentials.", data };
12573
+ return { text: renderNestIndexMarkdown(nests, baseUrl), data };
12185
12574
  }
12186
12575
  if (SERVER_WRITE_TOOLS.has(toolName)) {
12187
12576
  if (isSuspended()) {
@@ -12213,9 +12602,6 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
12213
12602
  const nests = await buildNestIndex(userId, nestScope);
12214
12603
  if (!nests.length) return "No nests accessible with these credentials.";
12215
12604
  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
12605
  const results = await Promise.allSettled(
12220
12606
  nonEmpty.map(async (n) => {
12221
12607
  const engine2 = await engineCache.get(n.id);
@@ -12226,25 +12612,66 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
12226
12612
  nestId: n.id,
12227
12613
  userId,
12228
12614
  userEmail,
12229
- baseUrl
12615
+ baseUrl,
12616
+ // Same attribution the single-nest branch below passes (§9.4). A
12617
+ // fan-out read is still one call per nest on the trace, and each row
12618
+ // has to name the caller — without this the derived agent/session is
12619
+ // dropped and only an explicit `client` in the args survives.
12620
+ client
12230
12621
  });
12231
12622
  return { nest: n, result };
12232
12623
  })
12233
12624
  );
12234
12625
  const parts = [];
12626
+ let firstError;
12627
+ let firstRejection;
12628
+ const tag = (n, items) => Array.isArray(items) ? items.map((it) => ({ ...it, nest: { id: n.id, name: n.name } })) : [];
12629
+ const perNestDocs = [];
12630
+ const perNestHits = [];
12631
+ let count = 0;
12632
+ let nodesTraversed = 0;
12633
+ let hopsUsed = 0;
12634
+ let mode;
12235
12635
  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})
12240
-
12241
- ${text}`);
12636
+ if (r.status !== "fulfilled") {
12637
+ firstRejection ??= r;
12638
+ continue;
12242
12639
  }
12640
+ const { nest: n, result } = r.value;
12641
+ if (typeof result !== "string" && result.isError) {
12642
+ firstError ??= result;
12643
+ continue;
12644
+ }
12645
+ parts.push(`## ${n.name} (${n.id})
12646
+
12647
+ ${typeof result === "string" ? result : result.text}`);
12648
+ const d = typeof result === "string" ? void 0 : result.data;
12649
+ if (!d) continue;
12650
+ perNestDocs.push(tag(n, d.documents));
12651
+ perNestHits.push(tag(n, d.results));
12652
+ count += d.count ?? 0;
12653
+ nodesTraversed += d.traversal?.nodes_traversed ?? 0;
12654
+ hopsUsed = Math.max(hopsUsed, d.traversal?.hops_used ?? 0);
12655
+ mode ??= d.traversal?.mode;
12243
12656
  }
12244
- if (!parts.length) {
12245
- return "No results found across any accessible nests.";
12657
+ const interleave = (lists) => {
12658
+ const out = [];
12659
+ for (let i = 0; lists.some((l) => i < l.length); i++) {
12660
+ for (const l of lists) if (i < l.length) out.push(l[i]);
12661
+ }
12662
+ return out;
12663
+ };
12664
+ if (!parts.length && firstError) return firstError;
12665
+ if (!parts.length && firstRejection) throw firstRejection.reason;
12666
+ const documents = interleave(perNestDocs);
12667
+ const listLimit = typeof args.limit === "number" && args.limit >= 1 ? Math.floor(args.limit) : 50;
12668
+ 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) };
12669
+ const reply = (text) => toolName === "context_overview" ? text : { text, data };
12670
+ if (!nonEmpty.length) {
12671
+ return reply("All accessible nests are empty. Use context_create to add documents first.");
12246
12672
  }
12247
- return parts.join("\n\n---\n\n");
12673
+ if (!parts.length) return reply("No results found across any accessible nests.");
12674
+ return reply(parts.join("\n\n---\n\n"));
12248
12675
  }
12249
12676
  const inaccessible = `Nest not found or not accessible: "${nestArg}". Use nest_index to see what these credentials can read.`;
12250
12677
  if (!nestArg) {
@@ -12262,10 +12689,11 @@ ${text}`);
12262
12689
  nestId: nest.id,
12263
12690
  userId,
12264
12691
  userEmail,
12265
- baseUrl
12692
+ baseUrl,
12693
+ client
12266
12694
  });
12267
12695
  }
12268
- function createServerMcp(userId, userEmail, nestScope, baseUrl) {
12696
+ function createServerMcp(userId, userEmail, nestScope, baseUrl, c) {
12269
12697
  const server = new McpServer2(
12270
12698
  { name: "contextnest-server", version: "1.0.0" },
12271
12699
  { capabilities: { tools: {} } }
@@ -12275,14 +12703,15 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
12275
12703
  const required = tool.inputSchema.required || [];
12276
12704
  const shape = {};
12277
12705
  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
- }
12706
+ let field2;
12707
+ if (def.type === "string") field2 = z2.string();
12708
+ else if (def.type === "number") field2 = z2.number();
12709
+ else if (def.type === "array") field2 = z2.array(z2.string());
12710
+ else field2 = z2.any();
12711
+ if (!required.includes(key)) field2 = field2.optional();
12712
+ shape[key] = field2;
12713
+ }
12714
+ Object.assign(shape, clientToolField);
12286
12715
  server.tool(tool.name, tool.description, shape, async (args) => {
12287
12716
  const out = await handleServerToolCall(
12288
12717
  tool.name,
@@ -12290,14 +12719,15 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
12290
12719
  userId,
12291
12720
  userEmail,
12292
12721
  nestScope,
12293
- baseUrl
12722
+ baseUrl,
12723
+ attributeRequest(c, void 0, connectionClient(server))
12294
12724
  );
12295
12725
  return toCallToolResult(out);
12296
12726
  });
12297
12727
  }
12298
12728
  return server;
12299
12729
  }
12300
- var serverMcpRoutes = new Hono23();
12730
+ var serverMcpRoutes = new Hono24();
12301
12731
  serverMcpRoutes.all("/", async (c) => {
12302
12732
  const userId = c.get("userId");
12303
12733
  const userEmail = await getUserEmail3(userId);
@@ -12305,7 +12735,8 @@ serverMcpRoutes.all("/", async (c) => {
12305
12735
  userId,
12306
12736
  userEmail,
12307
12737
  c.get("nestScope"),
12308
- requestBaseUrl(c.req.url)
12738
+ requestBaseUrl(c.req.url),
12739
+ c
12309
12740
  );
12310
12741
  const transport = new WebStandardStreamableHTTPServerTransport2({
12311
12742
  sessionIdGenerator: void 0,
@@ -12319,7 +12750,7 @@ serverMcpRoutes.all("/", async (c) => {
12319
12750
  await server.server.close();
12320
12751
  }
12321
12752
  });
12322
- var nestIndexRoutes = new Hono23();
12753
+ var nestIndexRoutes = new Hono24();
12323
12754
  nestIndexRoutes.get("/", async (c) => {
12324
12755
  const userId = c.get("userId");
12325
12756
  const nests = await buildNestIndex(userId, c.get("nestScope"));
@@ -12413,6 +12844,20 @@ Write endpoints (need write permission):
12413
12844
  - \`GET ${B}/me/drafts\` \u2014 your documents never submitted for review
12414
12845
  - \`GET ${B}/me/notifications\` \u2014 watcher and review notifications
12415
12846
 
12847
+ ## Say who you are: \`client\`
12848
+
12849
+ Every write body and every MCP tool call takes an optional
12850
+ \`client\` object \u2014 \`{ "agent": "claude-code", "session_id": "s-9f2" }\`, plus any
12851
+ custom scalar keys. A write that seals a version records it on that version
12852
+ (\`GET /nodes/<id>/versions\` and \`context_versions\` serve it back); every call
12853
+ records it on this server's activity trace, which is how a READ gets attributed.
12854
+
12855
+ Send it. Nothing requires it and no call is refused for omitting it, but a vault
12856
+ serving several agents cannot otherwise tell which of you wrote what. It is a
12857
+ label, never an identity claim: it is not authenticated, grants nothing, and is
12858
+ never part of a hash. Bounded \u2014 scalar values up to 512 chars, at most 16 custom
12859
+ keys beyond \`agent\` and \`session_id\`.
12860
+
12416
12861
  ## MCP (Model Context Protocol)
12417
12862
 
12418
12863
  Streamable HTTP, stateless. Two endpoints:
@@ -12421,7 +12866,7 @@ Streamable HTTP, stateless. Two endpoints:
12421
12866
  Tools: \`nest_index\` (call first), then:
12422
12867
  - Fan-out reads (omit \`nest\` to search/list ALL nests): \`context_search\`,
12423
12868
  \`context_list\`, \`context_query\`, \`context_overview\`
12424
- - Targeted reads (\`nest\` required): \`context_get\`, \`context_resolve\`, \`context_comments\`
12869
+ - Targeted reads (\`nest\` required): \`context_get\`, \`context_resolve\`, \`context_comments\`, \`context_versions\`
12425
12870
  - Writes (\`nest\` required): \`nest_create\`, \`context_create\`, \`context_update\`,
12426
12871
  \`context_delete\`, \`context_move\`, \`context_submit_review\`
12427
12872
  - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, the full toolset. Same names as the
@@ -12458,9 +12903,11 @@ on the command line.
12458
12903
  export CONTEXTNEST_API_KEY=<api-key>
12459
12904
  # every nest these credentials can read, through the server-level endpoint
12460
12905
  ctx vault add contextnest --url ${B}/mcp --bearer-env CONTEXTNEST_API_KEY
12461
- # or one nest
12906
+ ctx vault list # lists contextnest/<nest> for each nest
12907
+ ctx search "pricing" --vault contextnest # reads span every nest
12908
+ ctx update nodes/x --tags a,b --vault contextnest/<nest> # a write names one nest
12909
+ # or pin one nest by its own endpoint
12462
12910
  ctx vault add mynest --url ${B}/nests/<nest-id>/mcp --bearer-env CONTEXTNEST_API_KEY
12463
- ctx query "#gtm" --vault mynest
12464
12911
  \`\`\`
12465
12912
 
12466
12913
  Servers in "open" auth mode: drop \`--bearer-env\`. Pushing a local folder up
@@ -12479,14 +12926,14 @@ is \`ctx push --server ${B} --nest <nest-id>\` with the same env var set.
12479
12926
  through steward review before they become AI-visible.
12480
12927
  `;
12481
12928
  }
12482
- var llmsTxtRoutes = new Hono23();
12929
+ var llmsTxtRoutes = new Hono24();
12483
12930
  llmsTxtRoutes.get("/", (c) => {
12484
12931
  const md = renderLlmsTxt(requestBaseUrl(c.req.url));
12485
12932
  return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
12486
12933
  });
12487
12934
 
12488
12935
  // src/governance/routes.ts
12489
- import { Hono as Hono24 } from "hono";
12936
+ import { Hono as Hono25 } from "hono";
12490
12937
 
12491
12938
  // src/governance/feed-service.ts
12492
12939
  var SNIPPET_CHARS = 200;
@@ -12918,7 +13365,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
12918
13365
  if (Number.isNaN(n) || n < 1) return def;
12919
13366
  return Math.min(n, max);
12920
13367
  }
12921
- var governanceRoutes = new Hono24();
13368
+ var governanceRoutes = new Hono25();
12922
13369
  governanceRoutes.get("/stewards", async (c) => {
12923
13370
  const nestId = c.req.param("nestId");
12924
13371
  const scope = c.req.query("scope");
@@ -13058,6 +13505,7 @@ governanceRoutes.post("/review-queue/bulk", async (c) => {
13058
13505
  }
13059
13506
  const userEmail = await getUserEmail4(c);
13060
13507
  const baseUrl = requestBaseUrl(c.req.url);
13508
+ const client = attributeRequest(c, body.client);
13061
13509
  const results = [];
13062
13510
  for (const nodeId of nodeIds) {
13063
13511
  try {
@@ -13068,7 +13516,8 @@ governanceRoutes.post("/review-queue/bulk", async (c) => {
13068
13516
  approvedBy: userEmail,
13069
13517
  note: body.note,
13070
13518
  override: false,
13071
- baseUrl
13519
+ baseUrl,
13520
+ client
13072
13521
  });
13073
13522
  } else {
13074
13523
  await reject({
@@ -13286,7 +13735,7 @@ governanceRoutes.get("/activity", async (c) => {
13286
13735
  const activity = await getActivity({ nestId, limit });
13287
13736
  return c.json({ activity });
13288
13737
  });
13289
- var governanceNodeRoutes = new Hono24();
13738
+ var governanceNodeRoutes = new Hono25();
13290
13739
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
13291
13740
  const nestId = c.req.param("nestId");
13292
13741
  const nodeId = c.req.param("nodeId");
@@ -13492,7 +13941,10 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
13492
13941
  requestedBy: userEmail,
13493
13942
  note: body.note,
13494
13943
  priority: body.priority,
13495
- baseUrl: requestBaseUrl(c.req.url)
13944
+ baseUrl: requestBaseUrl(c.req.url),
13945
+ // Submitting seals the draft into the engine's append-only chain, so the
13946
+ // block lands on that entry as well as on this request's trace row.
13947
+ client: attributeRequest(c, body.client)
13496
13948
  });
13497
13949
  } catch (err) {
13498
13950
  const msg = err instanceof Error ? err.message : String(err);
@@ -13626,7 +14078,8 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
13626
14078
  approvedBy: userEmail,
13627
14079
  note: body.note,
13628
14080
  override: body.override && isAdmin,
13629
- baseUrl: requestBaseUrl(c.req.url)
14081
+ baseUrl: requestBaseUrl(c.req.url),
14082
+ client: attributeRequest(c, body.client)
13630
14083
  });
13631
14084
  return c.json({ review: request });
13632
14085
  } catch (err) {
@@ -13641,6 +14094,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
13641
14094
  throw new ValidationError("Rejection note is required");
13642
14095
  }
13643
14096
  const userEmail = await getUserEmail4(c);
14097
+ attributeRequest(c, body.client);
13644
14098
  try {
13645
14099
  const request = await reject({
13646
14100
  nestId,
@@ -13753,6 +14207,26 @@ function posthogKeyError(value) {
13753
14207
  return null;
13754
14208
  }
13755
14209
 
14210
+ // src/shared/public-base-url.ts
14211
+ var UNSAFE = /["'`\\$;|&<>(){}\s]/;
14212
+ function publicBaseUrlError(value) {
14213
+ const v = value.trim();
14214
+ if (!v) return null;
14215
+ let parsed;
14216
+ try {
14217
+ parsed = new URL(v);
14218
+ } catch {
14219
+ return "Public base URL must be a full http(s) URL, e.g. https://nest.example.com";
14220
+ }
14221
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
14222
+ return "Public base URL must use http or https, e.g. https://nest.example.com";
14223
+ if (!parsed.hostname)
14224
+ return "Public base URL must include a hostname, e.g. https://nest.example.com";
14225
+ if (UNSAFE.test(v))
14226
+ return "Public base URL must not contain spaces, quotes or shell characters (\" ' ` \\ $ ; | & < > ( ) { }) \u2014 it is pasted into terminal commands and config files.";
14227
+ return null;
14228
+ }
14229
+
13756
14230
  // src/app.ts
13757
14231
  import { serveStatic } from "@hono/node-server/serve-static";
13758
14232
  import { fileURLToPath } from "url";
@@ -13802,7 +14276,10 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
13802
14276
  c.set("nestScope", null);
13803
14277
  return next();
13804
14278
  }
13805
- return c.json({ error: "Missing or invalid credentials" }, 401);
14279
+ return c.json(
14280
+ { error: "Authentication required \u2014 sign in or provide an API key.", code: "missing_credentials" },
14281
+ 401
14282
+ );
13806
14283
  });
13807
14284
  function nestPathParts(path) {
13808
14285
  return path.replace(/^\/nests\//, "").split("/").filter(Boolean);
@@ -13820,14 +14297,24 @@ var flexAuthWithPromptowlTicket = createMiddleware2(
13820
14297
  return flexAuthMiddleware(c, next);
13821
14298
  }
13822
14299
  let claims = null;
14300
+ let expired = false;
13823
14301
  for (const secret of secrets) {
13824
14302
  try {
13825
14303
  claims = verifySsoTicket(token, secret);
13826
14304
  break;
13827
- } catch {
14305
+ } catch (err) {
14306
+ if (err instanceof SsoTokenError && err.message === "Ticket expired") {
14307
+ expired = true;
14308
+ }
13828
14309
  }
13829
14310
  }
13830
14311
  if (!claims) {
14312
+ if (expired) {
14313
+ return c.json(
14314
+ { error: "Service ticket expired \u2014 reconnect to refresh.", code: "ticket_expired" },
14315
+ 401
14316
+ );
14317
+ }
13831
14318
  return c.json({ error: "Invalid service ticket" }, 401);
13832
14319
  }
13833
14320
  if (claims.scope !== "mcp" || claims.jti) {
@@ -13874,7 +14361,7 @@ async function canApproveInNest(nestId, email) {
13874
14361
  return ok;
13875
14362
  }
13876
14363
  function createApp() {
13877
- const app = new Hono25({ router: new LinearRouter() });
14364
+ const app = new Hono26({ router: new LinearRouter() });
13878
14365
  const corsOrigins = config.CORS_ORIGINS;
13879
14366
  app.use(
13880
14367
  "*",
@@ -13892,7 +14379,8 @@ function createApp() {
13892
14379
  const lenStr = c.req.header("Content-Length");
13893
14380
  if (lenStr) {
13894
14381
  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;
14382
+ const isPost = c.req.method === "POST";
14383
+ 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
14384
  if (Number.isFinite(len) && len > cap) {
13897
14385
  return c.json(
13898
14386
  {
@@ -13917,7 +14405,10 @@ function createApp() {
13917
14405
  nestId: /^\/nests\/([^/]+)/.exec(path)?.[1] ?? null,
13918
14406
  userId: c.get("userId") ?? null,
13919
14407
  status: c.res.status,
13920
- durationMs: Date.now() - started
14408
+ durationMs: Date.now() - started,
14409
+ // Set by whichever handler validated the request's `client` block
14410
+ // (§9.4). Absent on a request that sent none.
14411
+ client: c.get("clientMetadata")
13921
14412
  });
13922
14413
  });
13923
14414
  app.get("/health", (c) => {
@@ -13962,6 +14453,11 @@ function createApp() {
13962
14453
  // API would refuse it — existing nodes of the type stay readable.
13963
14454
  type_artifact_enabled: config.TYPE_ARTIFACT_ENABLED,
13964
14455
  type_table_enabled: config.TYPE_TABLE_ENABLED,
14456
+ // This server hosts several organizations (PromptOwl-hosted) rather than
14457
+ // one. The visibility picker words 'Organization' and 'Public' by it and
14458
+ // confirms before 'org'; the server enforces the same in
14459
+ // PATCH /nests/:id/visibility.
14460
+ shared_deployment: config.SHARED_DEPLOYMENT,
13965
14461
  // Sub-agent runs gate its own recursion surface separately (env-only,
13966
14462
  // must clear a security review — NOT a UI toggle). Exposed read-only so
13967
14463
  // the Runs page shows the "spawn sub-agent" option only when it's live.
@@ -14085,6 +14581,7 @@ function createApp() {
14085
14581
  oidc_auto_provision: config.OIDC_AUTO_PROVISION,
14086
14582
  oidc_department_tagging: config.OIDC_DEPARTMENT_TAGGING,
14087
14583
  people_suggest_all_users: config.PEOPLE_SUGGEST_ALL_USERS,
14584
+ shared_deployment: config.SHARED_DEPLOYMENT,
14088
14585
  // Agent SSO / token-exchange. MCP_SIGNING_SECRET is WRITE-ONLY — only its
14089
14586
  // presence is reported, never the value (same discipline as the OIDC secret).
14090
14587
  sso_token_exchange_enabled: config.SSO_TOKEN_EXCHANGE_ENABLED,
@@ -14137,8 +14634,8 @@ function createApp() {
14137
14634
  return c.json({ error: "Invalid JSON body" }, 400);
14138
14635
  }
14139
14636
  const fieldErrors = {};
14140
- const addError = (field, message) => {
14141
- fieldErrors[field] = message;
14637
+ const addError = (field2, message) => {
14638
+ fieldErrors[field2] = message;
14142
14639
  };
14143
14640
  const pending = [];
14144
14641
  if ("promptowl_sign_in_gate" in body) {
@@ -14159,7 +14656,9 @@ function createApp() {
14159
14656
  }
14160
14657
  if ("public_base_url" in body) {
14161
14658
  const v = String(body.public_base_url ?? "").trim().replace(/\/$/, "");
14162
- pending.push({ name: "PUBLIC_BASE_URL", value: v || null });
14659
+ const err = v ? publicBaseUrlError(v) : null;
14660
+ if (err) addError("public_base_url", err);
14661
+ else pending.push({ name: "PUBLIC_BASE_URL", value: v || null });
14163
14662
  }
14164
14663
  if ("telemetry_enabled" in body) {
14165
14664
  pending.push({ name: "TELEMETRY_ENABLED", value: body.telemetry_enabled ? "true" : "false" });
@@ -14313,6 +14812,12 @@ function createApp() {
14313
14812
  value: body.people_suggest_all_users ? "true" : null
14314
14813
  });
14315
14814
  }
14815
+ if ("shared_deployment" in body) {
14816
+ pending.push({
14817
+ name: "SHARED_DEPLOYMENT",
14818
+ value: body.shared_deployment ? "true" : null
14819
+ });
14820
+ }
14316
14821
  if ("sso_token_exchange_enabled" in body) {
14317
14822
  pending.push({
14318
14823
  name: "SSO_TOKEN_EXCHANGE_ENABLED",
@@ -14363,13 +14868,13 @@ function createApp() {
14363
14868
  const effAllowedDomains = "oidc_allowed_domains" in body ? String(body.oidc_allowed_domains ?? "").split(",").map((d) => d.trim()).filter(Boolean) : config.OIDC_ALLOWED_DOMAINS;
14364
14869
  const effAutoProvision = "oidc_auto_provision" in body ? !!body.oidc_auto_provision : config.OIDC_AUTO_PROVISION;
14365
14870
  if (effAutoProvision && effAllowedDomains.length === 0) {
14366
- for (const field of [
14871
+ for (const field2 of [
14367
14872
  "ms_token_exchange_enabled",
14368
14873
  "google_token_exchange_enabled"
14369
14874
  ]) {
14370
- if (body[field])
14875
+ if (body[field2])
14371
14876
  addError(
14372
- field,
14877
+ field2,
14373
14878
  "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
14879
  );
14375
14880
  }
@@ -14819,7 +15324,7 @@ function createApp() {
14819
15324
  app.route("/index", nestIndexRoutes);
14820
15325
  app.use("/mcp", flexAuthWithPromptowlTicket);
14821
15326
  app.route("/mcp", serverMcpRoutes);
14822
- const nestsApp = new Hono25();
15327
+ const nestsApp = new Hono26();
14823
15328
  nestsApp.use("*", async (c, next) => {
14824
15329
  const parts = nestPathParts(c.req.path);
14825
15330
  const ticketEligible = parts.length === 2 && (c.req.method === "POST" && parts[1] === "context" || c.req.method === "GET" && parts[1] === "nodes" || parts[1] === "mcp");
@@ -14976,7 +15481,9 @@ function createApp() {
14976
15481
  stewardEditorBypass = resolved.some(
14977
15482
  (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor"
14978
15483
  );
14979
- } else if (parts.length === 2 && c.req.method === "POST") {
15484
+ } else if (c.req.method === "POST" && // POST /nodes (create) and POST /nodes/pdf (PDF upload — a create, or
15485
+ // a new version of a pdf node) share the nest-scope editor rule.
15486
+ (parts.length === 2 || parts.length === 3 && parts[2] === "pdf")) {
14980
15487
  const resolved = await resolveStewardsForNode(nestId, "");
14981
15488
  stewardEditorBypass = resolved.some(
14982
15489
  (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor" && r.steward.scope === "nest"
@@ -15038,6 +15545,7 @@ function createApp() {
15038
15545
  });
15039
15546
  nestsApp.route("/", nestRoutes);
15040
15547
  nestsApp.route("/:nestId", governanceRoutes);
15548
+ nestsApp.route("/:nestId/nodes", pdfNodeRoutes);
15041
15549
  nestsApp.route("/:nestId/nodes", annotationRoutes);
15042
15550
  nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
15043
15551
  nestsApp.route("/:nestId/nodes", nodeRoutes);