@promptowl/contextnest-community 1.19.0 → 1.21.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 (83) hide show
  1. package/CONFIGURATION.md +83 -1
  2. package/README.md +1 -1
  3. package/dist/{chunk-FBDEESNJ.js → chunk-4KJGQHWB.js} +113 -218
  4. package/dist/{chunk-ENDHMQPD.js → chunk-6JGQX4GA.js} +415 -64
  5. package/dist/{chunk-TNAZJYL5.js → chunk-6PJQSKKV.js} +12 -4
  6. package/dist/{chunk-BXUVIAMP.js → chunk-IGPJ74O4.js} +60 -12
  7. package/dist/{chunk-HYSTFMFG.js → chunk-LPPKPEYI.js} +131 -1
  8. package/dist/{chunk-3GMGLYTZ.js → chunk-N2DTJFCD.js} +2 -2
  9. package/dist/{chunk-GSYMJ3A4.js → chunk-XWGEXQU3.js} +6 -2
  10. package/dist/{client-SW5I6ZBK.js → client-BD6PAUCZ.js} +1 -1
  11. package/dist/{engine-V4DY4PYN.js → engine-IKZQ46P7.js} +2 -2
  12. package/dist/{external-edit-service-3NK5DGLA.js → external-edit-service-LTON57HO.js} +3 -3
  13. package/dist/{grants-service-CADI6LIB.js → grants-service-LSNZN6NG.js} +2 -2
  14. package/dist/index.js +2704 -1156
  15. package/dist/{migrations.postgres-5VI4RDTS.js → migrations.postgres-JA2YGLS3.js} +66 -1
  16. package/dist/{review-service-LK4M2C43.js → review-service-6JK64IRT.js} +8 -6
  17. package/dist/{stewardship-service-JAFSU5GN.js → stewardship-service-2YKGBP63.js} +3 -3
  18. package/dist/{version-service-G7SFFKFO.js → version-service-PJV74PVQ.js} +11 -3
  19. package/dist/web3/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  20. package/dist/web3/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  21. package/dist/web3/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  22. package/dist/web3/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  23. package/dist/web3/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  24. package/dist/web3/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  25. package/dist/web3/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  26. package/dist/web3/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  27. package/dist/web3/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  28. package/dist/web3/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  29. package/dist/web3/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  30. package/dist/web3/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  31. package/dist/web3/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  32. package/dist/web3/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  33. package/dist/web3/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  34. package/dist/web3/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  35. package/dist/web3/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  36. package/dist/web3/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  37. package/dist/web3/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  38. package/dist/web3/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  39. package/dist/web3/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  40. package/dist/web3/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  41. package/dist/web3/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  42. package/dist/web3/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  43. package/dist/web3/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  44. package/dist/web3/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  45. package/dist/web3/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  46. package/dist/web3/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  47. package/dist/web3/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  48. package/dist/web3/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  49. package/dist/web3/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  50. package/dist/web3/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  51. package/dist/web3/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  52. package/dist/web3/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  53. package/dist/web3/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  54. package/dist/web3/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  55. package/dist/web3/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  56. package/dist/web3/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  57. package/dist/web3/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  58. package/dist/web3/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  59. package/dist/web3/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  60. package/dist/web3/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  61. package/dist/web3/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  62. package/dist/web3/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  63. package/dist/web3/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  64. package/dist/web3/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  65. package/dist/web3/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  66. package/dist/web3/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  67. package/dist/web3/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  68. package/dist/web3/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  69. package/dist/web3/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  70. package/dist/web3/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  71. package/dist/web3/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  72. package/dist/web3/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  73. package/dist/web3/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  74. package/dist/web3/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  75. package/dist/web3/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  76. package/dist/web3/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  77. package/dist/web3/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  78. package/dist/web3/assets/index-T4pilFNL.css +1 -0
  79. package/dist/web3/assets/index-deyhV93k.js +1382 -0
  80. package/dist/web3/index.html +2 -2
  81. package/package.json +6 -1
  82. package/dist/web3/assets/index-C26nM5Kn.css +0 -1
  83. package/dist/web3/assets/index-C4oalc2K.js +0 -1065
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` | `""` | **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. When unset, `GET /auth/sso` returns `404` and the feature is disabled; self-hosted users keep using the manual device-code flow. |
63
- | `PUBLIC_BASE_URL` | `""` | This server's canonical external URL (e.g. `https://community.promptowl.ai`). Three uses. (1) Checked against the SSO ticket's `aud` claim so a ticket minted for this server can't be replayed against another — only when `OFFICIAL_COMMUNITY_SSO_SECRET` is set; when unset, the audience check is skipped. (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 `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. |
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,11 @@ 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
  | `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. |
71
+ | `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). |
72
+ | `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. |
73
+ | `MCP_TOKEN_TTL_SECONDS` | `300` | Lifetime of a minted MCP bearer, clamped to 30–3600. |
74
+ | `MS_TOKEN_EXCHANGE_ENABLED` / `MS_CLIENT_ID` / `MS_TENANT_ID` | `false` / `""` / `""` | Microsoft Entra provider. `MS_CLIENT_ID` is the audience an inbound token must carry; `MS_TENANT_ID` must be the directory **GUID** (it builds the required issuer `https://login.microsoftonline.com/<tenant>/v2.0` — `common`/`organizations`/a domain name will not match the discovery document and every exchange returns `provider_misconfigured`). |
75
+ | `GOOGLE_TOKEN_EXCHANGE_ENABLED` / `GOOGLE_CLIENT_ID` | `false` / `""` | Google provider. Issuer is fixed (`https://accounts.google.com`); `GOOGLE_CLIENT_ID` is the audience an inbound token must carry. |
71
76
  | `ENV_FILE_PATH` | `$DATA_ROOT/.env` | Path to an optional `.env` file the server reads at boot (in addition to `$cwd/.env`). **No longer used for persistence** — the License Setup Page and Settings page now write to the database, not this file (see [Runtime settings persistence](#runtime-settings-persistence)). Kept for operators who bootstrap config from a mounted `.env`. |
72
77
  | `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
73
78
  | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
@@ -264,6 +269,83 @@ a toast; codes: `disabled`, `not_configured`, `discovery_failed`,
264
269
 
265
270
  ---
266
271
 
272
+ ## Agent SSO (token exchange)
273
+
274
+ `POST /auth/token-exchange` lets an external agent, acting for a user your IdP
275
+ (Microsoft Entra / Google) has **already** authenticated, exchange that IdP's ID
276
+ token for a short-lived MCP bearer — no second login, no static API key.
277
+ RFC 8693-shaped. **Off by default and security-critical**: it trusts a token
278
+ minted elsewhere and hands back access, so read this section before enabling it.
279
+
280
+ Configure it with the `SSO_TOKEN_EXCHANGE_ENABLED` / `MCP_SIGNING_SECRET` /
281
+ `MS_*` / `GOOGLE_*` vars above, or from **Settings → Single sign-on → Agent SSO**
282
+ (superadmin). It reuses `OIDC_ALLOWED_DOMAINS` and `OIDC_AUTO_PROVISION` — the
283
+ same who-may-sign-in policy as browser login.
284
+
285
+ **Who this is for — and who it is not.** The minted bearer is short-lived by
286
+ design (`MCP_TOKEN_TTL_SECONDS`, default 300s, hard ceiling 3600s), and there is
287
+ no refresh token: renewal means calling `POST /auth/token-exchange` again with
288
+ the IdP token. So this fits a **programmatic** client — an agent runtime, a
289
+ backend service, a desktop app that already did an MSAL/Google sign-in — that
290
+ can re-exchange in code before a session or on a `401`, with no human involved.
291
+
292
+ It does **not** fit a client whose credential is typed once into a config file
293
+ (Claude Desktop, Cursor, any `mcpServers` JSON block): nothing here rewrites that
294
+ file, and the ceiling is an hour, so the connection would break repeatedly. Those
295
+ clients keep using a long-lived API key (`cnst_…`) — this endpoint is additive
296
+ and changes nothing about API-key or browser sign-in.
297
+
298
+ **What is verified** on the inbound `subject_token`, before anything is minted:
299
+
300
+ - **signature** against the provider's JWKS, algorithm pinned to `RS256`
301
+ - **issuer** — exact per provider (the tenant-scoped Entra issuer, or Google's)
302
+ - **audience** — your configured client id, so a token minted for *another*
303
+ application cannot be replayed here
304
+ - **expiry** (30s clock tolerance)
305
+ - the same `email_verified` + domain-allowlist policy as browser login
306
+ (see [Email-verification trust assumption](#email-verification-trust-assumption))
307
+
308
+ **Trust model / documented tradeoff.** Unlike the browser flow there is no
309
+ session to bind, so the OIDC `nonce` binding is intentionally dropped; trust
310
+ rests on signature + issuer + audience + expiry + `email_verified`. That is the
311
+ standard token-exchange model, and it is why the audience check (and a
312
+ correctly scoped IdP app registration) carries the weight here.
313
+
314
+ **Provisioning fails closed.** JIT-provisioning from a token minted elsewhere is
315
+ only allowed when `OIDC_ALLOWED_DOMAINS` bounds who may self-create. With
316
+ auto-provision on and an empty allowlist, every exchange for an unknown email is
317
+ refused with `no_account`, and the Settings page refuses to save that
318
+ combination — otherwise any account at a public IdP (Google = the whole
319
+ internet) could sign itself up and mint a bearer.
320
+
321
+ **Entra caveat (`preferred_username`).** Entra v2 tokens often omit `email` and
322
+ carry the address in `preferred_username`, which is a directory attribute — in a
323
+ tenant where guests or external identities can set their own mail attribute, a
324
+ token can assert an address that matches an existing local account. Pinning
325
+ `MS_TENANT_ID` to your own directory bounds that to identities your tenant
326
+ admits; treat inviting a guest as granting whatever that email address owns here.
327
+
328
+ **Rate limiting.** The per-IP cap on this endpoint reads the client IP from
329
+ `X-Forwarded-For`, so it is only a real cap behind a proxy that overwrites that
330
+ header. Deploy accordingly — an internet-facing server with a pass-through
331
+ header can be sprayed from a single host.
332
+
333
+ **Scope.** The minted bearer carries `scope: "mcp"`. That is a *route*
334
+ allowlist, not a reduced permission set: it reaches `/index`, `/mcp`, and the
335
+ ticket-eligible `/nests/:id/*` routes (including MCP write tools) with the
336
+ user's full stewardship rights. Keep `MCP_TOKEN_TTL_SECONDS` short.
337
+
338
+ **Revocation.** Turning the master flag off stops *minting* but does not revoke
339
+ bearers already issued — they remain valid until they expire (≤ 1h). Clear
340
+ `MCP_SIGNING_SECRET` to invalidate every outstanding bearer at once.
341
+
342
+ Error codes returned by the endpoint: `disabled` (404), `rate_limited` (429),
343
+ `bad_request`, `provider_disabled`, `provider_misconfigured` (400),
344
+ `invalid_subject_token` (401), `email_not_verified`, `domain_not_allowed`,
345
+ `no_account` (403), `server_error` (500).
346
+
347
+ ---
348
+
267
349
  ## Database backends
268
350
 
269
351
  The governance/auth metadata (users, sessions, nests registry, stewards, reviews,
package/README.md CHANGED
@@ -15,7 +15,7 @@ ContextNest Community Edition is a self-hosted server that lets you:
15
15
  - Export a nest as a portable bundle and re-import it on another self-hosted host
16
16
  - Apply stewardship workflows — draft, pending review, approved
17
17
  - Share nests with collaborators or publish them read-only to the public
18
- - Serve approved context to AI agents via MCP or HTTP — connect one by pasting a generated setup prompt or downloading a ready `.env`
18
+ - Serve approved context to AI agents via MCP or HTTP — connect one by pasting a generated setup prompt or downloading a ready `.env`, or register the nest in the ctx CLI (`ctx vault add <alias> --url <server>/nests/<id>/mcp --bearer-env CONTEXTNEST_API_KEY`, then `ctx query "#tag" --vault <alias>`)
19
19
  - Sync with the PromptOwl hosted platform for multi-user collaboration
20
20
 
21
21
  The server runs locally or on your own infrastructure. Your PromptOwl account handles authentication, entitlement, and governance metadata.
@@ -4,19 +4,19 @@ import {
4
4
  resolveNestWideRoles,
5
5
  resolveStewardsForNode,
6
6
  stewardCoverageForUser
7
- } from "./chunk-3GMGLYTZ.js";
7
+ } from "./chunk-N2DTJFCD.js";
8
8
  import {
9
9
  grantCoversNode,
10
10
  listUserGrants,
11
11
  resolveNodeGrant
12
- } from "./chunk-GSYMJ3A4.js";
12
+ } from "./chunk-XWGEXQU3.js";
13
13
  import {
14
14
  createVersion,
15
15
  getApprovedVersion,
16
16
  getApprovedVersions,
17
17
  getCurrentVersion,
18
18
  setApprovedVersion
19
- } from "./chunk-BXUVIAMP.js";
19
+ } from "./chunk-IGPJ74O4.js";
20
20
  import {
21
21
  buildDocContext,
22
22
  buildTitleMap,
@@ -28,14 +28,15 @@ import {
28
28
  isStewardshipEnabled,
29
29
  nestName,
30
30
  notifyEmailForNest,
31
+ notifyReviewRequested,
32
+ notifyReviewResolved,
31
33
  nowExpr,
32
34
  opContext,
33
- permissionLabel,
34
35
  prettyFolderPath,
35
36
  resolveNestPermission,
36
37
  sendEmailToRecipient,
37
38
  titleForNode
38
- } from "./chunk-ENDHMQPD.js";
39
+ } from "./chunk-6JGQX4GA.js";
39
40
  import {
40
41
  ConflictError,
41
42
  NotFoundError,
@@ -44,175 +45,10 @@ import {
44
45
  import {
45
46
  config,
46
47
  getDb
47
- } from "./chunk-HYSTFMFG.js";
48
+ } from "./chunk-LPPKPEYI.js";
48
49
 
49
50
  // src/governance/review-service.ts
50
- import { v4 as uuid3 } from "uuid";
51
-
52
- // src/governance/notify-service.ts
53
- import { v4 as uuid } from "uuid";
54
- async function listWatchers(nestId, nodeId) {
55
- return await getDb().all(
56
- `SELECT id, node_id, user_email, created_by, created_at
57
- FROM watchers WHERE nest_id = ? AND node_id = ? ORDER BY created_at`,
58
- [nestId, nodeId]
59
- );
60
- }
61
- async function addWatcher(nestId, nodeId, userEmail, createdBy) {
62
- await getDb().run(
63
- `INSERT INTO watchers (id, nest_id, node_id, user_email, created_by, created_at)
64
- VALUES (?, ?, ?, ?, ?, ?)
65
- ON CONFLICT(nest_id, node_id, user_email) DO NOTHING`,
66
- [uuid(), nestId, nodeId, userEmail.toLowerCase(), createdBy, (/* @__PURE__ */ new Date()).toISOString()]
67
- );
68
- }
69
- async function removeWatcher(nestId, nodeId, userEmail) {
70
- const db = getDb();
71
- const row = await db.get(
72
- "SELECT id FROM watchers WHERE nest_id = ? AND node_id = ? AND LOWER(user_email) = LOWER(?)",
73
- [nestId, nodeId, userEmail]
74
- );
75
- if (!row) return false;
76
- await db.run("DELETE FROM watchers WHERE id = ?", [row.id]);
77
- return true;
78
- }
79
- async function notifyReviewRequested(params) {
80
- const { nestId, nodeId, requestedBy, reviewId } = params;
81
- const db = getDb();
82
- try {
83
- const rows = await db.all(
84
- `SELECT DISTINCT w.user_email, w.node_id AS via
85
- FROM watchers w
86
- WHERE w.nest_id = ?
87
- AND (w.node_id = ?
88
- OR w.node_id IN (
89
- SELECT e.from_node FROM edges e
90
- JOIN edge_types t ON t.id = e.type_id
91
- WHERE e.nest_id = ? AND e.to_node = ? AND t.is_flow = 1
92
- ))`,
93
- [nestId, nodeId, nestId, nodeId]
94
- );
95
- const now = (/* @__PURE__ */ new Date()).toISOString();
96
- const label = await describeNode(nestId, nodeId);
97
- let created = 0;
98
- for (const r of rows) {
99
- if (r.user_email.toLowerCase() === requestedBy.toLowerCase()) continue;
100
- await db.run(
101
- `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
102
- VALUES (?, ?, ?, 'review_requested', ?, ?, ?)`,
103
- [
104
- uuid(),
105
- nestId,
106
- r.user_email.toLowerCase(),
107
- reviewId,
108
- r.via === nodeId ? `"${label}" has a new version awaiting review (submitted by ${requestedBy})` : `Agent "${await describeNode(nestId, r.via)}" produced output on "${label}" \u2014 awaiting review`,
109
- now
110
- ]
111
- );
112
- created++;
113
- }
114
- return created;
115
- } catch (err) {
116
- console.error("[notify] review fan-out failed", nestId, nodeId, err);
117
- return 0;
118
- }
119
- }
120
- async function notifyReviewResolved(params) {
121
- const { nestId, nodeId, status, resolvedBy, requestedBy, reviewId } = params;
122
- const db = getDb();
123
- try {
124
- const rows = await db.all(
125
- `SELECT DISTINCT w.user_email
126
- FROM watchers w
127
- WHERE w.nest_id = ?
128
- AND (w.node_id = ?
129
- OR w.node_id IN (
130
- SELECT e.from_node FROM edges e
131
- JOIN edge_types t ON t.id = e.type_id
132
- WHERE e.nest_id = ? AND e.to_node = ? AND t.is_flow = 1
133
- ))`,
134
- [nestId, nodeId, nestId, nodeId]
135
- );
136
- const recipients = new Set(rows.map((r) => r.user_email.toLowerCase()));
137
- recipients.add(requestedBy.toLowerCase());
138
- recipients.delete(resolvedBy.toLowerCase());
139
- const now = (/* @__PURE__ */ new Date()).toISOString();
140
- const label = await describeNode(nestId, nodeId);
141
- let created = 0;
142
- for (const email of recipients) {
143
- await db.run(
144
- `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
145
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
146
- [
147
- uuid(),
148
- nestId,
149
- email,
150
- `review_${status}`,
151
- reviewId,
152
- `"${label}" was ${status} by ${resolvedBy}`,
153
- now
154
- ]
155
- );
156
- created++;
157
- }
158
- return created;
159
- } catch (err) {
160
- console.error("[notify] resolution fan-out failed", nestId, nodeId, err);
161
- return 0;
162
- }
163
- }
164
- async function notifyNestInvite(params) {
165
- const { nestId, inviteeEmail, permission, nestName: nestName2 } = params;
166
- try {
167
- await getDb().run(
168
- `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
169
- VALUES (?, ?, ?, 'nest_invite', ?, ?, ?)`,
170
- [
171
- uuid(),
172
- nestId,
173
- inviteeEmail.toLowerCase(),
174
- nestId,
175
- `You were added to "${nestName2}" as ${permissionLabel(permission)}`,
176
- (/* @__PURE__ */ new Date()).toISOString()
177
- ]
178
- );
179
- } catch (err) {
180
- console.error("[notify] invite inbox insert failed", nestId, inviteeEmail, err);
181
- }
182
- }
183
- async function listNotifications(userEmail, opts = {}) {
184
- const limit = Math.min(opts.limit ?? 50, 200);
185
- return await getDb().all(
186
- `SELECT n.id, n.nest_id, n.kind, n.subject_id, n.message, n.created_at, n.read_at,
187
- COALESCE(
188
- rr.node_id,
189
- CASE WHEN dr.target_type = 'document' THEN dr.node_id END
190
- ) AS node_id
191
- FROM notifications n
192
- LEFT JOIN review_requests rr ON rr.id = n.subject_id
193
- LEFT JOIN deletion_requests dr ON dr.id = n.subject_id
194
- WHERE LOWER(n.user_email) = LOWER(?)${opts.unreadOnly ? " AND n.read_at IS NULL" : ""}
195
- ORDER BY n.created_at DESC LIMIT ${limit}`,
196
- [userEmail]
197
- );
198
- }
199
- async function markNotificationsRead(userEmail, ids) {
200
- const db = getDb();
201
- const now = (/* @__PURE__ */ new Date()).toISOString();
202
- if (ids === "all") {
203
- await db.run(
204
- "UPDATE notifications SET read_at = ? WHERE LOWER(user_email) = LOWER(?) AND read_at IS NULL",
205
- [now, userEmail]
206
- );
207
- return;
208
- }
209
- for (const id of ids.slice(0, 200)) {
210
- await db.run(
211
- "UPDATE notifications SET read_at = ? WHERE id = ? AND LOWER(user_email) = LOWER(?)",
212
- [now, id, userEmail]
213
- );
214
- }
215
- }
51
+ import { v4 as uuid2 } from "uuid";
216
52
 
217
53
  // src/workflow/env-routes.ts
218
54
  import { Hono as Hono2 } from "hono";
@@ -270,7 +106,7 @@ async function filterAccessible(nestId, userId, userEmail, nodes, approvedVersio
270
106
 
271
107
  // src/workflow/edge-type-routes.ts
272
108
  import { Hono } from "hono";
273
- import { v4 as uuid2 } from "uuid";
109
+ import { v4 as uuid } from "uuid";
274
110
 
275
111
  // src/shared/json.ts
276
112
  var safeJson = (s) => {
@@ -283,6 +119,20 @@ var safeJson = (s) => {
283
119
  };
284
120
 
285
121
  // src/workflow/flow-graph.ts
122
+ async function nodeTrail(db, nestId, ids) {
123
+ const unique = [...new Set(ids)];
124
+ const titles = /* @__PURE__ */ new Map();
125
+ try {
126
+ const rows = await db.all(
127
+ `SELECT node_id, title FROM node_index
128
+ WHERE nest_id = ? AND node_id IN (${unique.map(() => "?").join(",")})`,
129
+ [nestId, ...unique]
130
+ );
131
+ for (const r of rows) if (r.title) titles.set(r.node_id, r.title);
132
+ } catch {
133
+ }
134
+ return ids.map((id) => `"${titles.get(id) ?? id}"`).join(" \u2192 ");
135
+ }
286
136
  async function assertFlowGraphAcyclic(nestId, extraFlowTypeId) {
287
137
  const db = getDb();
288
138
  const rows = await db.all(
@@ -297,7 +147,7 @@ async function assertFlowGraphAcyclic(nestId, extraFlowTypeId) {
297
147
  for (const r of rows) {
298
148
  if (r.from_node === r.to_node) {
299
149
  throw new ConflictError(
300
- `cycle: ["${r.from_node}"] \u2014 a flow edge cannot be a self-loop`
150
+ `cycle: ${await nodeTrail(db, nestId, [r.from_node])} cannot flow into itself`
301
151
  );
302
152
  }
303
153
  nodes.add(r.from_node);
@@ -363,7 +213,7 @@ async function seedDefaultEdgeTypes(nestId) {
363
213
  (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
364
214
  VALUES (?, ?, ?, ?, 'directed', ?, NULL, ?, ?, ?, ?)
365
215
  ON CONFLICT DO NOTHING`,
366
- [uuid2(), nestId, name, description, isFlow, color, createdBy, now, now]
216
+ [uuid(), nestId, name, description, isFlow, color, createdBy, now, now]
367
217
  );
368
218
  }
369
219
  }
@@ -451,7 +301,7 @@ edgeTypeRoutes.post("/", requireWorkflowPlane, async (c) => {
451
301
  `INSERT INTO edge_types
452
302
  (id, nest_id, name, description, direction, is_flow, condition_schema, color, created_by, created_at, updated_at)
453
303
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
454
- [uuid2(), nestId, name, body.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
304
+ [uuid(), nestId, name, body.description.trim(), direction, isFlow, conditionSchema, color, definedBy, now, now]
455
305
  );
456
306
  }
457
307
  });
@@ -862,31 +712,60 @@ function withNodeWriteLock(key, fn) {
862
712
  // src/governance/review-service.ts
863
713
  async function submitForReview(params) {
864
714
  const db = getDb();
865
- const existing = await db.get(
866
- "SELECT id FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending'",
867
- [params.nestId, params.nodeId]
868
- );
869
- if (existing) {
870
- throw new Error("A review is already pending for this node");
871
- }
872
- const id = uuid3();
873
- await db.run(
874
- `INSERT INTO review_requests
715
+ const { id, version } = await withNodeWriteLock(
716
+ nodeWriteKey(params.nestId, params.nodeId),
717
+ async () => {
718
+ const existing = await db.get(
719
+ "SELECT id FROM review_requests WHERE nest_id = ? AND node_id = ? AND status = 'pending'",
720
+ [params.nestId, params.nodeId]
721
+ );
722
+ if (existing) {
723
+ throw new Error("A review is already pending for this node");
724
+ }
725
+ const version2 = await getCurrentVersion(params.nestId, params.nodeId) || params.version;
726
+ const id2 = uuid2();
727
+ await db.run(
728
+ `INSERT INTO review_requests
875
729
  (id, nest_id, node_id, version, requested_by, request_note, priority)
876
730
  VALUES (?, ?, ?, ?, ?, ?, ?)`,
877
- [
878
- id,
879
- params.nestId,
880
- params.nodeId,
881
- params.version,
882
- params.requestedBy,
883
- params.note || null,
884
- params.priority || "normal"
885
- ]
886
- );
887
- await db.run(
888
- "UPDATE node_versions SET status = 'pending_review' WHERE nest_id = ? AND node_id = ? AND version = ?",
889
- [params.nestId, params.nodeId, params.version]
731
+ [
732
+ id2,
733
+ params.nestId,
734
+ params.nodeId,
735
+ version2,
736
+ params.requestedBy,
737
+ params.note || null,
738
+ params.priority || "normal"
739
+ ]
740
+ );
741
+ await db.run(
742
+ "UPDATE node_versions SET status = 'pending_review' WHERE nest_id = ? AND node_id = ? AND version = ?",
743
+ [params.nestId, params.nodeId, version2]
744
+ );
745
+ try {
746
+ const { storage, versions: versionManager } = await engineCache.get(
747
+ params.nestId
748
+ );
749
+ const node = await storage.readDocument(params.nodeId);
750
+ const history = await versionManager.getHistory(params.nodeId);
751
+ const alreadySealed = (history?.versions ?? []).some(
752
+ (v) => v.version === (node.frontmatter.version ?? 1)
753
+ );
754
+ if (!alreadySealed) {
755
+ await versionManager.createVersion(node, params.requestedBy, {
756
+ note: params.note || "Submitted for review"
757
+ });
758
+ }
759
+ } catch (err) {
760
+ console.error(
761
+ "VersionManager.createVersion failed (submit for review)",
762
+ params.nestId,
763
+ params.nodeId,
764
+ err
765
+ );
766
+ }
767
+ return { id: id2, version: version2 };
768
+ }
890
769
  );
891
770
  const reviewCtx = await buildDocContext(params.nestId, params.nodeId, params.baseUrl);
892
771
  void dispatchEvent({
@@ -894,18 +773,18 @@ async function submitForReview(params) {
894
773
  nestId: params.nestId,
895
774
  subjectId: params.nodeId,
896
775
  actor: params.requestedBy,
897
- message: `Review requested on *${reviewCtx.label}* (v${params.version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
776
+ message: `Review requested on *${reviewCtx.label}* (v${version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`
898
777
  });
899
778
  void notifyNestEvent(
900
779
  params.nestId,
901
- `:memo: Review requested on *${reviewCtx.label}* (v${params.version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`,
780
+ `:memo: Review requested on *${reviewCtx.label}* (v${version}) by ${params.requestedBy}${params.note ? ` \u2014 "${params.note}"` : ""}`,
902
781
  {
903
782
  status: "pending_review",
904
783
  docTitle: reviewCtx.docTitle,
905
784
  path: reviewCtx.path,
906
785
  link: reviewCtx.link,
907
786
  actor: params.requestedBy,
908
- version: params.version,
787
+ version,
909
788
  note: params.note || null
910
789
  }
911
790
  );
@@ -929,7 +808,7 @@ async function submitForReview(params) {
929
808
  path: reviewCtx.path,
930
809
  link: reviewCtx.link,
931
810
  actor: params.requestedBy,
932
- version: params.version,
811
+ version,
933
812
  note: params.note || null
934
813
  });
935
814
  }
@@ -1168,6 +1047,18 @@ async function cancelReview(params) {
1168
1047
  [params.nestId, params.nodeId]
1169
1048
  );
1170
1049
  if (!pending) return null;
1050
+ if (String(pending.requested_by).toLowerCase() !== params.cancelledBy.toLowerCase()) {
1051
+ const verdict = await canUserApprove(
1052
+ params.nestId,
1053
+ params.nodeId,
1054
+ params.cancelledBy
1055
+ );
1056
+ if (!verdict.allowed) {
1057
+ throw new Error(
1058
+ "Only the submitter or a reviewer can withdraw this review"
1059
+ );
1060
+ }
1061
+ }
1171
1062
  await db.run(
1172
1063
  `UPDATE review_requests
1173
1064
  SET status = 'cancelled', resolved_by = ?, resolved_at = ${nowExpr(db)}
@@ -1258,9 +1149,12 @@ async function getPendingReview(nestId, nodeId) {
1258
1149
  );
1259
1150
  return row ? rowToReviewRequest(row) : null;
1260
1151
  }
1152
+ function isNestTarget(t) {
1153
+ return t === "nest" || t === "nest_archive";
1154
+ }
1261
1155
  async function deletionTargetTitle(request, titles) {
1262
1156
  if (request.targetType === "folder") return prettyFolderPath(request.nodeId);
1263
- if (request.targetType === "nest") return await nestName(request.nestId);
1157
+ if (isNestTarget(request.targetType)) return await nestName(request.nestId);
1264
1158
  return titles?.get(request.nodeId) ?? await titleForNode(request.nestId, request.nodeId);
1265
1159
  }
1266
1160
  async function nestAdminEmails(nestId) {
@@ -1282,7 +1176,7 @@ async function inboxInsert(nestId, recipients, kind, subjectId, message) {
1282
1176
  await db.run(
1283
1177
  `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
1284
1178
  VALUES (?, ?, ?, ?, ?, ?, ?)`,
1285
- [uuid3(), nestId, email.toLowerCase(), kind, subjectId, message, now]
1179
+ [uuid2(), nestId, email.toLowerCase(), kind, subjectId, message, now]
1286
1180
  );
1287
1181
  } catch (err) {
1288
1182
  console.error("[notify] deletion inbox insert failed", nestId, email, err);
@@ -1297,7 +1191,7 @@ async function deletionTargetContext(request, baseUrl) {
1297
1191
  const name = await nestName(request.nestId);
1298
1192
  const label = request.targetType === "folder" ? prettyFolderPath(request.nodeId) : name;
1299
1193
  return {
1300
- noun: request.targetType,
1194
+ noun: request.targetType === "nest_archive" ? "nest" : request.targetType,
1301
1195
  label,
1302
1196
  docTitle: label,
1303
1197
  path: name,
@@ -1362,7 +1256,8 @@ async function notifyDeletionRequested(request, baseUrl) {
1362
1256
  async function notifyDeletionResolved(params) {
1363
1257
  try {
1364
1258
  const { outcome, resolvedBy, note } = params;
1365
- const verb = outcome === "deleted" ? "deleted" : "rejected the deletion of";
1259
+ const archivedInstead = outcome === "deleted" && params.targetType === "nest_archive";
1260
+ const verb = archivedInstead ? "archived" : outcome === "deleted" ? "deleted" : "rejected the deletion of";
1366
1261
  const line = `${resolvedBy} ${verb} *${params.docTitle}*${note ? ` \u2014 "${note}"` : ""}`;
1367
1262
  const recipient = params.requestedBy.toLowerCase();
1368
1263
  const canInbox = !(params.targetType === "nest" && outcome === "deleted");
@@ -1372,7 +1267,7 @@ async function notifyDeletionResolved(params) {
1372
1267
  [recipient],
1373
1268
  `deletion_${outcome}`,
1374
1269
  params.requestId,
1375
- outcome === "deleted" ? `"${params.docTitle}" was deleted by ${resolvedBy} \u2014 your request was accepted` : `Your deletion request for "${params.docTitle}" was rejected by ${resolvedBy}${note ? ` \u2014 "${note}"` : ""}`
1270
+ outcome === "deleted" ? `"${params.docTitle}" was ${archivedInstead ? "archived" : "deleted"} by ${resolvedBy} \u2014 your request was accepted` : `Your deletion request for "${params.docTitle}" was rejected by ${resolvedBy}${note ? ` \u2014 "${note}"` : ""}`
1376
1271
  );
1377
1272
  }
1378
1273
  void dispatchEvent({
@@ -1392,7 +1287,10 @@ async function notifyDeletionResolved(params) {
1392
1287
  if (recipient !== resolvedBy.toLowerCase()) {
1393
1288
  const link = params.baseUrl ? `${params.baseUrl}/?nest=${encodeURIComponent(params.nestId)}` : "";
1394
1289
  void sendEmailToRecipient(recipient, {
1395
- status: outcome === "deleted" ? "deletion_deleted" : "deletion_declined",
1290
+ // The email has to agree with the inbox row and the channel line above:
1291
+ // an honoured archive is not a deletion, and the deletion template says
1292
+ // the nest is gone.
1293
+ status: archivedInstead ? "deletion_archived" : outcome === "deleted" ? "deletion_deleted" : "deletion_declined",
1396
1294
  docTitle: params.docTitle,
1397
1295
  path: params.nestId,
1398
1296
  link: outcome === "deleted" ? link : void 0,
@@ -1413,14 +1311,15 @@ async function requestDeletion(params) {
1413
1311
  const db = getDb();
1414
1312
  const targetType = params.targetType || "document";
1415
1313
  const noun = targetType === "document" ? "node" : targetType === "folder" ? "folder" : "nest";
1314
+ const kind = targetType === "nest_archive" ? "An archive request" : "A deletion request";
1416
1315
  const existing = await db.get(
1417
1316
  "SELECT id FROM deletion_requests WHERE nest_id = ? AND node_id = ? AND target_type = ? AND status = 'pending'",
1418
1317
  [params.nestId, params.nodeId, targetType]
1419
1318
  );
1420
1319
  if (existing) {
1421
- throw new ConflictError(`A deletion request is already pending for this ${noun}`);
1320
+ throw new ConflictError(`${kind} is already pending for this ${noun}`);
1422
1321
  }
1423
- const id = uuid3();
1322
+ const id = uuid2();
1424
1323
  try {
1425
1324
  await db.run(
1426
1325
  `INSERT INTO deletion_requests (id, nest_id, node_id, target_type, requested_by, reason)
@@ -1441,7 +1340,7 @@ async function requestDeletion(params) {
1441
1340
  params.nodeId,
1442
1341
  err
1443
1342
  );
1444
- throw new ConflictError(`A deletion request is already pending for this ${noun}`);
1343
+ throw new ConflictError(`${kind} is already pending for this ${noun}`);
1445
1344
  }
1446
1345
  const request = await getDeletionRequest(id);
1447
1346
  await notifyDeletionRequested(request, params.baseUrl);
@@ -1584,17 +1483,12 @@ function rowToReviewRequest(row) {
1584
1483
  }
1585
1484
 
1586
1485
  export {
1587
- listWatchers,
1588
- addWatcher,
1589
- removeWatcher,
1590
- notifyNestInvite,
1591
- listNotifications,
1592
- markNotificationsRead,
1593
1486
  resolveCallerEmail,
1594
1487
  canReadNode,
1595
1488
  canRequestDeletion,
1596
1489
  filterAccessible,
1597
1490
  safeJson,
1491
+ nodeTrail,
1598
1492
  requireWorkflowPlane,
1599
1493
  seedDefaultEdgeTypes,
1600
1494
  parseConditionSchema,
@@ -1616,6 +1510,7 @@ export {
1616
1510
  getReviewQueue,
1617
1511
  getReviewHistory,
1618
1512
  getPendingReview,
1513
+ isNestTarget,
1619
1514
  deletionTargetTitle,
1620
1515
  notifyDeletionResolved,
1621
1516
  requestDeletion,