@promptowl/contextnest-community 1.26.0 → 1.27.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 (94) hide show
  1. package/API.md +34 -1
  2. package/CONFIGURATION.md +32 -1
  3. package/dist/{chunk-TRSGPS27.js → chunk-H3W72HN3.js} +28 -0
  4. package/dist/{chunk-DGPTJXIV.js → chunk-MB52DWWP.js} +4 -4
  5. package/dist/{chunk-IEAWRHMV.js → chunk-NFFUZF2B.js} +23 -2
  6. package/dist/{chunk-PICUHQEM.js → chunk-O3JEI33N.js} +2 -2
  7. package/dist/{chunk-SWA5M5XX.js → chunk-QMQDWRHN.js} +128 -22
  8. package/dist/{chunk-I5EDEZBQ.js → chunk-RXCVXG34.js} +1 -1
  9. package/dist/{chunk-2QOJE7A5.js → chunk-SE5U4J4T.js} +4 -4
  10. package/dist/{chunk-RPGFHESR.js → chunk-VHEMGQ3I.js} +1 -1
  11. package/dist/{chunk-M6PGYMBD.js → chunk-VRLAP4HB.js} +33 -15
  12. package/dist/{engine-QNMFDIBV.js → engine-HLYQLP5T.js} +3 -3
  13. package/dist/{external-edit-service-4MEGPT4D.js → external-edit-service-3DFQ4EZZ.js} +4 -4
  14. package/dist/{grants-service-24GLUBY4.js → grants-service-WICV4Y2J.js} +2 -2
  15. package/dist/index.js +804 -304
  16. package/dist/{invited-user-AM6A3WGO.js → invited-user-XEBX6BM4.js} +3 -3
  17. package/dist/{review-service-2EWN7WWO.js → review-service-GB43UQVH.js} +7 -7
  18. package/dist/{stewardship-service-TDZUC2Q6.js → stewardship-service-KDBPWRZI.js} +4 -4
  19. package/dist/{version-service-C3JTZLJH.js → version-service-JYHFPSYP.js} +8 -4
  20. package/dist/web3/assets/{ActivityTracePage-DzwWIjjx.js → ActivityTracePage-CkRoyIc-.js} +1 -1
  21. package/dist/web3/assets/AgentDocsPage-BTRXmliv.js +1 -0
  22. package/dist/web3/assets/CollaboratorManager-CqJIkvs-.js +1 -0
  23. package/dist/web3/assets/CollaboratorsTab-JcO1P-qm.js +1 -0
  24. package/dist/web3/assets/DocumentEditor-D1GLeeEP.js +36 -0
  25. package/dist/web3/assets/DocumentsTab-BAgSxTX1.js +6 -0
  26. package/dist/web3/assets/ExternalEditsTab-DuuTXZap.js +1 -0
  27. package/dist/web3/assets/{MarkdownEditor-CKh4WSn-.js → MarkdownEditor-BELFww9q.js} +116 -126
  28. package/dist/web3/assets/{NestPageHeader-Cizo8nUi.js → NestPageHeader-Bbje2ncY.js} +1 -1
  29. package/dist/web3/assets/NestView-D4OMXPQ7.js +73 -0
  30. package/dist/web3/assets/OverviewTab-FIDGjR3Q.js +6 -0
  31. package/dist/web3/assets/{PersonCombobox-BKI7dff4.js → PersonCombobox-D52m_jhc.js} +1 -1
  32. package/dist/web3/assets/{ReviewActions-CS8JXgXF.js → ReviewActions-DYqBcWlv.js} +2 -2
  33. package/dist/web3/assets/ReviewTab-DoxfygOb.js +1 -0
  34. package/dist/web3/assets/StewardsTab-CFsYjbEn.js +1 -0
  35. package/dist/web3/assets/{SubmitForReviewModal-Biq3m79i.js → SubmitForReviewModal-AW2OLNAE.js} +1 -1
  36. package/dist/web3/assets/{alert-dialog-BvF3NFr7.js → alert-dialog-p-fBLSWx.js} +2 -2
  37. package/dist/web3/assets/{arrow-left-CWIsv-sG.js → arrow-left-CiErCCTG.js} +1 -1
  38. package/dist/web3/assets/{backlinks-Cqcn7n1J.js → backlinks-DaVSYvhV.js} +2 -2
  39. package/dist/web3/assets/{boxes-ClbgQqG2.js → boxes-VWrCCMzW.js} +1 -1
  40. package/dist/web3/assets/{chevron-left-CK_ZPFm9.js → chevron-left-CsD8gZwL.js} +1 -1
  41. package/dist/web3/assets/{circle-check-Bw_aTXiH.js → circle-check-BBCXd9LI.js} +1 -1
  42. package/dist/web3/assets/{circle-x-DkIG68H7.js → circle-x-C9LsnORQ.js} +1 -1
  43. package/dist/web3/assets/{code-xml-BlR7EN6l.js → code-xml-BaoV7HiQ.js} +1 -1
  44. package/dist/web3/assets/{corner-down-right-DSVxK3tf.js → corner-down-right-DgiTXrtw.js} +1 -1
  45. package/dist/web3/assets/{count-skeleton-DToLM74B.js → count-skeleton-CnEE4uF-.js} +1 -1
  46. package/dist/web3/assets/{earth-BXhVPvCR.js → earth-BmMbVt9k.js} +1 -1
  47. package/dist/web3/assets/{file-exclamation-point-BevGZgtj.js → file-exclamation-point-qJeca9-A.js} +1 -1
  48. package/dist/web3/assets/{folder-input-m8UBV4fA.js → folder-input-D7R93LJ6.js} +1 -1
  49. package/dist/web3/assets/index-DNmWTxN7.css +1 -0
  50. package/dist/web3/assets/index-DvuUIUP6.js +451 -0
  51. package/dist/web3/assets/{page-DzKwP5Ds.js → page-BJQuaTRW.js} +2 -2
  52. package/dist/web3/assets/{page-B6pkc9cd.js → page-Bb8xGOPQ.js} +2 -2
  53. package/dist/web3/assets/{page-Deq6IKiN.js → page-BtM-qDzr.js} +2 -2
  54. package/dist/web3/assets/{page-DPY01SOj.js → page-Bxd0EG25.js} +1 -1
  55. package/dist/web3/assets/page-C3QCO6IR.js +1 -0
  56. package/dist/web3/assets/{page-CptuAPJc.js → page-CfoR0khy.js} +2 -2
  57. package/dist/web3/assets/page-D7Zr5tAI.js +1 -0
  58. package/dist/web3/assets/{page-Dwbp4Seu.js → page-DATSlHCW.js} +2 -2
  59. package/dist/web3/assets/page-DTjPQizZ.js +45 -0
  60. package/dist/web3/assets/{page-Bgy9Ng9p.js → page-DspptlWr.js} +1 -1
  61. package/dist/web3/assets/{page-Dh57L0RG.js → page-I6vAFJOj.js} +4 -4
  62. package/dist/web3/assets/page-QxI4Ud2s.js +9 -0
  63. package/dist/web3/assets/{page-BJrFCKXB.js → page-YcNNg-dX.js} +1 -1
  64. package/dist/web3/assets/page-kM7eO5Kj.js +1 -0
  65. package/dist/web3/assets/{page-title-DVq4qvMR.js → page-title-Dn5jANiU.js} +1 -1
  66. package/dist/web3/assets/{play-CXhS6Pgq.js → play-B2k_Ix-L.js} +1 -1
  67. package/dist/web3/assets/{send-DdfwnIrj.js → send-DPNxv3As.js} +1 -1
  68. package/dist/web3/assets/{settings-DKX0Q4gO.js → settings-DZjq-OQA.js} +1 -1
  69. package/dist/web3/assets/{share-2-CxmXRt58.js → share-2-C-OnE2ru.js} +1 -1
  70. package/dist/web3/assets/{tag-DeYC_mRv.js → tag-CwJl7FdI.js} +1 -1
  71. package/dist/web3/assets/{trash-2-B0HyPTnI.js → trash-2-aljmIff4.js} +1 -1
  72. package/dist/web3/assets/{user-plus-Ckm_Isyc.js → user-plus-DPerZexX.js} +1 -1
  73. package/dist/web3/assets/{zap-D2ifcnYb.js → zap-CHZtiN7P.js} +1 -1
  74. package/dist/web3/index.html +2 -2
  75. package/package.json +6 -2
  76. package/dist/web3/assets/AgentDocsPage-ixP2UdAE.js +0 -1
  77. package/dist/web3/assets/CollaboratorManager-C9VAXZ0h.js +0 -1
  78. package/dist/web3/assets/CollaboratorsTab-tq4ZTexH.js +0 -1
  79. package/dist/web3/assets/DocumentEditor-C2CP2fiE.js +0 -36
  80. package/dist/web3/assets/DocumentsTab-Mh7KxUoB.js +0 -6
  81. package/dist/web3/assets/ExternalEditsTab-B_CWKXZ_.js +0 -1
  82. package/dist/web3/assets/NestView-EW-sraEb.js +0 -73
  83. package/dist/web3/assets/OverviewTab-CjidsMX_.js +0 -6
  84. package/dist/web3/assets/ReviewTab-CwVrXzw5.js +0 -1
  85. package/dist/web3/assets/StewardsTab-C-iP6gnh.js +0 -1
  86. package/dist/web3/assets/index-BOVYgMvj.js +0 -29
  87. package/dist/web3/assets/index-DVnaYqj9.css +0 -1
  88. package/dist/web3/assets/index-WYDmEOfO.js +0 -404
  89. package/dist/web3/assets/page-C2zoxumw.js +0 -1
  90. package/dist/web3/assets/page-CJWO06dN.js +0 -1
  91. package/dist/web3/assets/page-Dea2SblF.js +0 -9
  92. package/dist/web3/assets/page-Dfjg8p7Z.js +0 -1
  93. package/dist/web3/assets/page-YxjrE5b5.js +0 -45
  94. package/dist/web3/assets/x-mNtADYHc.js +0 -6
