@promptowl/contextnest-community 1.25.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 (121) hide show
  1. package/API.md +102 -9
  2. package/CONFIGURATION.md +604 -117
  3. package/README.md +3 -2
  4. package/dist/{chunk-JDAOA5KP.js → chunk-H3W72HN3.js} +67 -14
  5. package/dist/{chunk-2KHM5C7V.js → chunk-MB52DWWP.js} +13 -15
  6. package/dist/{chunk-TI7HMP64.js → chunk-NFFUZF2B.js} +23 -2
  7. package/dist/{chunk-F66VCBLA.js → chunk-O3JEI33N.js} +51 -2
  8. package/dist/{chunk-7FROMOZM.js → chunk-QMQDWRHN.js} +180 -550
  9. package/dist/chunk-RXCVXG34.js +523 -0
  10. package/dist/{chunk-32SZCH36.js → chunk-SE5U4J4T.js} +4 -4
  11. package/dist/{chunk-KPGNZLZL.js → chunk-VHEMGQ3I.js} +1 -1
  12. package/dist/{chunk-DTLMBF4W.js → chunk-VRLAP4HB.js} +42 -17
  13. package/dist/{engine-TER6FDBP.js → engine-HLYQLP5T.js} +3 -2
  14. package/dist/{external-edit-service-CAGENHDJ.js → external-edit-service-3DFQ4EZZ.js} +4 -3
  15. package/dist/{grants-service-2OTFS7EH.js → grants-service-WICV4Y2J.js} +2 -2
  16. package/dist/index.js +1669 -521
  17. package/dist/invited-user-XEBX6BM4.js +12 -0
  18. package/dist/{migrations.postgres-23VJJJJX.js → migrations.postgres-HGSQ7IA5.js} +4 -0
  19. package/dist/{review-service-4G2XUFS6.js → review-service-GB43UQVH.js} +7 -6
  20. package/dist/{stewardship-service-S22V2JKG.js → stewardship-service-KDBPWRZI.js} +4 -3
  21. package/dist/{version-service-X4FAG2OZ.js → version-service-JYHFPSYP.js} +8 -3
  22. package/dist/web3/assets/ActivityTracePage-CkRoyIc-.js +1 -0
  23. package/dist/web3/assets/AgentDocsPage-BTRXmliv.js +1 -0
  24. package/dist/web3/assets/CollaboratorManager-CqJIkvs-.js +1 -0
  25. package/dist/web3/assets/CollaboratorsTab-JcO1P-qm.js +1 -0
  26. package/dist/web3/assets/DocumentEditor-D1GLeeEP.js +36 -0
  27. package/dist/web3/assets/DocumentsTab-BAgSxTX1.js +6 -0
  28. package/dist/web3/assets/ExternalEditsTab-DuuTXZap.js +1 -0
  29. package/dist/web3/assets/{MarkdownEditor-Dfk5uIE5.js → MarkdownEditor-BELFww9q.js} +130 -135
  30. package/dist/web3/assets/MarkdownEditor-ndtIx5x0.css +1 -0
  31. package/dist/web3/assets/NestPageHeader-Bbje2ncY.js +1 -0
  32. package/dist/web3/assets/NestView-D4OMXPQ7.js +73 -0
  33. package/dist/web3/assets/OverviewTab-FIDGjR3Q.js +6 -0
  34. package/dist/web3/assets/PersonCombobox-D52m_jhc.js +1 -0
  35. package/dist/web3/assets/{ReviewActions-BXDTDPp1.js → ReviewActions-DYqBcWlv.js} +2 -2
  36. package/dist/web3/assets/ReviewTab-DoxfygOb.js +1 -0
  37. package/dist/web3/assets/StewardsTab-CFsYjbEn.js +1 -0
  38. package/dist/web3/assets/SubmitForReviewModal-AW2OLNAE.js +1 -0
  39. package/dist/web3/assets/{alert-dialog-4SiLKlz8.js → alert-dialog-p-fBLSWx.js} +2 -2
  40. package/dist/web3/assets/{arrow-left-BAgIocLn.js → arrow-left-CiErCCTG.js} +1 -1
  41. package/dist/web3/assets/backlinks-DaVSYvhV.js +19 -0
  42. package/dist/web3/assets/boxes-VWrCCMzW.js +6 -0
  43. package/dist/web3/assets/{chevron-left-XR4ReJ9Z.js → chevron-left-CsD8gZwL.js} +1 -1
  44. package/dist/web3/assets/{circle-check-CSkZUEFK.js → circle-check-BBCXd9LI.js} +1 -1
  45. package/dist/web3/assets/{circle-x-in2o3aHU.js → circle-x-C9LsnORQ.js} +1 -1
  46. package/dist/web3/assets/{code-xml-pPQgdctI.js → code-xml-BaoV7HiQ.js} +1 -1
  47. package/dist/web3/assets/{corner-down-right-CuxZWscX.js → corner-down-right-DgiTXrtw.js} +1 -1
  48. package/dist/web3/assets/count-skeleton-CnEE4uF-.js +1 -0
  49. package/dist/web3/assets/{earth-CXOcThqn.js → earth-BmMbVt9k.js} +1 -1
  50. package/dist/web3/assets/{file-exclamation-point-CSphs1VD.js → file-exclamation-point-qJeca9-A.js} +1 -1
  51. package/dist/web3/assets/{folder-input-BAwK9tFL.js → folder-input-D7R93LJ6.js} +1 -1
  52. package/dist/web3/assets/index-DNmWTxN7.css +1 -0
  53. package/dist/web3/assets/index-DvuUIUP6.js +451 -0
  54. package/dist/web3/assets/{page-CUjuSs5Q.js → page-BJQuaTRW.js} +3 -3
  55. package/dist/web3/assets/page-Bb8xGOPQ.js +2 -0
  56. package/dist/web3/assets/page-BtM-qDzr.js +6 -0
  57. package/dist/web3/assets/page-Bxd0EG25.js +1 -0
  58. package/dist/web3/assets/page-C3QCO6IR.js +1 -0
  59. package/dist/web3/assets/page-CfoR0khy.js +6 -0
  60. package/dist/web3/assets/page-D7Zr5tAI.js +1 -0
  61. package/dist/web3/assets/page-DATSlHCW.js +6 -0
  62. package/dist/web3/assets/page-DTjPQizZ.js +45 -0
  63. package/dist/web3/assets/page-DspptlWr.js +1 -0
  64. package/dist/web3/assets/{page-xzYqkBUP.js → page-I6vAFJOj.js} +5 -5
  65. package/dist/web3/assets/page-QxI4Ud2s.js +9 -0
  66. package/dist/web3/assets/page-YcNNg-dX.js +1 -0
  67. package/dist/web3/assets/page-kM7eO5Kj.js +1 -0
  68. package/dist/web3/assets/{page-title-BkqlUA0j.js → page-title-Dn5jANiU.js} +1 -1
  69. package/dist/web3/assets/{play-B8xE9j5f.js → play-B2k_Ix-L.js} +1 -1
  70. package/dist/web3/assets/{send-CvAgOUPG.js → send-DPNxv3As.js} +1 -1
  71. package/dist/web3/assets/{settings-EY4A_DYv.js → settings-DZjq-OQA.js} +1 -1
  72. package/dist/web3/assets/{share-2-vGmZUl90.js → share-2-C-OnE2ru.js} +1 -1
  73. package/dist/web3/assets/{tag-Br_y1f00.js → tag-CwJl7FdI.js} +1 -1
  74. package/dist/web3/assets/{trash-2-B4hRXo-p.js → trash-2-aljmIff4.js} +1 -1
  75. package/dist/web3/assets/{user-plus-4CTYEkd3.js → user-plus-DPerZexX.js} +1 -1
  76. package/dist/web3/assets/{zap-BcsKR0ef.js → zap-CHZtiN7P.js} +1 -1
  77. package/dist/web3/index.html +2 -2
  78. package/package.json +8 -3
  79. package/dist/client-D44ZHDTJ.js +0 -11
  80. package/dist/keys-CTOGAG3W.js +0 -20
  81. package/dist/web3/assets/ActivityTracePage-D2iIbEph.js +0 -1
  82. package/dist/web3/assets/AgentDocsPage-Dkhgb1hk.js +0 -1
  83. package/dist/web3/assets/CollaboratorManager-Bo81D--Q.js +0 -1
  84. package/dist/web3/assets/CollaboratorsTab-DRCitQuX.js +0 -1
  85. package/dist/web3/assets/DocumentEditor-DV2WHYbj.js +0 -36
  86. package/dist/web3/assets/DocumentsTab-BUxBCN8o.js +0 -6
  87. package/dist/web3/assets/ExternalEditsTab-kIBimZyE.js +0 -1
  88. package/dist/web3/assets/MarkdownEditor-CsVusP2d.css +0 -1
  89. package/dist/web3/assets/NestPageHeader-yOK-OIYZ.js +0 -1
  90. package/dist/web3/assets/NestView-DwIagxwJ.js +0 -68
  91. package/dist/web3/assets/OverviewTab-DFOFMVyE.js +0 -1
  92. package/dist/web3/assets/PersonCombobox-D350WlrJ.js +0 -1
  93. package/dist/web3/assets/ReasonDialog-CN3ECAnQ.js +0 -1
  94. package/dist/web3/assets/ReviewTab-CZewAYiz.js +0 -1
  95. package/dist/web3/assets/StewardsTab-AagNWcWS.js +0 -1
  96. package/dist/web3/assets/SubmitForReviewModal-BOZJxwRN.js +0 -1
  97. package/dist/web3/assets/backlinks-Cb8Uf8mw.js +0 -24
  98. package/dist/web3/assets/card-UDyMRcps.js +0 -1
  99. package/dist/web3/assets/count-skeleton-DBWCDhRy.js +0 -1
  100. package/dist/web3/assets/dates-BCxbm4_q.js +0 -1
  101. package/dist/web3/assets/index-A0_ymcqL.js +0 -29
  102. package/dist/web3/assets/index-B5hLclEq.js +0 -389
  103. package/dist/web3/assets/index-DrUAtoQM.css +0 -1
  104. package/dist/web3/assets/page-BHD4beun.js +0 -1
  105. package/dist/web3/assets/page-BSYUXYmO.js +0 -11
  106. package/dist/web3/assets/page-BYc2DIux.js +0 -45
  107. package/dist/web3/assets/page-BfibLg8e.js +0 -1
  108. package/dist/web3/assets/page-CGe8MtUu.js +0 -9
  109. package/dist/web3/assets/page-CIgPJmG6.js +0 -2
  110. package/dist/web3/assets/page-Ck31nx9b.js +0 -1
  111. package/dist/web3/assets/page-Cpdj_11Y.js +0 -1
  112. package/dist/web3/assets/page-D0sN3GO6.js +0 -1
  113. package/dist/web3/assets/page-DEpH91X2.js +0 -1
  114. package/dist/web3/assets/page-l8-B7CGt.js +0 -1
  115. package/dist/web3/assets/page-mnSHmk6A.js +0 -6
  116. package/dist/web3/assets/refresh-cw-CsCtmWwX.js +0 -6
  117. package/dist/web3/assets/scroll-area-CbcH6yrZ.js +0 -1
  118. package/dist/web3/assets/scroll-area-dRWncRqa.css +0 -1
  119. package/dist/web3/assets/select-DrL0mpRN.js +0 -6
  120. package/dist/web3/assets/triangle-alert-CsYGyfGZ.js +0 -6
  121. package/dist/web3/assets/x-qxt1WrUi.js +0 -6
