@promptowl/contextnest-community 1.19.0 → 1.20.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 (82) hide show
  1. package/CONFIGURATION.md +83 -1
  2. package/dist/{chunk-GSYMJ3A4.js → chunk-3LUUJUWS.js} +1 -1
  3. package/dist/{chunk-ENDHMQPD.js → chunk-7CPP6FIU.js} +251 -39
  4. package/dist/{chunk-TNAZJYL5.js → chunk-EY7WOKJR.js} +3 -3
  5. package/dist/{chunk-FBDEESNJ.js → chunk-GVUBZTI3.js} +26 -184
  6. package/dist/{chunk-BXUVIAMP.js → chunk-KHMSDTJJ.js} +2 -2
  7. package/dist/{chunk-3GMGLYTZ.js → chunk-O5DYL7ZG.js} +2 -2
  8. package/dist/{chunk-HYSTFMFG.js → chunk-XCFKS65Y.js} +107 -1
  9. package/dist/{client-SW5I6ZBK.js → client-7Z2IVCPW.js} +1 -1
  10. package/dist/{engine-V4DY4PYN.js → engine-VZTSDUPB.js} +2 -2
  11. package/dist/{external-edit-service-3NK5DGLA.js → external-edit-service-3FVK3OD7.js} +3 -3
  12. package/dist/{grants-service-CADI6LIB.js → grants-service-WC27TRY3.js} +2 -2
  13. package/dist/index.js +2203 -1056
  14. package/dist/{migrations.postgres-5VI4RDTS.js → migrations.postgres-L42OXR7K.js} +47 -1
  15. package/dist/{review-service-LK4M2C43.js → review-service-TJRHRMMZ.js} +6 -6
  16. package/dist/{stewardship-service-JAFSU5GN.js → stewardship-service-HRVD7SIS.js} +3 -3
  17. package/dist/{version-service-G7SFFKFO.js → version-service-5PIFT2OY.js} +3 -3
  18. package/dist/web3/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  19. package/dist/web3/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  20. package/dist/web3/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  21. package/dist/web3/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  22. package/dist/web3/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  23. package/dist/web3/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  24. package/dist/web3/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  25. package/dist/web3/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  26. package/dist/web3/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  27. package/dist/web3/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  28. package/dist/web3/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  29. package/dist/web3/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  30. package/dist/web3/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  31. package/dist/web3/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  32. package/dist/web3/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  33. package/dist/web3/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  34. package/dist/web3/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  35. package/dist/web3/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  36. package/dist/web3/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  37. package/dist/web3/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  38. package/dist/web3/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  39. package/dist/web3/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  40. package/dist/web3/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  41. package/dist/web3/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  42. package/dist/web3/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  43. package/dist/web3/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  44. package/dist/web3/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  45. package/dist/web3/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  46. package/dist/web3/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  47. package/dist/web3/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  48. package/dist/web3/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  49. package/dist/web3/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  50. package/dist/web3/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  51. package/dist/web3/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  52. package/dist/web3/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  53. package/dist/web3/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  54. package/dist/web3/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  55. package/dist/web3/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  56. package/dist/web3/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  57. package/dist/web3/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  58. package/dist/web3/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  59. package/dist/web3/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  60. package/dist/web3/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  61. package/dist/web3/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  62. package/dist/web3/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  63. package/dist/web3/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  64. package/dist/web3/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  65. package/dist/web3/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  66. package/dist/web3/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  67. package/dist/web3/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  68. package/dist/web3/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  69. package/dist/web3/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  70. package/dist/web3/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  71. package/dist/web3/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  72. package/dist/web3/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  73. package/dist/web3/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  74. package/dist/web3/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  75. package/dist/web3/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  76. package/dist/web3/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  77. package/dist/web3/assets/index-D4UG3T04.js +1365 -0
  78. package/dist/web3/assets/index-Dw1UTDIk.css +1 -0
  79. package/dist/web3/index.html +2 -2
  80. package/package.json +6 -1
  81. package/dist/web3/assets/index-C26nM5Kn.css +0 -1
  82. 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,