package/API.md CHANGED
@@ -1592,7 +1592,7 @@ Body (give `prompt` OR `selector`; everything else optional):
1592
1592
  }
1593
1593
  ```
1594
1594
  - `prompt` — natural language. Tokens are matched against tag names and titles, and every document is scored against them (title hits weigh most, then tags, then body words); documents under half the top score are dropped as noise. A prompt that matches nothing returns `nodes: []` — never the whole nest. `trace.compiled.fallback` is `"fulltext"` when body scoring alone produced the answer.
1595
- - `selector` — explicit selector grammar (e.g. `#auth`). Wins over `prompt`.
1595
+ - `selector` — explicit selector grammar (e.g. `#auth`). Wins over `prompt`. May carry `[[Title]]` parts in an OR union (`#auth | [[Auth Overview]]`), and `[[nodes/auth-overview]]` names one document by id where a title may match several.
1596
1596
  - `hops` — link expansion depth (`[[wikilinks]]` and `contextnest://` links, both directions). Defaults to `0` for a `prompt` (neighbours of a keyword match are not answers to the question) and `2` for a `selector`.
1597
1597
  - `max_tokens` — budget, floored at 50, default 4000. A node that doesn't fit is skipped and the next tried (`trace.truncated_by_budget` counts the skips); only when nothing fits does the first node go through oversized.
1598
1598
  - 400 `prompt or selector is required` if both missing.
