@promptowl/contextnest-community 1.24.0 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/API.md +109 -6
  2. package/CONFIGURATION.md +7 -2
  3. package/README.md +1 -1
  4. package/STEWARDSHIP.md +24 -0
  5. package/dist/{chunk-2TPQTN4Y.js → chunk-2KHM5C7V.js} +37 -3
  6. package/dist/{chunk-XUIWAWDO.js → chunk-32SZCH36.js} +4 -4
  7. package/dist/{chunk-KIAAEHWL.js → chunk-7FROMOZM.js} +23 -5
  8. package/dist/{chunk-5EZOPA47.js → chunk-DTLMBF4W.js} +10 -8
  9. package/dist/{chunk-LA3VTQ22.js → chunk-JDAOA5KP.js} +43 -1
  10. package/dist/{chunk-I3CSD6CK.js → chunk-KPGNZLZL.js} +2 -2
  11. package/dist/{chunk-YVMSM7LS.js → chunk-LHWOJPFE.js} +7 -0
  12. package/dist/{chunk-ZTT4U4NE.js → chunk-TI7HMP64.js} +99 -12
  13. package/dist/{client-KEY4PYJH.js → client-D44ZHDTJ.js} +1 -1
  14. package/dist/{engine-S3QBQ7LH.js → engine-TER6FDBP.js} +3 -3
  15. package/dist/{external-edit-service-6FNFPOJJ.js → external-edit-service-CAGENHDJ.js} +4 -4
  16. package/dist/{grants-service-UT3EQ3R7.js → grants-service-2OTFS7EH.js} +3 -3
  17. package/dist/index.js +863 -355
  18. package/dist/{migrations.postgres-AIQ7WSU7.js → migrations.postgres-23VJJJJX.js} +12 -2
  19. package/dist/{review-service-HHGOTO6P.js → review-service-4G2XUFS6.js} +7 -7
  20. package/dist/{stewardship-service-4DHNRULG.js → stewardship-service-S22V2JKG.js} +6 -4
  21. package/dist/{version-service-KIOQME6U.js → version-service-X4FAG2OZ.js} +4 -4
  22. package/dist/web3/assets/ActivityTracePage-D2iIbEph.js +1 -0
  23. package/dist/web3/assets/{AgentDocsPage-CIiaBqMy.js → AgentDocsPage-Dkhgb1hk.js} +1 -1
  24. package/dist/web3/assets/CollaboratorManager-Bo81D--Q.js +1 -0
  25. package/dist/web3/assets/CollaboratorsTab-DRCitQuX.js +1 -0
  26. package/dist/web3/assets/DocumentEditor-DV2WHYbj.js +36 -0
  27. package/dist/web3/assets/{DocumentsTab-Vba-AzBv.js → DocumentsTab-BUxBCN8o.js} +2 -2
  28. package/dist/web3/assets/{ExternalEditsTab-BSzAQIGM.js → ExternalEditsTab-kIBimZyE.js} +1 -1
  29. package/dist/web3/assets/MarkdownEditor-CsVusP2d.css +1 -0
  30. package/dist/web3/assets/MarkdownEditor-Dfk5uIE5.js +648 -0
  31. package/dist/web3/assets/{NestPageHeader-CoLUV1Oz.js → NestPageHeader-yOK-OIYZ.js} +1 -1
  32. package/dist/web3/assets/NestView-DwIagxwJ.js +68 -0
  33. package/dist/web3/assets/{OverviewTab-Dxi-Hu-N.js → OverviewTab-DFOFMVyE.js} +1 -1
  34. package/dist/web3/assets/{PersonCombobox-CPOlNs6G.js → PersonCombobox-D350WlrJ.js} +1 -1
  35. package/dist/web3/assets/{ReasonDialog-DgLxfRWv.js → ReasonDialog-CN3ECAnQ.js} +1 -1
  36. package/dist/web3/assets/{ReviewActions-OfOdqzCn.js → ReviewActions-BXDTDPp1.js} +1 -1
  37. package/dist/web3/assets/{ReviewTab-CyX8yxd3.js → ReviewTab-CZewAYiz.js} +1 -1
  38. package/dist/web3/assets/StewardsTab-AagNWcWS.js +1 -0
  39. package/dist/web3/assets/{SubmitForReviewModal-Ci2DdWs0.js → SubmitForReviewModal-BOZJxwRN.js} +1 -1
  40. package/dist/web3/assets/{alert-dialog-DTozKlmV.js → alert-dialog-4SiLKlz8.js} +1 -1
  41. package/dist/web3/assets/{arrow-left-BCKt4CwQ.js → arrow-left-BAgIocLn.js} +1 -1
  42. package/dist/web3/assets/backlinks-Cb8Uf8mw.js +24 -0
  43. package/dist/web3/assets/{card-XG2dP5Xf.js → card-UDyMRcps.js} +1 -1
  44. package/dist/web3/assets/{chevron-left-BPwUT9DS.js → chevron-left-XR4ReJ9Z.js} +1 -1
  45. package/dist/web3/assets/{circle-check-DI8IBGeo.js → circle-check-CSkZUEFK.js} +1 -1
  46. package/dist/web3/assets/{circle-x-VKVhC88W.js → circle-x-in2o3aHU.js} +1 -1
  47. package/dist/web3/assets/client-attribution-BraarYaP.js +1 -0
  48. package/dist/web3/assets/{code-xml-CMabYZvH.js → code-xml-pPQgdctI.js} +1 -1
  49. package/dist/web3/assets/{corner-down-right-BGxwK6wH.js → corner-down-right-CuxZWscX.js} +1 -1
  50. package/dist/web3/assets/{count-skeleton-DMbdpscX.js → count-skeleton-DBWCDhRy.js} +1 -1
  51. package/dist/web3/assets/{earth-BEp1EKTo.js → earth-CXOcThqn.js} +1 -1
  52. package/dist/web3/assets/{file-exclamation-point-r8qZGIjP.js → file-exclamation-point-CSphs1VD.js} +1 -1
  53. package/dist/web3/assets/{folder-input-Cmtx1Jhk.js → folder-input-BAwK9tFL.js} +1 -1
  54. package/dist/web3/assets/{index-C1wTSjfP.js → index-A0_ymcqL.js} +1 -1
  55. package/dist/web3/assets/index-B5hLclEq.js +389 -0
  56. package/dist/web3/assets/index-DrUAtoQM.css +1 -0
  57. package/dist/web3/assets/page-BHD4beun.js +1 -0
  58. package/dist/web3/assets/{page-CPjWQNKx.js → page-BSYUXYmO.js} +1 -1
  59. package/dist/web3/assets/page-BYc2DIux.js +45 -0
  60. package/dist/web3/assets/{page-Bicf02Cz.js → page-BfibLg8e.js} +1 -1
  61. package/dist/web3/assets/page-CGe8MtUu.js +9 -0
  62. package/dist/web3/assets/{page-CQt3ZGbf.js → page-CIgPJmG6.js} +1 -1
  63. package/dist/web3/assets/{page-CHb0F5hV.js → page-CUjuSs5Q.js} +1 -1
  64. package/dist/web3/assets/{page-BYUFhYWZ.js → page-Ck31nx9b.js} +1 -1
  65. package/dist/web3/assets/{page-BExMvttJ.js → page-Cpdj_11Y.js} +1 -1
  66. package/dist/web3/assets/{page-CtaW65El.js → page-D0sN3GO6.js} +1 -1
  67. package/dist/web3/assets/page-DEpH91X2.js +1 -0
  68. package/dist/web3/assets/{page-CC0NJi8v.js → page-l8-B7CGt.js} +1 -1
  69. package/dist/web3/assets/{page-DGOL9l8i.js → page-mnSHmk6A.js} +1 -1
  70. package/dist/web3/assets/{page-title-ClvvBsIo.js → page-title-BkqlUA0j.js} +1 -1
  71. package/dist/web3/assets/{page-ByyVLDVo.js → page-xzYqkBUP.js} +5 -5
  72. package/dist/web3/assets/{play-C5YNKpAH.js → play-B8xE9j5f.js} +1 -1
  73. package/dist/web3/assets/{refresh-cw-BhHzSH9m.js → refresh-cw-CsCtmWwX.js} +1 -1
  74. package/dist/web3/assets/{scroll-area-hz-xtayP.js → scroll-area-CbcH6yrZ.js} +1 -1
  75. package/dist/web3/assets/{select-DBFTrUmI.js → select-DrL0mpRN.js} +2 -2
  76. package/dist/web3/assets/{send-0CHvmluT.js → send-CvAgOUPG.js} +1 -1
  77. package/dist/web3/assets/{settings-CeeF4LEb.js → settings-EY4A_DYv.js} +1 -1
  78. package/dist/web3/assets/{share-2-CMnOlOt-.js → share-2-vGmZUl90.js} +1 -1
  79. package/dist/web3/assets/{tag-DQ_6J5Gv.js → tag-Br_y1f00.js} +1 -1
  80. package/dist/web3/assets/{trash-2-HBi-Pslz.js → trash-2-B4hRXo-p.js} +1 -1
  81. package/dist/web3/assets/{triangle-alert-CDy8-7sv.js → triangle-alert-CsYGyfGZ.js} +1 -1
  82. package/dist/web3/assets/{user-plus-CvBNcpn4.js → user-plus-4CTYEkd3.js} +1 -1
  83. package/dist/web3/assets/{x-By7piikG.js → x-qxt1WrUi.js} +1 -1
  84. package/dist/web3/assets/zap-BcsKR0ef.js +16 -0
  85. package/dist/web3/index.html +2 -2
  86. package/package.json +2 -2
  87. package/dist/web3/assets/ActivityTracePage-BOZHgJ8S.js +0 -1
  88. package/dist/web3/assets/CollaboratorManager-By91wrIr.js +0 -1
  89. package/dist/web3/assets/CollaboratorsTab-D1EE64Fs.js +0 -1
  90. package/dist/web3/assets/DocumentEditor-DrQfrW3d.js +0 -36
  91. package/dist/web3/assets/MarkdownEditor-74N_naNQ.css +0 -1
  92. package/dist/web3/assets/MarkdownEditor-C9zqZjC5.js +0 -643
  93. package/dist/web3/assets/NestView-CTZ55tXD.js +0 -63
  94. package/dist/web3/assets/StewardsTab-DBEEfAQI.js +0 -1
  95. package/dist/web3/assets/backlinks-CYd4xENL.js +0 -24
  96. package/dist/web3/assets/index-BM-h3DwI.css +0 -1
  97. package/dist/web3/assets/index-EaX2yql0.js +0 -389
  98. package/dist/web3/assets/page-B3yvQvHy.js +0 -1
  99. package/dist/web3/assets/page-BUOADv2X.js +0 -1
  100. package/dist/web3/assets/page-BsPMDm5d.js +0 -45
  101. package/dist/web3/assets/page-Cj1NetQ-.js +0 -1
  102. package/dist/web3/assets/zap-C2T8riBp.js +0 -11
