@promptowl/contextnest-community 1.23.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 (91) hide show
  1. package/API.md +2195 -0
  2. package/CONFIGURATION.md +8 -2
  3. package/README.md +62 -10
  4. package/STEWARDSHIP.md +253 -0
  5. package/dist/{chunk-2YPY2HGZ.js → chunk-2KHM5C7V.js} +40 -5
  6. package/dist/{chunk-XYD6V2LH.js → chunk-32SZCH36.js} +28 -33
  7. package/dist/{chunk-QLXC6542.js → chunk-7FROMOZM.js} +69 -27
  8. package/dist/{chunk-MLQU4I5J.js → chunk-DTLMBF4W.js} +10 -8
  9. package/dist/{chunk-QWNXWRWO.js → chunk-JDAOA5KP.js} +67 -1
  10. package/dist/{chunk-UOMW7TT6.js → chunk-KPGNZLZL.js} +2 -2
  11. package/dist/{chunk-YVMSM7LS.js → chunk-LHWOJPFE.js} +7 -0
  12. package/dist/{chunk-5CVZMHHB.js → chunk-TI7HMP64.js} +99 -12
  13. package/dist/{client-LWDMNKX3.js → client-D44ZHDTJ.js} +1 -1
  14. package/dist/{engine-QFF2IA3G.js → engine-TER6FDBP.js} +3 -3
  15. package/dist/{external-edit-service-F7D3VSA2.js → external-edit-service-CAGENHDJ.js} +4 -4
  16. package/dist/{grants-service-TBGTCO5K.js → grants-service-2OTFS7EH.js} +3 -3
  17. package/dist/index.js +1120 -331
  18. package/dist/{migrations.postgres-APCVSYUE.js → migrations.postgres-23VJJJJX.js} +56 -5
  19. package/dist/{review-service-A7PNLWG5.js → review-service-4G2XUFS6.js} +7 -7
  20. package/dist/{stewardship-service-32UHIRQ5.js → stewardship-service-S22V2JKG.js} +6 -4
  21. package/dist/{version-service-RJ6CWZ3O.js → version-service-X4FAG2OZ.js} +4 -4
  22. package/dist/web3/assets/ActivityTracePage-D2iIbEph.js +1 -0
  23. package/dist/web3/assets/AgentDocsPage-Dkhgb1hk.js +1 -0
  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-BUxBCN8o.js +6 -0
  28. package/dist/web3/assets/ExternalEditsTab-kIBimZyE.js +1 -0
  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-yOK-OIYZ.js +1 -0
  32. package/dist/web3/assets/NestView-DwIagxwJ.js +68 -0
  33. package/dist/web3/assets/OverviewTab-DFOFMVyE.js +1 -0
  34. package/dist/web3/assets/PersonCombobox-D350WlrJ.js +1 -0
  35. package/dist/web3/assets/ReasonDialog-CN3ECAnQ.js +1 -0
  36. package/dist/web3/assets/ReviewActions-BXDTDPp1.js +6 -0
  37. package/dist/web3/assets/ReviewTab-CZewAYiz.js +1 -0
  38. package/dist/web3/assets/StewardsTab-AagNWcWS.js +1 -0
  39. package/dist/web3/assets/SubmitForReviewModal-BOZJxwRN.js +1 -0
  40. package/dist/web3/assets/alert-dialog-4SiLKlz8.js +7 -0
  41. package/dist/web3/assets/arrow-left-BAgIocLn.js +6 -0
  42. package/dist/web3/assets/backlinks-Cb8Uf8mw.js +24 -0
  43. package/dist/web3/assets/card-UDyMRcps.js +1 -0
  44. package/dist/web3/assets/chevron-left-XR4ReJ9Z.js +6 -0
  45. package/dist/web3/assets/circle-check-CSkZUEFK.js +6 -0
  46. package/dist/web3/assets/circle-x-in2o3aHU.js +6 -0
  47. package/dist/web3/assets/client-attribution-BraarYaP.js +1 -0
  48. package/dist/web3/assets/code-xml-pPQgdctI.js +6 -0
  49. package/dist/web3/assets/corner-down-right-CuxZWscX.js +6 -0
  50. package/dist/web3/assets/count-skeleton-DBWCDhRy.js +1 -0
  51. package/dist/web3/assets/dates-BCxbm4_q.js +1 -0
  52. package/dist/web3/assets/earth-CXOcThqn.js +6 -0
  53. package/dist/web3/assets/file-exclamation-point-CSphs1VD.js +6 -0
  54. package/dist/web3/assets/folder-input-BAwK9tFL.js +11 -0
  55. package/dist/web3/assets/folder-target-CUSWqImF.js +1 -0
  56. package/dist/web3/assets/index-A0_ymcqL.js +29 -0
  57. package/dist/web3/assets/index-B5hLclEq.js +389 -0
  58. package/dist/web3/assets/index-DrUAtoQM.css +1 -0
  59. package/dist/web3/assets/page-BHD4beun.js +1 -0
  60. package/dist/web3/assets/page-BSYUXYmO.js +11 -0
  61. package/dist/web3/assets/page-BYc2DIux.js +45 -0
  62. package/dist/web3/assets/page-BfibLg8e.js +1 -0
  63. package/dist/web3/assets/page-CGe8MtUu.js +9 -0
  64. package/dist/web3/assets/page-CIgPJmG6.js +2 -0
  65. package/dist/web3/assets/page-CUjuSs5Q.js +24 -0
  66. package/dist/web3/assets/page-Ck31nx9b.js +1 -0
  67. package/dist/web3/assets/page-Cpdj_11Y.js +1 -0
  68. package/dist/web3/assets/page-D0sN3GO6.js +1 -0
  69. package/dist/web3/assets/page-DEpH91X2.js +1 -0
  70. package/dist/web3/assets/page-l8-B7CGt.js +1 -0
  71. package/dist/web3/assets/page-mnSHmk6A.js +6 -0
  72. package/dist/web3/assets/page-title-BkqlUA0j.js +1 -0
  73. package/dist/web3/assets/page-xzYqkBUP.js +16 -0
  74. package/dist/web3/assets/play-B8xE9j5f.js +6 -0
  75. package/dist/web3/assets/refresh-cw-CsCtmWwX.js +6 -0
  76. package/dist/web3/assets/scroll-area-CbcH6yrZ.js +1 -0
  77. package/dist/web3/assets/scroll-area-dRWncRqa.css +1 -0
  78. package/dist/web3/assets/select-DrL0mpRN.js +6 -0
  79. package/dist/web3/assets/send-CvAgOUPG.js +6 -0
  80. package/dist/web3/assets/settings-EY4A_DYv.js +6 -0
  81. package/dist/web3/assets/share-2-vGmZUl90.js +6 -0
  82. package/dist/web3/assets/tag-Br_y1f00.js +11 -0
  83. package/dist/web3/assets/trash-2-B4hRXo-p.js +6 -0
  84. package/dist/web3/assets/triangle-alert-CsYGyfGZ.js +6 -0
  85. package/dist/web3/assets/user-plus-4CTYEkd3.js +6 -0
  86. package/dist/web3/assets/x-qxt1WrUi.js +6 -0
  87. package/dist/web3/assets/zap-BcsKR0ef.js +16 -0
  88. package/dist/web3/index.html +2 -2
  89. package/package.json +5 -3
  90. package/dist/web3/assets/index-BJ-LRNis.js +0 -1382
  91. package/dist/web3/assets/index-DPMEt-A_.css +0 -1
package/dist/index.js CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  submitForReview,
39
39
  withNodeWriteLock,
40
40
  withdrawDeletionRequest
41
- } from "./chunk-MLQU4I5J.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-2YPY2HGZ.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-UOMW7TT6.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-5CVZMHHB.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-XYD6V2LH.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-QLXC6542.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-QWNXWRWO.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
@@ -1696,6 +1723,15 @@ authRoutes.get("/admin-status", async (c) => {
1696
1723
  me
1697
1724
  });
1698
1725
  });