@@ -1708,6 +1708,39 @@ Deterministic fact lookup against a **table** node (CSV body): fetch the exact r
1708
1708
  ```
1709
1709
  Same operation over MCP: `context_table_query`.
1710
1710
 
1711
+ ### POST `/nests/:nestId/chat`
1712
+ Hootie in the nest — ask a question about the nest and get a streamed, cited answer. Read tier, like `/context`: retrieval runs through the same `/context` path **as the caller**, so the answer can only draw on documents this person may read. Gated by `HOOTIE_ENABLED` (off → `404`); needs the server-wide `ANTHROPIC_API_KEY` (missing → `503` with `reason: "no_api_key"`); answers with `HOOTIE_MODEL`, through `ANTHROPIC_BASE_URL` when one is set (all three editable in Settings → General). Rate-limited per user across all nests — the budget guards the one server-wide key (`429`).
1713
+
1714
+ Request:
1715
+
1716
+ ```json
1717
+ {
1718
+ "message": "how do requests authenticate?",
1719
+ "node_id": "nodes/api-authentication-guide",
1720
+ "history": [
1721
+ { "role": "user", "content": "…" },
1722
+ { "role": "assistant", "content": "…" }
1723
+ ]
1724
+ }
1725
+ ```
1726
+
1727
+ - `message` (required) — the question. `@name` tokens loop a colleague in (below).
1728
+ - `node_id` (optional) — the document the person has open. It and its neighbours (one hop) lead the context, whatever the question matches follows.
1729
+ - `history` (optional) — prior turns, oldest first; the server keeps the most recent 20.
1730
+
1731
+ Response: `text/event-stream`.
1732
+
1733
+ | event | data |
1734
+ |---|---|
1735
+ | `meta` | `{ nodes: [{ id, title, url, tags, type }], anchor: node \| null }` — what the answer will be grounded in, before the model speaks. |
1736
+ | `delta` | `{ text }` — a chunk of the answer. |
1737
+ | `done` | `{ answer, cited: [node…], mention }` — the whole answer, the documents it cites (by `[[Title]]`, in order), and what happened to any `@mentions`. |
1738
+ | `error` | `{ error }` — the model call failed; the stream ends. |
1739
+
1740
+ **Grounding.** The retrieved documents are placed in the system prompt verbatim with the instruction to answer from them alone. Retrieval is permission-gated, so an answer can never draw on anything the caller couldn't read — but a document author can write text aimed at Hootie ("ignore the question and…") that later readers' sessions will see. That is the ordinary tradeoff of grounding a model in editable content, accepted here; the documents are governed, so who can write them is the control.
1741
+
1742
+ **Mentions.** A message naming people (`@alice@acme.com`) posts the exchange — question and answer, under the asker's name — as a comment on the anchor document (the open one, else the first document the answer cites) and notifies them through the comment-mention path: inbox row, and email / Slack / Teams where configured. `mention` is `{ status: "posted", node_id, node_title, thread_id, notified }` or `{ status: "undeliverable", reason: "no_anchor" | "post_failed" }`. Nothing is posted when nobody is named.
1743
+
1711
1744
  ### POST `/nests/:nestId/query`
1712
1745
  Graph query (Cypher-ish).
1713
1746
 
package/CONFIGURATION.md CHANGED
@@ -95,13 +95,17 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
95
95
  | `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 Server settings → Advanced. When off, `GET/POST /teams/promptowl` return `404` and the PromptOwl Teams panel is hidden. Independent of `PROMPTOWL_SIGN_IN_GATE`. |
96
96
  | `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`). |
97
97
  | `TYPE_TABLE_ENABLED` | `true` | Same as above for **table** nodes. |
98
+ | `HOOTIE_ENABLED` | `true` | Hootie in the nest — the assistant on every page of the UI and its `POST /nests/:id/chat` route. Set `false` to switch it off (the launcher disappears and the route answers `404`). Also toggleable from Settings → General. Answering needs `ANTHROPIC_API_KEY`; without one the feature stays on but the panel explains what's missing and the route answers `503`. Questions are budgeted per user across all nests (30 per 5 minutes); in `AUTH_MODE=open` every caller is the one anonymous user, so the budget is shared by everyone on that server. |
99
+ | `HOOTIE_MODEL` | `claude-opus-5` | The model Hootie answers with. Also editable from Settings → General. Through a gateway, use the gateway's name for it (`ollama/llama3.2`, `gpt-5`, …). |
100
+ | `ANTHROPIC_BASE_URL` | _(unset — Anthropic directly)_ | A LiteLLM or PromptOwl gateway speaking `/v1/messages`. When set, **every** model call this server makes goes through it — Hootie, and agent runs via the run bundle's env floor — with `ANTHROPIC_API_KEY` as the gateway's virtual key: budgets, spend logs, and any model the gateway fronts. Also editable from Settings → General. |
98
101
  | `FEATURE_WORKFLOW_PLANE` | _(unset — off)_ | Enables the workflow plane: typed edges, edge-type registry, governed runs. Optional feature; also toggleable from Settings. |
99
102
  | `FEATURE_SUBAGENT_RUNS` | _(unset — off)_ | Lets runs spawn nested sub-agent runs (recursion). Gated separately from the plane; requires `FEATURE_WORKFLOW_PLANE`. Also toggleable from Server settings → Advanced (turning the plane off forces this off too). Opens a recursion surface — enable only after reviewing the depth/fan-out caps. |
100
103
  | `SUBAGENT_MAX_DEPTH` | `8` | Max sub-agent nesting depth (clamped 1..32) — bounds the call tree's HEIGHT so it stays finite/haltable. |
101
104
  | `SUBAGENT_MAX_CHILDREN` | `16` | Max direct children a single run may spawn (clamped 1..128) — bounds the call tree's WIDTH. Together with `SUBAGENT_MAX_DEPTH` this caps total tree size so a runner can't fork-bomb the DB. |
102
105
  | `RUN_MAX_STEPS` | `10000` | Max steps a single run may accumulate (clamped 10..100000) — bounds a runaway/hostile runner's step log. |
103
106
  | `RUN_MAX_CONCURRENT_ROOTS` | `50` | Max concurrently-running root (depth-0) runs per nest (clamped 1..1000). The subagent caps bound a single tree; this bounds how many trees can run at once, so triggers can't flood a nest. |