package/API.md CHANGED
@@ -14,6 +14,70 @@ Anonymous open mode (`AUTH_MODE=open`) bypasses auth on most read endpoints.
14
14
 
15
15
  All bodies are JSON unless noted. All errors return `{ "error": "msg" }` with appropriate HTTP status.
16
16
 
17
+ ## Caller attribution (`client`)
18
+
19
+ `edited_by` names a person and a read names nobody, so neither answers *which
20
+ agent, in which session*. Every write body below, and every MCP tool call, takes
21
+ an optional `client` object that does ([spec §9.4](https://github.com/PromptOwl/ContextNest/blob/main/CONTEXT_NEST_SPEC.md)):
22
+
23
+ ```json
24
+ { "title": "API Design", "content": "…",
25
+ "client": { "agent": "claude-code", "session_id": "s-9f2", "run": 7 } }
26
+ ```
27
+
28
+ - `agent` and `session_id` are reserved; any other key is custom and is recorded
29
+ verbatim. Values are scalars (string / number / boolean).
30
+ - A write that creates a version records it **on that version** — it comes back
31
+ on `GET .../versions` and on MCP `context_versions` (in the prose and the
32
+ structured payload). Every path that seals a version takes it: create, edit,
33
+ approve, revert, and import (`POST /nests/import`, `POST /nests/unsynced/sync`,
34
+ which stamp every row they seed), plus `POST /review-queue/bulk`, whose one
35
+ block rides every approval in the batch. `POST .../submit-review` seals it
36
+ into the engine's own version chain (`history.yaml`), which is the entry that
37
+ submission appends. Every MCP tool call, and every REST
38
+ call that accepts `client`, records it on the activity trace
39
+ (`GET /admin/trace`, `GET /nests/:nestId/trace`, where it comes back as a
40
+ parsed `client` object) — which is where a call that seals no version is
41
+ attributed: a **read**, and a **rejection**, which commits no content.
42
+ - **Entirely optional.** No endpoint requires it and an unattributed call is a
43
+ valid call. A call that carries none stores nothing rather than an empty
44
+ object — "not attributed" and "attributed to nobody" are different claims.
45
+ - **A label, not an identity.** It is never authenticated, never used to
46
+ authorize, and never an input to a hash chain, so histories written before the
47
+ field existed keep verifying byte for byte. `editedBy` remains the
48
+ authoritative authoring record.
49
+ - **Bounded**, because an untrusted caller writes it into an append-only audit
50
+ trail: values ≤512 chars, ≤16 custom keys, scalars only → `400` otherwise. A
51
+ near-miss on a reserved key (`sessionId`, `Agent`) is refused rather than
52
+ filed as a custom key, which would look attributed and not be.
53
+ - The server fills what the transport already knows, **per key, under** anything
54
+ the caller sent. Precedence is caller > env > connection: on MCP the
55
+ connection means the `initialize` handshake's `clientInfo.name` and the
56
+ transport's session id; behind that sits the label the operator gave the API
57
+ key. `CONTEXTNEST_AGENT` / `CONTEXTNEST_SESSION_ID` are the env layer;
58
+ `CONTEXTNEST_NO_ATTRIBUTION=1` derives nothing (a `client` the caller sent
59
+ explicitly is still honoured). See `CONFIGURATION.md`.
60
+ - Note the `/mcp` endpoints are **stateless** — one server per request, no
61
+ session issued — so a handshake's `clientInfo` is not in scope by the time a
62
+ tool call arrives, and `agent` comes from the key label in practice. An agent
63
+ that wants to name itself exactly should send `client` rather than rely on
64
+ what the connection happens to report.
65
+
66
+ - **Treat a recorded `client` as untrusted text when you read it back.** The
67
+ values are whatever the caller sent, stored verbatim and unescaped by design —
68
+ an audit record that rewrites what the caller said is worth less than none —
69
+ and `context_versions` prints `agent` into the prose an LLM client reads
70
+ (`[via claude-code]`). That is the same standing as every other caller-written
71
+ string this server serves back: document titles, bodies, change notes and
72
+ review notes all reach an agent's context the same way. Bounds (≤512 chars,
73
+ scalars) cap the size, not the content. Render it as data, never as
74
+ instructions, and the usual rule applies to anything downstream: escape at the
75
+ point of use. This server's own UI does — React escapes it as a text node, and
76
+ nothing interpolates it into SQL (`client_json` is always a bound parameter).
77
+
78
+ Distinct from `metadata`, which is frontmatter: `metadata` describes the
79
+ document, `client` describes the call that touched it.
80
+
17
81
  ---
18
82
 
19
83
  ## 1. Auth
@@ -182,7 +246,7 @@ Related `/admin/settings` fields (GET/PATCH, superadmin only): `sso_token_exchan
182
246
  A nest = a context vault (folder of markdown documents + governance state).
183
247
 
184
248
  ### GET `/nests`
185
- Returns owned + shared nests. Archived nests are **absent** — as they are from every other listing, the work inbox, cross-nest search and the MCP index. Each row carries `pinned` — the calling user's own bookmark (see `POST /nests/:nestId/pin`).
249
+ Returns owned + shared nests. Archived nests are **absent** — as they are from every other listing, the work inbox, cross-nest search and the MCP index. Each row carries `pinned` — the calling user's own bookmark (see `POST /nests/:nestId/pin`). Nests reachable through `org`/`public` visibility alone are included too and carry `discovered: true` (owned and shared rows carry `false`) — the dashboard's default *My nests* scope leaves them out.
186
250
  ```json
187
251
  { "nests": [{ "id": "...", "user_id": "...", "name": "...", "slug": "...", "visibility": "private", "created_at": "..." }] }
188
252
  ```
@@ -235,7 +299,8 @@ Pins are per `(user, nest)` in the `nest_pins` table and cascade with the nest.
235
299
  "stewardship_enabled": false,
236
300
  "allow_self_approve": false,
237
301
  "prime_only_review": false,
238
- "prime_tags": ["prime-document"]
302
+ "prime_tags": ["prime-document"],
303
+ "creator_is_reviewer": false
239
304
  }
240
305
  ```
241
306
 
@@ -249,6 +314,7 @@ Every field is optional; only the ones present are changed.
249
314
  - `allow_self_approve` — owner/admin writes publish immediately instead of drafting.
250
315
  - `prime_only_review` — in a governed nest, only **prime** documents need approval; everything else self-publishes. Off by default.
251
316
  - `prime_tags` — tags that mark a document prime. Accepts an array or a comma-separated string; stored and returned normalized (no leading `#`, lowercased, deduped). See `STEWARDSHIP.md → Prime documents`.
317
+ - `creator_is_reviewer` — in a governed nest, whoever creates a document becomes its document-scope `reviewer` and v1 publishes immediately; later changes go through review as usual (the creator approves teammates' changes, another reviewer approves the creator's). Prime documents still draft. Future creates only. Off by default. See `STEWARDSHIP.md → Creators review their own documents`.
252
318
 
253
319
  Turning stewardship **off** wipes stewards and pending reviews and is owner-only; the other fields are non-destructive and never change the state of anything already published.
254
320
 
@@ -264,7 +330,7 @@ Turning stewardship **off** wipes stewards and pending reviews and is owner-only
264
330
  | `public` | anyone, including anonymous callers |
265
331
  `org` and `public` readers see **approved content only** — drafts and pending versions stay with collaborators and stewards.
266
332
  Under `AUTH_MODE=open` every caller resolves to the anonymous user, so there is no "other authenticated user" for `org` to admit: on an open-mode deployment `org` behaves as `private`. Use it on a deployment with real logins.
267
- - **Outside-org publish guardrail.** Setting `visibility: "public"` **requires** `acknowledge_public: true` (`org` does not — it stays inside the deployment, so it crosses no organizational boundary). Without it the request is rejected `409`:
333
+ - **Outside-org publish guardrail.** Setting `visibility: "public"` **requires** `acknowledge_public: true`. `org` does not on a single-organization server — it stays inside the deployment, so it crosses no organizational boundary. On a **shared deployment** (`SHARED_DEPLOYMENT=true` / Settings → General → *This server hosts several organizations* — the PromptOwl-hosted service) the deployment *is* several organizations, so `org` requires the same `acknowledge_public: true` and the `warning` names that boundary. Without the acknowledgement the request is rejected `409`:
268
334
  ```json
269
335
  {
270
336
  "error": "Publishing this nest requires acknowledgement.",
@@ -272,7 +338,15 @@ Turning stewardship **off** wipes stewards and pending reviews and is owner-only
272
338
  "warning": "This makes the nest readable by anyone on the internet, outside your organization."
273
339
  }
274
340
  ```
275
- Going back to `private` needs no acknowledgement.
341
+ On a shared deployment, `org` without the acknowledgement is rejected the same way, with its own warning:
342
+ ```json
343
+ {
344
+ "error": "Opening this nest to the organization requires acknowledgement.",
345
+ "requires_acknowledgement": true,
346
+ "warning": "This server is shared: 'Organization' makes the nest readable by every account on this server, including other organizations — not just yours."
347
+ }
348
+ ```
349
+ Going back to `private` needs no acknowledgement. `GET /health` reports `shared_deployment` so a client can word its own confirmation the same way.
276
350
 
277
351
  ### PATCH `/nests/:nestId/reader-mode` (server-admin / superadmin only)
278
352
  Configure **Public Docs Reader Mode** — a clean, read-only public docs view of the nest. Superadmin-gated via `isServerAdminUserId` (license admin OR `access.yaml` super_admins ∪ the DB-granted set; the same gate as `/nests/unsynced` and `/admin/*`). A plain nest-owner/admin who is not a superadmin gets `403`.
@@ -635,7 +709,8 @@ Optional query params, applied before the per-node enrichment:
635
709
  "scope": "team",
636
710
  "status": "draft",
637
711
  "folder": "gtm/deals",
638
- "schedule": "0 6 * * *"
712
+ "schedule": "0 6 * * *",
713
+ "client": { "agent": "claude-code", "session_id": "s-9f2" }
639
714
  }