@@ -3,7 +3,7 @@ import {
3
3
  } from "./chunk-YVMSM7LS.js";
4
4
  import {
5
5
  getDb
6
- } from "./chunk-HYSTFMFG.js";
6
+ } from "./chunk-XCFKS65Y.js";
7
7
 
8
8
  // src/governance/grants-service.ts
9
9
  import { v4 as uuid } from "uuid";
@@ -8,7 +8,7 @@ import {
8
8
  config,
9
9
  getDb,
10
10
  isEmailish
11
- } from "./chunk-HYSTFMFG.js";
11
+ } from "./chunk-XCFKS65Y.js";
12
12
  import {
13
13
  ANON_USER_ID
14
14
  } from "./chunk-YB3LKF7U.js";
@@ -27,7 +27,7 @@ import { createEngineApi } from "@promptowl/contextnest-engine/api";
27
27
 
28
28
  // src/nests/service.ts
29
29
  import { mkdirSync } from "fs";
30
- import { v4 as uuid2 } from "uuid";
30
+ import { v4 as uuid3 } from "uuid";
31
31
  import { NestStorage } from "@promptowl/contextnest-engine";
32
32
 
33
33
  // src/shared/paths.ts
@@ -590,7 +590,7 @@ function parseAccessYaml(content) {
590
590
  }
591
591
 
592
592
  // src/governance/teams-service.ts
593
- import { v4 as uuid } from "uuid";
593
+ import { v4 as uuid2 } from "uuid";
594
594
 
595
595
  // src/governance/roles.ts
596
596
  function collabPermToRole(permission) {
@@ -997,6 +997,9 @@ async function notifyEmailForNest(nestId, message, details) {
997
997
  }
998
998
  }
999
999
  }
1000
+ function isEmailConfigured() {
1001
+ return !!config.SMTP_URL && !!config.NOTIFY_EMAIL_FROM;
1002
+ }
1000
1003
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1001
1004
  function recipientEnvelope(to, from) {
1002
1005
  const recipients = Array.isArray(to) ? to : [to];
@@ -1118,6 +1121,172 @@ async function buildDocContext(nestId, nodeId, baseUrl) {
1118
1121
  };
1119
1122
  }
1120
1123
 