104
- | `ANTHROPIC_API_KEY` | _(unset)_ | Server-wide default runner key for workflow-plane agent runs. Never returned by the API — the health endpoint reports presence only, and Settings shows a masked tail. |
107
+ | `ANTHROPIC_API_KEY` | _(unset)_ | Server-wide default runner key for workflow-plane agent runs, and the key Hootie answers with. Never returned by the API — the health endpoint reports presence only, and Settings shows a masked tail. |
108
+ | `SECRETS_KEY` | _(unset — a key is generated and stored in the database)_ | Master key for the credentials this server stores encrypted (the secret settings rows and every nest Tool-environment value). Any string; it is hashed to 32 bytes. Optional, and stronger than the default when your deployment has a secret manager: the key then lives outside the database it protects. Values already written under the generated key cannot be read after you set it. See [Runtime settings persistence](#runtime-settings-persistence). |
105
109
  | `SLACK_WEBHOOK_URL` | _(unset — connector off)_ | Slack incoming-webhook URL for governance-event notifications (review submitted/approved/rejected, collaborator added). `https://` only — the URL embeds a secret. Also editable from Settings in the UI. |
106
110
  | `MSTEAMS_WEBHOOK_URL` | _(unset — connector off)_ | Microsoft Teams incoming-webhook URL for the same governance events, posted as Adaptive Cards. `https://` only — the URL embeds a secret. Also editable from Settings in the UI. See [Microsoft Teams notifications](#microsoft-teams-notifications). |
107
111
  | `EMAIL_PROVIDER` | `smtp` | Email transport: `smtp` (uses `SMTP_URL`) or `ses` (AWS SES through the AWS SDK, for servers that can't use SMTP). Also editable from Settings. |
@@ -875,6 +879,33 @@ to keys you don't set in the environment stay put. Clearing a setting in the UI
875
879
  writes a tombstone, so a value you removed is not resurrected from the environment
876
880
  on the next boot.
877
881
 
882
+ **Credentials are encrypted at rest.** The secret rows — `ANTHROPIC_API_KEY`,
883
+ `OIDC_CLIENT_SECRET`, `MCP_SIGNING_SECRET`, `SMTP_URL`, `SLACK_WEBHOOK_URL`,
884
+ `MSTEAMS_WEBHOOK_URL`, `SES_SECRET_ACCESS_KEY`, `PROMPTOWL_KEY` — and every value in a
885
+ nest's Tool environment (`nest_env`) are stored AES-256-GCM encrypted, so a row read in
886
+ isolation is not the credential. Non-secret settings stay readable: what a server is
887
+ configured to do is worth being able to see. One known exception: the license
888
+ validation cache (`license_cache`) is still keyed by the license key itself.
889
+
890
+ The master key needs nothing configured: on first use the server generates one and
891
+ stores it in the database (`server_settings`, key `SECRETS_MASTER_KEY`), so it survives
892
+ redeploys, is shared by every instance, and travels with your database backups. That row
893
+ is not a setting — it is never loaded into the process environment and never returned by
894
+ the settings API.
895
+
896
+ Set **`SECRETS_KEY`** in the deploy environment to supply the key yourself instead; it
897
+ takes precedence over the stored one, and any string works (it is hashed to 32 bytes).
898
+ This is the stronger arrangement, because the key then lives somewhere the database is
899
+ not: a database dump alone cannot open the values. With the stored key, a dump that
900
+ includes that row includes the key — the encryption protects against a value being read
901
+ or logged incidentally, not against an attacker holding the whole database. If you set
902
+ `SECRETS_KEY` on a server that already wrote values under the stored key, it cannot read
903
+ them: the server still boots, logs which settings it could not open, and treats each as
904
+ unset so an admin can re-enter it.
905
+
906
+ Values written before any of this existed are read back as plaintext and re-encrypted the
907
+ next time they are saved — nothing to migrate.
908
+
878
909
  ### Cloud Run + Cloud SQL (PostgreSQL)
879
910
 
880
911
  Attach the Cloud SQL instance to the service (`--add-cloudsql-instances`) so the
@@ -501,6 +501,34 @@ var config = {
501
501
  get TYPE_TABLE_ENABLED() {
502
502
  return process.env.TYPE_TABLE_ENABLED !== "false";
503
503
  },
504
+ /**
505
+ * Hootie in the nest: the floating assistant on every page of the UI and
506
+ * its POST /nests/:id/chat route (default ON). Superadmin-editable at
507
+ * runtime via /admin/settings (hootie_enabled). Off → the route 404s and
508
+ * the launcher is hidden. Answering additionally needs the server-wide
509
+ * ANTHROPIC_API_KEY; without one the route answers 503, not 404 — the
510
+ * feature is on, it just isn't configured yet.
511
+ */
512
+ get HOOTIE_ENABLED() {
513
+ return process.env.HOOTIE_ENABLED !== "false";
514
+ },
515
+ /** Model Hootie answers with. Superadmin-editable at runtime via
516
+ * /admin/settings (hootie_model). A gateway-side name (`ollama/…`,
517
+ * `gpt-…`) is fine when ANTHROPIC_BASE_URL points at one. */
518
+ get HOOTIE_MODEL() {
519
+ return process.env.HOOTIE_MODEL || "claude-opus-5";
520
+ },
521
+ /**
522
+ * Where the Anthropic-shaped model calls go. Empty = Anthropic directly.
523
+ * Set to a LiteLLM (or PromptOwl) gateway to route Hootie AND agent runs
524
+ * through it — one setting: the runner honours the same variable via the
525
+ * run bundle's env floor. Superadmin-editable (anthropic_base_url).
526
+ * Never a per-user vendor key: that is the shadow-AI hole the gateway
527
+ * exists to close.
528
+ */
529
+ get ANTHROPIC_BASE_URL() {
530
+ return (process.env.ANTHROPIC_BASE_URL || "").trim().replace(/\/$/, "");
531
+ },
504
532
  /**
505
533
  * Sub-agent runs: lets a run spawn nested runs (an external runner calling
506
534
  * back into POST /run for a child agent). Gated SEPARATELY from the base
@@ -7,7 +7,7 @@ import {
7
7
  resolveNestAccess,
8
8
  resolveNestPermission,
9
9
  resolveTeamRolesForUser
10
- } from "./chunk-SWA5M5XX.js";
10
+ } from "./chunk-QMQDWRHN.js";
11
11
  import {
12
12
  ConflictError,
13
13
  ValidationError
@@ -19,12 +19,12 @@ import {
19
19
  collabPermToRole,
20
20
  primaryRole,
21
21
  sendEmailToRecipient
22
- } from "./chunk-I5EDEZBQ.js";
22
+ } from "./chunk-RXCVXG34.js";
23
23
  import {
24
24
  config,
25
25
  getDb,
26
26
  isEmailish
27
- } from "./chunk-TRSGPS27.js";
27
+ } from "./chunk-H3W72HN3.js";
28
28
 
29
29
  // src/governance/stewardship-service.ts
30
30
  import { v4 as uuid } from "uuid";
@@ -233,7 +233,7 @@ async function createStewardRecord(params) {
233
233
  );
234
234
  let userId = userRow?.id;
235
235
  if (!userId) {
236
- const { findOrCreateInvitedUser } = await import("./invited-user-AM6A3WGO.js");
236
+ const { findOrCreateInvitedUser } = await import("./invited-user-XEBX6BM4.js");
237
237
  userId = (await findOrCreateInvitedUser(email)).id;
238
238
  }
239
239
  const created = await assignSteward({
@@ -2,13 +2,13 @@ import {
2
2
  buildTitleMap,
3
3
  insertOrReplace,
4
4
  nowExpr
5
- } from "./chunk-SWA5M5XX.js";
5
+ } from "./chunk-QMQDWRHN.js";
6
6
  import {
7
7
  ValidationError
8
8
  } from "./chunk-LHWOJPFE.js";
9
9
  import {
10
10
  getDb
11
- } from "./chunk-TRSGPS27.js";
11
+ } from "./chunk-H3W72HN3.js";
12
12
 
13
13
  // src/governance/version-service.ts
14
14
  import { createHash } from "crypto";
@@ -215,6 +215,25 @@ async function setApprovedVersion(nestId, nodeId, version, approvedBy) {
215
215
  );
216
216
  await db.run(sql, [nestId, nodeId, version, approvedBy]);
217
217
  }
218
+ var APPROVED_HEAL_AUTHOR = "system:approved-heal";
219
+ async function moveApprovedVersion(nestId, nodeId, from, to) {
220
+ const db = getDb();
221
+ const moved = await db.run(
222
+ `UPDATE approved_versions
223
+ SET approved_version = ?, approved_by = ?, approved_at = ${nowExpr(db)}
224
+ WHERE nest_id = ? AND node_id = ? AND approved_version = ?`,
225
+ [to, APPROVED_HEAL_AUTHOR, nestId, nodeId, from]
226
+ );
227
+ if (moved.changes === 0) return false;
228
+ const note = `Approved version moved from v${from} to v${to} \u2014 v${from} could not be rebuilt`;
229
+ await db.run(
230
+ `UPDATE node_versions
231
+ SET change_note = CASE WHEN change_note IS NULL OR change_note = '' THEN ? ELSE change_note || ' \xB7 ' || ? END
232
+ WHERE nest_id = ? AND node_id = ? AND version = ?`,
233
+ [note, note, nestId, nodeId, to]
234
+ );
235
+ return true;
236
+ }
218
237
  async function getSealedVersion(nestId, nodeId) {
219
238
  const approved = await getApprovedVersion(nestId, nodeId);
220
239
  if (approved != null) return approved;
@@ -458,6 +477,8 @@ export {
458
477
  getApprovedVersion,
459
478
  getApprovedVersions,
460
479
  setApprovedVersion,
480
+ APPROVED_HEAL_AUTHOR,
481
+ moveApprovedVersion,
461
482
  getSealedVersion,
462
483
  deleteSupersededDrafts,
463
484
  deleteUnsealedVersions,
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  sendEmailToRecipient
3
- } from "./chunk-I5EDEZBQ.js";
3
+ } from "./chunk-RXCVXG34.js";
4
4
  import {
5
5
  config,
6
6
  getDb
7
- } from "./chunk-TRSGPS27.js";
7
+ } from "./chunk-H3W72HN3.js";
8
8
  import {
9
9
  normalizeEmail
10
10
  } from "./chunk-FRQJWGN3.js";
@@ -8,12 +8,12 @@ import {
8
8
  permissionLabel,
9
9
  sendEmailToRecipient,
10
10
  teamRoleToPermission
11
- } from "./chunk-I5EDEZBQ.js";
11
+ } from "./chunk-RXCVXG34.js";
12
12
  import {
13
13
  config,
14
14
  getDb,
15
15
  isEmailish
16
- } from "./chunk-TRSGPS27.js";
16
+ } from "./chunk-H3W72HN3.js";
17
17
  import {
18
18
  ANON_USER_ID
19
19
  } from "./chunk-YB3LKF7U.js";
@@ -156,6 +156,102 @@ function insertOrReplace(db, insertSql, conflictCols, set) {
156
156
  return db.dialect === "sqlite" ? insertSql.replace(/^\s*INSERT\s+INTO/i, "INSERT OR REPLACE INTO") : `${insertSql} ON CONFLICT (${conflictCols.join(", ")}) DO UPDATE SET ${set}`;
157
157
  }
158
158
 
159
+ // src/shared/secret-box.ts
160
+ import {
161
+ createCipheriv,
162
+ createDecipheriv,
163
+ createHash as createHash2,
164
+ randomBytes
165
+ } from "crypto";
166
+ var PREFIX = "enc.v1.";
167
+ var b64 = (b) => b.toString("base64url");
168
+ var MASTER_KEY_SETTING = "SECRETS_MASTER_KEY";
169
+ var SECRET_SETTINGS = /* @__PURE__ */ new Set([
170
+ "ANTHROPIC_API_KEY",
171
+ "OIDC_CLIENT_SECRET",
172
+ "MCP_SIGNING_SECRET",
173
+ "SMTP_URL",
174
+ "SLACK_WEBHOOK_URL",
175
+ "MSTEAMS_WEBHOOK_URL",
176
+ "SES_SECRET_ACCESS_KEY",
177
+ "PROMPTOWL_KEY"
178
+ ]);
179
+ function isSecretSetting(name) {
180
+ return SECRET_SETTINGS.has(name);
181
+ }
182
+ function isEncrypted(value) {
183
+ return value.startsWith(PREFIX);
184
+ }
185
+ async function encryptSecret(plain) {
186
+ if (isEncrypted(plain)) return plain;
187
+ const iv = randomBytes(12);
188
+ const cipher = createCipheriv("aes-256-gcm", await masterKey(), iv);
189
+ const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
190
+ return PREFIX + [b64(iv), b64(cipher.getAuthTag()), b64(ct)].join(".");
191
+ }
192
+ async function decryptSecret(stored) {
193
+ if (!isEncrypted(stored)) return stored;
194
+ const [iv, tag, ct] = stored.slice(PREFIX.length).split(".");
195
+ if (!iv || !tag || !ct) throw new Error("malformed encrypted value");
196
+ const decipher = createDecipheriv(
197
+ "aes-256-gcm",
198
+ await masterKey(),
199
+ Buffer.from(iv, "base64url")
200
+ );
201
+ decipher.setAuthTag(Buffer.from(tag, "base64url"));
202
+ return Buffer.concat([
203
+ decipher.update(Buffer.from(ct, "base64url")),
204
+ decipher.final()
205
+ ]).toString("utf8");
206
+ }
207
+ async function tryDecryptSecret(stored, label) {
208
+ try {
209
+ return await decryptSecret(stored);
210
+ } catch {
211
+ console.error(
212
+ `[secrets] cannot decrypt ${label} \u2014 SECRETS_KEY does not match the key this value was written with. Unset it to fall back to the stored key, or re-enter the value to replace it.`
213
+ );
214
+ return null;
215
+ }
216
+ }
217
+ var cached = null;
218
+ var inFlight = null;
219
+ async function masterKey() {
220
+ const fromEnv = (process.env.SECRETS_KEY || "").trim();
221
+ if (fromEnv) {
222
+ if (cached?.id !== fromEnv) {
223
+ cached = { id: fromEnv, key: createHash2("sha256").update(fromEnv).digest() };
224
+ }
225
+ return cached.key;
226
+ }
227
+ const db = getDb();
228
+ const id = `db:${db.dialect}:${dbIdentity(db)}`;
229
+ if (cached?.id === id) return cached.key;
230
+ if (!inFlight) inFlight = loadOrCreateKey(id).finally(() => inFlight = null);
231
+ return inFlight;
232
+ }
233
+ async function loadOrCreateKey(id) {
234
+ const db = getDb();
235
+ const read = async () => await db.get("SELECT value FROM server_settings WHERE key = ?", [
236
+ MASTER_KEY_SETTING
237
+ ]);
238
+ let row = await read();
239
+ if (!row?.value) {
240
+ const sql = db.dialect === "sqlite" ? `INSERT OR IGNORE INTO server_settings (key, value, env_at_write, updated_at)
241
+ VALUES (?, ?, NULL, ${nowExpr(db)})` : `INSERT INTO server_settings (key, value, env_at_write, updated_at)
242
+ VALUES (?, ?, NULL, ${nowExpr(db)}) ON CONFLICT (key) DO NOTHING`;
243
+ await db.run(sql, [MASTER_KEY_SETTING, randomBytes(32).toString("hex")]);
244
+ row = await read();
245
+ }
246
+ if (!row?.value) throw new Error("could not establish an encryption key");
247
+ const key = Buffer.from(row.value, "hex");
248
+ cached = { id, key };
249
+ return key;
250
+ }
251
+ function dbIdentity(db) {
252
+ return process.env.DATABASE_URL || process.env.DATA_ROOT || db.dialect;
253
+ }
254
+
159
255
  // src/db/settings.ts
160
256
  var deployBaseline = null;
161
257
  function norm(v) {
@@ -168,6 +264,9 @@ function baseline() {
168
264
  async function persistSetting(name, value) {
169
265
  const db = getDb();
170
266
  const envAtWrite = norm(baseline()[name]);
267
+ const seal = async (v) => v !== null && isSecretSetting(name) ? await encryptSecret(v) : v;
268
+ const sealedValue = await seal(value);
269
+ const sealedBaseline = await seal(envAtWrite);
171
270
  await db.run(
172
271
  insertOrReplace(
173
272
  db,
@@ -176,7 +275,7 @@ async function persistSetting(name, value) {
176
275
  ["key"],
177
276
  `value = excluded.value, env_at_write = excluded.env_at_write, updated_at = ${nowExpr(db)}`
178
277
  ),
179
- [name, value, envAtWrite]
278
+ [name, sealedValue, sealedBaseline]
180
279
  );
181
280
  }
182
281
  async function loadServerSettings() {
@@ -188,7 +287,12 @@ async function loadServerSettings() {
188
287
  );
189
288
  let applied = 0;
190
289
  for (const row of rows) {
290
+ if (row.key === MASTER_KEY_SETTING) continue;
191
291
  const envVal = norm(base[row.key]);
292
+ if (isSecretSetting(row.key)) {
293
+ row.value = row.value === null ? null : await tryDecryptSecret(row.value, row.key);
294
+ row.env_at_write = row.env_at_write === null ? null : await tryDecryptSecret(row.env_at_write, `${row.key} (baseline)`);
295
+ }
192
296
  if (envVal !== norm(row.env_at_write)) {
193
297
  await persistSetting(row.key, envVal);
194
298
  if (envVal === null) delete process.env[row.key];
@@ -317,15 +421,15 @@ function getSuspensionReason() {
317
421
  var REJECTION_STRIKES_TO_REVOKE = 3;
318
422
  var rejectionStreak = 0;
319
423
  var rejectionKey = null;
320
- function fromCache(cached) {
424
+ function fromCache(cached2) {
321
425
  return {
322
426
  valid: true,
323
- tier: cached.tier,
324
- org: cached.org,
325
- limits: cached.limits_json ? JSON.parse(cached.limits_json) : null,
427
+ tier: cached2.tier,
428
+ org: cached2.org,
429
+ limits: cached2.limits_json ? JSON.parse(cached2.limits_json) : null,
326
430
  suspended: suspensionConfirmed,
327
431
  suspendedReason: suspensionReason,
328
- ownerEmail: cached.owner_email || null
432
+ ownerEmail: cached2.owner_email || null
329
433
  };
330
434
  }
331
435
  async function validateLicense(opts = {}) {
@@ -347,14 +451,14 @@ async function _validateLicenseImpl(forceFresh, strict) {
347
451
  };
348
452
  }
349
453
  const db = getDb();
350
- const cached = await db.get(
454
+ const cached2 = await db.get(
351
455
  "SELECT * FROM license_cache WHERE key = ?",
352
456
  [key]
353
457
  );
354
- if (cached && !forceFresh) {
355
- const age = Date.now() - (/* @__PURE__ */ new Date(cached.validated_at + "Z")).getTime();
458
+ if (cached2 && !forceFresh) {
459
+ const age = Date.now() - (/* @__PURE__ */ new Date(cached2.validated_at + "Z")).getTime();
356
460
  if (age < CACHE_TTL_MS) {
357
- return fromCache(cached);
461
+ return fromCache(cached2);
358
462
  }
359
463
  }
360
464
  try {
@@ -365,11 +469,11 @@ async function _validateLicenseImpl(forceFresh, strict) {
365
469
  body: JSON.stringify({ key })
366
470
  });
367
471
  if (!res.ok) {
368
- if (cached) {
472
+ if (cached2) {
369
473
  console.warn(
370
474
  " PromptOwl unreachable, using cached license (grace period)"
371
475
  );
372
- return fromCache(cached);
476
+ return fromCache(cached2);
373
477
  }
374
478
  return {
375
479
  valid: false,
@@ -418,7 +522,7 @@ async function _validateLicenseImpl(forceFresh, strict) {
418
522
  if (data.valid !== true) {
419
523
  rejectionStreak = 0;
420
524
  rejectionKey = null;
421
- if (cached) return fromCache(cached);
525
+ if (cached2) return fromCache(cached2);
422
526
  return {
423
527
  valid: false,
424
528
  tier: "none",
@@ -438,14 +542,14 @@ async function _validateLicenseImpl(forceFresh, strict) {
438
542
  suspensionReason = null;
439
543
  }
440
544
  if (!data.valid && !data.suspended) {
441
- if (cached && !strict) {
545
+ if (cached2 && !strict) {
442
546
  rejectionStreak = rejectionKey === key ? rejectionStreak + 1 : 1;
443
547
  rejectionKey = key;
444
548
  if (rejectionStreak < REJECTION_STRIKES_TO_REVOKE) {
445
549
  console.warn(
446
550
  `[license] PromptOwl rejected the license (${rejectionStreak}/${REJECTION_STRIKES_TO_REVOKE}); keeping the cached license until the rejection is confirmed`
447
551
  );
448
- return fromCache(cached);
552
+ return fromCache(cached2);
449
553
  }
450
554
  }
451
555
  rejectionStreak = 0;
@@ -492,11 +596,11 @@ async function _validateLicenseImpl(forceFresh, strict) {
492
596
  ownerEmail: data.owner_email || null
493
597
  };
494
598
  } catch (err) {
495
- if (cached) {
599
+ if (cached2) {
496
600
  console.warn(
497
601
  ` PromptOwl validation failed (${err.message}), using cached license`
498
602
  );
499
- return fromCache(cached);
603
+ return fromCache(cached2);
500
604
  }
501
605
  return {
502
606
  valid: false,
@@ -1057,7 +1161,7 @@ async function resolveMemberIdentity(params) {
1057
1161
  if (!requested) {
1058
1162
  throw new ValidationError("user_id or email is required");
1059
1163
  }
1060
- const { findOrCreateInvitedUser } = await import("./invited-user-AM6A3WGO.js");
1164
+ const { findOrCreateInvitedUser } = await import("./invited-user-XEBX6BM4.js");
1061
1165
  const { id } = await findOrCreateInvitedUser(requested);
1062
1166
  return { userId: id, email: requested };
1063
1167
  }
@@ -2170,8 +2274,8 @@ async function ensureNodeIndex(nestId) {
2170
2274
  return run;
2171
2275
  }
2172
2276
  async function rebuildNodeIndex(nestId) {
2173
- const { engineCache: engineCache2 } = await import("./engine-QNMFDIBV.js");
2174
- const { documentsWithSuggestions } = await import("./external-edit-service-4MEGPT4D.js");
2277
+ const { engineCache: engineCache2 } = await import("./engine-HLYQLP5T.js");
2278
+ const { documentsWithSuggestions } = await import("./external-edit-service-3DFQ4EZZ.js");
2175
2279
  const { storage, dropDiscoveryCache } = await engineCache2.get(nestId);
2176
2280
  dropDiscoveryCache();
2177
2281
  const startedAt = writeGeneration.get(nestId) ?? 0;
@@ -2429,6 +2533,8 @@ export {
2429
2533
  insertOrReplace,
2430
2534
  trackEvent,
2431
2535
  startTelemetryLoop,
2536
+ encryptSecret,
2537
+ tryDecryptSecret,
2432
2538
  persistSetting,
2433
2539
  loadServerSettings,
2434
2540
  getCurrentLicense,
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  config,
3
3
  getDb
4
- } from "./chunk-TRSGPS27.js";
4
+ } from "./chunk-H3W72HN3.js";
5
5
 
6
6
  // src/governance/roles.ts
7
7
  function collabPermToRole(permission) {
@@ -6,10 +6,10 @@ import {
6
6
  markIndexStale,
7
7
  resolveNestPath,
8
8
  syncSuggestionRows
9
- } from "./chunk-SWA5M5XX.js";
9
+ } from "./chunk-QMQDWRHN.js";
10
10
  import {
11
11
  getDb
12
- } from "./chunk-TRSGPS27.js";
12
+ } from "./chunk-H3W72HN3.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-C3JTZLJH.js");
86
+ const { hasUnsealedDraft } = await import("./version-service-JYHFPSYP.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-C3JTZLJH.js");
257
+ const { upsertVersion, setApprovedVersion } = await import("./version-service-JYHFPSYP.js");
258
258
  await upsertVersion({
259
259
  nestId: input.nestId,
260
260
  nodeId: input.documentId,
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-LHWOJPFE.js";
4
4
  import {
5
5
  getDb
6
- } from "./chunk-TRSGPS27.js";
6
+ } from "./chunk-H3W72HN3.js";
7
7
 
8
8
  // src/governance/grants-service.ts
9
9
  import { v4 as uuid } from "uuid";
@@ -4,23 +4,24 @@ import {
4
4
  resolveNestWideRoles,
5
5
  resolveStewardsForNode,
6
6
  stewardCoverageForUser
7
- } from "./chunk-DGPTJXIV.js";
7
+ } from "./chunk-MB52DWWP.js";
8
8
  import {
9
9
  grantCoversNode,
10
10
  listUserGrants,
11
11
  resolveNodeGrant
12
- } from "./chunk-RPGFHESR.js";
12
+ } from "./chunk-VHEMGQ3I.js";
13
13
  import {
14
14
  createVersion,
15
15
  getApprovedVersion,
16
16
  getApprovedVersions,
17
17
  getCurrentVersion,
18
18
  setApprovedVersion
19
- } from "./chunk-IEAWRHMV.js";
19
+ } from "./chunk-NFFUZF2B.js";
20
20
  import {
21
21
  buildDocContext,
22
22
  buildTitleMap,
23
23
  describeNode,
24
+ encryptSecret,
24
25
  engineApi,
25
26
  engineCache,
26
27
  insertOrReplace,
@@ -34,8 +35,9 @@ import {
34
35
  opContext,
35
36
  prettyFolderPath,
36
37
  resolveNestPermission,
37
- titleForNode
38
- } from "./chunk-SWA5M5XX.js";
38
+ titleForNode,
39
+ tryDecryptSecret
40
+ } from "./chunk-QMQDWRHN.js";
39
41
  import {
40
42
  ConflictError,
41
43
  NotFoundError,
@@ -45,11 +47,11 @@ import {
45
47
  notifyEmailForNest,
46
48
  sendEmailToRecipient,
47
49
  transportSettings
48
- } from "./chunk-I5EDEZBQ.js";
50
+ } from "./chunk-RXCVXG34.js";
49
51
  import {
50
52
  config,
51
53
  getDb
52
- } from "./chunk-TRSGPS27.js";
54
+ } from "./chunk-H3W72HN3.js";
53
55
 
54
56
  // src/governance/review-service.ts
55
57
  import { v4 as uuid2 } from "uuid";
@@ -362,12 +364,18 @@ envRoutes.get("/", requireWorkflowPlane, async (c) => {
362
364
  );
363
365
  return c.json({
364
366
  count: rows.length,
365
- env: rows.map((r) => ({
366
- key: r.key,
367
- value_masked: mask(r.value),
368
- updated_by: r.updated_by,
369
- updated_at: r.updated_at
370
- }))
367
+ env: await Promise.all(
368
+ rows.map(async (r) => ({
369
+ key: r.key,
370
+ // A value this server can't open still has a name worth listing, so
371
+ // the admin can see what to re-enter.
372
+ value_masked: mask(
373
+ await tryDecryptSecret(r.value, `${r.key} on nest ${nestId}`) ?? ""
374
+ ),
375
+ updated_by: r.updated_by,
376
+ updated_at: r.updated_at
377
+ }))
378
+ )
371
379
  });
372
380
  });
373
381
  envRoutes.put("/", requireWorkflowPlane, async (c) => {
@@ -385,11 +393,12 @@ envRoutes.put("/", requireWorkflowPlane, async (c) => {
385
393
  const now = (/* @__PURE__ */ new Date()).toISOString();
386
394
  const by = await resolveCallerEmail(c.get("userId"));
387
395
  const db = getDb();
396
+ const sealed = await encryptSecret(body.value);
388
397
  await db.run(
389
398
  `INSERT INTO nest_env (nest_id, key, value, updated_by, updated_at)
390
399
  VALUES (?, ?, ?, ?, ?)
391
400
  ON CONFLICT(nest_id, key) DO UPDATE SET value = ?, updated_by = ?, updated_at = ?`,
392
- [nestId, key, body.value, by, now, body.value, by, now]
401
+ [nestId, key, sealed, by, now, sealed, by, now]
393
402
  );
394
403
  return c.json({ key, value_masked: mask(body.value) }, 201);
395
404
  });
@@ -412,7 +421,16 @@ async function envValues(nestId) {
412
421
  );
413
422
  const base = {};
414
423
  if (process.env.ANTHROPIC_API_KEY) base.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
415
- return { ...base, ...Object.fromEntries(rows.map((r) => [r.key, r.value])) };
424
+ if (config.ANTHROPIC_BASE_URL) base.ANTHROPIC_BASE_URL = config.ANTHROPIC_BASE_URL;
425
+ const stored = (await Promise.all(
426
+ rows.map(
427
+ async (r) => [
428
+ r.key,
429
+ await tryDecryptSecret(r.value, `${r.key} on nest ${nestId}`)
430
+ ]
431
+ )
432
+ )).filter((e) => e[1] !== null);
433
+ return { ...base, ...Object.fromEntries(stored) };
416
434
  }
417
435
 
418
436
  // src/notify/webhook.ts