640
715
  ```
641
716
  - Slug derived from title → `nodes/<slug>`.
@@ -644,6 +719,7 @@ Optional query params, applied before the per-node enrichment:
644
719
  - Optional `schedule` (free-form cron/interval string, opaque to the server) — **only valid when `type` is `agent` or `skill`**; sent on any other type → `400`. Stored in frontmatter metadata and echoed back on every node response. See [§9 `GET /runnables`](#get-nestsnestidrunnables-read).
645
720
  - Optional `prime` (boolean) flags the document as **prime** — it then always requires steward approval, whoever writes it. Owner/admin only; any other caller sending the field gets `403`. Omit to inherit from the nest's `prime_tags`; `false` exempts this document from an otherwise-prime tag. Echoed back on every node response as `prime` (absent when inherited). See `STEWARDSHIP.md → Prime documents`.
646
721
  - If stewardship enabled → status defaults `draft`. Else `approved`.
722
+ - Optional `client` — caller attribution, recorded on the version this create seals. See [Caller attribution](#caller-attribution-client).
647
723
  - Auto-syncs tag index for steward resolution.
648
724
 
649
725
  Response (201): `{ "node": { ... }, "stewards": [...] }`.
@@ -705,10 +781,12 @@ Headers (optional): `X-Base-Version: 3` — server returns 409 on conflict.
705
781
  "content": "...",
706
782
  "tags": ["..."],
707
783
  "changeNote": "fixed typo",
708
- "schedule": "0 7 * * *"
784
+ "schedule": "0 7 * * *",
785
+ "client": { "agent": "claude-code", "session_id": "s-9f2" }
709
786
  }
710
787
  ```
711
788
  Requires nest write permission.