1726
+ authRoutes.patch("/profile", authMiddleware, async (c) => {
1727
+ const userId = c.get("userId");
1728
+ const body = await c.req.json();
1729
+ if (typeof body.name !== "string") throw new ValidationError("name is required");
1730
+ const name = body.name.trim();
1731
+ if (name.length > 80) throw new ValidationError("name must be 80 characters or fewer");
1732
+ await getDb().run("UPDATE users SET name = ? WHERE id = ?", [name || null, userId]);
1733
+ return c.json({ ok: true, name: name || null });
1734
+ });
1699
1735
  authRoutes.post("/password", authMiddleware, async (c) => {
1700
1736
  const body = await c.req.json();
1701
1737
  if (!body.current || !body.next) {
@@ -1977,7 +2013,9 @@ import {
1977
2013
  DocumentNotFoundError,
1978
2014
  NODE_TYPES,
1979
2015
  TAG_PATTERN,
1980
- generateIndexMd
2016
+ generateIndexMd,
2017
+ readPdfBinary,
2018
+ pdfSidecarPath
1981
2019
  } from "@promptowl/contextnest-engine";
1982
2020
  import { rename, mkdir, rm, stat } from "fs/promises";
1983
2021
  import { dirname, join } from "path";
@@ -2557,8 +2595,40 @@ var ACCEPTED_NODE_TYPES = /* @__PURE__ */ new Set([
2557
2595
  ...NODE_TYPES,
2558
2596
  ...RUNNABLE_NODE_TYPES,
2559
2597
  "artifact",
2560
- "table"
2598
+ "table",
2599
+ "pdf"
2561
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
+ }
2612
+ function assertAcceptedType(type) {
2613
+ if (type === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
2614
+ throw new ValidationError("Artifact nodes are disabled on this server");
2615
+ }
2616
+ if (type === "table" && !config.TYPE_TABLE_ENABLED) {
2617
+ throw new ValidationError("Table nodes are disabled on this server");
2618
+ }
2619
+ if (!ACCEPTED_NODE_TYPES.has(type)) {
2620
+ throw new ValidationError(
2621
+ `unknown node type "${type}"; allowed: ${[...ACCEPTED_NODE_TYPES].sort().join(", ")}`
2622
+ );
2623
+ }
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
+ }
2562
2632
  function toNodeResponse(node) {
2563
2633
  const fm = node.frontmatter;
2564
2634
  const title = fm.title === void 0 || fm.title === null ? "" : String(fm.title);
@@ -2583,7 +2653,8 @@ function toNodeResponse(node) {
2583
2653
  // Same YAML-coercion rule as title above: `schedule: 123` parses as a
2584
2654
  // number — normalize to string at the API boundary.
2585
2655
  schedule: fm.metadata?.schedule != null ? String(fm.metadata.schedule) : void 0,
2586
- prime: documentPrimeFlag(fm.metadata)
2656
+ prime: documentPrimeFlag(fm.metadata),
2657
+ ...fm.type === "pdf" && fm.pdf ? { pdf: fm.pdf } : {}
2587
2658
  };
2588
2659
  }
2589
2660
  async function resolveAuthorship(nestId, nodes) {
@@ -2976,9 +3047,11 @@ async function assertMayFlagPrime(nestId, userEmail) {
2976
3047
  );
2977
3048
  }
2978
3049
  }
2979
- async function autoPublishReason(nestId, userEmail, doc) {
2980
- 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";
2981
3053
  if (await isPrimeDocument(nestId, doc.tags, doc.metadata)) return null;
3054
+ if (opts.creatorReviews) return "creator is the document's reviewer";
2982
3055
  if (await nestReviewsPrimeOnly(nestId)) {
2983
3056
  return "prime-only review, document is not prime";
2984
3057
  }
@@ -3008,17 +3081,13 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
3008
3081
  id = `${prefix}${slug || await generatedSlug(storage, prefix)}`;
3009
3082
  }
3010
3083
  const tags = requireTags(input.tags);
3011
- const requestedType = input.type || "document";
3012
- if (requestedType === "artifact" && !config.TYPE_ARTIFACT_ENABLED) {
3013
- throw new ValidationError("Artifact nodes are disabled on this server");
3084
+ const requestedType = input.pdf ? input.type || PDF_NODE_TYPE : input.type || "document";
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}.`);
3014
3088
  }
3015
- if (requestedType === "table" && !config.TYPE_TABLE_ENABLED) {
3016
- throw new ValidationError("Table nodes are disabled on this server");
3017
- }
3018
- if (!ACCEPTED_NODE_TYPES.has(requestedType)) {
3019
- throw new ValidationError(
3020
- `unknown node type "${requestedType}"; allowed: ${[...ACCEPTED_NODE_TYPES].sort().join(", ")}`
3021
- );
3089
+ if (input.pdf && requestedType !== PDF_NODE_TYPE) {
3090
+ throw new ValidationError(`a PDF upload creates a "pdf" node, not "${requestedType}"`);
3022
3091
  }
3023
3092
  if (input.schedule !== void 0 && !isRunnableType(input.type)) {
3024
3093
  throw new ValidationError(
@@ -3064,42 +3133,85 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
3064
3133
  ...input.schedule ? { schedule: input.schedule } : {},
3065
3134
  ...input.prime === void 0 ? {} : { prime: input.prime }
3066
3135
  };
3067
- const publishReason = await autoPublishReason(nestId, userEmail, {
3068
- tags,
3069
- metadata
3070
- });
3071
- const publishNote = publishReason === null ? void 0 : `Auto-published on create (${publishReason})`;
3072
- 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;
3073
3141
  try {
3074
- await engineApi.run(
3075
- "context_create",
3076
- {
3077
- id,
3078
- title: input.title,
3079
- content: input.content,
3080
- type: requestedType,
3081
- // A skill node is invalid without a skill block, and this surface has
3082
- // no field for one — default the trigger from the title, same as the
3083
- // CLI and mcp-server do.
3084
- ...requestedType === "skill" ? { trigger: `when asked to ${input.title.toLowerCase()}` } : {},
3085
- ...tags.length ? { tags } : {},
3086
- ...input.status ? { status: input.status } : {},
3087
- metadata,
3088
- publish: published,
3089
- ...publishNote ? { note: publishNote } : {}
3090
- },
3091
- await opContext(nestId, userEmail)
3142
+ const publishReason = await autoPublishReason(
3143
+ nestId,
3144
+ userEmail,
3145
+ { tags, metadata },
3146
+ { creatorReviews: creatorSteward?.canApprove === true, governed }
3092
3147
  );
3093
- } catch (err) {
3094
- let onDisk = true;
3148
+ publishNote = publishReason === null ? void 0 : `Auto-published on create (${publishReason})`;
3149
+ published = publishReason !== null;
3095
3150
  try {
3096
- await storage.readDocument(id);
3097
- } catch {
3098
- 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
+ }
3099
3213
  }
3100
- if (!onDisk) throw err;
3101
- console.error("publishDocument failed (node create auto-publish)", err);
3102
- published = false;
3214
+ throw err;
3103
3215
  }
3104
3216
  const node = await storage.readDocument(id);
3105
3217
  await syncNodeTags(nestId, id, tags);
@@ -3114,7 +3226,8 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
3114
3226
  author: userEmail,
3115
3227
  status: "published",
3116
3228
  tags,
3117
- changeNote: publishNote
3229
+ changeNote: publishNote,
3230
+ client: input.client
3118
3231
  });
3119
3232
  await setApprovedVersion(nestId, id, savedVersion, userEmail);
3120
3233
  } else {
@@ -3122,10 +3235,12 @@ async function createNodeOnce(nestId, input, userEmail, opts = {}) {
3122
3235
  nestId,
3123
3236
  nodeId: id,
3124
3237
  version: 1,
3125
- 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,
3126
3240
  author: userEmail,
3127
3241
  status: "draft",
3128
- tags
3242
+ tags,
3243
+ client: input.client
3129
3244
  });
3130
3245
  }
3131
3246
  await trackEvent("node.create", { nestId, nodeId: id });
@@ -3179,7 +3294,7 @@ async function alignOnDiskStatus(nestId, documentId, status) {
3179
3294
  );
3180
3295
  }
3181
3296
  }
3182
- async function registerImportedDocuments(nestId, userEmail) {
3297
+ async function registerImportedDocuments(nestId, userEmail, client) {
3183
3298
  const startedAt = Date.now();
3184
3299
  const tracked = await getTrackedNodeIds(nestId);
3185
3300
  (await engineCache.get(nestId)).invalidateDiscovery();
@@ -3209,7 +3324,8 @@ async function registerImportedDocuments(nestId, userEmail) {
3209
3324
  author: userEmail,
3210
3325
  status: d.status,
3211
3326
  changeNote: d.status === "published" ? "Imported from existing folder" : "Imported from existing folder \u2014 could not be published, kept as a draft",
3212
- tags
3327
+ tags,
3328
+ client
3213
3329
  });
3214
3330
  if (d.status === "published") {
3215
3331
  await setApprovedVersion(nestId, d.id, d.version, userEmail);
@@ -3231,6 +3347,44 @@ async function registerImportedDocuments(nestId, userEmail) {
3231
3347
  await trackEvent("nest.import.documents", { nestId, registered: docs.length });
3232
3348
  return docs.length;
3233
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
+ }
3234
3388
  async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3235
3389
  return withNodeWriteLock(nodeWriteKey(nestId, nodeId), async () => {
3236
3390
  try {
@@ -3243,6 +3397,35 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3243
3397
  } catch {
3244
3398
  throw new NotFoundError(`Node not found: ${nodeId}`);
3245
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
+ }
3246
3429
  const previousBody = node.body || "";
3247
3430
  const opInput = {};
3248
3431
  if (patch.content !== void 0) {
@@ -3282,6 +3465,20 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3282
3465
  };
3283
3466
  opInput.title = patch.title;
3284
3467
  }
3468
+ if (patch.type !== void 0 && patch.type !== node.frontmatter.type) {
3469
+ assertAcceptedType(patch.type);
3470
+ if (patch.type === "skill") {
3471
+ opInput.trigger = `when asked to ${String(node.frontmatter.title || nodeId).toLowerCase()}`;
3472
+ }
3473
+ if (isRunnableType(node.frontmatter.type) && !isRunnableType(patch.type)) {
3474
+ const metadata = { ...node.frontmatter.metadata };
3475
+ delete metadata.schedule;
3476
+ node = { ...node, frontmatter: { ...node.frontmatter, metadata } };
3477
+ opInput.metadata = { ...opInput.metadata, schedule: null };
3478
+ }
3479
+ node = { ...node, frontmatter: { ...node.frontmatter, type: patch.type } };
3480
+ opInput.type = patch.type;
3481
+ }
3285
3482
  if (patch.schedule !== void 0) {
3286
3483
  if (!isRunnableType(node.frontmatter.type)) {
3287
3484
  throw new ValidationError(
@@ -3324,11 +3521,30 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3324
3521
  if (publishReason === null || isAuthorEditingOwnReview) {
3325
3522
  const currentVersion = await getCurrentVersion(nestId, nodeId);
3326
3523
  const newVersion = await versionManager.nextVersion(nodeId, currentVersion);
3327
- await engineApi.run(
3328
- "context_update",
3329
- { id: nodeId, ...opInput, publish: false, version: newVersion },
3330
- await opContext(nestId, userEmail)
3331
- );
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
+ }
3332
3548
  node = await storage.readDocument(nodeId);
3333
3549
  await syncNodeTags(nestId, nodeId, currentTags);
3334
3550
  await deleteSupersededDrafts(nestId, nodeId, newVersion);
@@ -3340,7 +3556,8 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3340
3556
  author: userEmail,
3341
3557
  status: "draft",
3342
3558
  tags: currentTags,
3343
- changeNote: patch.changeNote
3559
+ changeNote: patch.changeNote,
3560
+ client: patch.client
3344
3561
  });
3345
3562
  if (isAuthorEditingOwnReview) {
3346
3563
  await repointPendingReview({ nestId, nodeId, newVersion });
@@ -3349,6 +3566,20 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3349
3566
  } else {
3350
3567
  const publishNote = patch.changeNote || `Auto-published on edit (${publishReason})`;
3351
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
+ }
3352
3583
  try {
3353
3584
  const result = await engineApi.run(
3354
3585
  "context_update",
@@ -3361,6 +3592,7 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3361
3592
  "publishDocument failed (node patch auto-publish)",
3362
3593
  err
3363
3594
  );
3595
+ if (pdfRollback) await pdfRollback();
3364
3596
  throw err;
3365
3597
  }
3366
3598
  node = await storage.readDocument(nodeId);
@@ -3373,7 +3605,8 @@ async function updateNode(nestId, nodeId, patch, userEmail, opts = {}) {
3373
3605
  author: userEmail,
3374
3606
  status: "published",
3375
3607
  tags: currentTags,
3376
- changeNote: publishNote
3608
+ changeNote: publishNote,
3609
+ client: patch.client
3377
3610
  });
3378
3611
  await setApprovedVersion(
3379
3612
  nestId,
@@ -3479,6 +3712,11 @@ async function moveNode(nestId, nodeId, folder, userEmail) {
3479
3712
  } catch {
3480
3713
  throw new NotFoundError(`Node not found: ${nodeId}`);
3481
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
+ }
3482
3720
  await assertNotReviewLocked(nestId, nodeId, userEmail, "moving");
3483
3721
  const leaf = nodeId.split("/").pop();
3484
3722
  const segments = folderToSegments(folder);
@@ -3575,6 +3813,16 @@ async function discardDraft(nestId, nodeId, userEmail) {
3575
3813
  await storage.writeDocument(nodeId, raw);
3576
3814
  await storage.regenerateIndex();
3577
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
+ }
3578
3826
  await syncNodeTags(
3579
3827
  nestId,
3580
3828
  nodeId,
@@ -3582,7 +3830,7 @@ async function discardDraft(nestId, nodeId, userEmail) {
3582
3830
  );
3583
3831
  await deleteUnsealedVersions(nestId, nodeId, sealed);
3584
3832
  await trackEvent("node.discard_draft", { nestId, nodeId, version: sealed });
3585
- return { deleted: false, version: sealed };
3833
+ return { deleted: false, version: sealed, ...warning ? { warning } : {} };
3586
3834
  });
3587
3835
  }
3588
3836
  async function deleteNode(nestId, nodeId, userEmail) {
@@ -3809,7 +4057,7 @@ function collectMarkdownFiles(dir, root) {
3809
4057
  }
3810
4058
  return out;
3811
4059
  }
3812
- async function syncUnsyncedFolder(userId, folderName, callerEmail) {
4060
+ async function syncUnsyncedFolder(userId, folderName, callerEmail, client) {
3813
4061
  console.log(
3814
4062
  `[unsynced] sync requested folder="${folderName}" user="${callerEmail}"`
3815
4063
  );
@@ -3850,7 +4098,7 @@ async function syncUnsyncedFolder(userId, folderName, callerEmail) {
3850
4098
  let documents;
3851
4099
  try {
3852
4100
  await stageImportedFiles(nest.id, files, callerEmail);
3853
- documents = await registerImportedDocuments(nest.id, callerEmail);
4101
+ documents = await registerImportedDocuments(nest.id, callerEmail, client);
3854
4102
  } catch (err) {
3855
4103
  await deleteNest(nest.id).catch(() => {
3856
4104
  });
@@ -3971,7 +4219,11 @@ nestRoutes.get("/", async (c) => {
3971
4219
  seen.add(n.id);
3972
4220
  deduped.push(n);
3973
4221
  }
3974
- 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
+ }));
3975
4227
  const docRows = await db.all(
3976
4228
  `SELECT nest_id,
3977
4229
  COUNT(DISTINCT CASE
@@ -4070,7 +4322,14 @@ nestRoutes.post("/:nestId", async (c) => {
4070
4322
  }
4071
4323
  const email2 = await resolveCallerEmail(userId);
4072
4324
  if (body.finalize) {
4073
- 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
+ });
4074
4333
  }
4075
4334
  const registered = await getDb().get(
4076
4335
  "SELECT COUNT(*) AS c FROM node_versions WHERE nest_id = ?",
@@ -4097,7 +4356,11 @@ nestRoutes.post("/:nestId", async (c) => {
4097
4356
  if (body.partial) {
4098
4357
  return c.json({ nest, rejected }, 201);
4099
4358
  }
4100
- const documents = await registerImportedDocuments(nest.id, email);
4359
+ const documents = await registerImportedDocuments(
4360
+ nest.id,
4361
+ email,
4362
+ attributeRequest(c, body.client)
4363
+ );
4101
4364
  return c.json({ nest, documents, rejected }, 201);
4102
4365
  } catch (err) {
4103
4366
  await deleteNest(nest.id).catch(() => {
@@ -4127,7 +4390,8 @@ nestRoutes.post("/:nestId/sync", async (c) => {
4127
4390
  const result = await syncUnsyncedFolder(
4128
4391
  userId,
4129
4392
  body.name,
4130
- await resolveCallerEmail(userId)
4393
+ await resolveCallerEmail(userId),
4394
+ attributeRequest(c, body.client)
4131
4395
  );
4132
4396
  return c.json(result, 201);
4133
4397
  });
@@ -4249,7 +4513,8 @@ nestRoutes.get("/:nestId/settings", async (c) => {
4249
4513
  stewardship_enabled: await isStewardshipEnabled(nestId),
4250
4514
  allow_self_approve: await nestAllowsSelfApprove(nestId),
4251
4515
  prime_only_review: await nestReviewsPrimeOnly(nestId),
4252
- prime_tags: await nestPrimeTags(nestId)
4516
+ prime_tags: await nestPrimeTags(nestId),
4517
+ creator_is_reviewer: await nestCreatorIsReviewer(nestId)
4253
4518
  });
4254
4519
  });
4255
4520
  nestRoutes.patch("/:nestId/settings", async (c) => {
@@ -4294,11 +4559,15 @@ nestRoutes.patch("/:nestId/settings", async (c) => {
4294
4559
  Array.isArray(body.prime_tags) ? body.prime_tags : [body.prime_tags]
4295
4560
  );
4296
4561
  }
4562
+ if (typeof body.creator_is_reviewer === "boolean") {
4563
+ await setCreatorIsReviewer(nestId, body.creator_is_reviewer);
4564
+ }
4297
4565
  return c.json({
4298
4566
  stewardship_enabled: await isStewardshipEnabled(nestId),
4299
4567
  allow_self_approve: await nestAllowsSelfApprove(nestId),
4300
4568
  prime_only_review: await nestReviewsPrimeOnly(nestId),
4301
4569
  prime_tags: await nestPrimeTags(nestId),
4570
+ creator_is_reviewer: await nestCreatorIsReviewer(nestId),
4302
4571
  wiped
4303
4572
  });
4304
4573
  });
@@ -4513,12 +4782,24 @@ sharingRoutes.patch("/visibility", async (c) => {
4513
4782
  if (!body.visibility || !["private", "org", "public"].includes(body.visibility)) {
4514
4783
  throw new ValidationError("visibility must be private, org or public");
4515
4784
  }
4516
- 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) {
4517
4788
  return c.json(
4518
4789
  {
4519
4790
  error: "Publishing this nest requires acknowledgement.",
4520
4791
  requires_acknowledgement: true,
4521
- 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."
4522
4803
  },
4523
4804
  409
4524
4805
  );
@@ -4590,12 +4871,19 @@ sharingRoutes.patch("/reader-mode", async (c) => {
4590
4871
  import { Hono as Hono5 } from "hono";
4591
4872
  import { v4 as uuid7 } from "uuid";
4592
4873
  var grantRoutes = new Hono5();
4593
- async function requireManage(c, nestId) {
4594
- if (!await canManageStewards(nestId, c.get("userId"))) {
4874
+ async function requireManage(c, nestId, opts = {}) {
4875
+ if (config.AUTH_MODE === "open") return;
4876
+ const access = await resolveNestAccess(nestId, c.get("userId"));
4877
+ if (access.rawPermission !== "owner" && access.rawPermission !== "admin") {
4595
4878
  throw new ForbiddenError(
4596
4879
  "Only a nest admin, the nest owner, or the server admin can manage sharing."
4597
4880
  );
4598
4881
  }
4882
+ if (access.archived && !opts.readOnly) {
4883
+ throw new ForbiddenError(
4884
+ "This nest is archived, so it is read-only. Restore it to change sharing."
4885
+ );
4886
+ }
4599
4887
  }
4600
4888
  async function resolveGrantee(email) {
4601
4889
  const db = getDb();
@@ -4685,7 +4973,7 @@ grantRoutes.post("/", async (c) => {
4685
4973
  });
4686
4974
  grantRoutes.get("/", async (c) => {
4687
4975
  const nestId = c.req.param("nestId");
4688
- await requireManage(c, nestId);
4976
+ await requireManage(c, nestId, { readOnly: true });
4689
4977
  const grants = await listGrants(nestId);
4690
4978
  const db = getDb();
4691
4979
  const withEmail = await Promise.all(
@@ -5305,7 +5593,7 @@ async function syncAnnotationsNode(nestId, nodeId, userEmail) {
5305
5593
  tags: ["annotations"]
5306
5594
  },
5307
5595
  userEmail,
5308
- { notifyMentions: false }
5596
+ { notifyMentions: false, creatorReviews: false }
5309
5597
  );
5310
5598
  } else {
5311
5599
  throw err;
@@ -5471,7 +5759,7 @@ var GATE_EYE_SCRIPT = `<script>
5471
5759
  </script>`;
5472
5760
  function gatePage(row, message) {
5473
5761
  const wantsCode = row.access_mode === "code";
5474
- const field = wantsCode ? `<label>Access code<span class="wrap">
5762
+ const field2 = wantsCode ? `<label>Access code<span class="wrap">
5475
5763
  <input id="cn-code" name="code" type="password" autocomplete="off" required autofocus>
5476
5764
  <button type="button" id="cn-eye" class="eye" aria-label="Show code" aria-pressed="false">${EYE_SVG}</button>
5477
5765
  </span></label>` : `<label>Your email<input name="email" type="email" autocomplete="email" required autofocus></label>`;
@@ -5495,7 +5783,7 @@ function gatePage(row, message) {
5495
5783
  .err{color:#be123c;font-weight:600}
5496
5784
  .brand{font-size:12px;color:#a8a29e;margin:18px 0 0;text-align:center}
5497
5785
  </style></head><body><form class="card" method="POST" action="/p/${esc(row.slug)}/gate">
5498
- <h1>Protected document</h1><p>${esc(note)}</p>${err}${field}
5786
+ <h1>Protected document</h1><p>${esc(note)}</p>${err}${field2}
5499
5787
  <button type="submit">${cta}</button>
5500
5788
  <p class="brand">Secured by PromptOwl \xB7 ContextNest</p>
5501
5789
  </form>${wantsCode ? GATE_EYE_SCRIPT : ""}</body></html>`;
@@ -6075,6 +6363,7 @@ function toSearchHit(node, query) {
6075
6363
 
6076
6364
  // src/nodes/routes.ts
6077
6365
  import { Hono as Hono11 } from "hono";
6366
+ import { parseDocument as parseDocument3, readPdfBinary as readPdfBinary2 } from "@promptowl/contextnest-engine";
6078
6367
 
6079
6368
  // src/nodes/markdown-export.ts
6080
6369
  import { parseDocument as parseDocument2 } from "@promptowl/contextnest-engine";
@@ -6191,7 +6480,8 @@ nodeRoutes.post("/", async (c) => {
6191
6480
  status: body.status,
6192
6481
  schedule: body.schedule,
6193
6482
  folder: body.folder,
6194
- prime: body.prime
6483
+ prime: body.prime,
6484
+ client: attributeRequest(c, body.client)
6195
6485
  },
6196
6486
  authorEmail
6197
6487
  );
@@ -6208,7 +6498,7 @@ nodeRoutes.post("/", async (c) => {
6208
6498
  nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
6209
6499
  const nestId = c.req.param("nestId");
6210
6500
  const nodeId = c.req.param("nodeId");
6211
- const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-32UHIRQ5.js");
6501
+ const { resolveStewardsWithFallback: resolveStewardsWithFallback2 } = await import("./stewardship-service-S22V2JKG.js");
6212
6502
  const { stewards, fallbackToOwner, ownerEmail } = await resolveStewardsWithFallback2(
6213
6503
  nestId,
6214
6504
  nodeId
@@ -6229,7 +6519,7 @@ nodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
6229
6519
  nodeRoutes.get("/:nodeId{.+}/reviews", async (c) => {
6230
6520
  const nestId = c.req.param("nestId");
6231
6521
  const nodeId = c.req.param("nodeId");
6232
- const { getReviewHistory: getReviewHistory2 } = await import("./review-service-A7PNLWG5.js");
6522
+ const { getReviewHistory: getReviewHistory2 } = await import("./review-service-4G2XUFS6.js");
6233
6523
  const history = await getReviewHistory2(nestId, nodeId);
6234
6524
  return c.json({ reviews: history });
6235
6525
  });
@@ -6250,7 +6540,7 @@ nodeRoutes.post("/:nodeId{.+}/discard", async (c) => {
6250
6540
  nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
6251
6541
  const nestId = c.req.param("nestId");
6252
6542
  const nodeId = c.req.param("nodeId");
6253
- const { versions: versionManager } = await engineCache.get(nestId);
6543
+ const { storage, versions: versionManager } = await engineCache.get(nestId);
6254
6544
  const userId = c.get("userId");
6255
6545
  const userEmail = await resolveCallerEmail(userId);
6256
6546
  if (!await canReadNode(nestId, nodeId, userId, userEmail)) {
@@ -6272,11 +6562,26 @@ nodeRoutes.post("/:nodeId{.+}/revert", async (c) => {
6272
6562
  `Version ${targetVersion} not found for ${nodeId}`
6273
6563
  );
6274
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
+ }
6275
6576
  const content = bodyOnly(nodeId, raw);
6276
6577
  const { node, version } = await updateNode(
6277
6578
  nestId,
6278
6579
  nodeId,
6279
- { 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
+ },
6280
6585
  userEmail
6281
6586
  );
6282
6587
  await trackEvent("node.revert", { nestId, nodeId, targetVersion });
@@ -6368,7 +6673,8 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
6368
6673
  }
6369
6674
  }
6370
6675
  response.myGrant = await resolveNodeGrant(nestId, userId, nodeId);
6371
- if (publicReader) {
6676
+ const approvedOnly = c.req.query("approved_only") === "1" || c.req.query("approved_only") === "true";
6677
+ if (publicReader || approvedOnly) {
6372
6678
  const approved = await getApprovedVersion(nestId, nodeId);
6373
6679
  if (approved != null) {
6374
6680
  let approvedRaw = "";
@@ -6378,6 +6684,10 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
6378
6684
  approved
6379
6685
  );
6380
6686
  response.content = bodyOnly(nodeId, approvedRaw);
6687
+ if (response.type === "pdf") {
6688
+ const approvedPdf = pdfMetaOf(nodeId, approvedRaw);
6689
+ if (approvedPdf) response.pdf = approvedPdf;
6690
+ }
6381
6691
  } catch (err) {
6382
6692
  console.error(
6383
6693
  "reconstructVersion failed (public single)",
@@ -6399,6 +6709,9 @@ nodeRoutes.get("/:nodeId{.+}", async (c) => {
6399
6709
  }
6400
6710
  return c.json({ node: response });
6401
6711
  }
6712
+ if (approvedOnly) {
6713
+ throw new NotFoundError("This document has no approved version yet.");
6714
+ }
6402
6715
  }
6403
6716
  response.status = node.pendingChange ? "external_edit_pending" : await getDisplayStatus(nestId, nodeId);
6404
6717
  if (response.status === "pending_review") {
@@ -6423,6 +6736,9 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
6423
6736
  const nestId = c.req.param("nestId");
6424
6737
  const nodeId = c.req.param("nodeId");
6425
6738
  const body = await c.req.json();
6739
+ if (body.type !== void 0 && typeof body.type !== "string") {
6740
+ throw new ValidationError("type must be a string");
6741
+ }
6426
6742
  const baseVersionHeader = c.req.header("X-Base-Version");
6427
6743
  if (baseVersionHeader) {
6428
6744
  const baseVersion = parseInt(baseVersionHeader, 10);
@@ -6453,7 +6769,9 @@ nodeRoutes.patch("/:nodeId{.+}", async (c) => {
6453
6769
  status: body.status,
6454
6770
  changeNote: body.changeNote,
6455
6771
  schedule: body.schedule,
6456
- prime: body.prime
6772
+ prime: body.prime,
6773
+ client: attributeRequest(c, body.client),
6774
+ type: body.type
6457
6775
  },
6458
6776
  authorEmail
6459
6777
  );
@@ -6465,6 +6783,13 @@ nodeRoutes.delete("/:nodeId{.+}", async (c) => {
6465
6783
  await deleteNode(nestId, nodeId, await getUserEmail(c));
6466
6784
  return c.json({ deleted: true });
6467
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
+ }
6468
6793
  async function getUserEmail(c) {
6469
6794
  const userId = c.get("userId");
6470
6795
  const db = getDb();
@@ -6957,7 +7282,18 @@ var STOPWORDS = /* @__PURE__ */ new Set([
6957
7282
  "ok",
6958
7283
  "please",
6959
7284
  "thanks",
6960
- "thank"
7285
+ "thank",
7286
+ // Greetings and fillers a chat turn opens with — never a topic.
7287
+ "hello",
7288
+ "hi",
7289
+ "hey",
7290
+ "there",
7291
+ "good",
7292
+ "morning",
7293
+ "afternoon",
7294
+ "evening",
7295
+ "bye",
7296
+ "cheers"
6961
7297
  ]);
6962
7298
  function tokenizePrompt(prompt) {
6963
7299
  const words = prompt.toLowerCase().split(/[^a-z0-9_-]+/).filter(Boolean).filter((w) => w.length >= 3 && !STOPWORDS.has(w));
@@ -6999,6 +7335,34 @@ async function compilePrompt(prompt, nestId, titles) {
6999
7335
  unmatched
7000
7336
  };
7001
7337
  }
7338
+ function scorePrompt(doc, tokens) {
7339
+ if (tokens.length === 0) return { score: 0, via: null };
7340
+ const title = doc.frontmatter.title || "";
7341
+ const tags = (doc.frontmatter.tags || []).map(normalizeTag);
7342
+ const body = (doc.body || "").toLowerCase();
7343
+ let sum = 0;
7344
+ let via = null;
7345
+ for (const t of tokens) {
7346
+ const inTitle = titleScore(title, t);
7347
+ if (inTitle > 0) {
7348
+ sum += inTitle;
7349
+ via = "title";
7350
+ } else if (tags.some((tag) => tag.startsWith(t))) {
7351
+ sum += 2;
7352
+ if (via !== "title") via = "tag";
7353
+ } else if (startsWord(body, t)) {
7354
+ sum += 1;
7355
+ via ??= "fulltext";
7356
+ }
7357
+ }
7358
+ return { score: sum / (4 * tokens.length), via };
7359
+ }
7360
+ function startsWord(text, term) {
7361
+ for (let i = text.indexOf(term); i !== -1; i = text.indexOf(term, i + 1)) {
7362
+ if (i === 0 || !/[a-z0-9]/.test(text[i - 1])) return true;
7363
+ }
7364
+ return false;
7365
+ }
7002
7366
 
7003
7367
  // src/nodes/readable-body.ts
7004
7368
  async function resolveReadableBody(nestId, nodeId, userId, workingBody) {
@@ -7151,6 +7515,17 @@ async function buildNestGraph(nestId, userId, userEmail, canSeeIdentities) {
7151
7515
  }
7152
7516
  }
7153
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
+ }
7154
7529
  const exposeStewards = await isStewardshipEnabled(nestId) && !await isPublicReader(nestId, userId);
7155
7530
  const stewardRows = exposeStewards ? (await listStewards({ nestId })).filter(
7156
7531
  // Drop document-scoped rows whose node the caller can't see.
@@ -7393,7 +7768,8 @@ queryRoutes.post("/context", async (c) => {
7393
7768
  const baseUrl = requestBaseUrl(c.req.url);
7394
7769
  const { query: queryEngine, storage } = await engineCache.get(nestId);
7395
7770
  const maxTokens = Math.max(50, body.max_tokens ?? 4e3);
7396
- const hops = body.hops ?? 2;
7771
+ const promptPath = !body.selector?.trim() && !!body.prompt;
7772
+ const hops = body.hops ?? (promptPath ? 0 : 2);
7397
7773
  const includeDrafts = body.include_drafts === true;
7398
7774
  const isVisible = (d) => includeDrafts || d.frontmatter.status === "published";
7399
7775
  let selector = body.selector?.trim() || null;
@@ -7427,7 +7803,7 @@ queryRoutes.post("/context", async (c) => {
7427
7803
  compileDetail.matchedTitles.map((t) => t.toLowerCase())
7428
7804
  );
7429
7805
  titleMatches = allDocs.filter(
7430
- (d) => matchedLower.has(d.frontmatter.title.toLowerCase())
7806
+ (d) => matchedLower.has(d.frontmatter.title.toLowerCase()) && isVisible(d)
7431
7807
  );
7432
7808
  }
7433
7809
  }
@@ -7491,6 +7867,27 @@ queryRoutes.post("/context", async (c) => {
7491
7867
  }
7492
7868
  }
7493
7869
  }
7870
+ const relevance = /* @__PURE__ */ new Map();
7871
+ let fallback = null;
7872
+ if (compileDetail) {
7873
+ const tokens = tokenizePrompt(body.prompt);
7874
+ const compiled = new Set(documents.map((d) => d.id));
7875
+ for (const d of allDocs) {
7876
+ const { score, via } = scorePrompt(d, tokens);
7877
+ if (compiled.has(d.id)) relevance.set(d.id, { score, via: via ?? "hop" });
7878
+ else if (score > 0 && isVisible(d)) {
7879
+ relevance.set(d.id, { score, via });
7880
+ documents.push(d);
7881
+ }
7882
+ }
7883
+ if (compiled.size === 0 && documents.length > 0) fallback = "fulltext";
7884
+ const top = Math.max(0, ...documents.map((d) => relevance.get(d.id).score));
7885
+ documents = documents.filter((d) => {
7886
+ const r = relevance.get(d.id);
7887
+ return compiled.has(d.id) || r.via === "hop" || r.score >= top / 2;
7888
+ });
7889
+ documents.sort((a, b) => relevance.get(b.id).score - relevance.get(a.id).score);
7890
+ }
7494
7891
  const userId = c.get("userId");
7495
7892
  const userEmail = await resolveCallerEmail(userId);
7496
7893
  const beforePermission = documents.length;
@@ -7508,10 +7905,14 @@ queryRoutes.post("/context", async (c) => {
7508
7905
  for (const doc of readable) {
7509
7906
  const block = formatContextBlock(doc, doc.url);
7510
7907
  const blockTokens = approxTokens(block);
7511
- if (tokenCount + blockTokens > maxTokens && included.length > 0) break;
7908
+ if (tokenCount + blockTokens > maxTokens) continue;
7512
7909
  included.push(doc);
7513
7910
  tokenCount += blockTokens;
7514
7911
  }
7912
+ if (included.length === 0 && readable.length > 0) {
7913
+ included.push(readable[0]);
7914
+ tokenCount = approxTokens(formatContextBlock(readable[0], readable[0].url));
7915
+ }
7515
7916
  const truncatedByBudget = accessible.length - included.length;
7516
7917
  const contextMarkdown = included.map((n) => formatContextBlock(n, n.url)).join("\n\n---\n\n");
7517
7918
  trackEvent("context.resolve", {
@@ -7528,7 +7929,8 @@ queryRoutes.post("/context", async (c) => {
7528
7929
  title: n.frontmatter.title,
7529
7930
  url: n.url,
7530
7931
  tags: n.frontmatter.tags || [],
7531
- type: n.frontmatter.type || "document"
7932
+ type: n.frontmatter.type || "document",
7933
+ ...relevance.get(n.id)
7532
7934
  })),
7533
7935
  trace: {
7534
7936
  selector_used: selector,
@@ -7536,7 +7938,8 @@ queryRoutes.post("/context", async (c) => {
7536
7938
  compiled: compileDetail ? {
7537
7939
  matched_tags: compileDetail.matchedTags,
7538
7940
  matched_titles: compileDetail.matchedTitles,
7539
- unmatched_tokens: compileDetail.unmatched
7941
+ unmatched_tokens: compileDetail.unmatched,
7942
+ fallback
7540
7943
  } : null,
7541
7944
  total_matched: documents.length,
7542
7945
  permission_filtered: permissionFiltered,
@@ -7721,11 +8124,11 @@ queryRoutes.get("/export", async (c) => {
7721
8124
  let tokens = 0;
7722
8125
  for (const f of fields) {
7723
8126
  const t = approxTokens(nodeToMarkdown(f));
7724
- if (tokens + t > maxTokens && kept.length > 0) break;
8127
+ if (tokens + t > maxTokens) continue;
7725
8128
  kept.push(f);
7726
8129
  tokens += t;
7727
8130
  }
7728
- included = kept;
8131
+ included = kept.length > 0 ? kept : fields.slice(0, 1);
7729
8132
  }
7730
8133
  trackEvent("nest.export", {
7731
8134
  nestId,
@@ -7771,7 +8174,8 @@ queryRoutes.post("/publish", async (c) => {
7771
8174
  content: doc.content,
7772
8175
  type: doc.type,
7773
8176
  tags: doc.tags,
7774
- scope: doc.scope
8177
+ scope: doc.scope,
8178
+ folder: doc.folder
7775
8179
  },
7776
8180
  userEmail
7777
8181
  );
@@ -7807,6 +8211,9 @@ import { Hono as Hono15 } from "hono";
7807
8211
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7808
8212
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
7809
8213
 
8214
+ // src/mcp/tools.ts
8215
+ import { parseDocument as parseDocument4 } from "@promptowl/contextnest-engine";
8216
+
7810
8217
  // src/telemetry/trace-log.ts
7811
8218
  var PRUNE_EVERY = 500;
7812
8219
  var insertsSincePrune = 0;
@@ -7815,8 +8222,8 @@ async function logTraceEvent(e) {
7815
8222
  const db = getDb();
7816
8223
  await db.run(
7817
8224
  `INSERT INTO api_events
7818
- (ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms)
7819
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
8225
+ (ts, kind, method, path, tool, nest_id, user_id, user_email, status, duration_ms, client_json)
8226
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
7820
8227
  [
7821
8228
  (/* @__PURE__ */ new Date()).toISOString(),
7822
8229
  e.kind,
@@ -7827,7 +8234,8 @@ async function logTraceEvent(e) {
7827
8234
  e.userId ?? null,
7828
8235
  e.userEmail ?? null,
7829
8236
  e.status ?? null,
7830
- e.durationMs ?? null
8237
+ e.durationMs ?? null,
8238
+ serializeClient(e.client)
7831
8239
  ]
7832
8240
  );
7833
8241
  if (++insertsSincePrune >= PRUNE_EVERY) {
@@ -7876,7 +8284,13 @@ async function listTraceEvents(filters = {}) {
7876
8284
  LIMIT ? OFFSET ?`,
7877
8285
  [...args, limit, offset]
7878
8286
  );
7879
- 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
+ };
7880
8294
  } catch {
7881
8295
  return { events: [], total: 0 };
7882
8296
  }
@@ -8924,6 +9338,19 @@ function summarizeNode(node, body) {
8924
9338
  body: body ?? void 0
8925
9339
  };
8926
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
+ }
8927
9354
  var TOOL_DEFINITIONS = [
8928
9355
  {
8929
9356
  name: "context_init",
@@ -9154,7 +9581,7 @@ var TOOL_DEFINITIONS = [
9154
9581
  tags: {
9155
9582
  type: "array",
9156
9583
  items: { type: "string" },
9157
- 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."
9158
9585
  },
9159
9586
  status: {
9160
9587
  type: "string",
@@ -9323,10 +9750,14 @@ var TOOL_DEFINITIONS = [
9323
9750
  type: "object",
9324
9751
  properties: {
9325
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
+ },
9326
9757
  note: { type: "string", description: "Optional note for the reviewer" },
9327
9758
  priority: { type: "string", description: "Priority: low, normal (default), high, urgent" }
9328
- },
9329
- required: ["title"]
9759
+ }
9760
+ // One of id or title — see context_update.
9330
9761
  }
9331
9762
  },
9332
9763
  {
@@ -9464,8 +9895,30 @@ async function resolveLlmBody(ctx, node, gate) {
9464
9895
  async function handleToolCall(toolName, args, ctx) {
9465
9896
  const started = Date.now();
9466
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
+ }
9467
9920
  try {
9468
- return await runTool(toolName, args, ctx);
9921
+ return await runTool(toolName, args, attributed);
9469
9922
  } catch (err) {
9470
9923
  if (isVaultLockTimeout(err) || err instanceof VaultBusyError) {
9471
9924
  status = 503;
@@ -9484,7 +9937,10 @@ async function handleToolCall(toolName, args, ctx) {
9484
9937
  userId: ctx.userId,
9485
9938
  userEmail: ctx.userEmail,
9486
9939
  status,
9487
- 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
9488
9944
  });
9489
9945
  }
9490
9946
  }
@@ -9679,7 +10135,8 @@ ${list}`,
9679
10135
  const meta = [
9680
10136
  `**Title:** ${node.frontmatter.title}`,
9681
10137
  `**Type:** ${node.frontmatter.type || "document"}`,
9682
- 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)
9683
10140
  ].filter(Boolean).join("\n");
9684
10141
  return {
9685
10142
  text: `${meta}
@@ -9942,7 +10399,8 @@ ${sections}`;
9942
10399
  type: args.type,
9943
10400
  tags: args.tags,
9944
10401
  scope: args.scope,
9945
- folder: args.folder
10402
+ folder: args.folder,
10403
+ client: ctx.client
9946
10404
  },
9947
10405
  userEmail
9948
10406
  );
@@ -9970,16 +10428,16 @@ ${sections}`;
9970
10428
  "UNAUTHORIZED_ACTION"
9971
10429
  );
9972
10430
  }
9973
- const mergedTags = args.tags ? [.../* @__PURE__ */ new Set([...node.frontmatter.tags || [], ...args.tags])] : void 0;
9974
10431
  const { node: updated, version } = await updateNode(
9975
10432
  nestId,
9976
10433
  node.id,
9977
10434
  {
9978
10435
  content: args.content,
9979
10436
  append: args.append,
9980
- tags: mergedTags,
10437
+ tags: args.tags,
9981
10438
  status: args.status,
9982
- changeNote: args.changeNote
10439
+ changeNote: args.changeNote,
10440
+ client: ctx.client
9983
10441
  },
9984
10442
  userEmail
9985
10443
  );
@@ -10032,10 +10490,11 @@ Use context_request_deletion to ask a nest owner/admin to delete it, with a reas
10032
10490
  args.folder,
10033
10491
  userEmail
10034
10492
  );
10493
+ const data = { id: moved.id, previous_id: previousId, title: moved.frontmatter.title };
10035
10494
  if (previousId === moved.id) {
10036
- 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 };
10037
10496
  }
10038
- return `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}\``;
10497
+ return { text: `Moved **${moved.frontmatter.title}**: \`${previousId}\` \u2192 \`${moved.id}\``, data };
10039
10498
  }
10040
10499
  // ─── Governance Tool Handlers ──────────────────────────────────────
10041
10500
  case "context_stewards": {
@@ -10256,21 +10715,20 @@ ${list}${more}
10256
10715
  Submit one with \`context_submit_review\`.`;
10257
10716
  }
10258
10717
  case "context_submit_review": {
10259
- const docs = await storage.discoverDocuments();
10260
- const node = docs.find(
10261
- (n) => n.frontmatter.title.toLowerCase() === args.title.toLowerCase()
10262
- );
10263
- if (!node) return toolError(`Node not found: ${args.title}`, "DOCUMENT_NOT_FOUND");
10718
+ const resolved = await resolveTargetNode(storage, args);
10719
+ if (typeof resolved === "string") return toolError(resolved, "DOCUMENT_NOT_FOUND");
10720
+ const node = resolved;
10721
+ const nodeLabel = args.title || args.id;
10264
10722
  const submitCheck = await canUserEdit(ctx.nestId, node.id, userEmail);
10265
10723
  if (!submitCheck.allowed) {
10266
10724
  return toolError(
10267
- `You don't have permission to submit "${args.title}" for review: ${submitCheck.reason}`,
10725
+ `You don't have permission to submit "${nodeLabel}" for review: ${submitCheck.reason}`,
10268
10726
  "UNAUTHORIZED_ACTION"
10269
10727
  );
10270
10728
  }
10271
10729
  const currentVersion = await getCurrentVersion(ctx.nestId, node.id);
10272
10730
  if (currentVersion === 0)
10273
- return toolError(`No versions found for "${args.title}"`, "VERSION_NOT_FOUND");
10731
+ return toolError(`No versions found for "${nodeLabel}"`, "VERSION_NOT_FOUND");
10274
10732
  try {
10275
10733
  const request = await submitForReview({
10276
10734
  nestId: ctx.nestId,
@@ -10279,18 +10737,19 @@ Submit one with \`context_submit_review\`.`;
10279
10737
  requestedBy: ctx.userEmail,
10280
10738
  note: args.note,
10281
10739
  priority: args.priority,
10282
- baseUrl: ctx.baseUrl
10740
+ baseUrl: ctx.baseUrl,
10741
+ client: ctx.client
10283
10742
  });
10284
- const resolved = await resolveStewardsForNode(
10743
+ const resolved2 = await resolveStewardsForNode(
10285
10744
  ctx.nestId,
10286
10745
  node.id
10287
10746
  );
10288
- const stewardList = resolved.length > 0 ? `
10747
+ const stewardList = resolved2.length > 0 ? `
10289
10748
 
10290
10749
  **Stewards who can review:**
10291
- ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "";
10750
+ ${resolved2.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "";
10292
10751
  return {
10293
- text: `Submitted "${args.title}" v${currentVersion} for review (${request.priority} priority).${stewardList}`,
10752
+ text: `Submitted "${nodeLabel}" v${currentVersion} for review (${request.priority} priority).${stewardList}`,
10294
10753
  // No catalog shape to conform to — `context_submit_review` is a
10295
10754
  // governance-namespace operation and that namespace is declared but
10296
10755
  // unimplemented in the engine (`api/index.ts`, NAMESPACES). So this
@@ -10311,7 +10770,7 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
10311
10770
  requested_by: request.requestedBy,
10312
10771
  requested_at: request.requestedAt
10313
10772
  },
10314
- reviewers: resolved.map((r) => r.steward.userEmail)
10773
+ reviewers: resolved2.map((r) => r.steward.userEmail)
10315
10774
  }
10316
10775
  };
10317
10776
  } catch (err) {
@@ -10330,7 +10789,8 @@ ${resolved.map((r) => `- ${r.steward.userEmail} (${r.source})`).join("\n")}` : "
10330
10789
  nodeId: node.id,
10331
10790
  approvedBy: ctx.userEmail,
10332
10791
  note: args.note,
10333
- baseUrl: ctx.baseUrl
10792
+ baseUrl: ctx.baseUrl,
10793
+ client: ctx.client
10334
10794
  });
10335
10795
  return `Approved "${args.title}" v${request.version}. This version is now live for AI queries.${args.note ? `
10336
10796
  Note: ${args.note}` : ""}`;
@@ -10371,7 +10831,9 @@ Reason: ${args.note}`;
10371
10831
  };
10372
10832
  }
10373
10833
  const list = allVersions.map(
10374
- (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}]` : ""}`
10375
10837
  ).join("\n");
10376
10838
  return {
10377
10839
  text: `# Version History: ${label}
@@ -10395,7 +10857,11 @@ ${list}`,
10395
10857
  status: v.status,
10396
10858
  edited_by: v.editedBy,
10397
10859
  edited_at: v.editedAt,
10398
- 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 } : {}
10399
10865
  }))
10400
10866
  }
10401
10867
  };
@@ -10456,8 +10922,8 @@ ${list}`,
10456
10922
  if (!target) return "target is required (a node id or folder prefix).";
10457
10923
  if (!["read", "write"].includes(role)) return "role must be read or write.";
10458
10924
  try {
10459
- const { createGrant: createGrant2 } = await import("./grants-service-TBGTCO5K.js");
10460
- const db = (await import("./client-LWDMNKX3.js")).getDb();
10925
+ const { createGrant: createGrant2 } = await import("./grants-service-2OTFS7EH.js");
10926
+ const db = (await import("./client-D44ZHDTJ.js")).getDb();
10461
10927
  const { normalizeEmail: normalizeEmail2 } = await import("./email-R7DFS6E5.js");
10462
10928
  const e = normalizeEmail2(String(args.email || ""));
10463
10929
  if (!e) return "email is required.";
@@ -10506,7 +10972,8 @@ ${list}`;
10506
10972
  const { nest, documents } = await syncUnsyncedFolder(
10507
10973
  userId,
10508
10974
  args.name,
10509
- userEmail
10975
+ userEmail,
10976
+ ctx.client
10510
10977
  );
10511
10978
  return `Synced folder "${args.name}" \u2192 nest **${nest.name}** (${nest.id}) with ${documents} document(s).`;
10512
10979
  } catch (err) {
@@ -10529,7 +10996,7 @@ async function getUserEmail2(userId) {
10529
10996
  const user = await db.get("SELECT email FROM users WHERE id = ?", [userId]);
10530
10997
  return user?.email || "anonymous@localhost";
10531
10998
  }
10532
- function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
10999
+ function createMcpServerForNest(nestId, userId, userEmail, baseUrl, c) {
10533
11000
  const server = new McpServer(
10534
11001
  { name: `contextnest-${nestId}`, version: "1.0.0" },
10535
11002
  { capabilities: { tools: {} } }
@@ -10540,16 +11007,18 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
10540
11007
  const required = tool.inputSchema.required || [];
10541
11008
  const shape = {};
10542
11009
  for (const [key, def] of Object.entries(props)) {
10543
- let field;
10544
- if (def.type === "string") field = z.string();
10545
- else if (def.type === "number") field = z.number();
10546
- else if (def.type === "array") field = z.array(z.string());
10547
- else field = z.any();
10548
- if (!required.includes(key)) field = field.optional();
10549
- shape[key] = field;
10550
- }
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);
10551
11019
  server.tool(tool.name, tool.description, shape, async (args) => {
10552
11020
  const engine = await engineCache.get(nestId);
11021
+ const client = attributeRequest(c, void 0, connectionClient(server));
10553
11022
  const out = await handleToolCall(tool.name, args, {
10554
11023
  storage: engine.storage,
10555
11024
  queryEngine: engine.query,
@@ -10557,7 +11026,8 @@ function createMcpServerForNest(nestId, userId, userEmail, baseUrl) {
10557
11026
  nestId,
10558
11027
  userId,
10559
11028
  userEmail,
10560
- baseUrl
11029
+ baseUrl,
11030
+ client
10561
11031
  });
10562
11032
  return toCallToolResult(out);
10563
11033
  });
@@ -10572,7 +11042,8 @@ mcpRoutes.all("/", async (c) => {
10572
11042
  nestId,
10573
11043
  userId,
10574
11044
  userEmail,
10575
- requestBaseUrl(c.req.url)
11045
+ requestBaseUrl(c.req.url),
11046
+ c
10576
11047
  );
10577
11048
  const transport = new WebStandardStreamableHTTPServerTransport({
10578
11049
  sessionIdGenerator: void 0,
@@ -11642,8 +12113,185 @@ assetRoutes.get("/:file", async (c) => {
11642
12113
  });
11643
12114
  });
11644
12115
 
11645
- // src/auth/community-sites-routes.ts
12116
+ // src/nodes/pdf-routes.ts
11646
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";
11647
12295
  var serverAdminAllowed = async (c) => config.AUTH_MODE === "open" || await isServerAdminUserId(c.get("userId"));
11648
12296
  var isHttpUrl = (v) => /^https?:\/\/\S+$/i.test(v);
11649
12297
  var serializeSite = (site) => ({
@@ -11655,7 +12303,7 @@ var serializeSite = (site) => ({
11655
12303
  created_at: site.created_at,
11656
12304
  updated_at: site.updated_at
11657
12305
  });
11658
- var communitySiteRoutes = new Hono22();
12306
+ var communitySiteRoutes = new Hono23();
11659
12307
  communitySiteRoutes.get("/", async (c) => {
11660
12308
  if (!await serverAdminAllowed(c))
11661
12309
  return c.json({ error: "Only the server admin can view this." }, 403);
@@ -11743,11 +12391,24 @@ communitySiteRoutes.delete("/:id", async (c) => {
11743
12391
  });
11744
12392
 
11745
12393
  // src/mcp/server-routes.ts
11746
- import { Hono as Hono23 } from "hono";
12394
+ import { Hono as Hono24 } from "hono";
11747
12395
  import { McpServer as McpServer2 } from "@modelcontextprotocol/sdk/server/mcp.js";
11748
12396
  import { WebStandardStreamableHTTPServerTransport as WebStandardStreamableHTTPServerTransport2 } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
11749
12397
  import { z as z2 } from "zod";
11750
- var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set(["nest_create", "context_create"]);
12398
+ var SERVER_WRITE_TOOLS = /* @__PURE__ */ new Set([
12399
+ "nest_create",
12400
+ "context_create",
12401
+ "context_update",
12402
+ "context_delete",
12403
+ "context_move",
12404
+ "context_submit_review"
12405
+ ]);
12406
+ var FANOUT_READ_TOOLS = /* @__PURE__ */ new Set([
12407
+ "context_search",
12408
+ "context_list",
12409
+ "context_query",
12410
+ "context_overview"
12411
+ ]);
11751
12412
  async function getUserEmail3(userId) {
11752
12413
  const db = getDb();
11753
12414
  const user = await db.get("SELECT email FROM users WHERE id = ?", [
@@ -11847,116 +12508,45 @@ var NEST_ARG = {
11847
12508
  description: "Target nest \u2014 id, or exact name/slug (case-insensitive)"
11848
12509
  }
11849
12510
  };
12511
+ var NEST_ARG_OPTIONAL = {
12512
+ nest: {
12513
+ type: "string",
12514
+ description: "Target nest \u2014 id or name. Omit to search/list across ALL accessible nests."
12515
+ }
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
+ }
11850
12544
  var SERVER_TOOL_DEFINITIONS = [
11851
12545
  {
11852
12546
  name: "nest_index",
11853
- description: "Grounded index of every nest these credentials can read: name, id, permission, document count. Call this FIRST, then target a nest with the other tools.",
12547
+ description: "Grounded index of every nest these credentials can read: name, id, permission, document count. Call this FIRST.",
11854
12548
  inputSchema: { type: "object", properties: {} }
11855
12549
  },
11856
- {
11857
- name: "context_query",
11858
- description: "Run a structured selector query against one nest. Selector grammar: #tag, type:X, [[Title]], scope:X, combined with +AND, |OR, -NOT.",
11859
- inputSchema: {
11860
- type: "object",
11861
- properties: {
11862
- ...NEST_ARG,
11863
- query: { type: "string", description: "Selector query" },
11864
- hops: {
11865
- type: "number",
11866
- description: "Graph traversal depth from the matches (default: 2)"
11867
- }
11868
- },
11869
- required: ["nest", "query"]
11870
- }
11871
- },
11872
- {
11873
- name: "context_search",
11874
- description: "Full-text keyword search across one nest \u2014 the same index-backed match as REST GET /search. Returns `count` (everything found) and the first `limit` hits.",
11875
- inputSchema: {
11876
- type: "object",
11877
- properties: {
11878
- ...NEST_ARG,
11879
- query: { type: "string", description: "Search terms" },
11880
- limit: {
11881
- type: "number",
11882
- description: "Max hits to return (default 20, max 200)"
11883
- }
11884
- },
11885
- required: ["nest", "query"]
11886
- }
11887
- },
11888
- {
11889
- name: "context_get",
11890
- description: "Get the FULL content of a node in a nest by title or id.",
11891
- inputSchema: {
11892
- type: "object",
11893
- properties: {
11894
- ...NEST_ARG,
11895
- title: { type: "string", description: "Title of the node" },
11896
- id: { type: "string", description: "ID of the node" }
11897
- },
11898
- required: ["nest"]
11899
- }
11900
- },
11901
- {
11902
- name: "context_list",
11903
- description: "Browse one nest's contents with optional type, tag, or limit filters.",
11904
- inputSchema: {
11905
- type: "object",
11906
- properties: {
11907
- ...NEST_ARG,
11908
- type: { type: "string", description: "Filter by node type" },
11909
- tag: { type: "string", description: "Filter by tag" },
11910
- limit: { type: "number", description: "Max nodes to return" }
11911
- },
11912
- required: ["nest"]
11913
- }
11914
- },
11915
- {
11916
- name: "context_overview",
11917
- description: "Map of one nest: node count, types, tags, title+snippet per node.",
11918
- inputSchema: {
11919
- type: "object",
11920
- properties: { ...NEST_ARG },
11921
- required: ["nest"]
11922
- }
11923
- },
11924
- {
11925
- name: "context_resolve",
11926
- description: "Full deterministic context resolution against one nest \u2014 selector + hops + token budget, complete node bodies.",
11927
- inputSchema: {
11928
- type: "object",
11929
- properties: {
11930
- ...NEST_ARG,
11931
- selector: { type: "string", description: "Selector query string" },
11932
- max_tokens: {
11933
- type: "number",
11934
- description: "Approximate token budget (default: 8000)"
11935
- },
11936
- hops: {
11937
- type: "number",
11938
- description: "Graph traversal depth from the matches (default: 2)"
11939
- }
11940
- },
11941
- required: ["nest", "selector"]
11942
- }
11943
- },
11944
- {
11945
- // Read-only, so it belongs on this endpoint: an agent reading a nest's
11946
- // documents across nests also needs the feedback left on them. Writing a
11947
- // comment stays on the per-nest endpoint with the other write tools.
11948
- name: "context_comments",
11949
- 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.",
11950
- inputSchema: {
11951
- type: "object",
11952
- properties: {
11953
- nest: { type: "string", description: "Nest name or id (see nest_index)" },
11954
- title: { type: "string", description: "Title of the node" },
11955
- id: { type: "string", description: "ID of the node" }
11956
- },
11957
- required: ["nest"]
11958
- }
11959
- },
11960
12550
  {
11961
12551
  name: "nest_create",
11962
12552
  description: "Create a new nest owned by the caller. Returns the new nest's id \u2014 target it with the other tools afterwards.",
@@ -11969,32 +12559,18 @@ var SERVER_TOOL_DEFINITIONS = [
11969
12559
  required: ["name"]
11970
12560
  }
11971
12561
  },
11972
- {
11973
- name: "context_create",
11974
- description: "Create a new knowledge node (document) in a nest. Needs write permission on that nest. A `folder` path creates the folder implicitly \u2014 folders exist through the nodes placed in them.",
11975
- inputSchema: {
11976
- type: "object",
11977
- properties: {
11978
- ...NEST_ARG,
11979
- title: { type: "string", description: "Descriptive title" },
11980
- content: { type: "string", description: "Markdown content body" },
11981
- type: { type: "string", description: "Node type (default: document)" },
11982
- tags: { type: "array", items: { type: "string" }, description: "Tags" },
11983
- scope: { type: "string", description: "Visibility scope" },
11984
- folder: {
11985
- type: "string",
11986
- description: 'Folder path under nodes/ (e.g. "gtm/deals"); segments are slugified'
11987
- }
11988
- },
11989
- required: ["nest", "title", "content"]
11990
- }
11991
- }
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
+ })
11992
12567
  ];
11993
- async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl) {
12568
+ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope, baseUrl, client) {
11994
12569
  if (toolName === "nest_index") {
11995
12570
  const nests = await buildNestIndex(userId, nestScope);
11996
- if (!nests.length) return "No nests accessible with these credentials.";
11997
- 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 };
11998
12574
  }
11999
12575
  if (SERVER_WRITE_TOOLS.has(toolName)) {
12000
12576
  if (isSuspended()) {
@@ -12021,8 +12597,87 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
12021
12597
  throw err;
12022
12598
  }
12023
12599
  }
12024
- const inaccessible = `Nest not found or not accessible: "${args.nest}". Use nest_index to see what these credentials can read.`;
12025
- const nest = await resolveNestRef(String(args.nest ?? ""), userId);
12600
+ const nestArg = args.nest;
12601
+ if (!nestArg && FANOUT_READ_TOOLS.has(toolName)) {
12602
+ const nests = await buildNestIndex(userId, nestScope);
12603
+ if (!nests.length) return "No nests accessible with these credentials.";
12604
+ const nonEmpty = nests.filter((n) => (n.document_count ?? 0) > 0);
12605
+ const results = await Promise.allSettled(
12606
+ nonEmpty.map(async (n) => {
12607
+ const engine2 = await engineCache.get(n.id);
12608
+ const result = await handleToolCall(toolName, args, {
12609
+ storage: engine2.storage,
12610
+ queryEngine: engine2.query,
12611
+ versionManager: engine2.versions,
12612
+ nestId: n.id,
12613
+ userId,
12614
+ userEmail,
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
12621
+ });
12622
+ return { nest: n, result };
12623
+ })
12624
+ );
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;
12635
+ for (const r of results) {
12636
+ if (r.status !== "fulfilled") {
12637
+ firstRejection ??= r;
12638
+ continue;
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;
12656
+ }
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.");
12672
+ }
12673
+ if (!parts.length) return reply("No results found across any accessible nests.");
12674
+ return reply(parts.join("\n\n---\n\n"));
12675
+ }
12676
+ const inaccessible = `Nest not found or not accessible: "${nestArg}". Use nest_index to see what these credentials can read.`;
12677
+ if (!nestArg) {
12678
+ return "This tool requires a `nest` argument. Use nest_index to see available nests.";
12679
+ }
12680
+ const nest = await resolveNestRef(String(nestArg), userId);
12026
12681
  if (!nest) return inaccessible;
12027
12682
  if (nestScope && nest.id !== nestScope) return inaccessible;
12028
12683
  if (!await canRead(nest.id, userId)) return inaccessible;
@@ -12034,10 +12689,11 @@ async function handleServerToolCall(toolName, args, userId, userEmail, nestScope
12034
12689
  nestId: nest.id,
12035
12690
  userId,
12036
12691
  userEmail,
12037
- baseUrl
12692
+ baseUrl,
12693
+ client
12038
12694
  });
12039
12695
  }
12040
- function createServerMcp(userId, userEmail, nestScope, baseUrl) {
12696
+ function createServerMcp(userId, userEmail, nestScope, baseUrl, c) {
12041
12697
  const server = new McpServer2(
12042
12698
  { name: "contextnest-server", version: "1.0.0" },
12043
12699
  { capabilities: { tools: {} } }
@@ -12047,14 +12703,15 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
12047
12703
  const required = tool.inputSchema.required || [];
12048
12704
  const shape = {};
12049
12705
  for (const [key, def] of Object.entries(props)) {
12050
- let field;
12051
- if (def.type === "string") field = z2.string();
12052
- else if (def.type === "number") field = z2.number();
12053
- else if (def.type === "array") field = z2.array(z2.string());
12054
- else field = z2.any();
12055
- if (!required.includes(key)) field = field.optional();
12056
- shape[key] = field;
12057
- }
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);
12058
12715
  server.tool(tool.name, tool.description, shape, async (args) => {
12059
12716
  const out = await handleServerToolCall(
12060
12717
  tool.name,
@@ -12062,14 +12719,15 @@ function createServerMcp(userId, userEmail, nestScope, baseUrl) {
12062
12719
  userId,
12063
12720
  userEmail,
12064
12721
  nestScope,
12065
- baseUrl
12722
+ baseUrl,
12723
+ attributeRequest(c, void 0, connectionClient(server))
12066
12724
  );
12067
12725
  return toCallToolResult(out);
12068
12726
  });
12069
12727
  }
12070
12728
  return server;
12071
12729
  }
12072
- var serverMcpRoutes = new Hono23();
12730
+ var serverMcpRoutes = new Hono24();
12073
12731
  serverMcpRoutes.all("/", async (c) => {
12074
12732
  const userId = c.get("userId");
12075
12733
  const userEmail = await getUserEmail3(userId);
@@ -12077,7 +12735,8 @@ serverMcpRoutes.all("/", async (c) => {
12077
12735
  userId,
12078
12736
  userEmail,
12079
12737
  c.get("nestScope"),
12080
- requestBaseUrl(c.req.url)
12738
+ requestBaseUrl(c.req.url),
12739
+ c
12081
12740
  );
12082
12741
  const transport = new WebStandardStreamableHTTPServerTransport2({
12083
12742
  sessionIdGenerator: void 0,
@@ -12091,7 +12750,7 @@ serverMcpRoutes.all("/", async (c) => {
12091
12750
  await server.server.close();
12092
12751
  }
12093
12752
  });
12094
- var nestIndexRoutes = new Hono23();
12753
+ var nestIndexRoutes = new Hono24();
12095
12754
  nestIndexRoutes.get("/", async (c) => {
12096
12755
  const userId = c.get("userId");
12097
12756
  const nests = await buildNestIndex(userId, c.get("nestScope"));
@@ -12164,26 +12823,61 @@ Other read endpoints (all under \`${B}/nests/<nest-id>\`):
12164
12823
  - \`GET /nodes/<node-id>?format=raw\` \u2014 direct consumption path: the node BODY as plain text (no JSON envelope; add \`&frontmatter=1\` for the stored YAML block)
12165
12824
  - \`GET /export?format=markdown\` \u2014 whole nest as one markdown file (optional \`selector\`, \`max_tokens\`)
12166
12825
  - \`GET /graph\` \u2014 ontology graph (nodes, links, tags, stewards)
12826
+ - \`GET /folders\` \u2014 folder tree; \`GET /nodes?folder=<path>\` \u2014 one folder level
12827
+ - \`POST /table-query\` \u2014 exact rows from a table node: \`{ "table": "[[Title]]", "query": "where <col> <op> <value> [select cols] [limit N]" }\`
12828
+ - \`GET /nodes/<node-id>/versions\` \u2014 history (who, when, approval state); \`/versions/<n>\` \u2014 one version
12829
+ - \`GET /nodes/<node-id>/comments\` \u2014 review threads (anchored quotes, replies, open/resolved)
12167
12830
 
12168
12831
  Write endpoints (need write permission):
12169
12832
  - \`POST /nodes\` \u2014 \`{ "title", "content", "tags"?, "type"?, "folder"? }\`
12170
12833
  - \`PATCH /nodes/<node-id>\` \u2014 \`{ "content"? , "append"?, "tags"?, "title"? }\`
12171
- - \`DELETE /nodes/<node-id>\`
12172
- - Governance: \`POST /nodes/<id>/submit-review\`, \`/approve\`, \`/reject\`
12834
+ - \`DELETE /nodes/<node-id>\`; \`POST /nodes/<id>/request-deletion\` when you lack delete rights
12835
+ - \`POST /nodes/<id>/move\` \u2014 \`{ "folder" }\`; history and comments follow
12836
+ - \`POST /nodes/<id>/revert\` \u2014 \`{ "targetVersion" }\` restores a past version as a new one
12837
+ - \`POST /nodes/<id>/comments\` \u2014 leave or reply to a review comment
12838
+ - Governance: \`POST /nodes/<id>/submit-review\`, \`/approve\`, \`/reject\`, \`/cancel-review\`
12839
+ - \`GET /review-queue\` \u2014 what is waiting on a steward in this nest
12840
+
12841
+ ## Your inbox (server-level)
12842
+
12843
+ - \`GET ${B}/me/work\` \u2014 reviews waiting on you and handoffs, across every nest
12844
+ - \`GET ${B}/me/drafts\` \u2014 your documents never submitted for review
12845
+ - \`GET ${B}/me/notifications\` \u2014 watcher and review notifications
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\`.
12173
12860
 
12174
12861
  ## MCP (Model Context Protocol)
12175
12862
 
12176
12863
  Streamable HTTP, stateless. Two endpoints:
12177
12864
 
12178
- - \`${B}/mcp\` \u2014 server-level, one connection for everything readable.
12179
- Tools: \`nest_index\` (call first), then \`context_query\`, \`context_search\`,
12180
- \`context_get\`, \`context_list\`, \`context_overview\`, \`context_resolve\` \u2014
12181
- each takes a \`nest\` argument (id or exact name) plus the same
12182
- selector/hops/max_tokens parameters as the REST API. Two bootstrap writes:
12183
- \`nest_create\` (new nest owned by the caller) and \`context_create\`
12184
- (new document in a writable nest; a \`folder\` path creates the folder).
12185
- - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, full toolset including writes and
12186
- governance (create/update, submit/approve/reject, stewards, sharing).
12865
+ - \`${B}/mcp\` \u2014 server-level, one connection for everything.
12866
+ Tools: \`nest_index\` (call first), then:
12867
+ - Fan-out reads (omit \`nest\` to search/list ALL nests): \`context_search\`,
12868
+ \`context_list\`, \`context_query\`, \`context_overview\`
12869
+ - Targeted reads (\`nest\` required): \`context_get\`, \`context_resolve\`, \`context_comments\`, \`context_versions\`
12870
+ - Writes (\`nest\` required): \`nest_create\`, \`context_create\`, \`context_update\`,
12871
+ \`context_delete\`, \`context_move\`, \`context_submit_review\`
12872
+ - \`${B}/nests/<nest-id>/mcp\` \u2014 per-nest, the full toolset. Same names as the
12873
+ server-level tools without the \`nest\` argument, plus:
12874
+ - read: \`context_init\` (operating instructions \u2014 call first), \`context_export\`, \`context_table_query\`, \`context_versions\`
12875
+ - write: \`context_update\`, \`context_delete\`, \`context_move\`, \`context_request_deletion\`
12876
+ - review: \`context_submit_review\`, \`context_approve\`, \`context_reject\`, \`context_review_queue\`, \`context_my_drafts\`
12877
+ - comments: \`context_comment\`, \`context_resolve_comment\`
12878
+ - people: \`context_stewards\`, \`context_assign_steward\`, \`context_share_nest\`, \`context_grant\`
12879
+ - admin: \`context_deletion_queue\`, \`context_resolve_deletion\`, \`context_unsynced_list\`, \`context_sync_folder\`
12880
+ - \`workflow_*\` (runs, edges, edge types) when the nest's workflow plane is on
12187
12881
  Sharing a slice is one call: \`context_grant\` with
12188
12882
  \`{ "email", "target_type": "document" | "folder", "target", "role": "read" | "write" }\`
12189
12883
  \u2014 \`target\` is a node id (\`nodes/win-loss-log\`) or a folder prefix
@@ -12209,9 +12903,11 @@ on the command line.
12209
12903
  export CONTEXTNEST_API_KEY=<api-key>
12210
12904
  # every nest these credentials can read, through the server-level endpoint
12211
12905
  ctx vault add contextnest --url ${B}/mcp --bearer-env CONTEXTNEST_API_KEY
12212
- # 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
12213
12910
  ctx vault add mynest --url ${B}/nests/<nest-id>/mcp --bearer-env CONTEXTNEST_API_KEY
12214
- ctx query "#gtm" --vault mynest
12215
12911
  \`\`\`
12216
12912
 
12217
12913
  Servers in "open" auth mode: drop \`--bearer-env\`. Pushing a local folder up
@@ -12230,14 +12926,14 @@ is \`ctx push --server ${B} --nest <nest-id>\` with the same env var set.
12230
12926
  through steward review before they become AI-visible.
12231
12927
  `;
12232
12928
  }
12233
- var llmsTxtRoutes = new Hono23();
12929
+ var llmsTxtRoutes = new Hono24();
12234
12930
  llmsTxtRoutes.get("/", (c) => {
12235
12931
  const md = renderLlmsTxt(requestBaseUrl(c.req.url));
12236
12932
  return c.text(md, 200, { "Content-Type": "text/markdown; charset=utf-8" });
12237
12933
  });
12238
12934
 
12239
12935
  // src/governance/routes.ts
12240
- import { Hono as Hono24 } from "hono";
12936
+ import { Hono as Hono25 } from "hono";
12241
12937
 
12242
12938
  // src/governance/feed-service.ts
12243
12939
  var SNIPPET_CHARS = 200;
@@ -12669,7 +13365,7 @@ function parseLimit(raw, def = 100, max = 1e3) {
12669
13365
  if (Number.isNaN(n) || n < 1) return def;
12670
13366
  return Math.min(n, max);
12671
13367
  }
12672
- var governanceRoutes = new Hono24();
13368
+ var governanceRoutes = new Hono25();
12673
13369
  governanceRoutes.get("/stewards", async (c) => {
12674
13370
  const nestId = c.req.param("nestId");
12675
13371
  const scope = c.req.query("scope");
@@ -12809,6 +13505,7 @@ governanceRoutes.post("/review-queue/bulk", async (c) => {
12809
13505
  }
12810
13506
  const userEmail = await getUserEmail4(c);
12811
13507
  const baseUrl = requestBaseUrl(c.req.url);
13508
+ const client = attributeRequest(c, body.client);
12812
13509
  const results = [];
12813
13510
  for (const nodeId of nodeIds) {
12814
13511
  try {
@@ -12819,7 +13516,8 @@ governanceRoutes.post("/review-queue/bulk", async (c) => {
12819
13516
  approvedBy: userEmail,
12820
13517
  note: body.note,
12821
13518
  override: false,
12822
- baseUrl
13519
+ baseUrl,
13520
+ client
12823
13521
  });
12824
13522
  } else {
12825
13523
  await reject({
@@ -13022,6 +13720,7 @@ governanceRoutes.get("/external-edits", async (c) => {
13022
13720
  await scanNestForDrift(nestId, "user:refresh");
13023
13721
  }
13024
13722
  const entries = await listNestExternalEdits(nestId);
13723
+ if (c.req.query("count") === "1") return c.json({ total: entries.length });
13025
13724
  return c.json({ entries, total: entries.length });
13026
13725
  });
13027
13726
  governanceRoutes.post("/external-edits/scan", async (c) => {
@@ -13036,7 +13735,7 @@ governanceRoutes.get("/activity", async (c) => {
13036
13735
  const activity = await getActivity({ nestId, limit });
13037
13736
  return c.json({ activity });
13038
13737
  });
13039
- var governanceNodeRoutes = new Hono24();
13738
+ var governanceNodeRoutes = new Hono25();
13040
13739
  governanceNodeRoutes.get("/:nodeId{.+}/stewards", async (c) => {
13041
13740
  const nestId = c.req.param("nestId");
13042
13741
  const nodeId = c.req.param("nodeId");
@@ -13242,7 +13941,10 @@ governanceNodeRoutes.post("/:nodeId{.+}/submit-review", async (c) => {
13242
13941
  requestedBy: userEmail,
13243
13942
  note: body.note,
13244
13943
  priority: body.priority,
13245
- 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)
13246
13948
  });
13247
13949
  } catch (err) {
13248
13950
  const msg = err instanceof Error ? err.message : String(err);
@@ -13376,7 +14078,8 @@ governanceNodeRoutes.post("/:nodeId{.+}/approve", async (c) => {
13376
14078
  approvedBy: userEmail,
13377
14079
  note: body.note,
13378
14080
  override: body.override && isAdmin,
13379
- baseUrl: requestBaseUrl(c.req.url)
14081
+ baseUrl: requestBaseUrl(c.req.url),
14082
+ client: attributeRequest(c, body.client)
13380
14083
  });
13381
14084
  return c.json({ review: request });
13382
14085
  } catch (err) {
@@ -13391,6 +14094,7 @@ governanceNodeRoutes.post("/:nodeId{.+}/reject", async (c) => {
13391
14094
  throw new ValidationError("Rejection note is required");
13392
14095
  }
13393
14096
  const userEmail = await getUserEmail4(c);
14097
+ attributeRequest(c, body.client);
13394
14098
  try {
13395
14099
  const request = await reject({
13396
14100
  nestId,
@@ -13488,6 +14192,41 @@ async function ensureAnonymousUser() {
13488
14192
  return ANON_USER_ID;
13489
14193
  }
13490
14194
 
14195
+ // src/shared/posthog-key.ts
14196
+ var POSTHOG_KEY_MIN_TOKEN = 20;
14197
+ function posthogKeyError(value) {
14198
+ const v = value.trim();
14199
+ if (!v) return null;
14200
+ if (!v.startsWith("phc_"))
14201
+ return "PostHog project key must start with phc_ \u2014 copy the project API key from PostHog settings.";
14202
+ const token = v.slice(4);
14203
+ if (!/^[A-Za-z0-9]*$/.test(token))
14204
+ return "PostHog project key may only contain letters and digits after phc_ \u2014 check for spaces or punctuation.";
14205
+ if (token.length < POSTHOG_KEY_MIN_TOKEN)
14206
+ return `PostHog project key looks too short \u2014 a real key is about 47 characters long (phc_ plus ${POSTHOG_KEY_MIN_TOKEN}+ letters and digits). Copy the whole key from PostHog settings.`;
14207
+ return null;
14208
+ }
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
+
13491
14230
  // src/app.ts
13492
14231
  import { serveStatic } from "@hono/node-server/serve-static";
13493
14232
  import { fileURLToPath } from "url";
@@ -13537,7 +14276,10 @@ var flexAuthMiddleware = createMiddleware2(async (c, next) => {
13537
14276
  c.set("nestScope", null);
13538
14277
  return next();
13539
14278
  }
13540
- 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
+ );
13541
14283
  });
13542
14284
  function nestPathParts(path) {
13543
14285
  return path.replace(/^\/nests\//, "").split("/").filter(Boolean);
@@ -13555,14 +14297,24 @@ var flexAuthWithPromptowlTicket = createMiddleware2(
13555
14297
  return flexAuthMiddleware(c, next);
13556
14298
  }
13557
14299
  let claims = null;
14300
+ let expired = false;
13558
14301
  for (const secret of secrets) {
13559
14302
  try {
13560
14303
  claims = verifySsoTicket(token, secret);
13561
14304
  break;
13562
- } catch {
14305
+ } catch (err) {
14306
+ if (err instanceof SsoTokenError && err.message === "Ticket expired") {
14307
+ expired = true;
14308
+ }
13563
14309
  }
13564
14310
  }
13565
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
+ }
13566
14318
  return c.json({ error: "Invalid service ticket" }, 401);
13567
14319
  }
13568
14320
  if (claims.scope !== "mcp" || claims.jti) {
@@ -13609,7 +14361,7 @@ async function canApproveInNest(nestId, email) {
13609
14361
  return ok;
13610
14362
  }
13611
14363
  function createApp() {
13612
- const app = new Hono25({ router: new LinearRouter() });
14364
+ const app = new Hono26({ router: new LinearRouter() });
13613
14365
  const corsOrigins = config.CORS_ORIGINS;
13614
14366
  app.use(
13615
14367
  "*",
@@ -13627,7 +14379,8 @@ function createApp() {
13627
14379
  const lenStr = c.req.header("Content-Length");
13628
14380
  if (lenStr) {
13629
14381
  const len = parseInt(lenStr, 10);
13630
- 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;
13631
14384
  if (Number.isFinite(len) && len > cap) {
13632
14385
  return c.json(
13633
14386
  {
@@ -13652,7 +14405,10 @@ function createApp() {
13652
14405
  nestId: /^\/nests\/([^/]+)/.exec(path)?.[1] ?? null,
13653
14406
  userId: c.get("userId") ?? null,
13654
14407
  status: c.res.status,
13655
- 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")
13656
14412
  });
13657
14413
  });
13658
14414
  app.get("/health", (c) => {
@@ -13697,6 +14453,11 @@ function createApp() {
13697
14453
  // API would refuse it — existing nodes of the type stay readable.
13698
14454
  type_artifact_enabled: config.TYPE_ARTIFACT_ENABLED,
13699
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,
13700
14461
  // Sub-agent runs gate its own recursion surface separately (env-only,
13701
14462
  // must clear a security review — NOT a UI toggle). Exposed read-only so
13702
14463
  // the Runs page shows the "spawn sub-agent" option only when it's live.
@@ -13820,6 +14581,7 @@ function createApp() {
13820
14581
  oidc_auto_provision: config.OIDC_AUTO_PROVISION,
13821
14582
  oidc_department_tagging: config.OIDC_DEPARTMENT_TAGGING,
13822
14583
  people_suggest_all_users: config.PEOPLE_SUGGEST_ALL_USERS,
14584
+ shared_deployment: config.SHARED_DEPLOYMENT,
13823
14585
  // Agent SSO / token-exchange. MCP_SIGNING_SECRET is WRITE-ONLY — only its
13824
14586
  // presence is reported, never the value (same discipline as the OIDC secret).
13825
14587
  sso_token_exchange_enabled: config.SSO_TOKEN_EXCHANGE_ENABLED,
@@ -13872,8 +14634,8 @@ function createApp() {
13872
14634
  return c.json({ error: "Invalid JSON body" }, 400);
13873
14635
  }
13874
14636
  const fieldErrors = {};
13875
- const addError = (field, message) => {
13876
- fieldErrors[field] = message;
14637
+ const addError = (field2, message) => {
14638
+ fieldErrors[field2] = message;
13877
14639
  };
13878
14640
  const pending = [];
13879
14641
  if ("promptowl_sign_in_gate" in body) {
@@ -13894,14 +14656,18 @@ function createApp() {
13894
14656
  }
13895
14657
  if ("public_base_url" in body) {
13896
14658
  const v = String(body.public_base_url ?? "").trim().replace(/\/$/, "");
13897
- 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 });
13898
14662
  }
13899
14663
  if ("telemetry_enabled" in body) {
13900
14664
  pending.push({ name: "TELEMETRY_ENABLED", value: body.telemetry_enabled ? "true" : "false" });
13901
14665
  }
13902
14666
  if ("posthog_key" in body) {
13903
14667
  const v = String(body.posthog_key ?? "").trim();
13904
- pending.push({ name: "POSTHOG_KEY", value: v || null });
14668
+ const keyError = posthogKeyError(v);
14669
+ if (keyError) addError("posthog_key", keyError);
14670
+ else pending.push({ name: "POSTHOG_KEY", value: v || null });
13905
14671
  }
13906
14672
  if ("posthog_host" in body) {
13907
14673
  const v = String(body.posthog_host ?? "").trim().replace(/\/$/, "");
@@ -14046,6 +14812,12 @@ function createApp() {
14046
14812
  value: body.people_suggest_all_users ? "true" : null
14047
14813
  });
14048
14814
  }
14815
+ if ("shared_deployment" in body) {
14816
+ pending.push({
14817
+ name: "SHARED_DEPLOYMENT",
14818
+ value: body.shared_deployment ? "true" : null
14819
+ });
14820
+ }
14049
14821
  if ("sso_token_exchange_enabled" in body) {
14050
14822
  pending.push({
14051
14823
  name: "SSO_TOKEN_EXCHANGE_ENABLED",
@@ -14096,13 +14868,13 @@ function createApp() {
14096
14868
  const effAllowedDomains = "oidc_allowed_domains" in body ? String(body.oidc_allowed_domains ?? "").split(",").map((d) => d.trim()).filter(Boolean) : config.OIDC_ALLOWED_DOMAINS;
14097
14869
  const effAutoProvision = "oidc_auto_provision" in body ? !!body.oidc_auto_provision : config.OIDC_AUTO_PROVISION;
14098
14870
  if (effAutoProvision && effAllowedDomains.length === 0) {
14099
- for (const field of [
14871
+ for (const field2 of [
14100
14872
  "ms_token_exchange_enabled",
14101
14873
  "google_token_exchange_enabled"
14102
14874
  ]) {
14103
- if (body[field])
14875
+ if (body[field2])
14104
14876
  addError(
14105
- field,
14877
+ field2,
14106
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."
14107
14879
  );
14108
14880
  }
@@ -14552,7 +15324,7 @@ function createApp() {
14552
15324
  app.route("/index", nestIndexRoutes);
14553
15325
  app.use("/mcp", flexAuthWithPromptowlTicket);
14554
15326
  app.route("/mcp", serverMcpRoutes);
14555
- const nestsApp = new Hono25();
15327
+ const nestsApp = new Hono26();
14556
15328
  nestsApp.use("*", async (c, next) => {
14557
15329
  const parts = nestPathParts(c.req.path);
14558
15330
  const ticketEligible = parts.length === 2 && (c.req.method === "POST" && parts[1] === "context" || c.req.method === "GET" && parts[1] === "nodes" || parts[1] === "mcp");
@@ -14666,7 +15438,7 @@ function createApp() {
14666
15438
  const isCommentAction = /\/comments$/.test(path) || /\/comments\/[^/]+\/resolve$/.test(path);
14667
15439
  const isDeletionRequestAction = /\/request-deletion$/.test(path) || /\/deletion-requests\/[^/]+\/withdraw$/.test(path);
14668
15440
  const isStewardRoster = path.includes("/stewards") && !path.includes("/nodes/");
14669
- if (isStewardRoster && permission !== "owner" && permission !== "admin") {
15441
+ if (isStewardRoster && access.rawPermission !== "owner" && access.rawPermission !== "admin") {
14670
15442
  return c.json(
14671
15443
  {
14672
15444
  error: "You don't have permission to manage stewards. Only a nest admin, the nest owner, or the server admin can do this."
@@ -14674,6 +15446,13 @@ function createApp() {
14674
15446
  403
14675
15447
  );
14676
15448
  }
15449
+ if (isStewardRoster && access.archived && c.req.method !== "GET") {
15450
+ return deny(
15451
+ 403,
15452
+ "This nest is archived, so it is read-only. Restore it to change stewards.",
15453
+ { archived: true }
15454
+ );
15455
+ }
14677
15456
  const resource = parts[1];
14678
15457
  if (resource === "visibility" || resource === "reader-mode") {
14679
15458
  required = "admin";
@@ -14702,7 +15481,9 @@ function createApp() {
14702
15481
  stewardEditorBypass = resolved.some(
14703
15482
  (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor"
14704
15483
  );
14705
- } 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")) {
14706
15487
  const resolved = await resolveStewardsForNode(nestId, "");
14707
15488
  stewardEditorBypass = resolved.some(
14708
15489
  (r) => r.steward.userEmail.toLowerCase() === userEmail.toLowerCase() && r.steward.role === "editor" && r.steward.scope === "nest"
@@ -14712,6 +15493,13 @@ function createApp() {
14712
15493
  const grantLevel = grantRole ? permissionLevel(grantRole) : grantNestVisible ? permissionLevel("read") : 0;
14713
15494
  const effectiveLevel = Math.max(permissionLevel(permission), grantLevel);
14714
15495
  if (!stewardEditorBypass && effectiveLevel < permissionLevel(required)) {
15496
+ if (access.archived && permissionLevel(access.rawPermission) >= permissionLevel(required)) {
15497
+ return deny(
15498
+ 403,
15499
+ "This nest is archived, so it is read-only. Restore it to make changes.",
15500
+ { archived: true }
15501
+ );
15502
+ }
14715
15503
  return deny(
14716
15504
  403,
14717
15505
  `You don't have access to perform this action on this nest. Required permission: '${required}', your permission: '${permission}'. Ask the nest owner or a server admin to grant you ${required} access.`,
@@ -14757,6 +15545,7 @@ function createApp() {
14757
15545
  });
14758
15546
  nestsApp.route("/", nestRoutes);
14759
15547
  nestsApp.route("/:nestId", governanceRoutes);
15548
+ nestsApp.route("/:nestId/nodes", pdfNodeRoutes);
14760
15549
  nestsApp.route("/:nestId/nodes", annotationRoutes);
14761
15550
  nestsApp.route("/:nestId/nodes", governanceNodeRoutes);
14762
15551
  nestsApp.route("/:nestId/nodes", nodeRoutes);