package/CONFIGURATION.md CHANGED
@@ -11,7 +11,7 @@ The server supports two authentication modes. You pick one per instance with `AU
11
11
  Every request requires an `Authorization: Bearer cnst_...` header. Use this for any multi-user or internet-facing deployment.
12
12
 
13
13
  - Users register via `POST /auth/register` (email + password) or log in with PromptOwl via `POST /auth/device` → `POST /auth/promptowl`.
14
- - API keys are per-user (`cnst_<64-hex>`) and can be scoped to a single nest for service-account use.
14
+ - API keys are per-user (`cnst_<64-hex>`) and can be scoped to a single nest for service-account use. A nest-scoped key reaches that nest (its REST routes and `/nests/<id>/mcp`), the server `/mcp` and `/index` filtered to it, and `GET /nests` / `/search` / `/stats` narrowed to it — and nothing else: other nests, `/auth/keys*` (so it can't mint itself a wider key), `/admin/*`, teammates, teams, people and `/me/*` answer `403` with `code: "nest_scoped_key"`, even when the key's owner is a server admin. Mint one on the API keys page (scope picker) or from a nest's Connect dialog ("Create a key for this nest only"). See `API.md → Nest-scoped API keys`.
15
15
  - Rate-limited login / register / device-auth endpoints (sliding window, per IP + per email).
16
16
  - First PromptOwl-authenticated user becomes the server admin (atomic claim). Admin can invite teammates at `POST /auth/invite`.
17
17
 
@@ -58,18 +58,19 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
58
58
  | `PROMPTOWL_API_URL` | `https://app.promptowl.ai` | PromptOwl's API origin — used for device auth, license validation, telemetry. Override for air-gapped or test setups. |
59
59
  | `PROMPTOWL_KEY` | `""` | Your PromptOwl Community License key (`pk_...`). Unlicensed instances still run and serve reads, but every write returns `503` until a valid key is installed. Can also be set via the browser License Setup Page, which persists it to the database (`server_settings` table) so it survives a rebuild and reaches every instance — see [Runtime settings persistence](#runtime-settings-persistence). Setting it here in the deploy environment takes precedence on the next boot. |
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
- | `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
- | `OFFICIAL_COMMUNITY_SSO_SECRET` | `""` | **Legacy — official deployment only, leave unset on self-hosted.** Shared HMAC secret enabling the one-click "Open Community" SSO auto-login from PromptOwl. Must exactly match the same-named var on PromptOwl. Superseded by the DB-backed **Settings → Community sites** list (`/admin/community-sites`), which supports multiple official sites each with their own name/url/secret/active flag — this env var still works as an implicit extra site for backward compatibility. When no site is configured (env var unset and no DB rows), `GET /auth/sso` returns `404` and the feature is disabled; self-hosted users keep using the manual device-code flow. See `API.md → Community sites`. |
63
- | `PUBLIC_BASE_URL` | `""` | This server's canonical external URL (e.g. `https://community.promptowl.ai`). Two uses. (1) Checked against the SSO ticket's `aud` claim so a ticket minted for this server can't be replayed against another — whenever a ticket-signing secret is set (`OFFICIAL_COMMUNITY_SSO_SECRET` on the official deployment, `MCP_SIGNING_SECRET` on a self-hosted one); with neither set no ticket verifies at all, so the audience check is moot. (2) **Set this whenever `OIDC_ENABLED=true`.** It's the base for the OIDC `redirect_uri` sent to your IdP and replayed at token exchange. Unset, that URI is derived from the incoming request's `Host` header — fine when your proxy overwrites `Host` with a trusted value, but a proxy that passes an attacker-supplied `Host` through would feed it straight into the redirect URI. Setting this pins the value regardless of what the proxy forwards. (Your IdP's own redirect-URI allowlist is a second line of defence, not a substitute.) (3) **Set this wherever email- or invite-gated publish links are used.** The magic link mailed by `POST /p/:slug/gate` is built from it; unset, it falls back to the request origin, so a proxy forwarding an attacker-supplied `Host` would send the recipient a one-time access token pointing at the attacker's domain — the classic reset-link poisoning. (4) **Set this wherever `POST /nests/:id/context` citations reach users.** The `url` on each node and the `_source:` line in each context block are built from it; unset, they fall back to the request origin, so a proxy forwarding an untrusted `Host` would put an attacker-influenced URL in front of both the model and the reader as a trustworthy citation. (5) It is rendered into the copy-paste setup snippets on **Settings → Connecting agents** — a `bash` block and quoted strings in `config.toml` / `config.yaml` / `mcp.json`. Because those are pasted verbatim into other people's terminals, `PATCH /admin/settings` refuses a value that is not a plain `http(s)` URL, or that contains whitespace, quotes or shell characters (`" ' ` \ $ ; | & < > ( ) { }`). A value stored before that check is not rewritten, so re-save it if the panel shows it oddly. |
64
- | `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). |
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 Server settings → General. The server refuses `disabled` while PromptOwl sign-in is also `disabled`, unless SSO (OIDC) is on and fully configured (that would leave no way to log in — see [Making SSO the only way in](#making-sso-the-only-way-in)). Unknown values fall back to `open`. |
62
+ | `OFFICIAL_COMMUNITY_SSO_SECRET` | `""` | **Legacy — official deployment only, leave unset on self-hosted.** Shared HMAC secret enabling the one-click "Open Community" SSO auto-login from PromptOwl. Must exactly match the same-named var on PromptOwl. Superseded by the DB-backed **Server settings → Community sites** list (`/admin/community-sites`), which supports multiple official sites each with their own name/url/secret/active flag — this env var still works as an implicit extra site for backward compatibility. When no site is configured (env var unset and no DB rows), `GET /auth/sso` returns `404` and the feature is disabled; self-hosted users keep using the manual device-code flow. See `API.md → Community sites`. |
63
+ | `PUBLIC_BASE_URL` | `""` | This server's canonical external URL (e.g. `https://community.promptowl.ai`). Two uses. (1) Checked against the SSO ticket's `aud` claim so a ticket minted for this server can't be replayed against another — whenever a ticket-signing secret is set (`OFFICIAL_COMMUNITY_SSO_SECRET` on the official deployment, `MCP_SIGNING_SECRET` on a self-hosted one); with neither set no ticket verifies at all, so the audience check is moot. (2) **Set this whenever `OIDC_ENABLED=true`.** It's the base for the OIDC `redirect_uri` sent to your IdP and replayed at token exchange. Unset, that URI is derived from the incoming request's `Host` header — fine when your proxy overwrites `Host` with a trusted value, but a proxy that passes an attacker-supplied `Host` through would feed it straight into the redirect URI. Setting this pins the value regardless of what the proxy forwards. (Your IdP's own redirect-URI allowlist is a second line of defence, not a substitute.) (3) **Set this wherever email- or invite-gated publish links are used.** The magic link mailed by `POST /p/:slug/gate` is built from it; unset, it falls back to the request origin, so a proxy forwarding an attacker-supplied `Host` would send the recipient a one-time access token pointing at the attacker's domain — the classic reset-link poisoning. (4) **Set this wherever `POST /nests/:id/context` citations reach users.** The `url` on each node and the `_source:` line in each context block are built from it; unset, they fall back to the request origin, so a proxy forwarding an untrusted `Host` would put an attacker-influenced URL in front of both the model and the reader as a trustworthy citation. (5) It is rendered into the copy-paste setup snippets on **Server settings → Connecting agents** — a `bash` block and quoted strings in `config.toml` / `config.yaml` / `mcp.json`. Because those are pasted verbatim into other people's terminals, `PATCH /admin/settings` refuses a value that is not a plain `http(s)` URL, or that contains whitespace, quotes or shell characters (`" ' ` \ $ ; | & < > ( ) { }`). A value stored before that check is not rewritten, so re-save it if the panel shows it oddly. |
64
+ | `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 **Admin → Server settings → Single sign-on**. See the [Single sign-on (OIDC) admin guide](#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. |
67
67
  | `OIDC_CLIENT_SECRET` | `""` | Client secret from your IdP app registration. Write-only on the Settings API — `GET /admin/settings` reports only `oidc_client_secret_set: true/false`, never the value. |
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
- | `PEOPLE_SUGGEST_ALL_USERS` | `false` | Widen the add-a-person suggestions (`GET /people/suggest`, behind every "add a person" field) and the `@mention` pickers from *people the caller already shares a nest or team with* to **every registered account on this server**. **Leave this off on a server that hosts more than one organisation** — the PromptOwl-hosted deployment does, and turning it on there would offer one customer's staff another customer's email addresses. On a single-company self-hosted server the whole directory is the useful answer, which is what this is for. Suggestions never gate the invite either way: an address that appears in no list is still valid to type. In the `@` pickers the widening is an invite: a write+ author mentioning someone off the nest adds them as a `read` collaborator before notifying (a read-only author's mention of an outsider reaches nobody). A server admin always sees every account (they administer them). Also editable from **Settings → General → People directory**. |
71
- | `SHARED_DEPLOYMENT` | `false` | This server hosts **several unrelated organizations** (the PromptOwl-hosted deployment) rather than one company. A nest's `org` visibility means *every authenticated account on this deployment* — on a single-company self-hosted server that is the organization, so the tier flips in one click; on a shared server it is every customer. With this on: `PATCH /nests/:id/visibility` to `org` requires `acknowledge_public: true` exactly as `public` always does (enforced by the server, not only the UI), and the UI's visibility picker confirms before *Organization* and words both tiers to say who they really reach. Reported on `GET /health` as `shared_deployment`. Also editable from **Settings → General → This server hosts several organizations**. **Turning it on guards future changes only** — nests already at `org` stay readable by every account until their admins reconfirm or narrow them. On an already-populated server, audit them first: the dashboard's *Organization* scope lists every one, or `SELECT id, name, user_id FROM nests WHERE visibility = 'org'`. |
72
- | `OIDC_DEPARTMENT_TAGGING` | `false` | Auto-tag newly **created** documents with the creator's directory department: `dept:<slugified-department>` (lowercase, spaces → dashes, e.g. `dept:customer-success`) is appended to the document's tags, deduped against user-supplied tags. Applies on create only — never on update, never retroactively — and a user without a stored department is a silent no-op. The department is captured from the OIDC `department` ID-token claim on every SSO login (a login without the claim clears it, so directory moves propagate), so this is only meaningful when your IdP emits that claim — see [Department auto-tagging](#department-auto-tagging). Also editable from Settings → Single sign-on. |
70
+ | `NEST_CREATION` | `admins` | Who may create or import nests (REST, folder import, MCP `nest_create`). `admins`: server admins only — the default until a server is on a paid plan; everyone else sees "You don't have access to any nests yet. Ask [admin] to share one." instead of a create button. `everyone`: any signed-in user. Open mode is unaffected. Also editable from **Server settings → General → Nest creation**. |
71
+ | `PEOPLE_SUGGEST_ALL_USERS` | `false` | Widen the add-a-person suggestions (`GET /people/suggest`, behind every "add a person" field) and the `@mention` pickers from *people the caller already shares a nest or team with* to **every registered account on this server**. **Leave this off on a server that hosts more than one organisation** — the PromptOwl-hosted deployment does, and turning it on there would offer one customer's staff another customer's email addresses. On a single-company self-hosted server the whole directory is the useful answer, which is what this is for. Suggestions never gate the invite either way: an address that appears in no list is still valid to type. In the `@` pickers the widening is an invite: a write+ author mentioning someone off the nest adds them as a `read` collaborator before notifying (a read-only author's mention of an outsider reaches nobody). A server admin always sees every account (they administer them). Also editable from **Server settings → General → People directory**. |
72
+ | `SHARED_DEPLOYMENT` | `false` | This server hosts **several unrelated organizations** (the PromptOwl-hosted deployment) rather than one company. A nest's `org` visibility means *every authenticated account on this deployment* — on a single-company self-hosted server that is the organization, so the tier flips in one click; on a shared server it is every customer. With this on: `PATCH /nests/:id/visibility` to `org` requires `acknowledge_public: true` exactly as `public` always does (enforced by the server, not only the UI), and the UI's visibility picker confirms before *Organization* and words both tiers to say who they really reach. Reported on `GET /health` as `shared_deployment`. Also editable from **Server settings → General → This server hosts several organizations**. **Turning it on guards future changes only** — nests already at `org` stay readable by every account until their admins reconfirm or narrow them. On an already-populated server, audit them first: the dashboard's *Organization* scope lists every one, or `SELECT id, name, user_id FROM nests WHERE visibility = 'org'`. |
73
+ | `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 Server settings → Single sign-on. |
73
74
  | `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). |
74
75
  | `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. |
75
76
  | `MCP_TOKEN_TTL_SECONDS` | `300` | Lifetime of a minted MCP bearer, clamped to 30–3600. |
@@ -78,9 +79,9 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
78
79
  | `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`. |
79
80
  | `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
80
81
  | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
81
- | `POSTHOG_KEY` | `""` | PostHog project API key for product analytics in the UI. Empty = analytics off (the default — a self-hosted install brings its own project). Served to the browser via `/health`, but only while `TELEMETRY_ENABLED` is on, so that switch turns off everything this server sends outward. Also editable from Settings → Advanced. |
82
- | `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog ingestion host. Set it to your own region or self-hosted PostHog. Also editable from Settings → Advanced. |
83
- | `TRACE_RETENTION_DAYS` | `14` | Activity-trace retention window in days (the `api_events` rows behind `GET /admin/trace` and `GET /nests/:id/trace`). Rows older than this are pruned opportunistically (every ~500 inserts). `0` = keep forever (pruning is skipped entirely). Capped at `3650`; invalid/negative values fall back to `14`. Also editable from Settings → Advanced. |
82
+ | `POSTHOG_KEY` | `""` | PostHog project API key for product analytics in the UI. Empty = analytics off (the default — a self-hosted install brings its own project). Served to the browser via `/health`, but only while `TELEMETRY_ENABLED` is on, so that switch turns off everything this server sends outward. Also editable from Server settings → Advanced. |
83
+ | `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog ingestion host. Set it to your own region or self-hosted PostHog. Also editable from Server settings → Advanced. |
84
+ | `TRACE_RETENTION_DAYS` | `14` | Activity-trace retention window in days (the `api_events` rows behind `GET /admin/trace` and `GET /nests/:id/trace`). Rows older than this are pruned opportunistically (every ~500 inserts). `0` = keep forever (pruning is skipped entirely). Capped at `3650`; invalid/negative values fall back to `14`. Also editable from Server settings → Advanced. |
84
85
  | `DRIFT_SCAN_INTERVAL_MS` | `30000` (30 s) | How often the drift scanner walks every nest for files edited outside the app (external edits) and stages them as suggestions for review. Set `0` to disable — do this on a GCS FUSE or other network mount, where the walk is slow and costly. Unset, empty or non-numeric falls back to the default. |
85
86
  | `CONTEXTNEST_AGENT` | `""` | Overrides the `agent` this server derives for caller attribution (`client`, spec §9.4) when a call sends none of its own. Without it the name comes from the MCP `initialize` handshake's `clientInfo.name` when the connection reports one, else the label of the API key the call authenticated with. A caller's own `client.agent` always wins. |
86
87
  | `CONTEXTNEST_SESSION_ID` | `""` | Same, for `session_id` — otherwise the MCP transport's session id, or an `Mcp-Session-Id` request header when the client sends one. Left absent when nothing reports one: the `/mcp` endpoints are stateless and issue no session, and a placeholder would be indistinguishable from a real id. |
@@ -90,20 +91,27 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
90
91
  | `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. The asset-upload route (`POST /nests/:id/assets`) is exempt up to the video cap below, and the PDF upload route (`POST /nests/:id/nodes/pdf`) up to `PDF_MAX_MB`. |
91
92
  | `VIDEO_MAX_MB` | `30` | Max size (in MB) of a video uploaded into a doc. Default `30` keeps it under Cloud Run's ~32 MiB HTTP/1 request-body limit, so an oversized video is rejected with a clear message instead of a bare `413` from the platform. Raise only where the deployment can actually accept larger request bodies (not behind Cloud Run, or on HTTP/2 / direct-to-bucket upload). Images are fixed at 10 MB. |
92
93
  | `PDF_MAX_MB` | `25` | Max size (in MB) of a PDF uploaded as a `pdf` document (`POST /nests/:id/nodes/pdf`). A larger file is refused with `413` and nothing is written. Default `25` keeps the request under Cloud Run's ~32 MiB HTTP/1 limit; raise it only where the deployment accepts larger request bodies. The PDF route is exempt from `MAX_BODY_BYTES` up to this cap. |
93
- | `LOGO_URL` | _(unset)_ | Custom logo shown in the UI header + login screen. Must start with `https://`, `http://`, or `data:image/` — other schemes (`file://`, relative, `javascript:`) are rejected with a warning and the bundled icon is used. |
94
- | `PROMPTOWL_TEAMS_ENABLED` | _(unset — off)_ | Lets users who signed in with PromptOwl import their PromptOwl teams as local teams. Off by default; set `true` to enable, or toggle from Settings → Advanced. When off, `GET/POST /teams/promptowl` return `404` and the PromptOwl Teams panel is hidden. Independent of `PROMPTOWL_SIGN_IN_GATE`. |
94
+ | `LOGO_URL` | _(unset)_ | Custom logo shown in the UI header + login screen. Must start with `https://`, `http://`, or `data:image/` — other schemes (`file://`, relative, `javascript:`) are rejected with a warning and the bundled icon is used. Server-wide: a nest's own reader branding (`PATCH /nests/:id/branding`, see API.md) overrides it for that nest's public reader only, and falls back to it when the nest sets no logo. |
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`. |
95
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`). |
96
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. |
97
101
  | `FEATURE_WORKFLOW_PLANE` | _(unset — off)_ | Enables the workflow plane: typed edges, edge-type registry, governed runs. Optional feature; also toggleable from Settings. |
98
- | `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 Settings → Advanced (turning the plane off forces this off too). Opens a recursion surface — enable only after reviewing the depth/fan-out caps. |
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. |
99
103
  | `SUBAGENT_MAX_DEPTH` | `8` | Max sub-agent nesting depth (clamped 1..32) — bounds the call tree's HEIGHT so it stays finite/haltable. |
100
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. |
101
105
  | `RUN_MAX_STEPS` | `10000` | Max steps a single run may accumulate (clamped 10..100000) — bounds a runaway/hostile runner's step log. |
102
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. |
103
- | `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). |
104
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. |
105
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). |
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. |
106
112
  | `SMTP_URL` | _(unset — connector off)_ | SMTP connection URL for email notifications (`smtp://` or `smtps://`, credentials inline). Requires `NOTIFY_EMAIL_FROM` and `NOTIFY_EMAIL_TO`. Also editable from Settings. |
113
+ | `SES_REGION` | _(unset — connector off)_ | AWS region for SES (e.g. `us-east-1`) when `EMAIL_PROVIDER=ses`. The From address must be a verified SES identity. |
114
+ | `SES_ACCESS_KEY_ID` / `SES_SECRET_ACCESS_KEY` | _(unset)_ | Optional static SES credentials (need `ses:SendEmail`). Saving one email provider in Settings clears the other one's settings. Leave the keys unset to use the AWS default credential chain (IAM role, `AWS_*` env, shared profile). The secret is write-only in Settings. |
107
115
  | `NOTIFY_EMAIL_FROM` | _(unset)_ | From address for notification emails. |
108
116
  | `NOTIFY_EMAIL_TO` | _(unset)_ | Comma-separated recipients for notification emails. |
109
117
  | `NOTIFY_DEBOUNCE_MS` | `15000` | Per-nest buffer window before notifications flush; bursts collapse into one digest message. |
@@ -112,7 +120,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
112
120
 
113
121
  ## Microsoft Teams notifications
114
122
 
115
- Set `MSTEAMS_WEBHOOK_URL` (or paste the URL into **Settings → Notifications →
123
+ Set `MSTEAMS_WEBHOOK_URL` (or paste the URL into **Server settings → Notifications →
116
124
  Microsoft Teams notifications**) and the server posts the same governance
117
125
  events the Slack connector covers — review requested / approved / rejected,
118
126
  collaborator added, plus per-nest burst digests — to one Teams channel as
@@ -137,7 +145,7 @@ webhooks are now created with the **Workflows** (Power Automate) app:
137
145
  confirm the team + channel.
138
146
  3. Create the flow and **copy the HTTP POST URL** it shows (a
139
147
  `https://….logic.azure.com/…` or `https://….powerplatform.com/…` address).
140
- 4. Paste that URL into **Settings → Notifications → Microsoft Teams
148
+ 4. Paste that URL into **Server settings → Notifications → Microsoft Teams
141
149
  notifications** (or set `MSTEAMS_WEBHOOK_URL`).
142
150
  5. Save, then click **Send test** on the card to post a test message and
143
151
  confirm the channel receives it. (The button tests the *saved* URL — save
@@ -168,114 +176,566 @@ with `GET`/`PATCH`/`DELETE` on the same path.
168
176
 
169
177
  ## Single sign-on (OIDC)
170
178
 
171
- Generic OpenID Connect sign-in against any spec-compliant identity provider —
172
- Microsoft Entra ID and Google are the first-class presets in **Settings →
173
- Single sign-on**. The server runs a standard authorization-code flow with PKCE:
174
- `GET /auth/oidc/login` redirects to your IdP, `GET /auth/oidc/callback` verifies
175
- the returned ID token (issuer, audience, nonce, signature against the issuer's
176
- JWKS) and starts a normal browser session. Users are looked up by email;
177
- unknown emails are created automatically when `OIDC_AUTO_PROVISION` is on.
178
-
179
- All seven `OIDC_*` values are also editable at runtime from **Settings → Single
180
- sign-on** (superadmin only) — no restart needed. The Settings page shows the
181
- exact **redirect URI** to register with your IdP:
182
-
183
- ```
184
- <PUBLIC_BASE_URL or server origin>/auth/oidc/callback
185
- ```
186
-
187
- Set `PUBLIC_BASE_URL` when the server sits behind a reverse proxy so the
188
- redirect URI is derived from the canonical address rather than the incoming
189
- Host header.
179
+ This section is the admin guide to single sign-on. It covers what SSO does,
180
+ how to set it up for each identity provider, what happens to existing accounts,
181
+ API keys and agents, and how to fix each sign-in error.
182
+
183
+ **In this section:** [Overview](#overview) ·
184
+ [Prerequisites](#prerequisites) · [Settings reference](#settings-reference) ·
185
+ [Microsoft Entra ID](#connect-microsoft-entra-id) · [Google](#connect-google) ·
186
+ [Okta](#connect-okta) · [Keycloak](#connect-keycloak) ·
187
+ [Any other OIDC provider](#connect-any-other-oidc-provider) ·
188
+ [User lifecycle](#user-lifecycle) ·
189
+ [API keys, agents and MCP](#how-sso-relates-to-api-keys-agents-and-mcp) ·
190
+ [Shared deployments](#hosted-and-shared-deployments) ·
191
+ [Department auto-tagging](#department-auto-tagging) ·
192
+ [Troubleshooting](#troubleshooting-sso-sign-in)
193
+
194
+ ### Overview
195
+
196
+ With SSO turned on, the login page shows one more button. Someone clicks it,
197
+ signs in at your identity provider (IdP), and comes back signed in to
198
+ ContextNest. The session is the same one a password login creates: an httpOnly
199
+ cookie that lasts 30 days.
200
+
201
+ - **Protocol:** OpenID Connect, authorization-code flow with PKCE (S256).
202
+ `GET /auth/oidc/login` sends the browser to your IdP.
203
+ `GET /auth/oidc/callback` gets the code back, redeems it, checks the ID token
204
+ (signature against the issuer's JWKS, issuer, audience, expiry, nonce) and
205
+ starts the session. Endpoint details are in `API.md → GET /auth/oidc/login`.
206
+ - **Providers:** any OIDC provider that meets the
207
+ [requirements below](#connect-any-other-oidc-provider). The settings page
208
+ has one-click presets for **Microsoft Entra ID** and **Google**. **Okta** and
209
+ **Keycloak** work as a custom issuer. SAML is not supported.
210
+ - **Button label:** "Sign in with Microsoft" when the issuer host is
211
+ `login.microsoftonline.com`, "Sign in with Google" for
212
+ `accounts.google.com`, and "Sign in with SSO" for anything else. The button
213
+ only appears when SSO is on **and** the issuer, client ID and client secret
214
+ are all set.
215
+ - **Where to configure:** **Admin → Server settings → Single sign-on** in the
216
+ left rail, or the `OIDC_*` environment variables. Changes made in the UI take
217
+ effect at once, without a restart.
218
+ - **Who can configure:** a server admin. That is the license admin or a
219
+ superadmin (granted on **Admin → Teammates**, or listed under `super_admins`
220
+ in `access.yaml`). In `AUTH_MODE=open` nobody signs in, so SSO has no effect
221
+ there. SSO is for `AUTH_MODE=key`, the default.
222
+ - **One setup per server:** there is a single issuer, client and domain
223
+ allowlist for the whole server. See
224
+ [Hosted and shared deployments](#hosted-and-shared-deployments).
225
+
226
+ SSO is separate from three other features with similar names:
227
+
228
+ - **Sign in with PromptOwl**, the device flow (`PROMPTOWL_SIGN_IN_GATE`).
229
+ - **One-click "Open Community" login** from PromptOwl (`GET /auth/sso`,
230
+ **Server settings → Community sites**).
231
+ - **Agent SSO** token exchange ([below](#agent-sso-token-exchange)).
232
+
233
+ Each has its own settings.
234
+
235
+ ### Prerequisites
236
+
237
+ 1. **An https address.** The issuer must be `https://` (the server refuses
238
+ anything else). Your ContextNest server should also be on https: the session
239
+ and flow cookies get the `Secure` flag on https, and most IdPs only accept an
240
+ `http://` redirect URI for `localhost`.
241
+ 2. **`PUBLIC_BASE_URL` set to that address.** Set it on
242
+ **Server settings → General → Public base URL**, or in the environment, e.g.
243
+ `https://nest.acme.com`, with no trailing path. The redirect URI is built
244
+ from it. If it is unset, the server uses the origin of the incoming request,
245
+ which behind a reverse proxy is whatever `Host` header the proxy forwards.
246
+ See the `PUBLIC_BASE_URL` row in the env var table for why you should always
247
+ pin it.
248
+ 3. **The redirect URI registered at your IdP, exactly:**
249
+
250
+ ```
251
+ <PUBLIC_BASE_URL>/auth/oidc/callback
252
+ ```
253
+
254
+ The Single sign-on tab shows this value with a **Copy** button. Before you
255
+ save, it is built from the Public base URL you have typed, or from this
256
+ browser's address if that field is empty. People must reach ContextNest at
257
+ this same address. The flow cookies are set on the host where sign-in
258
+ starts, so starting on a different host fails with `state_mismatch`.
259
+ 4. **Outbound access from the server to the IdP.** The server fetches
260
+ `<issuer>/.well-known/openid-configuration`, the JWKS and the token
261
+ endpoint. Each call times out after 10 seconds.
262
+ 5. **A server clock that is correct (NTP).** ID-token expiry is checked with
263
+ no clock tolerance.
264
+ 6. **A second way in while you test.** Keep password or PromptOwl sign-in on
265
+ until an SSO login has worked. The server will not let you turn off
266
+ password and PromptOwl sign-in unless SSO is on and fully configured, so
267
+ there is always a way in.
268
+
269
+ ### Settings reference
270
+
271
+ All of these are on **Admin → Server settings → Single sign-on**, in the
272
+ **Single sign-on (OIDC)** card. The exception is `PUBLIC_BASE_URL`, which is on
273
+ the **General** tab. One **Save** button, next to the page title, saves every
274
+ tab. If a value is set both in the environment and in the UI, see
275
+ [Runtime settings persistence](#runtime-settings-persistence) for which one
276
+ wins.
277
+
278
+ | Env var | UI field | Default | What it does |
279
+ |---|---|---|---|
280
+ | `OIDC_ENABLED` | On/Off switch at the top of the card | `false` | Turns SSO on. The server refuses to turn it on until the issuer, client ID and client secret are all set. If one of them is cleared later while SSO is still on, the button disappears and the card warns *Enabled but not configured*. |
281
+ | `OIDC_ISSUER` | **Issuer URL**, plus the **Microsoft Entra ID** / **Google** preset buttons | `""` | The issuer, e.g. `https://login.microsoftonline.com/<tenant-id>/v2.0`, `https://accounts.google.com`, `https://acme.okta.com` or `https://sso.acme.com/realms/acme`. **https only.** In the environment, a non-https value is ignored, a warning is logged (`[config] OIDC_ISSUER rejected`) and SSO stays off. In the UI, saving it fails. A trailing slash is removed. The issuer must serve `/.well-known/openid-configuration`, and the `issuer` in that document must match this value exactly (a trailing slash aside). |
282
+ | `OIDC_CLIENT_ID` | **Client ID** | `""` | The client (application) ID from your IdP. The ID token's audience must equal it. |
283
+ | `OIDC_CLIENT_SECRET` | **Client secret** | `""` | The client secret from your IdP. **Write-only:** the UI and `GET /admin/settings` only show whether it is set. Paste a new one to replace it. **Clear secret** removes it. The server sends it in the body of the token request (`client_secret_post`). |
284
+ | `OIDC_ALLOWED_DOMAINS` | **Allowed email domains** | `""` (any domain) | A comma-separated list of domains, e.g. `acme.com, contractors.acme.com`, stored lowercase. The domain of the user's email (the part after the last `@`) must be on the list **exactly**. Subdomains are not included automatically: `acme.com` does not allow `eu.acme.com`. The UI rejects anything that is not a bare domain. |
285
+ | `OIDC_AUTO_PROVISION` | **Auto-provision new users** | `true` | On: anyone the IdP signs in, and the domain list allows, gets an account the first time. Off: only emails that already have an account can sign in. Others get `not_invited`. See [User lifecycle](#user-lifecycle). |
286
+ | `OIDC_DEPARTMENT_TAGGING` | **Department auto-tagging** | `false` | Adds `dept:<department>` to documents a user creates. See [Department auto-tagging](#department-auto-tagging). |
287
+ | `PUBLIC_BASE_URL` | **General → Public base URL** | `""` (request origin) | The base of the redirect URI and of the post-logout return address. |
288
+
289
+ The following are **fixed** and cannot be configured:
290
+
291
+ - Scopes: `openid email profile`.
292
+ - Response type: `code`, with PKCE `S256`.
293
+ - Client authentication: `client_secret_post`. `client_secret_basic` and
294
+ `private_key_jwt` are not supported.
295
+ - ID-token algorithm: `RS256` only.
296
+ - Flow cookies (state, nonce, PKCE verifier) last 10 minutes.
297
+ - Discovery documents are cached for 5 minutes.
298
+ - Calls to the IdP time out after 10 seconds.
299
+ - Sessions last 30 days.
300
+ - Rate limit: 100 requests per 15 minutes per client IP, counted separately for
301
+ `/auth/oidc/login` and `/auth/oidc/callback`.
302
+
303
+ The `/admin/settings` API names for the same fields are `oidc_enabled`,
304
+ `oidc_issuer`, `oidc_client_id`, `oidc_client_secret`, `oidc_allowed_domains`,
305
+ `oidc_auto_provision` and `oidc_department_tagging` (see `API.md`).
190
306
 
191
307
  ### Connect Microsoft Entra ID
192
308
 
193
- 1. **Register an app.** Entra admin center → *Identity → Applications → App
194
- registrations → New registration*. Name it (e.g. "ContextNest"), leave the
195
- account type at *Accounts in this organizational directory only*.
196
- 2. **Add the redirect URI.** In the registration: *Authentication → Add a
197
- platform → Web*, and paste the redirect URI shown on the ContextNest
198
- Settings page (`https://<your-server>/auth/oidc/callback`).
199
- 3. **Create a client secret.** *Certificates & secrets → New client secret*.
200
- Copy the secret **value** (not the ID) immediately — it's shown once.
201
- 4. **Collect the IDs.** On the app's *Overview* page copy the **Application
309
+ 1. **Register an app.** In the Entra admin center, go to *Identity →
310
+ Applications → App registrations → New registration*. Name it, e.g.
311
+ "ContextNest", and leave the account type at *Accounts in this
312
+ organizational directory only* (single tenant).
313
+ 2. **Add the redirect URI.** In the registration, go to *Authentication → Add
314
+ a platform → Web* and paste the redirect URI from the Single sign-on tab
315
+ (`https://<your-server>/auth/oidc/callback`).
316
+ 3. **Create a client secret.** Go to *Certificates & secrets → New client
317
+ secret*. Copy the secret **Value**, not the Secret ID. It is shown only
318
+ once. Note when it expires: after that date, sign-in fails with
319
+ `exchange_failed` until you paste a new secret.
320
+ 4. **Collect the IDs.** On the app's *Overview* page, copy the **Application
202
321
  (client) ID** and the **Directory (tenant) ID**.
203
- 5. **Fill in ContextNest.** Settings → Single sign-on: click **Microsoft Entra
204
- ID**, paste the tenant ID when prompted (issuer becomes
205
- `https://login.microsoftonline.com/<tenant>/v2.0`), then paste the client
206
- ID and the secret value. Optionally restrict **Allowed email domains** to
207
- your org's domain. Save, then flip the toggle **On** and save again (or in
208
- the same save).
209
- 6. **Test.** Open the login page in a private window — a **Sign in with
210
- Microsoft** button appears and round-trips through Entra. Entra accounts
211
- without an `email` claim fall back to `preferred_username` (the UPN).
212
-
213
- For **Google**: create an OAuth client ID (type *Web application*) in the
214
- Google Cloud console, add the same redirect URI under *Authorized redirect
215
- URIs*, and use the **Google** preset (issuer `https://accounts.google.com`)
216
- with that client ID/secret.
322
+ 5. **Fill in ContextNest.** On **Admin → Server settings → Single sign-on**,
323
+ click **Microsoft Entra ID** and paste the tenant ID when asked. The issuer
324
+ becomes `https://login.microsoftonline.com/<tenant-id>/v2.0`. Paste the
325
+ client ID and the secret value. You can also set **Allowed email domains**
326
+ to your organization's domains. Turn the switch **On** and **Save**. The
327
+ switch and the fields can go in the same save.
328
+ 6. **Test.** Open the login page in a private window. A **Sign in with
329
+ Microsoft** button should appear and take you through Entra.
330
+
331
+ Entra-specific notes:
332
+
333
+ - **Use the tenant ID (a GUID), not `common`, `organizations` or a domain
334
+ name.** The server requires the discovery document's `issuer` to match the
335
+ configured issuer exactly. For the multi-tenant endpoints, Entra's document
336
+ contains the placeholder `{tenantid}`, so the check fails and sign-in stops
337
+ with `discovery_failed`. Only single-tenant sign-in is supported.
338
+ - **Email claim.** Entra v2 ID tokens often have no `email` claim. The server
339
+ then uses `preferred_username` (the UPN). The address people sign in with
340
+ may therefore be their UPN, which may differ from their mail address.
341
+ Account matching uses this address (see [User lifecycle](#user-lifecycle)).
342
+ - **Only let assigned users in (optional).** Go to *Enterprise applications →
343
+ (your app) → Properties → Assignment required? → Yes*, then assign users or
344
+ groups. Entra then refuses anyone else. Depending on where Entra stops the
345
+ sign-in, the user sees either Entra's own error page or `provider_error`.
346
+ - **Sign-out:** Entra publishes an `end_session_endpoint`, so ContextNest
347
+ signs the user out of Entra as well (see
348
+ [Offboarding and sign-out](#revoking-access-offboarding)).
349
+
350
+ ### Connect Google
351
+
352
+ 1. **Choose the project.** In the Google Cloud console, pick (or create) the
353
+ project that will own the sign-in client.
354
+ 2. **Set up the consent screen.** Go to *APIs & Services → OAuth consent
355
+ screen*. Newer consoles call this *Google Auth Platform → Branding /
356
+ Audience*.
357
+ - **Internal:** only accounts in your Google Workspace organization can
358
+ sign in. This is the right choice for a company server.
359
+ - **External:** any Google account can sign in. While the app is in
360
+ *Testing*, only the test users you list can sign in.
361
+ 3. **Create the client.** Go to *APIs & Services → Credentials → Create
362
+ credentials → OAuth client ID* (newer consoles: *Google Auth Platform →
363
+ Clients → Create client*). Choose **Application type: Web application**.
364
+ 4. **Add the redirect URI.** Under **Authorized redirect URIs**, add the
365
+ redirect URI from the Single sign-on tab exactly, then click **Create**.
366
+ 5. **Copy the credentials.** Copy the **Client ID** and **Client secret**.
367
+ 6. **Fill in ContextNest.** On **Admin → Server settings → Single sign-on**,
368
+ click **Google**. The issuer becomes `https://accounts.google.com`. Paste
369
+ the client ID and secret, and **set Allowed email domains** to your
370
+ Workspace domains. Turn the switch **On**, **Save**, and test in a private
371
+ window. A **Sign in with Google** button should appear.
372
+
373
+ Google-specific notes:
374
+
375
+ - **Set Allowed email domains.** `accounts.google.com` will sign in *any*
376
+ Google account. With auto-provision on and no domain list, anyone with a
377
+ Google account gets an account on your server. The Single sign-on tab shows
378
+ a warning when that combination is set.
379
+ - **Sign-out:** Google has no `end_session_endpoint`. Signing out of
380
+ ContextNest ends the ContextNest session only. The user stays signed in to
381
+ Google.
382
+
383
+ ### Connect Okta
384
+
385
+ These are the standard steps in the Okta Admin Console. The Okta-side behavior
386
+ noted at the end has not been tested against a live Okta org, so check it on
387
+ yours.
388
+
389
+ 1. **Create the app integration.** Go to *Applications → Applications → Create
390
+ App Integration*. Choose **Sign-in method: OIDC - OpenID Connect** and
391
+ **Application type: Web Application**, then **Next**.
392
+ 2. **General settings.** Name it, e.g. "ContextNest". Under **Grant type**,
393
+ keep **Authorization Code** checked.
394
+ - **Sign-in redirect URIs:** the redirect URI from the Single sign-on tab,
395
+ e.g. `https://nest.acme.com/auth/oidc/callback`.
396
+ - **Sign-out redirect URIs:** your base URL with a trailing slash, e.g.
397
+ `https://nest.acme.com/`. This is the address ContextNest asks Okta to
398
+ return to after sign-out.
399
+ - **Assignments:** choose who may use the app (everyone, or selected
400
+ groups). **Save**.
401
+ 3. **Copy the credentials.** On the app's **General** tab, copy the **Client
402
+ ID** and a **Client secret**. **Client authentication** should be **Client
403
+ secret**, not *Public key / Private key*.
404
+ 4. **Find the issuer.** It is one of these:
405
+ - Your Okta org URL, e.g. `https://acme.okta.com`. This is the org
406
+ authorization server.
407
+ - A custom authorization server, e.g. `https://acme.okta.com/oauth2/default`
408
+ (*Security → API → Authorization Servers → Issuer URI*).
409
+
410
+ Either works, as long as the value you enter matches the `issuer` in
411
+ `<issuer>/.well-known/openid-configuration`. Open that URL in a browser to
412
+ check. If you use a custom domain, use the issuer the discovery document
413
+ shows.
414
+ 5. **Fill in ContextNest.** On **Admin → Server settings → Single sign-on**,
415
+ paste the issuer into **Issuer URL**. There is no preset, and the page
416
+ shows *Custom issuer*. Paste the **Client ID** and **Client secret**, set
417
+ **Allowed email domains** if you want, turn it **On** and **Save**. The
418
+ login page shows **Sign in with SSO**.
419
+
420
+ Okta notes that were **not verified against a live Okta org:**
421
+
422
+ - **Client authentication method.** ContextNest sends the secret in the
423
+ request body (`client_secret_post`). If sign-in fails with `exchange_failed`
424
+ and the server log shows `[oidc] token exchange failed with status 401`,
425
+ check the app's `token_endpoint_auth_method`. The Admin Console may not show
426
+ it, so use Okta's Apps API to set it to `client_secret_post` and try again.
427
+ - **Sign-out.** ContextNest sends `post_logout_redirect_uri` and `client_id`
428
+ to Okta's logout endpoint, but not `id_token_hint`. If Okta shows an error
429
+ page on sign-out, the ContextNest session has **already** ended (it is
430
+ deleted before the redirect). Only the Okta session is left open.
431
+
432
+ ### Connect Keycloak
433
+
434
+ These steps are for the current admin console (Keycloak 19 and later).
435
+
436
+ 1. **Create the client.** In your realm, go to *Clients → Create client*.
437
+ Choose **Client type: OpenID Connect** and enter a **Client ID**, e.g.
438
+ `contextnest`. **Next**.
439
+ 2. **Capability config.** Turn **Client authentication** **On**. This makes it
440
+ a confidential client, which ContextNest requires. Keep **Standard flow**
441
+ checked. **Next**.
442
+ 3. **Login settings.**
443
+ - **Valid redirect URIs:** the redirect URI from the Single sign-on tab.
444
+ - **Valid post logout redirect URIs:** your base URL with a trailing slash,
445
+ e.g. `https://nest.acme.com/`.
446
+
447
+ **Save**.
448
+ 4. **Copy the secret.** On the client's **Credentials** tab, keep **Client
449
+ Authenticator: Client Id and Secret** and copy the **Client Secret**.
450
+ 5. **Find the issuer.** It is `https://<keycloak-host>/realms/<realm>`. Older
451
+ Keycloak versions (before 17) add `/auth`:
452
+ `https://<keycloak-host>/auth/realms/<realm>`. Open
453
+ `<issuer>/.well-known/openid-configuration` and use its `issuer` value
454
+ exactly. If Keycloak runs behind a proxy without a correct hostname
455
+ setting, the document may show an internal or `http://` issuer. That fails
456
+ with `discovery_failed`, so fix Keycloak's hostname configuration first.
457
+ 6. **Fill in ContextNest.** Paste the issuer, client ID and client secret on
458
+ **Admin → Server settings → Single sign-on**, turn it **On** and **Save**.
459
+ The login page shows **Sign in with SSO**.
460
+
461
+ Keycloak-specific notes:
462
+
463
+ - **Verified email is required.** Keycloak includes `email_verified` in the ID
464
+ token, and ContextNest refuses a token where it is `false`
465
+ (`email_not_verified`). Users created in the admin console start with
466
+ *Email verified* **off**. Turn it on for each user, or have users verify
467
+ their address.
468
+ - **Users need an email address.** A user without one produces a token
469
+ ContextNest cannot use (`invalid_token`).
470
+ - **Lock down open registration.** If the realm allows self-registration,
471
+ anyone can create a Keycloak account with any address. Turn off *User
472
+ registration* or set **Allowed email domains**. See the
473
+ [trust assumption](#email-verification-trust-assumption).
474
+ - **Signature algorithm.** Keep the realm's default `RS256`. If the client's
475
+ *ID token signature algorithm* is set to anything else (`ES256`, `PS256`,
476
+ …), every login fails with `invalid_token`.
477
+ - **Department (optional).** For [department auto-tagging](#department-auto-tagging),
478
+ add a *User Attribute* mapper to the client's dedicated scope. Set the token
479
+ claim name to `department`, the claim type to String, and turn on *Add to
480
+ ID token*.
481
+
482
+ ### Connect any other OIDC provider
483
+
484
+ Any provider that meets all of these requirements works. Each one is checked by
485
+ the server, and a failure shows up as the error code given.
486
+
487
+ | Requirement | Error if not met |
488
+ |---|---|
489
+ | Issuer is `https://` and serves `<issuer>/.well-known/openid-configuration` | Refused when saving (UI), or `not_configured` (env) / `discovery_failed` |
490
+ | The discovery document has `issuer`, `authorization_endpoint`, `token_endpoint` and `jwks_uri`, and its `issuer` matches the configured issuer exactly (a trailing slash aside) | `discovery_failed` |
491
+ | Authorization-code flow, with the client secret in the request body (`client_secret_post`). PKCE `S256` parameters are always sent. | `exchange_failed` |
492
+ | The token response includes an `id_token` | `exchange_failed` |
493
+ | The ID token is signed `RS256` with a key in `jwks_uri`, its `aud` is the client ID, it has not expired, and it returns the `nonce` that was sent | `invalid_token` |
494
+ | The ID token has an email-shaped `email` claim, or failing that a `preferred_username` claim | `invalid_token` |
495
+ | `email_verified` is not `false`. A missing claim is accepted. | `email_not_verified` |
496
+
497
+ Optional claims the server uses: `name` (display name for new accounts) and
498
+ `department` (see [Department auto-tagging](#department-auto-tagging)). If the
499
+ discovery document has an `end_session_endpoint`, sign-out also ends the IdP
500
+ session.
501
+
502
+ Enter the issuer in **Issuer URL** (the page shows *Custom issuer*), fill in the
503
+ client ID and secret, and register the redirect URI with the provider.
504
+
505
+ ### User lifecycle
506
+
507
+ **How accounts are matched.** ContextNest links an SSO login to an account
508
+ **by email address only**. It does not store the IdP's subject ID (`sub`). The
509
+ address comes from `email`, or from `preferred_username` if `email` is missing.
510
+ It is trimmed and lowercased, and matched against existing accounts without
511
+ regard to case. As a result:
512
+
513
+ - **An existing password account with the same email is the same account.**
514
+ The first SSO login signs into it, with the same nests, teams, stewardship
515
+ roles, API keys and admin rights. SSO does not change or remove the
516
+ password, so the person can still sign in with it, unless you turn password
517
+ sign-in off (see [Making SSO the only way in](#making-sso-the-only-way-in)).
518
+ - **Invited people can use SSO straight away.** An invite (Teammates, a nest
519
+ share, a team, a steward assignment) creates the account and sends a
520
+ temporary password. If the invited email matches their IdP email, they can
521
+ click the SSO button instead. Any first sign-in clears the account's
522
+ *Invited* badge.
523
+ - **A changed email is a new account.** If someone's email or UPN changes at
524
+ the IdP, their next SSO login does not match the old account. With
525
+ auto-provision on, they get a new, empty account. With it off, they get
526
+ `not_invited`. Share their nests with the new address again, and remove the
527
+ old account on Teammates.
528
+ - **Admin rights belong to the account,** not to how it signed in. A
529
+ superadmin listed by email in `access.yaml` or granted on Teammates is still
530
+ an admin when signing in with SSO.
531
+
532
+ **Auto-provision on (the default).** The first successful SSO login by an
533
+ unknown email creates an account. The display name comes from the `name`
534
+ claim, and the password is random and unusable. A new account sees only
535
+ nests shared with it (directly or through a team) and nests whose visibility
536
+ is *Organization* or *Public*. The account
537
+ can sign in only through SSO, unless an admin sets a password with **Reset
538
+ password** on Teammates. Auto-provision also applies when **Manual sign-in**
539
+ is *Invite only*, because that setting governs email-and-password
540
+ registration, not SSO. Limit who can get in with **Allowed email domains**, or
541
+ by assigning the app to specific users or groups at the IdP.
542
+
543
+ **Auto-provision off (invite-only SSO).** Only emails that already have an
544
+ account can sign in. Everyone else is sent back with `not_invited`. Add people
545
+ first with **Admin → Teammates → Invite Teammate**, or by sharing a nest or
546
+ document with their email. They can then use the SSO button.
547
+
548
+ **Allowed email domains** apply to every SSO login, new and existing
549
+ accounts alike. An existing account whose domain is not on the list gets
550
+ `domain_not_allowed`. It can still use password or PromptOwl sign-in if those
551
+ are on.
552
+
553
+ #### Making SSO the only way in
554
+
555
+ On **Server settings → General**, set **Manual sign-in** to **Off** and
556
+ **Sign in with PromptOwl** to **Off**. The login page then shows only the SSO
557
+ button, and `POST /auth/login` / `POST /auth/register` return `403`. The server
558
+ refuses this combination unless SSO is on and has an issuer, client ID and
559
+ secret. It also refuses to turn SSO off or clear its settings while SSO is the
560
+ only way in.
561
+
562
+ Consider a break-glass option: set **Sign in with PromptOwl** to **Admins
563
+ only**. The license admin can then still sign in through `?admin=1` on the
564
+ login page if the IdP is down.
565
+
566
+ API keys keep working either way. They are not a sign-in method (see
567
+ [below](#how-sso-relates-to-api-keys-agents-and-mcp)).
217
568
 
218
- ### Department auto-tagging
569
+ ### Revoking access (offboarding)
219
570
 
220
- With `OIDC_DEPARTMENT_TAGGING` on (Settings → Single sign-on → **Department
221
- auto-tagging**), every document a user **creates** is tagged
222
- `dept:<slugified-department>` from their directory department — e.g. a user in
223
- *Customer Success* creates docs tagged `dept:customer-success`. Create-only:
224
- edits never add or change the tag, and existing documents are never
225
- retro-tagged. Users without a department (password accounts, or an IdP that
226
- doesn't emit the claim) create untagged documents — never an error.
227
-
228
- The department is read from the **`department` claim** in the OIDC ID token
229
- and stored on the user at every SSO login: a new value updates it, an absent
230
- claim clears it, so directory moves propagate on the user's next sign-in.
231
-
232
- Microsoft Entra ID does **not** emit the claim by default — add it to the app
233
- registration: *Token configuration → Add optional claim → Token type: **ID**
234
- → select **department** → Add* (grant the suggested Microsoft Graph
235
- permission if prompted), and make sure the users' *Department* field is
236
- populated in Entra. Other IdPs work too as long as they emit a string
237
- `department` claim in the ID token (e.g. a Keycloak user-attribute mapper).
238
-
239
- > **Privacy note:** the `dept:<slug>` tag becomes part of the document's
240
- > visible metadata — anyone who can read the document (collaborators, shared
241
- > nests, public nests) can see the creator's directory department. That's
242
- > PII-adjacent organizational data; consider this before enabling on servers
243
- > where documents are shared beyond the creator's own team or made public.
571
+ **Disabling someone at the IdP only blocks new SSO logins.** It does not
572
+ affect:
573
+
574
+ - ContextNest sessions they already have. These stay valid until they expire,
575
+ up to 30 days after login.
576
+ - Their **API keys**. These do not expire and never contact the IdP.
577
+ - Their password, if the account had one or an admin has set one, while
578
+ password sign-in is on.
579
+
580
+ To cut access completely, **remove the user in ContextNest**: go to
581
+ **Admin → Teammates**, find the person and click **Remove**. This deletes their
582
+ account, all their API keys, their active sessions, their stewardship roles
583
+ and their nest-collaborator grants in one step. Documents they wrote stay in
584
+ their nests. A few limits apply:
585
+
586
+ - Removal is refused while the user **owns** nests. Transfer or delete those
587
+ nests first.
588
+ - A server admin cannot be removed until their superadmin access is revoked.
589
+ - Nobody can remove their own account.
590
+
591
+ The full procedure has two steps:
592
+
593
+ 1. **Disable the user at the IdP.** Do this first. With auto-provision on, a
594
+ user who is still active at the IdP can sign in again after removal and
595
+ get a new, empty account.
596
+ 2. **Remove the user on Admin → Teammates.** This ends their sessions and
597
+ revokes their keys.
598
+
599
+ Bearers minted by [Agent SSO](#agent-sso-token-exchange) look up the user by
600
+ email on every request, so they stop working once the user is removed.
601
+
602
+ **Sign-out (RP-initiated logout).** When someone who signed in with SSO clicks
603
+ **Log out**, the app goes to `GET /auth/oidc/logout`. This deletes the
604
+ ContextNest session and cookies. Then, if the IdP publishes an
605
+ `end_session_endpoint` (Entra, Okta and Keycloak do; Google does not), it sends
606
+ the browser there with `post_logout_redirect_uri=<PUBLIC_BASE_URL>/` and
607
+ `client_id`, so the IdP session ends too. Without that, the next click on the
608
+ SSO button would sign the person straight back in. Register `<base URL>/` as a
609
+ sign-out / post-logout redirect URI at IdPs that require it (Okta, Keycloak).
610
+ If the IdP has no logout endpoint, or cannot be reached, the local session is
611
+ still cleared and the user lands on the login page.
612
+
613
+ ### How SSO relates to API keys, agents and MCP
614
+
615
+ SSO controls how **people sign in to the web app**. It does not control how
616
+ programs connect. Turning SSO on changes nothing for existing keys or agent
617
+ connections.
618
+
619
+ | Way in | Effect of turning on SSO |
620
+ |---|---|
621
+ | **Browser, email and password** | Keeps working. Controlled by **Manual sign-in** (Server settings → General). |
622
+ | **Browser, Sign in with PromptOwl** | Keeps working. Controlled by **Sign in with PromptOwl** (Server settings → General). |
623
+ | **Browser, SSO button** | New. Creates the same 30-day session as a password login. |
624
+ | **API keys (`cnst_…`)**, used by the REST API, the `ctx` CLI and MCP clients | **No change.** Any signed-in user, however they signed in, creates keys on **Workspace → API keys**, and keys are checked without contacting the IdP. Disabling someone at the IdP does **not** revoke their keys. Removing them on Teammates does. |
625
+ | **MCP clients** (Claude Desktop, Claude Code, Cursor, VS Code…) | **No change.** They connect to `<server>/mcp` or `<server>/nests/<id>/mcp` with an API key. They do not sign in through your IdP. |
626
+ | **Agent SSO (token exchange)** | Separate and **off** by default. An agent that already holds an Entra or Google ID token for a user exchanges it at `POST /auth/token-exchange` for a short-lived MCP bearer. It has its own switches (Single sign-on tab → **Agent SSO — token exchange** card) but uses **Allowed email domains** and **Auto-provision new users** from the SSO card. See [Agent SSO (token exchange)](#agent-sso-token-exchange). |
627
+ | **One-click "Open Community" login from PromptOwl** (`GET /auth/sso`) | Not related. It uses signed tickets from PromptOwl (**Server settings → Community sites**), not your IdP. |
628
+
629
+ ### Hosted and shared deployments
630
+
631
+ The SSO settings apply to the **whole server**: one issuer, one client, one
632
+ domain list and one auto-provision setting for every user. There is no
633
+ per-organization or per-nest IdP. On a server that hosts several
634
+ organizations (`SHARED_DEPLOYMENT=true`, such as the PromptOwl-hosted service):
635
+
636
+ - Turning on SSO puts the same button in front of **every** user of the
637
+ server, and it signs in against one organization's IdP.
638
+ - With auto-provision on, anyone that IdP signs in (and the domain list allows)
639
+ gets an account on the shared server. That account can read every nest
640
+ whose visibility is *Organization* or *Public*, which on a shared server
641
+ means nests belonging to other customers.
642
+ - Only a server admin, meaning the operator, can change these settings.
643
+ Customers of a shared server cannot bring their own IdP.
644
+
645
+ An organization that needs its own IdP should run its own ContextNest server.
646
+ Per-organization identity (SAML / SCIM, a multi-tenant admin console) is in the
647
+ Enterprise edition (see `README.md`).
244
648
 
245
- ### Revoking access (offboarding)
649
+ ### Department auto-tagging
246
650
 
247
- Disabling a user **at the IdP** only blocks *new* sign-ins — an existing
248
- ContextNest session stays valid until it expires (30 days). To cut access
249
- immediately, **remove the user in ContextNest** (Settings → users): that wipes
250
- all of their active sessions at once. Real offboarding is therefore two steps —
251
- disable at the IdP *and* remove in ContextNest — with the ContextNest step being
252
- the one that ends live sessions.
253
-
254
- When a user signs themselves out, ContextNest performs an **RP-initiated
255
- logout**: if the IdP advertises an `end_session_endpoint` (Entra, Okta,
256
- Keycloak do; Google does not), the browser is bounced through it so the IdP
257
- session ends too and the next "Sign in with SSO" click doesn't silently
258
- re-authenticate. Where the IdP has no logout endpoint, the local session is
259
- cleared and the user lands back on the login page (the IdP session persists —
260
- that's the IdP's own timeout to manage).
651
+ With `OIDC_DEPARTMENT_TAGGING` on (**Admin → Server settings → Single sign-on →
652
+ Department auto-tagging**), every document a user **creates** is tagged
653
+ `dept:<slugified-department>` from their directory department. For example, a
654
+ user in *Customer Success* creates documents tagged `dept:customer-success`.
655
+ It applies to new documents only: edits never add or change the tag, and
656
+ existing documents are never tagged afterwards. Users without a department
657
+ (password accounts, or an IdP that does not send the claim) create untagged
658
+ documents, with no error.
659
+
660
+ The department comes from the **`department` claim** in the OIDC ID token. It
661
+ is saved on the user at every SSO login. A new value replaces the old one and a
662
+ missing claim clears it, so directory moves take effect at the user's next
663
+ sign-in. Whitespace is trimmed and collapsed, and the value is cut to 120
664
+ characters.
665
+
666
+ Microsoft Entra ID does **not** send the claim by default. Add it to the app
667
+ registration: *Token configuration → Add optional claim → Token type: **ID** →
668
+ select **department** → Add*. Grant the suggested Microsoft Graph permission if
669
+ asked, and make sure users' *Department* field is filled in in Entra. Other
670
+ IdPs work too, as long as they send a string `department` claim in the ID token
671
+ (e.g. a Keycloak user-attribute mapper, see [Keycloak](#connect-keycloak)).
672
+
673
+ > **Privacy note:** the `dept:<slug>` tag is part of the document's visible
674
+ > metadata. Anyone who can read the document (collaborators, shared nests,
675
+ > public nests) can see the creator's directory department. That is
676
+ > PII-adjacent organizational data. Think about this before turning it on for
677
+ > servers where documents are shared beyond the creator's own team or made
678
+ > public.
261
679
 
262
680
  ### Email-verification trust assumption
263
681
 
264
- An ID token whose `email_verified` claim is **explicitly `false`** is refused
265
- (`?sso_error=email_not_verified`). When the claim is **absent**, the server
266
- trusts the email claim — Microsoft Entra ID v2 tokens frequently omit it, and
267
- both Entra and Google guarantee address ownership, so refusing on absence
268
- would break the primary providers. This is a deliberate trust assumption:
269
- **when your IdP allows unverified self-registered emails (e.g. an open
270
- Keycloak realm), configure `oidc_allowed_domains` and disable self-registration
271
- at the IdP** — otherwise anyone able to assert an arbitrary email at your IdP
272
- could sign in as the matching local account.
273
-
274
- Sign-in failures bounce back to the app as `/?sso_error=<code>` and surface as
275
- a toast; codes: `disabled`, `not_configured`, `discovery_failed`,
276
- `provider_error`, `state_mismatch`, `exchange_failed`, `invalid_token`,
277
- `email_not_verified`, `domain_not_allowed`, `not_invited`, `rate_limited`,
278
- `service_error`.
682
+ An ID token whose `email_verified` claim is **explicitly `false`** (the boolean
683
+ or the string `"false"`) is refused with `?sso_error=email_not_verified`. If
684
+ the claim is **missing**, the server trusts the email claim. Microsoft Entra ID
685
+ v2 tokens often leave it out, and both Entra and Google guarantee address
686
+ ownership, so refusing when it is missing would break the main providers.
687
+
688
+ This is a deliberate trust assumption. **If your IdP allows unverified,
689
+ self-registered emails (e.g. an open Keycloak realm), set Allowed email domains
690
+ and turn off self-registration at the IdP.** Otherwise anyone who can claim an
691
+ arbitrary email at your IdP can sign in as the local account with that email,
692
+ including an admin's.
693
+
694
+ ### Troubleshooting SSO sign-in
695
+
696
+ When an SSO sign-in fails, the browser lands on `/?sso_error=<code>`. The app
697
+ shows the message below as a toast, removes the parameter from the address bar
698
+ and shows the normal sign-in options. For the codes that involve the IdP, the
699
+ server log has the details: `[oidc] discovery failed: …`,
700
+ `[oidc] token exchange failed with status <n>`,
701
+ `[oidc] ID token validation failed: …` and
702
+ `[oidc] provisioning/session failed: …`.
703
+
704
+ | `sso_error` | Message the user sees | Cause | Fix |
705
+ |---|---|---|---|
706
+ | `disabled` | Single sign-on is turned off on this server. | `OIDC_ENABLED` is off, but something (an old bookmark, a link) opened `/auth/oidc/login` or the callback. | Turn SSO on, or remove the link. |
707
+ | `not_configured` | Single sign-on isn't fully configured. Contact your admin. | SSO is on, but the issuer, client ID or client secret is empty. This also happens when an `OIDC_ISSUER` in the environment was ignored for not being `https://` (look for `[config] OIDC_ISSUER rejected` in the log). | Fill in all three on the Single sign-on tab. The issuer must be `https://`. |
708
+ | `rate_limited` | Too many sign-in attempts. Wait a moment and try again. | More than 100 sign-in requests from one client IP in 15 minutes. Behind a proxy that does not set `X-Forwarded-For` / `X-Real-IP`, every user shares the proxy's IP. | Wait up to 15 minutes. Make sure your proxy passes the real client IP. |
709
+ | `discovery_failed` | Couldn't reach the identity provider. Try again, or contact your admin. | The server could not load `<issuer>/.well-known/openid-configuration`: wrong issuer URL, no outbound network access, a timeout after 10 seconds, a non-200 response, missing required fields, or **an `issuer` in the document that differs from the configured issuer**. Entra `common` / `organizations`, or an Entra domain name in place of the tenant GUID, fail on that last check. So does a Keycloak that reports an internal hostname. | Open the discovery URL yourself and copy its `issuer` value exactly into **Issuer URL**. Check that the server can reach the IdP. |
710
+ | `provider_error` | Your identity provider declined the sign-in. Try again. | The IdP returned `?error=…` to the callback instead of a code. Typical reasons: the user cancelled or declined consent, the user is not assigned to the app, or a conditional-access policy blocked the sign-in. Some IdPs show their own error page for these cases instead of returning. | Check the IdP's sign-in logs for the reason. Assign the user or group to the app, or fix the policy. |
711
+ | `state_mismatch` | The sign-in attempt expired or didn't match this browser. Try again. | The flow cookies were missing or did not match the `state` sent back. The user took more than 10 minutes at the IdP, the browser blocked cookies, or sign-in started on a different host from the redirect URI (e.g. started at `http://10.0.0.5:3000` with a redirect URI on `https://nest.acme.com`). Opening an old callback URL again also causes it. | Start again from the login page. Make sure people use the same address as `PUBLIC_BASE_URL`. |
712
+ | `exchange_failed` | Sign-in couldn't be completed with the identity provider. Try again. | The code could not be exchanged for an ID token. The token endpoint returned an error (wrong or **expired client secret**, a client ID for a different app, a redirect URI that does not match, or an IdP that requires `client_secret_basic` or `private_key_jwt`), the call timed out, no `code` came back, or the response had no `id_token` (e.g. the `openid` scope is not allowed). | Paste a new client secret. Check the client ID and redirect URI. Set the IdP client to send the secret in the request body (`client_secret_post`). The log shows the HTTP status. |
713
+ | `invalid_token` | The identity provider returned an invalid sign-in token. Try again. | The ID token failed a check: signature, issuer, audience (`aud` is not the client ID), expiry or not-before (**server clock off**), algorithm not `RS256`, or the nonce did not match. It also happens when the token has no email-shaped `email` or `preferred_username` claim, e.g. a Keycloak user with no email address. | Check the client ID and the IdP's ID-token signing algorithm (`RS256`). Sync the server clock with NTP. Give the user an email address at the IdP. |
714
+ | `email_not_verified` | Your identity provider reports your email address as unverified. … | The ID token has `email_verified: false`. This is common with Keycloak users created by an admin. | Mark the email as verified at the IdP, or have the user verify it. See the [trust assumption](#email-verification-trust-assumption). |
715
+ | `domain_not_allowed` | Your email domain isn't allowed on this server. Contact your admin. | The domain of the user's email is not on **Allowed email domains**. Matching is exact, so subdomains must be listed separately. With Entra, the address may be the UPN rather than the mail address. | Add the domain to the list, or have the user sign in with an address on an allowed domain. |
716
+ | `not_invited` | No account exists for your email on this server. Ask your admin for an invite. | **Auto-provision new users** is off, and no account has this email. The email may also have changed at the IdP (see [User lifecycle](#user-lifecycle)). | Invite the person on **Admin → Teammates**, or share a nest with their email, or turn auto-provision on. |
717
+ | `service_error` | Something went wrong signing you in. Please try again. | A database error while finding or creating the account or starting the session. | Check the server log (`[oidc] provisioning/session failed`) and the database connection. |
718
+
719
+ **Errors that appear at the IdP, not in ContextNest.** Some failures stop at
720
+ the IdP's own error page, and the browser never comes back. The most common
721
+ one is a redirect URI that is not registered exactly: Entra `AADSTS50011`,
722
+ Google `Error 400: redirect_uri_mismatch`, or Okta and Keycloak "invalid
723
+ redirect uri". Google also blocks, on its own page, users outside an *Internal*
724
+ consent screen's organization, and users who are not on the test list while
725
+ the app is in *Testing*. Copy the redirect URI from the Single sign-on tab again and
726
+ compare it character by character: scheme, host, port, path, and no trailing
727
+ slash. Check that it matches `PUBLIC_BASE_URL`.
728
+
729
+ **No SSO button on the login page.** The button appears only when SSO is on
730
+ *and* the issuer, client ID and secret are all set. The Single sign-on tab
731
+ warns *Enabled but not configured* when one is missing. The page reads this
732
+ state from `GET /health` (`oidc_enabled`, `oidc_issuer`).
733
+
734
+ **Other `sso_error` codes.** `not_supported`, `missing_ticket`,
735
+ `invalid_ticket`, `bad_audience`, `ticket_used` and `sign_in_restricted` come
736
+ from the PromptOwl one-click login (`GET /auth/sso`), not from OIDC. See
737
+ `API.md → GET /auth/sso` and **Server settings → Community sites**.
738
+ `rate_limited` and `service_error` are used by both flows.
279
739
 
280
740
  ---
281
741
 
@@ -288,8 +748,8 @@ RFC 8693-shaped. **Off by default and security-critical**: it trusts a token
288
748
  minted elsewhere and hands back access, so read this section before enabling it.
289
749
 
290
750
  Configure it with the `SSO_TOKEN_EXCHANGE_ENABLED` / `MCP_SIGNING_SECRET` /
291
- `MS_*` / `GOOGLE_*` vars above, or from **Settings → Single sign-on → Agent SSO**
292
- (superadmin). It reuses `OIDC_ALLOWED_DOMAINS` and `OIDC_AUTO_PROVISION` — the
751
+ `MS_*` / `GOOGLE_*` vars above, or from **Admin → Server settings → Single sign-on →
752
+ Agent SSO — token exchange** (server admins). It reuses `OIDC_ALLOWED_DOMAINS` and `OIDC_AUTO_PROVISION` — the
293
753
  same who-may-sign-in policy as browser login.
294
754
 
295
755
  **Who this is for — and who it is not.** The minted bearer is short-lived by
@@ -396,7 +856,7 @@ are printed to the server log.
396
856
 
397
857
  ### Runtime settings persistence
398
858
 
399
- Settings you change at runtime — everything on the **Settings page** (`/admin/settings`:
859
+ Settings you change at runtime — everything on **Admin → Server settings** (`/admin/settings`:
400
860
  sign-in gate, logo, base URL, upload limit, feature flags, OIDC SSO, Slack/Teams/SMTP connectors)
401
861
  plus the **installed license key** — are stored in the database (`server_settings`
402
862
  table), **not** in a `.env` file. This is deliberate: on Cloud Run the container
@@ -419,6 +879,33 @@ to keys you don't set in the environment stay put. Clearing a setting in the UI
419
879
  writes a tombstone, so a value you removed is not resurrected from the environment
420
880
  on the next boot.
421
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
+
422
909
  ### Cloud Run + Cloud SQL (PostgreSQL)
423
910
 
424
911
  Attach the Cloud SQL instance to the service (`--add-cloudsql-instances`) so the