789
+ - `client` — caller attribution, recorded on the version this edit seals. See [Caller attribution](#caller-attribution-client).
712
790
  - `schedule` — runnable types (`agent`/`skill`) only; sent for any other type → `400`. Send `""` to clear it.
713
791
  - `type` — re-type the node in place (the editor's "Turn into…"): `document`, `artifact`, `table`, `agent`, `skill`, `tool`, or any other engine type. Same gate as create — the artifact/table feature flags and the accepted set (`400` otherwise). The body is kept as-is. Converting to `skill` defaults the trigger from the title; converting away from a runnable type drops its `schedule`.
714
792
  - `prime` — owner/admin only (`403` otherwise). `true`/`false` set the document-scope flag, `null` clears it back to tag/nest inheritance. An edit that makes the document prime is itself gated, so it lands as a draft awaiting approval.
@@ -720,6 +798,23 @@ A node with a pending review is frozen — `423` — for callers who can't clear
720
798
 
721
799
  Purges everything keyed to the node id — version history, review requests, the approved-version pin, tag index, annotations and their comments, review comments, watchers, schedules, trigger hooks, workflow edges, document-scoped stewards and document grants — and leaves a deletion tombstone for `GET /changes`. Node ids derive from titles, so a later document can land on the same id; it must inherit none of the old one's readers, reviewers or history. Run traces are kept: they record what happened, not what exists. `POST /nests/:nestId/nodes/:nodeId/move` carries that same set to the new id instead of dropping it (folder grants excepted — they cover a path prefix, so a moved doc leaves one share and joins another).
722
800
 
801
+ ### POST `/nests/:nestId/nodes/pdf`
802
+ Upload a PDF as a governed `pdf` document. Multipart: `file` (required), optional `title` (default: the file name), `folder`, `tags` (repeat the field or comma-separate), `nodeId`, `changeNote`. Write permission (nest-scope steward editors too, as for `POST /nodes`).
803
+
804
+ The engine's `context_import_pdf` extracts the text — the node body, one `<!-- page N -->` marker per page — and stores the PDF beside the node as `<id>.pdf`, bound by sha256 in the node's `pdf:` frontmatter block. Governance is exactly a create's: in a governed nest the upload lands as a draft (or publishes when a create by that person would); without stewards it publishes.
805
+
806
+ - `nodeId` of an existing `pdf` node → the PDF is replaced as a **new version** of that node (same review lock, draft/publish rule and version rows as `PATCH`). The prior binary stays in history. Identical bytes are a no-op. A replace is two engine writes (stage the new PDF, then the same update/publish a text edit makes); if the second fails, the node's previous file and PDF are put back before the error is returned.
807
+ - `400` — no `file`, not a PDF (checked by the `%PDF-` header, not the extension), an encrypted/unreadable PDF, or `nodeId` names a node that is not a pdf. `413` — larger than `PDF_MAX_MB` (default 25 MB). Nothing is written on any refusal.
808
+ - A PDF with no text layer (a scan) imports with an empty body and `pdf.text_layer: false`; there is no OCR.
809
+ - Returns `201` `{ node, version, stewards? }` on create, `200` `{ node, version }` on replace. `node.pdf` is the `pdf:` block (`file`, `sha256`, `bytes`, `pages`, `text_layer`, `extractor`, `extractor_version`, `extracted_at`).
810
+
811
+ A pdf node's text is read-only: `PATCH` with a `content`/`append` that changes it → `409` (re-upload instead; echoing the stored body back is accepted). A pdf node can't be converted to another type or another type to `pdf` (`400`), can't be moved to another folder yet (`400`), and `POST /nodes` with `type: "pdf"` → `400`. `POST …/revert` re-imports the target version's PDF; `POST …/discard` restores the sealed version's PDF (the response carries a `warning` if that binary can't be restored); `DELETE` removes the binary with the node.
812
+
813
+ ### GET `/nests/:nestId/nodes/:nodeId/pdf`
814
+ The PDF binary, gated exactly like `GET /nodes/:nodeId`. A public reader (or `?approved_only=1`) always gets the **approved** version's bytes — never a draft's — whatever version is asked for; other readers get the current working copy, or `?version=N`. Every response is verified against the sha256 its version records (a mismatch is refused, not served).
815
+
816
+ Headers: `Content-Type: application/pdf`, `Content-Disposition: inline; filename="<slug>.pdf"` (`?download=1` → `attachment`), `Content-Security-Policy: sandbox`, `X-Content-Type-Options: nosniff`, `Cache-Control: private, no-cache`, and an `ETag` of the sha256 (`If-None-Match` → `304`). `404` when the node has no such version or no approved version (public). An id that is not a pdf node falls through to the node route, so a document whose own id ends in `/pdf` still reads.
817
+
723
818
  ### POST `/nests/:nestId/assets`
724
819
  Multipart upload (field `file`) of an image or video referenced from documents. Write permission.
725
820
 
@@ -1103,6 +1198,11 @@ Each version may also include `resolvedBy` + `resolutionStatus`, the steward who
1103
1198
  approved or rejected it, joined from `review_requests` (`node_versions` stores
1104
1199
  the status but not the resolver's email).
1105
1200
 
1201
+ `client` is the caller attribution the write carried — which agent, in which
1202
+ session (see [Caller attribution](#caller-attribution-client)). Absent on every
1203
+ version whose write sent none, which includes every version written before the
1204
+ field existed.
1205
+
1106
1206
  `externalEditVerdicts` carries the verdicts on out-of-band (direct filesystem)
1107
1207
  edits, oldest first, read from the engine's append-only chain-event log. They sit
1108
1208
  beside the version list rather than inside it because a **rejection commits no
@@ -1127,6 +1227,7 @@ approved version.
1127
1227
  "status": "pending_review",
1128
1228
  "changeNote": "...",
1129
1229
  "content": "",
1230
+ "client": { "agent": "claude-code", "session_id": "s-9f2" },
1130
1231
  "diff": "Index: v2\n===...\n--- v2\tv2\n+++ v2\tv3\n@@ -1,4 +1,5 @@\n title: doc\n-old line\n+new line\n"
1131
1232
  },
1132
1233
  {
@@ -1852,6 +1953,8 @@ Model Context Protocol endpoints — JSON-RPC over HTTP. Not exercised manually;
1852
1953
 
1853
1954
  **Denials are JSON-RPC.** Gate rejections on the MCP transport path return a JSON-RPC error envelope — `{ "jsonrpc": "2.0", "id": <echoed>, "error": { "code": -32000, "message": "<what failed — actionable reason>" } }` — instead of plain `{error}` JSON, so external clients (PromptOwl, Claude Desktop, Cursor) surface *why* a connection failed: key scoped to a different nest, server suspended, license required, or nest not found / no access.
1854
1955
 
1956
+ **Every tool takes `client`.** The `{ agent, session_id, …custom }` block described in [Caller attribution](#caller-attribution-client) is advertised on every tool's input schema, reads included — a write that seals a version records it there (`context_versions` serves it back), and every call records it on the activity trace. It is never authenticated and never used to authorize. Omitted, the server fills what the connection reports (the API key's label, an `Mcp-Session-Id` header) under anything the caller sent, per key.
1957
+
1855
1958
  **Titles are unique on this surface.** `context_get`, `context_update`, `context_versions`, `context_delete`, and `context_move` each resolve a document by title or by id (an id wins when both are sent, and an ambiguous title refuses rather than guessing). Title addressing is what makes uniqueness matter, so `context_create` refuses a title already used anywhere in the nest — including in another folder — and answers with the existing node's id plus what to do instead (`context_update` to change it, `context_move` to refile it). A duplicate would otherwise be unreachable by every tool here. REST `POST /nodes` keeps the looser rule (same title in a different folder is fine); both surfaces refuse a create whose id is already taken.
1856
1959
 
1857
1960
  **`context_search` is the REST search.** The tool runs the same index-backed match as `GET /nests/:id/search` — terms against the cached discovery set (title, body, type, tags), title hits first — and gates the *hits* with the same `filterAccessible` rule (public readers: approved-only; governed nests: steward coverage or a share grant), so the two surfaces return the same documents in the same order. It takes an optional `limit` (default 20, max 200) and answers `{ results, count }`: `count` is everything found, `results` the first `limit` hits, each with its LLM-visible body (approved version under stewardship; a hit with no approved version yet is listed but carries no body). It used to resolve every document's gated body *before* matching — one permission lookup plus one version reconstruction per node — which is what timed out `ctx search --vault <remote>` on large governed nests.
package/CONFIGURATION.md CHANGED
@@ -60,7 +60,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
60
60
  | `PROMPTOWL_SIGN_IN_GATE` | `open` | Restrict "Sign in with PromptOwl". `open` = anyone may; `admin-only` = only the license owner (admin) may, everyone else uses email/password (admin opens the login page with `?admin=1`); `disabled` = nobody may. Enforced server-side at `POST /auth/promptowl` and surfaced on the health endpoint. Unknown values fall back to `open`. |
61
61
  | `MANUAL_SIGN_IN` | `open` | Email + password sign-in mode. `open` = anyone may log in and self-register a new account; `invite-only` = existing/invited users may log in but brand-new self-registration returns `403` (the admin provisions accounts via invite/share/steward and shares the password — there is no self-service "set password", which would be account takeover without email verification); `disabled` = no email/password sign-in at all (`POST /auth/login` and `POST /auth/register` return `403`). Independent of `PROMPTOWL_SIGN_IN_GATE`, so the two methods are controlled separately (e.g. `invite-only` manual + `admin-only` PromptOwl). Also settable from Settings → General. The server refuses `disabled` while PromptOwl sign-in is also `disabled` (that would leave no way to log in). Unknown values fall back to `open`. |
62
62
  | `OFFICIAL_COMMUNITY_SSO_SECRET` | `""` | **Legacy — official deployment only, leave unset on self-hosted.** Shared HMAC secret enabling the one-click "Open Community" SSO auto-login from PromptOwl. Must exactly match the same-named var on PromptOwl. Superseded by the DB-backed **Settings → Community sites** list (`/admin/community-sites`), which supports multiple official sites each with their own name/url/secret/active flag — this env var still works as an implicit extra site for backward compatibility. When no site is configured (env var unset and no DB rows), `GET /auth/sso` returns `404` and the feature is disabled; self-hosted users keep using the manual device-code flow. See `API.md → Community sites`. |
63
- | `PUBLIC_BASE_URL` | `""` | This server's canonical external URL (e.g. `https://community.promptowl.ai`). Two uses. (1) Checked against the SSO ticket's `aud` claim so a ticket minted for this server can't be replayed against another — whenever a ticket-signing secret is set (`OFFICIAL_COMMUNITY_SSO_SECRET` on the official deployment, `MCP_SIGNING_SECRET` on a self-hosted one); with neither set no ticket verifies at all, so the audience check is moot. (2) **Set this whenever `OIDC_ENABLED=true`.** It's the base for the OIDC `redirect_uri` sent to your IdP and replayed at token exchange. Unset, that URI is derived from the incoming request's `Host` header — fine when your proxy overwrites `Host` with a trusted value, but a proxy that passes an attacker-supplied `Host` through would feed it straight into the redirect URI. Setting this pins the value regardless of what the proxy forwards. (Your IdP's own redirect-URI allowlist is a second line of defence, not a substitute.) (3) **Set this wherever email- or invite-gated publish links are used.** The magic link mailed by `POST /p/:slug/gate` is built from it; unset, it falls back to the request origin, so a proxy forwarding an attacker-supplied `Host` would send the recipient a one-time access token pointing at the attacker's domain — the classic reset-link poisoning. (4) **Set this wherever `POST /nests/:id/context` citations reach users.** The `url` on each node and the `_source:` line in each context block are built from it; unset, they fall back to the request origin, so a proxy forwarding an untrusted `Host` would put an attacker-influenced URL in front of both the model and the reader as a trustworthy citation. |
63
+ | `PUBLIC_BASE_URL` | `""` | This server's canonical external URL (e.g. `https://community.promptowl.ai`). Two uses. (1) Checked against the SSO ticket's `aud` claim so a ticket minted for this server can't be replayed against another — whenever a ticket-signing secret is set (`OFFICIAL_COMMUNITY_SSO_SECRET` on the official deployment, `MCP_SIGNING_SECRET` on a self-hosted one); with neither set no ticket verifies at all, so the audience check is moot. (2) **Set this whenever `OIDC_ENABLED=true`.** It's the base for the OIDC `redirect_uri` sent to your IdP and replayed at token exchange. Unset, that URI is derived from the incoming request's `Host` header — fine when your proxy overwrites `Host` with a trusted value, but a proxy that passes an attacker-supplied `Host` through would feed it straight into the redirect URI. Setting this pins the value regardless of what the proxy forwards. (Your IdP's own redirect-URI allowlist is a second line of defence, not a substitute.) (3) **Set this wherever email- or invite-gated publish links are used.** The magic link mailed by `POST /p/:slug/gate` is built from it; unset, it falls back to the request origin, so a proxy forwarding an attacker-supplied `Host` would send the recipient a one-time access token pointing at the attacker's domain — the classic reset-link poisoning. (4) **Set this wherever `POST /nests/:id/context` citations reach users.** The `url` on each node and the `_source:` line in each context block are built from it; unset, they fall back to the request origin, so a proxy forwarding an untrusted `Host` would put an attacker-influenced URL in front of both the model and the reader as a trustworthy citation. (5) It is rendered into the copy-paste setup snippets on **Settings → Connecting agents** — a `bash` block and quoted strings in `config.toml` / `config.yaml` / `mcp.json`. Because those are pasted verbatim into other people's terminals, `PATCH /admin/settings` refuses a value that is not a plain `http(s)` URL, or that contains whitespace, quotes or shell characters (`" ' ` \ $ ; | & < > ( ) { }`). A value stored before that check is not rewritten, so re-save it if the panel shows it oddly. |
64
64
  | `OIDC_ENABLED` | `false` | Turn on generic OIDC single sign-on (`GET /auth/oidc/login` / `GET /auth/oidc/callback`). Requires `OIDC_ISSUER`, `OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET` — the login-page button only appears once all three are set. Also editable from Settings → Single sign-on. See [Single sign-on (OIDC)](#single-sign-on-oidc). |
65
65
  | `OIDC_ISSUER` | `""` | OIDC issuer URL, e.g. `https://login.microsoftonline.com/<tenant>/v2.0` (Microsoft Entra ID) or `https://accounts.google.com` (Google). **`https://` only** — a non-https value is rejected with a warning and SSO stays off. Must serve `<issuer>/.well-known/openid-configuration`. |
66
66
  | `OIDC_CLIENT_ID` | `""` | Application (client) ID from your IdP app registration. |
@@ -68,6 +68,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
68
68
  | `OIDC_ALLOWED_DOMAINS` | `""` (any) | Comma-separated email-domain allowlist, e.g. `acme.com, contractors.acme.com`. When set, only accounts whose asserted email is on a listed domain may sign in (others bounce with `domain_not_allowed`). Empty allows any domain the IdP asserts. |
69
69
  | `OIDC_AUTO_PROVISION` | `true` | Create a user automatically on first successful OIDC sign-in (display name from the `name` claim). Set `false` to allow only pre-existing (invited/registered) users — unknown emails bounce with `not_invited`. |
70
70
  | `PEOPLE_SUGGEST_ALL_USERS` | `false` | Widen the add-a-person suggestions (`GET /people/suggest`, behind every "add a person" field) and the `@mention` pickers from *people the caller already shares a nest or team with* to **every registered account on this server**. **Leave this off on a server that hosts more than one organisation** — the PromptOwl-hosted deployment does, and turning it on there would offer one customer's staff another customer's email addresses. On a single-company self-hosted server the whole directory is the useful answer, which is what this is for. Suggestions never gate the invite either way: an address that appears in no list is still valid to type. In the `@` pickers the widening is an invite: a write+ author mentioning someone off the nest adds them as a `read` collaborator before notifying (a read-only author's mention of an outsider reaches nobody). A server admin always sees every account (they administer them). Also editable from **Settings → General → People directory**. |
71
+ | `SHARED_DEPLOYMENT` | `false` | This server hosts **several unrelated organizations** (the PromptOwl-hosted deployment) rather than one company. A nest's `org` visibility means *every authenticated account on this deployment* — on a single-company self-hosted server that is the organization, so the tier flips in one click; on a shared server it is every customer. With this on: `PATCH /nests/:id/visibility` to `org` requires `acknowledge_public: true` exactly as `public` always does (enforced by the server, not only the UI), and the UI's visibility picker confirms before *Organization* and words both tiers to say who they really reach. Reported on `GET /health` as `shared_deployment`. Also editable from **Settings → General → This server hosts several organizations**. **Turning it on guards future changes only** — nests already at `org` stay readable by every account until their admins reconfirm or narrow them. On an already-populated server, audit them first: the dashboard's *Organization* scope lists every one, or `SELECT id, name, user_id FROM nests WHERE visibility = 'org'`. |
71
72
  | `OIDC_DEPARTMENT_TAGGING` | `false` | Auto-tag newly **created** documents with the creator's directory department: `dept:<slugified-department>` (lowercase, spaces → dashes, e.g. `dept:customer-success`) is appended to the document's tags, deduped against user-supplied tags. Applies on create only — never on update, never retroactively — and a user without a stored department is a silent no-op. The department is captured from the OIDC `department` ID-token claim on every SSO login (a login without the claim clears it, so directory moves propagate), so this is only meaningful when your IdP emits that claim — see [Department auto-tagging](#department-auto-tagging). Also editable from Settings → Single sign-on. |
72
73
  | `SSO_TOKEN_EXCHANGE_ENABLED` | `false` | Master switch for `POST /auth/token-exchange` — an external agent exchanges an IdP ID token for a short-lived MCP bearer. Also needs `MCP_SIGNING_SECRET` and at least one provider; otherwise the endpoint returns `404`. Security-critical — see [Agent SSO (token exchange)](#agent-sso-token-exchange). |
73
74
  | `MCP_SIGNING_SECRET` | `""` | HMAC secret **this** server signs its minted MCP bearers with (distinct from `OFFICIAL_COMMUNITY_SSO_SECRET`, so self-hosted deployments can issue their own). Long random string. `/index` and `/mcp` accept a ticket signed with either secret. Write-only on the Settings API. **Clearing it is the revocation switch** — turning `SSO_TOKEN_EXCHANGE_ENABLED` off stops minting but already-minted bearers stay valid until they expire. |
@@ -81,10 +82,14 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
81
82
  | `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog ingestion host. Set it to your own region or self-hosted PostHog. Also editable from Settings → Advanced. |
82
83
  | `TRACE_RETENTION_DAYS` | `14` | Activity-trace retention window in days (the `api_events` rows behind `GET /admin/trace` and `GET /nests/:id/trace`). Rows older than this are pruned opportunistically (every ~500 inserts). `0` = keep forever (pruning is skipped entirely). Capped at `3650`; invalid/negative values fall back to `14`. Also editable from Settings → Advanced. |
83
84
  | `DRIFT_SCAN_INTERVAL_MS` | `30000` (30 s) | How often the drift scanner walks every nest for files edited outside the app (external edits) and stages them as suggestions for review. Set `0` to disable — do this on a GCS FUSE or other network mount, where the walk is slow and costly. Unset, empty or non-numeric falls back to the default. |
85
+ | `CONTEXTNEST_AGENT` | `""` | Overrides the `agent` this server derives for caller attribution (`client`, spec §9.4) when a call sends none of its own. Without it the name comes from the MCP `initialize` handshake's `clientInfo.name` when the connection reports one, else the label of the API key the call authenticated with. A caller's own `client.agent` always wins. |
86
+ | `CONTEXTNEST_SESSION_ID` | `""` | Same, for `session_id` — otherwise the MCP transport's session id, or an `Mcp-Session-Id` request header when the client sends one. Left absent when nothing reports one: the `/mcp` endpoints are stateless and issue no session, and a placeholder would be indistinguishable from a real id. |
87
+ | `CONTEXTNEST_NO_ATTRIBUTION` | `""` | Set to `1` to derive nothing at all. What this server derives lands in an append-only version history, so an operator who does not want an agent's name recorded there permanently needs to say so before the first write. A `client` the caller sent explicitly is still recorded — that is the caller's own record to make. |
84
88
  | `CORS_ORIGINS` | `*` in open mode; `http://localhost:5173,http://localhost:3838` in key mode | Comma-separated allowlist. Set to `*` to allow any origin (**only** safe in open mode — in key mode with Bearer tokens this enables CSRF). |
85
89
  | `FRAME_ANCESTORS` | `'self'` | Which origins may embed this server in an iframe, sent as CSP `frame-ancestors`. The default lets nothing but this origin frame the UI, which blocks clickjacking. Deployments that are meant to be embedded list the embedding origin — e.g. the PromptOwl Data Room iframes ContextNest, so that install sets `FRAME_ANCESTORS="https://app.promptowl.ai"`. Comma-separated; `'self'` is always included; `*` allows any site and disables the protection. Note the embedding page must be **same-site** (a sibling subdomain) for the session cookie to survive inside the frame — a genuinely cross-domain embed will render the login page no matter what this is set to. |
86
- | `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. The asset-upload route (`POST /nests/:id/assets`) is exempt up to the video cap below. |
90
+ | `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. The asset-upload route (`POST /nests/:id/assets`) is exempt up to the video cap below, and the PDF upload route (`POST /nests/:id/nodes/pdf`) up to `PDF_MAX_MB`. |
87
91
  | `VIDEO_MAX_MB` | `30` | Max size (in MB) of a video uploaded into a doc. Default `30` keeps it under Cloud Run's ~32 MiB HTTP/1 request-body limit, so an oversized video is rejected with a clear message instead of a bare `413` from the platform. Raise only where the deployment can actually accept larger request bodies (not behind Cloud Run, or on HTTP/2 / direct-to-bucket upload). Images are fixed at 10 MB. |
92
+ | `PDF_MAX_MB` | `25` | Max size (in MB) of a PDF uploaded as a `pdf` document (`POST /nests/:id/nodes/pdf`). A larger file is refused with `413` and nothing is written. Default `25` keeps the request under Cloud Run's ~32 MiB HTTP/1 limit; raise it only where the deployment accepts larger request bodies. The PDF route is exempt from `MAX_BODY_BYTES` up to this cap. |
88
93
  | `LOGO_URL` | _(unset)_ | Custom logo shown in the UI header + login screen. Must start with `https://`, `http://`, or `data:image/` — other schemes (`file://`, relative, `javascript:`) are rejected with a warning and the bundled icon is used. |
89
94
  | `PROMPTOWL_TEAMS_ENABLED` | _(unset — off)_ | Lets users who signed in with PromptOwl import their PromptOwl teams as local teams. Off by default; set `true` to enable, or toggle from Settings → Advanced. When off, `GET/POST /teams/promptowl` return `404` and the PromptOwl Teams panel is hidden. Independent of `PROMPTOWL_SIGN_IN_GATE`. |
90
95
  | `TYPE_ARTIFACT_ENABLED` | `true` | Set `false` to disable creation of **artifact** nodes server-wide (existing artifact nodes stay readable — never data loss). Runnable types (agent/skill/tool) are gated by `FEATURE_WORKFLOW_PLANE`, not here. Also editable from Settings (`/admin/settings`). |
package/README.md CHANGED
@@ -134,7 +134,7 @@ Keys are minted in the app (Workspace → API keys, or the Connect dialog) — o
134
134
  | Custom logo / branding | ✅ | ✅ |
135
135
  | Admin password reset + user removal (in-platform) | ✅ | ✅ |
136
136
  | Wiki backlinks, outline, hover-preview, link health | ✅ | ✅ |
137
- | Rich editor — tables, callouts, toggles, code highlight, find/replace, image & video upload | ✅ | ✅ |
137
+ | Rich editor — tables, callouts, toggles, code highlight, find/replace, image & video upload, YouTube / Vimeo embeds | ✅ | ✅ |
138
138
  | Folder organization — nested folders, move documents, lazy folder tree | ✅ | ✅ |
139
139
  | Scales to large vaults — nest listings served from a document index, not a disk crawl | ✅ | ✅ |
140
140
  | Steward version revert | ✅ | ✅ |
package/STEWARDSHIP.md CHANGED
@@ -90,6 +90,30 @@ Prime documents carry a **Prime** badge in the nest's document list, with a tool
90
90
 
91
91
  Flipping any of this only affects **future** writes. Marking a published document prime doesn't unpublish it; the next edit is what needs approval.
92
92
 
93
+ ## Creators review their own documents (`creator_is_reviewer`)
94
+
95
+ A per-nest flag (**off** by default; nest Settings → "Creators review their own documents") for the GitHub posture: anyone with write access creates documents, and the person who created a document is the one who approves changes to it.
96
+
97
+ When it's on, creating a document in a governed nest does two things:
98
+
99
+ 1. **The author is stewarded as a document-scope `reviewer`** of that document — exactly the row an admin would otherwise add by hand (`POST /nests/:id/stewards` with `scope: "document"`, or the `documents:` section of `stewards.yaml`). The nest owner is skipped; they already approve everything.
100
+ 2. **The first version publishes immediately.** The author is the document's reviewer, so nobody else needs to bless v1. This holds for the owner too: with this on, the owner's own new documents publish at v1 whether or not `allow_self_approve` is on — the two settings are independent, and this one only ever touches v1.
101
+
102
+ The row is written **before** the publish decision, so v1 only publishes when the author really can approve the next change. If a document-scope row for the author already exists on that id with a lesser role (an admin stewarded the id ahead of time), it is left alone and v1 lands as a draft instead. If the row can't be written, the create fails with nothing written.
103
+
104
+ Everything after v1 is the ordinary review cycle, and separation of duties is untouched:
105
+
106
+ | | Who approves |
107
+ |---|---|
108
+ | A teammate edits the document | The creator (plus any tag- or nest-level reviewer, and admins) |
109
+ | The creator edits their own document | Another reviewer — the creator can't approve their own submission |
110
+
111
+ **Prime wins.** A prime document still lands as a draft: its creator is made its reviewer, but someone else has to approve the first version.
112
+
113
+ Only **future** creates are affected. Turning it on doesn't steward anyone on existing documents, and turning it off leaves every steward row it created in place — remove them from the Stewards tab like any other. Every create surface participates (the editor, the REST and MCP APIs, and `ctx push` — the pusher authored what they pushed); the derived annotations document is the one exception, since nobody authored it.
114
+
115
+ Like prime, this only applies to **governed** nests. With stewardship off there is nothing to review, so the flag is inert until you turn stewardship on.
116
+
93
117
  ## Who sees what
94
118
 
95
119
  Governance gates two things: who can **approve**, and who can **read**.
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  buildTitleMap,
3
+ canApproveWith,
3
4
  canEditWith,
4
5
  canViewWith,
5
6
  collabPermToRole,
@@ -12,16 +13,16 @@ import {
12
13
  resolveNestPermission,
13
14
  resolveTeamRolesForUser,
14
15
  sendEmailToRecipient
15
- } from "./chunk-KIAAEHWL.js";
16
+ } from "./chunk-7FROMOZM.js";
16
17
  import {
17
18
  ConflictError,
18
19
  ValidationError
19
- } from "./chunk-YVMSM7LS.js";
20
+ } from "./chunk-LHWOJPFE.js";
20
21
  import {
21
22
  config,
22
23
  getDb,
23
24
  isEmailish
24
- } from "./chunk-LA3VTQ22.js";
25
+ } from "./chunk-JDAOA5KP.js";
25
26
 
26
27
  // src/governance/stewardship-service.ts
27
28
  import { v4 as uuid } from "uuid";
@@ -283,6 +284,38 @@ async function createStewardRecord(params) {
283
284
  }
284
285
  return results;
285
286
  }
287
+ async function stewardDocumentCreator(nestId, nodeId, userEmail) {
288
+ const email = userEmail.trim().toLowerCase();
289
+ if (!email) return { canApprove: false };
290
+ const ownerEmail = (await getNestOwnerEmail(nestId) || "").toLowerCase();
291
+ if (email === ownerEmail) return { canApprove: true };
292
+ const row = {
293
+ nestId,
294
+ scope: "document",
295
+ nodePattern: nodeId,
296
+ userEmail: email,
297
+ userId: await userIdForEmail(email),
298
+ role: "reviewer",
299
+ assignedBy: email,
300
+ assignedAt: (/* @__PURE__ */ new Date()).toISOString(),
301
+ isActive: true
302
+ };
303
+ try {
304
+ const created = await assignSteward(row);
305
+ return { canApprove: true, insertedId: created.id };
306
+ } catch (err) {
307
+ if (!isUniqueViolation(err)) throw err;
308
+ }
309
+ return {
310
+ canApprove: canApproveWith(
311
+ await resolveUserRoles(nestId, email, { nodeId })
312
+ )
313
+ };
314
+ }
315
+ function isUniqueViolation(err) {
316
+ const code = err?.code;
317
+ return code === "SQLITE_CONSTRAINT_UNIQUE" || code === "23505";
318
+ }
286
319
  async function resolveStewardsForNode(nestId, nodeId) {
287
320
  return (await resolve(nestId, nodeId)).stewards;
288
321
  }
@@ -638,6 +671,7 @@ export {
638
671
  getStewardsForScope,
639
672
  listStewards,
640
673
  createStewardRecord,
674
+ stewardDocumentCreator,
641
675
  resolveStewardsForNode,
642
676
  resolveStewardsWithFallback,
643
677
  getStewardRolesForUser,
@@ -6,10 +6,10 @@ import {
6
6
  markIndexStale,
7
7
  resolveNestPath,
8
8
  syncSuggestionRows
9
- } from "./chunk-KIAAEHWL.js";
9
+ } from "./chunk-7FROMOZM.js";
10
10
  import {
11
11
  getDb
12
- } from "./chunk-LA3VTQ22.js";
12
+ } from "./chunk-JDAOA5KP.js";
13
13
 
14
14
  // src/governance/external-edit-service.ts
15
15
  import { readFile, readdir } from "fs/promises";
@@ -83,7 +83,7 @@ async function scanDocumentForDriftInternal(nestId, documentId, actor) {
83
83
  if (await bodyMatchesLatestVersion(storage, documentId, drift.actualHash)) {
84
84
  return null;
85
85
  }
86
- const { hasUnsealedDraft } = await import("./version-service-KIOQME6U.js");
86
+ const { hasUnsealedDraft } = await import("./version-service-X4FAG2OZ.js");
87
87
  if (await hasUnsealedDraft(nestId, documentId)) {
88
88
  return null;
89
89
  }
@@ -254,7 +254,7 @@ async function listExternalEditVerdicts(nestId, documentId) {
254
254
  async function mirrorVersion(input) {
255
255
  const { storage } = await engineCache.get(input.nestId);
256
256
  const node = await storage.readDocument(input.documentId);
257
- const { upsertVersion, setApprovedVersion } = await import("./version-service-KIOQME6U.js");
257
+ const { upsertVersion, setApprovedVersion } = await import("./version-service-X4FAG2OZ.js");
258
258
  await upsertVersion({
259
259
  nestId: input.nestId,
260
260
  nodeId: input.documentId,
@@ -3,12 +3,12 @@ import {
3
3
  ForbiddenError,
4
4
  NotFoundError,
5
5
  ValidationError
6
- } from "./chunk-YVMSM7LS.js";
6
+ } from "./chunk-LHWOJPFE.js";
7
7
  import {
8
8
  config,
9
9
  getDb,
10
10
  isEmailish
11
- } from "./chunk-LA3VTQ22.js";
11
+ } from "./chunk-JDAOA5KP.js";
12
12
  import {
13
13
  ANON_USER_ID
14
14
  } from "./chunk-YB3LKF7U.js";
@@ -2066,6 +2066,21 @@ async function setPrimeOnlyReview(nestId, enabled) {
2066
2066
  nestId
2067
2067
  ]);
2068
2068
  }
2069
+ async function nestCreatorIsReviewer(nestId) {
2070
+ const db = getDb();
2071
+ const row = await db.get(
2072
+ "SELECT creator_is_reviewer FROM nests WHERE id = ?",
2073
+ [nestId]
2074
+ );
2075
+ return !!row?.creator_is_reviewer;
2076
+ }
2077
+ async function setCreatorIsReviewer(nestId, enabled) {
2078
+ const db = getDb();
2079
+ await db.run("UPDATE nests SET creator_is_reviewer = ? WHERE id = ?", [
2080
+ enabled ? 1 : 0,
2081
+ nestId
2082
+ ]);
2083
+ }
2069
2084
  async function nestPrimeTags(nestId) {
2070
2085
  const db = getDb();
2071
2086
  const row = await db.get("SELECT prime_tags FROM nests WHERE id = ?", [
@@ -2622,8 +2637,8 @@ async function ensureNodeIndex(nestId) {
2622
2637
  return run;
2623
2638
  }
2624
2639
  async function rebuildNodeIndex(nestId) {
2625
- const { engineCache: engineCache2 } = await import("./engine-S3QBQ7LH.js");
2626
- const { documentsWithSuggestions } = await import("./external-edit-service-6FNFPOJJ.js");
2640
+ const { engineCache: engineCache2 } = await import("./engine-TER6FDBP.js");
2641
+ const { documentsWithSuggestions } = await import("./external-edit-service-CAGENHDJ.js");
2627
2642
  const { storage, dropDiscoveryCache } = await engineCache2.get(nestId);
2628
2643
  dropDiscoveryCache();
2629
2644
  const startedAt = writeGeneration.get(nestId) ?? 0;
@@ -2869,7 +2884,8 @@ var engineCache = new NestEngineCache();
2869
2884
  var engineApi = createEngineApi();
2870
2885
  async function opContext(nestId, actor, onProgress) {
2871
2886
  const { storage, query, versions } = await engineCache.get(nestId);
2872
- return { storage, query, versions, actor, onProgress };
2887
+ const limits = { pdfMaxBytes: config.PDF_MAX_BYTES };
2888
+ return { storage, query, versions, actor, onProgress, limits };
2873
2889
  }
2874
2890
 
2875
2891
  export {
@@ -2917,6 +2933,8 @@ export {
2917
2933
  setAllowSelfApprove,
2918
2934
  nestReviewsPrimeOnly,
2919
2935
  setPrimeOnlyReview,
2936
+ nestCreatorIsReviewer,
2937
+ setCreatorIsReviewer,
2920
2938
  nestPrimeTags,
2921
2939
  setPrimeTags,
2922
2940
  disableStewardshipAndWipeGovernance,
@@ -4,19 +4,19 @@ import {
4
4
  resolveNestWideRoles,
5
5
  resolveStewardsForNode,
6
6
  stewardCoverageForUser
7
- } from "./chunk-2TPQTN4Y.js";
7
+ } from "./chunk-2KHM5C7V.js";
8
8
  import {
9
9
  grantCoversNode,
10
10
  listUserGrants,
11
11
  resolveNodeGrant
12
- } from "./chunk-I3CSD6CK.js";
12
+ } from "./chunk-KPGNZLZL.js";
13
13
  import {
14
14
  createVersion,
15
15
  getApprovedVersion,
16
16
  getApprovedVersions,
17
17
  getCurrentVersion,
18
18
  setApprovedVersion
19
- } from "./chunk-ZTT4U4NE.js";
19
+ } from "./chunk-TI7HMP64.js";
20
20
  import {
21
21
  buildDocContext,
22
22
  buildTitleMap,
@@ -37,16 +37,16 @@ import {
37
37
  resolveNestPermission,
38
38
  sendEmailToRecipient,
39
39
  titleForNode
40
- } from "./chunk-KIAAEHWL.js";
40
+ } from "./chunk-7FROMOZM.js";
41
41
  import {
42
42
  ConflictError,
43
43
  NotFoundError,
44
44
  ValidationError
45
- } from "./chunk-YVMSM7LS.js";
45
+ } from "./chunk-LHWOJPFE.js";
46
46
  import {
47
47
  config,
48
48
  getDb
49
- } from "./chunk-LA3VTQ22.js";
49
+ } from "./chunk-JDAOA5KP.js";
50
50
 
51
51
  // src/governance/review-service.ts
52
52
  import { v4 as uuid2 } from "uuid";
@@ -788,7 +788,8 @@ async function submitForReview(params) {
788
788
  );
789
789
  if (!alreadySealed) {
790
790
  await versionManager.createVersion(node, params.requestedBy, {
791
- note: params.note || "Submitted for review"
791
+ note: params.note || "Submitted for review",
792
+ ...params.client ? { client: params.client } : {}
792
793
  });
793
794
  }
794
795
  } catch (err) {
@@ -955,7 +956,8 @@ async function approve(params) {
955
956
  author: params.approvedBy,
956
957
  status: "published",
957
958
  tags,
958
- changeNote: note
959
+ changeNote: note,
960
+ client: params.client
959
961
  });
960
962
  await setApprovedVersion(
961
963
  params.nestId,
@@ -356,6 +356,22 @@ var config = {
356
356
  get PEOPLE_SUGGEST_ALL_USERS() {
357
357
  return process.env.PEOPLE_SUGGEST_ALL_USERS === "true";
358
358
  },
359
+ /**
360
+ * This server hosts several unrelated organizations (the PromptOwl-hosted
361
+ * deployment) rather than one company (a self-hosted install).
362
+ *
363
+ * OFF BY DEFAULT. Nest visibility 'org' means "every authenticated account
364
+ * on this deployment" (src/shared/access.ts) — on a single-company server
365
+ * that IS the organization, so the tier is safe to flip in one click. On a
366
+ * shared server it is every customer, so with this on: flipping a nest to
367
+ * 'org' needs the same explicit acknowledgement 'public' always needs
368
+ * (PATCH /nests/:id/visibility 409s without it — the server enforces it, not only the UI), and
369
+ * the UI words both tiers to say so. Exposed on /health as
370
+ * `shared_deployment`. Superadmin-togglable at runtime via /admin/settings.
371
+ */
372
+ get SHARED_DEPLOYMENT() {
373
+ return process.env.SHARED_DEPLOYMENT === "true";
374
+ },
359
375
  /**
360
376
  * Path to the .env file the server reads its config from and the license
361
377
  * install flow persists PROMPTOWL_KEY into. Defaults UNDER DATA_ROOT (not
@@ -640,6 +656,17 @@ var config = {
640
656
  get VIDEO_MAX_BYTES() {
641
657
  const mb = parseInt(process.env.VIDEO_MAX_MB || "30", 10);
642
658
  return (Number.isFinite(mb) && mb > 0 ? mb : 30) * 1024 * 1024;
659
+ },
660
+ /**
661
+ * Max PDF upload size in bytes (POST /nests/:id/nodes/pdf). Default 25 MB —
662
+ * under Cloud Run's 32 MiB HTTP/1 request-body limit, with room for the
663
+ * multipart framing, so an oversized PDF is refused by our own check (413
664
+ * with a clear message) rather than by the platform. Read per request, so a
665
+ * change takes effect without a restart. Override via PDF_MAX_MB.
666
+ */
667
+ get PDF_MAX_BYTES() {
668
+ const mb = parseInt(process.env.PDF_MAX_MB || "25", 10);
669
+ return (Number.isFinite(mb) && mb > 0 ? mb : 25) * 1024 * 1024;
643
670
  }
644
671
  };
645
672
 
@@ -828,6 +855,9 @@ function runMigrations(db) {
828
855
  if (!nestCols.includes("prime_tags")) {
829
856
  db.exec("ALTER TABLE nests ADD COLUMN prime_tags TEXT NOT NULL DEFAULT 'prime-document'");
830
857
  }
858
+ if (!nestCols.includes("creator_is_reviewer")) {
859
+ db.exec("ALTER TABLE nests ADD COLUMN creator_is_reviewer INTEGER NOT NULL DEFAULT 0");
860
+ }
831
861
  if (!nestCols.includes("index_synced_at")) {
832
862
  db.exec("ALTER TABLE nests ADD COLUMN index_synced_at TEXT");
833
863
  }
@@ -1781,6 +1811,18 @@ function runMigrations(db) {
1781
1811
  recordMigration("043_node_suggestion_details");
1782
1812
  })();
1783
1813
  }
1814
+ if (!hasMigration("044_client_attribution")) {
1815
+ for (const [table, col] of [
1816
+ ["node_versions", "client_json"],
1817
+ ["api_events", "client_json"]
1818
+ ]) {
1819
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);
1820
+ if (cols.length && !cols.includes(col)) {
1821
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} TEXT`);
1822
+ }
1823
+ }
1824
+ recordMigration("044_client_attribution");
1825
+ }
1784
1826
  }
1785
1827
  function fkViolationCounts(db) {
1786
1828
  const rows = db.pragma("foreign_key_check");
@@ -2055,7 +2097,7 @@ async function initDb() {
2055
2097
  if (config.DB_DRIVER === "postgres") {
2056
2098
  const { Pool } = await import("pg");
2057
2099
  const { PostgresAdapter } = await import("./adapter.postgres-6VZCMPQL.js");
2058
- const { runPostgresMigrations } = await import("./migrations.postgres-AIQ7WSU7.js");
2100
+ const { runPostgresMigrations } = await import("./migrations.postgres-23VJJJJX.js");
2059
2101
  const pool = new Pool(buildPgConfig());
2060
2102
  adapter = new PostgresAdapter(pool);
2061
2103
  await runPostgresMigrations(adapter);
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  ValidationError
3
- } from "./chunk-YVMSM7LS.js";
3
+ } from "./chunk-LHWOJPFE.js";
4
4
  import {
5
5
  getDb
6
- } from "./chunk-LA3VTQ22.js";
6
+ } from "./chunk-JDAOA5KP.js";
7
7
 
8
8
  // src/governance/grants-service.ts
9
9
  import { v4 as uuid } from "uuid";