@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.
- package/API.md +109 -6
- package/CONFIGURATION.md +7 -2
- package/README.md +1 -1
- package/STEWARDSHIP.md +24 -0
- package/dist/{chunk-2TPQTN4Y.js → chunk-2KHM5C7V.js} +37 -3
- package/dist/{chunk-XUIWAWDO.js → chunk-32SZCH36.js} +4 -4
- package/dist/{chunk-KIAAEHWL.js → chunk-7FROMOZM.js} +23 -5
- package/dist/{chunk-5EZOPA47.js → chunk-DTLMBF4W.js} +10 -8
- package/dist/{chunk-LA3VTQ22.js → chunk-JDAOA5KP.js} +43 -1
- package/dist/{chunk-I3CSD6CK.js → chunk-KPGNZLZL.js} +2 -2
- package/dist/{chunk-YVMSM7LS.js → chunk-LHWOJPFE.js} +7 -0
- package/dist/{chunk-ZTT4U4NE.js → chunk-TI7HMP64.js} +99 -12
- package/dist/{client-KEY4PYJH.js → client-D44ZHDTJ.js} +1 -1
- package/dist/{engine-S3QBQ7LH.js → engine-TER6FDBP.js} +3 -3
- package/dist/{external-edit-service-6FNFPOJJ.js → external-edit-service-CAGENHDJ.js} +4 -4
- package/dist/{grants-service-UT3EQ3R7.js → grants-service-2OTFS7EH.js} +3 -3
- package/dist/index.js +863 -355
- package/dist/{migrations.postgres-AIQ7WSU7.js → migrations.postgres-23VJJJJX.js} +12 -2
- package/dist/{review-service-HHGOTO6P.js → review-service-4G2XUFS6.js} +7 -7
- package/dist/{stewardship-service-4DHNRULG.js → stewardship-service-S22V2JKG.js} +6 -4
- package/dist/{version-service-KIOQME6U.js → version-service-X4FAG2OZ.js} +4 -4
- package/dist/web3/assets/ActivityTracePage-D2iIbEph.js +1 -0
- package/dist/web3/assets/{AgentDocsPage-CIiaBqMy.js → AgentDocsPage-Dkhgb1hk.js} +1 -1
- package/dist/web3/assets/CollaboratorManager-Bo81D--Q.js +1 -0
- package/dist/web3/assets/CollaboratorsTab-DRCitQuX.js +1 -0
- package/dist/web3/assets/DocumentEditor-DV2WHYbj.js +36 -0
- package/dist/web3/assets/{DocumentsTab-Vba-AzBv.js → DocumentsTab-BUxBCN8o.js} +2 -2
- package/dist/web3/assets/{ExternalEditsTab-BSzAQIGM.js → ExternalEditsTab-kIBimZyE.js} +1 -1
- package/dist/web3/assets/MarkdownEditor-CsVusP2d.css +1 -0
- package/dist/web3/assets/MarkdownEditor-Dfk5uIE5.js +648 -0
- package/dist/web3/assets/{NestPageHeader-CoLUV1Oz.js → NestPageHeader-yOK-OIYZ.js} +1 -1
- package/dist/web3/assets/NestView-DwIagxwJ.js +68 -0
- package/dist/web3/assets/{OverviewTab-Dxi-Hu-N.js → OverviewTab-DFOFMVyE.js} +1 -1
- package/dist/web3/assets/{PersonCombobox-CPOlNs6G.js → PersonCombobox-D350WlrJ.js} +1 -1
- package/dist/web3/assets/{ReasonDialog-DgLxfRWv.js → ReasonDialog-CN3ECAnQ.js} +1 -1
- package/dist/web3/assets/{ReviewActions-OfOdqzCn.js → ReviewActions-BXDTDPp1.js} +1 -1
- package/dist/web3/assets/{ReviewTab-CyX8yxd3.js → ReviewTab-CZewAYiz.js} +1 -1
- package/dist/web3/assets/StewardsTab-AagNWcWS.js +1 -0
- package/dist/web3/assets/{SubmitForReviewModal-Ci2DdWs0.js → SubmitForReviewModal-BOZJxwRN.js} +1 -1
- package/dist/web3/assets/{alert-dialog-DTozKlmV.js → alert-dialog-4SiLKlz8.js} +1 -1
- package/dist/web3/assets/{arrow-left-BCKt4CwQ.js → arrow-left-BAgIocLn.js} +1 -1
- package/dist/web3/assets/backlinks-Cb8Uf8mw.js +24 -0
- package/dist/web3/assets/{card-XG2dP5Xf.js → card-UDyMRcps.js} +1 -1
- package/dist/web3/assets/{chevron-left-BPwUT9DS.js → chevron-left-XR4ReJ9Z.js} +1 -1
- package/dist/web3/assets/{circle-check-DI8IBGeo.js → circle-check-CSkZUEFK.js} +1 -1
- package/dist/web3/assets/{circle-x-VKVhC88W.js → circle-x-in2o3aHU.js} +1 -1
- package/dist/web3/assets/client-attribution-BraarYaP.js +1 -0
- package/dist/web3/assets/{code-xml-CMabYZvH.js → code-xml-pPQgdctI.js} +1 -1
- package/dist/web3/assets/{corner-down-right-BGxwK6wH.js → corner-down-right-CuxZWscX.js} +1 -1
- package/dist/web3/assets/{count-skeleton-DMbdpscX.js → count-skeleton-DBWCDhRy.js} +1 -1
- package/dist/web3/assets/{earth-BEp1EKTo.js → earth-CXOcThqn.js} +1 -1
- package/dist/web3/assets/{file-exclamation-point-r8qZGIjP.js → file-exclamation-point-CSphs1VD.js} +1 -1
- package/dist/web3/assets/{folder-input-Cmtx1Jhk.js → folder-input-BAwK9tFL.js} +1 -1
- package/dist/web3/assets/{index-C1wTSjfP.js → index-A0_ymcqL.js} +1 -1
- package/dist/web3/assets/index-B5hLclEq.js +389 -0
- package/dist/web3/assets/index-DrUAtoQM.css +1 -0
- package/dist/web3/assets/page-BHD4beun.js +1 -0
- package/dist/web3/assets/{page-CPjWQNKx.js → page-BSYUXYmO.js} +1 -1
- package/dist/web3/assets/page-BYc2DIux.js +45 -0
- package/dist/web3/assets/{page-Bicf02Cz.js → page-BfibLg8e.js} +1 -1
- package/dist/web3/assets/page-CGe8MtUu.js +9 -0
- package/dist/web3/assets/{page-CQt3ZGbf.js → page-CIgPJmG6.js} +1 -1
- package/dist/web3/assets/{page-CHb0F5hV.js → page-CUjuSs5Q.js} +1 -1
- package/dist/web3/assets/{page-BYUFhYWZ.js → page-Ck31nx9b.js} +1 -1
- package/dist/web3/assets/{page-BExMvttJ.js → page-Cpdj_11Y.js} +1 -1
- package/dist/web3/assets/{page-CtaW65El.js → page-D0sN3GO6.js} +1 -1
- package/dist/web3/assets/page-DEpH91X2.js +1 -0
- package/dist/web3/assets/{page-CC0NJi8v.js → page-l8-B7CGt.js} +1 -1
- package/dist/web3/assets/{page-DGOL9l8i.js → page-mnSHmk6A.js} +1 -1
- package/dist/web3/assets/{page-title-ClvvBsIo.js → page-title-BkqlUA0j.js} +1 -1
- package/dist/web3/assets/{page-ByyVLDVo.js → page-xzYqkBUP.js} +5 -5
- package/dist/web3/assets/{play-C5YNKpAH.js → play-B8xE9j5f.js} +1 -1
- package/dist/web3/assets/{refresh-cw-BhHzSH9m.js → refresh-cw-CsCtmWwX.js} +1 -1
- package/dist/web3/assets/{scroll-area-hz-xtayP.js → scroll-area-CbcH6yrZ.js} +1 -1
- package/dist/web3/assets/{select-DBFTrUmI.js → select-DrL0mpRN.js} +2 -2
- package/dist/web3/assets/{send-0CHvmluT.js → send-CvAgOUPG.js} +1 -1
- package/dist/web3/assets/{settings-CeeF4LEb.js → settings-EY4A_DYv.js} +1 -1
- package/dist/web3/assets/{share-2-CMnOlOt-.js → share-2-vGmZUl90.js} +1 -1
- package/dist/web3/assets/{tag-DQ_6J5Gv.js → tag-Br_y1f00.js} +1 -1
- package/dist/web3/assets/{trash-2-HBi-Pslz.js → trash-2-B4hRXo-p.js} +1 -1
- package/dist/web3/assets/{triangle-alert-CDy8-7sv.js → triangle-alert-CsYGyfGZ.js} +1 -1
- package/dist/web3/assets/{user-plus-CvBNcpn4.js → user-plus-4CTYEkd3.js} +1 -1
- package/dist/web3/assets/{x-By7piikG.js → x-qxt1WrUi.js} +1 -1
- package/dist/web3/assets/zap-BcsKR0ef.js +16 -0
- package/dist/web3/index.html +2 -2
- package/package.json +2 -2
- package/dist/web3/assets/ActivityTracePage-BOZHgJ8S.js +0 -1
- package/dist/web3/assets/CollaboratorManager-By91wrIr.js +0 -1
- package/dist/web3/assets/CollaboratorsTab-D1EE64Fs.js +0 -1
- package/dist/web3/assets/DocumentEditor-DrQfrW3d.js +0 -36
- package/dist/web3/assets/MarkdownEditor-74N_naNQ.css +0 -1
- package/dist/web3/assets/MarkdownEditor-C9zqZjC5.js +0 -643
- package/dist/web3/assets/NestView-CTZ55tXD.js +0 -63
- package/dist/web3/assets/StewardsTab-DBEEfAQI.js +0 -1
- package/dist/web3/assets/backlinks-CYd4xENL.js +0 -24
- package/dist/web3/assets/index-BM-h3DwI.css +0 -1
- package/dist/web3/assets/index-EaX2yql0.js +0 -389
- package/dist/web3/assets/page-B3yvQvHy.js +0 -1
- package/dist/web3/assets/page-BUOADv2X.js +0 -1
- package/dist/web3/assets/page-BsPMDm5d.js +0 -45
- package/dist/web3/assets/page-Cj1NetQ-.js +0 -1
- 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
|
|
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
|
-
|
|
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-
|
|
16
|
+
} from "./chunk-7FROMOZM.js";
|
|
16
17
|
import {
|
|
17
18
|
ConflictError,
|
|
18
19
|
ValidationError
|
|
19
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-LHWOJPFE.js";
|
|
20
21
|
import {
|
|
21
22
|
config,
|
|
22
23
|
getDb,
|
|
23
24
|
isEmailish
|
|
24
|
-
} from "./chunk-
|
|
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-
|
|
9
|
+
} from "./chunk-7FROMOZM.js";
|
|
10
10
|
import {
|
|
11
11
|
getDb
|
|
12
|
-
} from "./chunk-
|
|
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-
|
|
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-
|
|
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-
|
|
6
|
+
} from "./chunk-LHWOJPFE.js";
|
|
7
7
|
import {
|
|
8
8
|
config,
|
|
9
9
|
getDb,
|
|
10
10
|
isEmailish
|
|
11
|
-
} from "./chunk-
|
|
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-
|
|
2626
|
-
const { documentsWithSuggestions } = await import("./external-edit-service-
|
|
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
|
-
|
|
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-
|
|
7
|
+
} from "./chunk-2KHM5C7V.js";
|
|
8
8
|
import {
|
|
9
9
|
grantCoversNode,
|
|
10
10
|
listUserGrants,
|
|
11
11
|
resolveNodeGrant
|
|
12
|
-
} from "./chunk-
|
|
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-
|
|
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-
|
|
40
|
+
} from "./chunk-7FROMOZM.js";
|
|
41
41
|
import {
|
|
42
42
|
ConflictError,
|
|
43
43
|
NotFoundError,
|
|
44
44
|
ValidationError
|
|
45
|
-
} from "./chunk-
|
|
45
|
+
} from "./chunk-LHWOJPFE.js";
|
|
46
46
|
import {
|
|
47
47
|
config,
|
|
48
48
|
getDb
|
|
49
|
-
} from "./chunk-
|
|
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-
|
|
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);
|