1124
+ // src/governance/notify-service.ts
1125
+ import { v4 as uuid } from "uuid";
1126
+ async function listWatchers(nestId, nodeId) {
1127
+ return await getDb().all(
1128
+ `SELECT id, node_id, user_email, created_by, created_at
1129
+ FROM watchers WHERE nest_id = ? AND node_id = ? ORDER BY created_at`,
1130
+ [nestId, nodeId]
1131
+ );
1132
+ }
1133
+ async function addWatcher(nestId, nodeId, userEmail, createdBy) {
1134
+ await getDb().run(
1135
+ `INSERT INTO watchers (id, nest_id, node_id, user_email, created_by, created_at)
1136
+ VALUES (?, ?, ?, ?, ?, ?)
1137
+ ON CONFLICT(nest_id, node_id, user_email) DO NOTHING`,
1138
+ [uuid(), nestId, nodeId, userEmail.toLowerCase(), createdBy, (/* @__PURE__ */ new Date()).toISOString()]
1139
+ );
1140
+ }
1141
+ async function removeWatcher(nestId, nodeId, userEmail) {
1142
+ const db = getDb();
1143
+ const row = await db.get(
1144
+ "SELECT id FROM watchers WHERE nest_id = ? AND node_id = ? AND LOWER(user_email) = LOWER(?)",
1145
+ [nestId, nodeId, userEmail]
1146
+ );
1147
+ if (!row) return false;
1148
+ await db.run("DELETE FROM watchers WHERE id = ?", [row.id]);
1149
+ return true;
1150
+ }
1151
+ async function notifyReviewRequested(params) {
1152
+ const { nestId, nodeId, requestedBy, reviewId } = params;
1153
+ const db = getDb();
1154
+ try {
1155
+ const rows = await db.all(
1156
+ `SELECT DISTINCT w.user_email, w.node_id AS via
1157
+ FROM watchers w
1158
+ WHERE w.nest_id = ?
1159
+ AND (w.node_id = ?
1160
+ OR w.node_id IN (
1161
+ SELECT e.from_node FROM edges e
1162
+ JOIN edge_types t ON t.id = e.type_id
1163
+ WHERE e.nest_id = ? AND e.to_node = ? AND t.is_flow = 1
1164
+ ))`,
1165
+ [nestId, nodeId, nestId, nodeId]
1166
+ );
1167
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1168
+ const label = await describeNode(nestId, nodeId);
1169
+ let created = 0;
1170
+ for (const r of rows) {
1171
+ if (r.user_email.toLowerCase() === requestedBy.toLowerCase()) continue;
1172
+ await db.run(
1173
+ `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
1174
+ VALUES (?, ?, ?, 'review_requested', ?, ?, ?)`,
1175
+ [
1176
+ uuid(),
1177
+ nestId,
1178
+ r.user_email.toLowerCase(),
1179
+ reviewId,
1180
+ 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`,
1181
+ now
1182
+ ]
1183
+ );
1184
+ created++;
1185
+ }
1186
+ return created;
1187
+ } catch (err) {
1188
+ console.error("[notify] review fan-out failed", nestId, nodeId, err);
1189
+ return 0;
1190
+ }
1191
+ }
1192
+ async function notifyReviewResolved(params) {
1193
+ const { nestId, nodeId, status, resolvedBy, requestedBy, reviewId } = params;
1194
+ const db = getDb();
1195
+ try {
1196
+ const rows = await db.all(
1197
+ `SELECT DISTINCT w.user_email
1198
+ FROM watchers w
1199
+ WHERE w.nest_id = ?
1200
+ AND (w.node_id = ?
1201
+ OR w.node_id IN (
1202
+ SELECT e.from_node FROM edges e
1203
+ JOIN edge_types t ON t.id = e.type_id
1204
+ WHERE e.nest_id = ? AND e.to_node = ? AND t.is_flow = 1
1205
+ ))`,
1206
+ [nestId, nodeId, nestId, nodeId]
1207
+ );
1208
+ const recipients = new Set(rows.map((r) => r.user_email.toLowerCase()));
1209
+ recipients.add(requestedBy.toLowerCase());
1210
+ recipients.delete(resolvedBy.toLowerCase());
1211
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1212
+ const label = await describeNode(nestId, nodeId);
1213
+ let created = 0;
1214
+ for (const email of recipients) {
1215
+ await db.run(
1216
+ `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
1217
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1218
+ [
1219
+ uuid(),
1220
+ nestId,
1221
+ email,
1222
+ `review_${status}`,
1223
+ reviewId,
1224
+ `"${label}" was ${status} by ${resolvedBy}`,
1225
+ now
1226
+ ]
1227
+ );
1228
+ created++;
1229
+ }
1230
+ return created;
1231
+ } catch (err) {
1232
+ console.error("[notify] resolution fan-out failed", nestId, nodeId, err);
1233
+ return 0;
1234
+ }
1235
+ }
1236
+ async function notifyNestInvite(params) {
1237
+ const { nestId, inviteeEmail, permission, nestName: nestName2, viaTeam } = params;
1238
+ const message = viaTeam ? `Your team "${viaTeam}" was given access to "${nestName2}" as ${permissionLabel(permission)}` : `You were added to "${nestName2}" as ${permissionLabel(permission)}`;
1239
+ try {
1240
+ await getDb().run(
1241
+ `INSERT INTO notifications (id, nest_id, user_email, kind, subject_id, message, created_at)
1242
+ VALUES (?, ?, ?, 'nest_invite', ?, ?, ?)`,
1243
+ [
1244
+ uuid(),
1245
+ nestId,
1246
+ inviteeEmail.toLowerCase(),
1247
+ nestId,
1248
+ message,
1249
+ (/* @__PURE__ */ new Date()).toISOString()
1250
+ ]
1251
+ );
1252
+ } catch (err) {
1253
+ console.error("[notify] invite inbox insert failed", nestId, inviteeEmail, err);
1254
+ }
1255
+ }
1256
+ async function listNotifications(userEmail, opts = {}) {
1257
+ const limit = Math.min(opts.limit ?? 50, 200);
1258
+ return await getDb().all(
1259
+ `SELECT n.id, n.nest_id, n.kind, n.subject_id, n.message, n.created_at, n.read_at,
1260
+ COALESCE(
1261
+ rr.node_id,
1262
+ CASE WHEN dr.target_type = 'document' THEN dr.node_id END
1263
+ ) AS node_id
1264
+ FROM notifications n
1265
+ LEFT JOIN review_requests rr ON rr.id = n.subject_id
1266
+ LEFT JOIN deletion_requests dr ON dr.id = n.subject_id
1267
+ WHERE LOWER(n.user_email) = LOWER(?)${opts.unreadOnly ? " AND n.read_at IS NULL" : ""}
1268
+ ORDER BY n.created_at DESC LIMIT ${limit}`,
1269
+ [userEmail]
1270
+ );
1271
+ }
1272
+ async function markNotificationsRead(userEmail, ids) {
1273
+ const db = getDb();
1274
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1275
+ if (ids === "all") {
1276
+ await db.run(
1277
+ "UPDATE notifications SET read_at = ? WHERE LOWER(user_email) = LOWER(?) AND read_at IS NULL",
1278
+ [now, userEmail]
1279
+ );
1280
+ return;
1281
+ }
1282
+ for (const id of ids.slice(0, 200)) {
1283
+ await db.run(
1284
+ "UPDATE notifications SET read_at = ? WHERE id = ? AND LOWER(user_email) = LOWER(?)",
1285
+ [now, id, userEmail]
1286
+ );
1287
+ }
1288
+ }
1289
+
1121
1290
  // src/governance/teams-service.ts
1122
1291
  var VALID_TEAM_ROLES = ["admin", "editor", "viewer"];
1123
1292
  function parseMembers(json) {
@@ -1196,7 +1365,7 @@ async function createTeam(params) {
1196
1365
  if (!name) throw new ValidationError("Team name is required");
1197
1366
  const db = getDb();
1198
1367
  const now = (/* @__PURE__ */ new Date()).toISOString();
1199
- const id = uuid();
1368
+ const id = uuid2();
1200
1369
  await db.run(
1201
1370
  "INSERT INTO teams (id, name, owner_id, members, source, external_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
1202
1371
  [
@@ -1316,10 +1485,10 @@ async function resolveMemberIdentity(params) {
1316
1485
  };
1317
1486
  }
1318
1487
  const { hashPassword } = await import("./keys-CTOGAG3W.js");
1319
- const userId = uuid();
1488
+ const userId = uuid2();
1320
1489
  await db.run(
1321
1490
  "INSERT INTO users (id, email, name, password_hash, is_invited) VALUES (?, ?, ?, ?, 1)",
1322
- [userId, requested, null, await hashPassword(uuid())]
1491
+ [userId, requested, null, await hashPassword(uuid2())]
1323
1492
  );
1324
1493
  return { userId, email: requested };
1325
1494
  }
@@ -1408,6 +1577,7 @@ async function listNestTeams(nestId) {
1408
1577
  async function shareTeamWithNest(params) {
1409
1578
  const db = getDb();
1410
1579
  let sharedMembers = [];
1580
+ let sharedTeamName = "";
1411
1581
  const refs = await db.transaction(async (tx) => {
1412
1582
  const team = await tx.get(
1413
1583
  "SELECT id, name, owner_id, members FROM teams WHERE id = ?",
@@ -1434,6 +1604,7 @@ async function shareTeamWithNest(params) {
1434
1604
  ]);
1435
1605
  const members = parseMembers(team.members);
1436
1606
  sharedMembers = members;
1607
+ sharedTeamName = team.name;
1437
1608
  const hasGovernanceRole = members.some(
1438
1609
  (m) => m.role === "viewer" || m.role === "editor"
1439
1610
  );
@@ -1468,6 +1639,16 @@ async function shareTeamWithNest(params) {
1468
1639
  permission: role,
1469
1640
  by: caller?.email || void 0
1470
1641
  });
1642
+ const permission = teamRoleToPermission(role) ?? role;
1643
+ for (const email of emails) {
1644
+ await notifyNestInvite({
1645
+ nestId: params.nestId,
1646
+ inviteeEmail: email,
1647
+ permission,
1648
+ nestName: shareNestName,
1649
+ viaTeam: sharedTeamName
1650
+ });
1651
+ }
1471
1652
  }
1472
1653
  } catch (err) {
1473
1654
  console.error("[notify] team-share email fan-out failed", params.nestId, err);
@@ -1696,6 +1877,17 @@ async function isImportedNest(nestId) {
1696
1877
  );
1697
1878
  return !!row?.is_imported;
1698
1879
  }
1880
+ function plainName(name, emptyMessage = "Name cannot be empty") {
1881
+ const typed = (name ?? "").trim();
1882
+ if (!typed) throw new ValidationError(emptyMessage);
1883
+ const stripped = typed.replace(/<[^>]*>/g, "").trim();
1884
+ if (!stripped) {
1885
+ throw new ValidationError(
1886
+ "That's not a valid nest name \u2014 it's only HTML markup. Use plain text."
1887
+ );
1888
+ }
1889
+ return stripped;
1890
+ }
1699
1891
  function toSlug(name) {
1700
1892
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1701
1893
  }
@@ -1730,6 +1922,22 @@ async function setStewardshipEnabled(nestId, enabled) {
1730
1922
  nestId
1731
1923
  ]);
1732
1924
  }
1925
+ async function setReaderMode(nestId, opts) {
1926
+ const db = getDb();
1927
+ const sets = [];
1928
+ const params = [];
1929
+ if (opts.enabled !== void 0) {
1930
+ sets.push("reader_mode = ?");
1931
+ params.push(opts.enabled ? 1 : 0);
1932
+ }
1933
+ if (opts.homeNode !== void 0) {
1934
+ sets.push("reader_home_node = ?");
1935
+ params.push(opts.homeNode ?? null);
1936
+ }
1937
+ if (sets.length === 0) return;
1938
+ params.push(nestId);
1939
+ await db.run(`UPDATE nests SET ${sets.join(", ")} WHERE id = ?`, params);
1940
+ }
1733
1941
  async function nestAllowsSelfApprove(nestId) {
1734
1942
  const db = getDb();
1735
1943
  const row = await db.get(
@@ -1806,8 +2014,7 @@ async function renameNest(nestId, patch) {
1806
2014
  const updates = [];
1807
2015
  const params = [];
1808
2016
  if (patch.name !== void 0) {
1809
- const trimmed = patch.name.trim();
1810
- if (!trimmed) throw new ValidationError("Name cannot be empty");
2017
+ const trimmed = plainName(patch.name);
1811
2018
  const slug = toSlug(trimmed);
1812
2019
  if (!slug) throw new ValidationError("Name must contain at least one alphanumeric character");
1813
2020
  const conflict = await db.get(
@@ -1837,10 +2044,9 @@ async function renameNest(nestId, patch) {
1837
2044
  return await db.get("SELECT * FROM nests WHERE id = ?", [nestId]);
1838
2045
  }
1839
2046
  async function createNest(userId, name, description) {
1840
- const id = uuid2();
2047
+ const id = uuid3();
1841
2048
  const db = getDb();
1842
- const trimmed = name.trim();
1843
- if (!trimmed) throw new ValidationError("Name cannot be empty");
2049
+ const trimmed = plainName(name);
1844
2050
  const slug = toSlug(trimmed);
1845
2051
  if (!slug) {
1846
2052
  throw new ValidationError(
@@ -1869,12 +2075,9 @@ async function createNest(userId, name, description) {
1869
2075
  return await db.get("SELECT * FROM nests WHERE id = ?", [id]);
1870
2076
  }
1871
2077
  async function importNest(userId, name) {
1872
- const nm = (name || "").trim();
1873
- if (!nm) {
1874
- throw new ValidationError("name is required");
1875
- }
2078
+ const nm = plainName(name || "", "name is required");
1876
2079
  const db = getDb();
1877
- const id = uuid2();
2080
+ const id = uuid3();
1878
2081
  const slug = toSlug(nm);
1879
2082
  const visibility = userId === ANON_USER_ID ? "public" : "private";
1880
2083
  mkdirSync(resolveNestPath(id), { recursive: true });
@@ -2068,6 +2271,21 @@ function isSkippedSegment(segment) {
2068
2271
  return segment.startsWith(".") || segment === "node_modules" || segment === "_suggestions";
2069
2272
  }
2070
2273
 
2274
+ // src/nodes/meta-files.ts
2275
+ var META_BASENAMES = /* @__PURE__ */ new Set([
2276
+ "INDEX",
2277
+ "CONTEXT",
2278
+ "CLAUDE",
2279
+ "GEMINI",
2280
+ "AGENTS",
2281
+ "QWEN",
2282
+ "README"
2283
+ ]);
2284
+ function isMetaFile(idOrPath) {
2285
+ const base = idOrPath.split(/[/\\]/).pop() ?? "";
2286
+ return META_BASENAMES.has(base.replace(/\.md$/i, ""));
2287
+ }
2288
+
2071
2289
  // src/shared/batch.ts
2072
2290
  var IO_CONCURRENCY = 32;
2073
2291
  async function mapBatched(items, fn, concurrency = IO_CONCURRENCY) {
@@ -2079,14 +2297,6 @@ async function mapBatched(items, fn, concurrency = IO_CONCURRENCY) {
2079
2297
  }
2080
2298
 
2081
2299
  // src/nodes/flat-storage.ts
2082
- var META_FILES = /* @__PURE__ */ new Set([
2083
- "INDEX.md",
2084
- "CONTEXT.md",
2085
- "CLAUDE.md",
2086
- "GEMINI.md",
2087
- "AGENTS.md",
2088
- "QWEN.md"
2089
- ]);
2090
2300
  var FlatNestStorage = class extends NestStorage2 {
2091
2301
  async discoverDocuments() {
2092
2302
  const root = this.root;
@@ -2100,7 +2310,7 @@ var FlatNestStorage = class extends NestStorage2 {
2100
2310
  const ids = [];
2101
2311
  for (const e of entries) {
2102
2312
  if (!e.isFile() || !e.name.endsWith(".md")) continue;
2103
- if (META_FILES.has(e.name)) continue;
2313
+ if (isMetaFile(e.name)) continue;
2104
2314
  const dir = e.parentPath ?? e.path ?? root;
2105
2315
  const rel = relative(root, join3(dir, e.name)).split(sep).join("/");
2106
2316
  if (rel.split("/").some(isSkippedSegment)) continue;
@@ -2247,8 +2457,8 @@ async function ensureNodeIndex(nestId) {
2247
2457
  return run;
2248
2458
  }
2249
2459
  async function rebuildNodeIndex(nestId) {
2250
- const { engineCache: engineCache2 } = await import("./engine-V4DY4PYN.js");
2251
- const { documentsWithSuggestions } = await import("./external-edit-service-3NK5DGLA.js");
2460
+ const { engineCache: engineCache2 } = await import("./engine-VZTSDUPB.js");
2461
+ const { documentsWithSuggestions } = await import("./external-edit-service-3FVK3OD7.js");
2252
2462
  const { storage, dropDiscoveryCache } = await engineCache2.get(nestId);
2253
2463
  dropDiscoveryCache();
2254
2464
  const startedAt = writeGeneration.get(nestId) ?? 0;
@@ -2345,18 +2555,11 @@ async function indexedFolderCounts(nestId) {
2345
2555
  // src/nodes/engine.ts
2346
2556
  var DISCOVERY_TTL_MS = 3e4;
2347
2557
  var isLive = (node) => node.frontmatter.status !== "rejected";
2348
- var DERIVED_VAULT_FILES = /* @__PURE__ */ new Set([
2349
- "INDEX.md",
2350
- "CONTEXT.md",
2351
- "CLAUDE.md",
2352
- "GEMINI.md",
2353
- "AGENTS.md",
2354
- "README.md"
2355
- ]);
2356
2558
  function withDiscoveryCache(nestId, storage) {
2357
2559
  const entries = /* @__PURE__ */ new Map();
2358
2560
  let inflight = null;
2359
- const discover = storage.discoverDocuments.bind(storage);
2561
+ const rawDiscover = storage.discoverDocuments.bind(storage);
2562
+ const discover = async (options) => (await rawDiscover(options)).filter((d) => !isMetaFile(d.id));
2360
2563
  const readDoc = storage.readDocument.bind(storage);
2361
2564
  const filtersRetired = !(storage instanceof FlatNestStorage);
2362
2565
  let generation = 0;
@@ -2407,6 +2610,7 @@ function withDiscoveryCache(nestId, storage) {
2407
2610
  await markIndexStale(nestId);
2408
2611
  };
2409
2612
  const patch = async (id) => {
2613
+ if (isMetaFile(id)) return;
2410
2614
  generation++;
2411
2615
  inflight = null;
2412
2616
  let node = null;
@@ -2448,8 +2652,7 @@ function withDiscoveryCache(nestId, storage) {
2448
2652
  wrap("init", () => desync());
2449
2653
  wrap("writeVaultFile", (args) => {
2450
2654
  const rel = String(args[0] ?? "");
2451
- const base = rel.split(/[/\\]/).pop() || "";
2452
- if (rel.endsWith(".md") && !DERIVED_VAULT_FILES.has(base)) return desync();
2655
+ if (rel.endsWith(".md") && !isMetaFile(rel)) return desync();
2453
2656
  });
2454
2657
  return { storage, invalidate, desync: () => void desync() };
2455
2658
  }
@@ -2513,9 +2716,9 @@ export {
2513
2716
  canEditWith,
2514
2717
  canApproveWith,
2515
2718
  primaryRole,
2516
- permissionLabel,
2517
2719
  verifySmtp,
2518
2720
  notifyEmailForNest,
2721
+ isEmailConfigured,
2519
2722
  sendEmailToRecipient,
2520
2723
  nestStorageRoot,
2521
2724
  resolveNestPath,
@@ -2525,6 +2728,7 @@ export {
2525
2728
  uniqueNestName,
2526
2729
  isStewardshipEnabled,
2527
2730
  setStewardshipEnabled,
2731
+ setReaderMode,
2528
2732
  nestAllowsSelfApprove,
2529
2733
  setAllowSelfApprove,
2530
2734
  nestReviewsPrimeOnly,
@@ -2563,6 +2767,14 @@ export {
2563
2767
  docLink,
2564
2768
  resolveNodeUrl,
2565
2769
  buildDocContext,
2770
+ listWatchers,
2771
+ addWatcher,
2772
+ removeWatcher,
2773
+ notifyReviewRequested,
2774
+ notifyReviewResolved,
2775
+ notifyNestInvite,
2776
+ listNotifications,
2777
+ markNotificationsRead,
2566
2778
  createTeam,
2567
2779
  findTeamByExternalId,
2568
2780
  listTeamsForUser,
@@ -4,10 +4,10 @@ import {
4
4
  markIndexStale,
5
5
  resolveNestPath,
6
6
  setSuggestionFlag
7
- } from "./chunk-ENDHMQPD.js";
7
+ } from "./chunk-7CPP6FIU.js";
8
8
  import {
9
9
  getDb
10
- } from "./chunk-HYSTFMFG.js";
10
+ } from "./chunk-XCFKS65Y.js";
11
11
 
12
12
  // src/governance/external-edit-service.ts
13
13
  import { readFile, readdir } from "fs/promises";
@@ -253,7 +253,7 @@ async function listExternalEditVerdicts(nestId, documentId) {
253
253
  async function mirrorVersion(input) {
254
254
  const { storage } = await engineCache.get(input.nestId);
255
255
  const node = await storage.readDocument(input.documentId);
256
- const { upsertVersion, setApprovedVersion } = await import("./version-service-G7SFFKFO.js");
256
+ const { upsertVersion, setApprovedVersion } = await import("./version-service-5PIFT2OY.js");
257
257
  await upsertVersion({
258
258
  nestId: input.nestId,
259
259
  nodeId: input.documentId,