@promptowl/contextnest-community 1.25.0 → 1.26.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 (120) hide show
  1. package/API.md +68 -8
  2. package/CONFIGURATION.md +572 -116
  3. package/README.md +3 -2
  4. package/dist/{chunk-32SZCH36.js → chunk-2QOJE7A5.js} +4 -4
  5. package/dist/{chunk-2KHM5C7V.js → chunk-DGPTJXIV.js} +13 -15
  6. package/dist/chunk-I5EDEZBQ.js +523 -0
  7. package/dist/{chunk-TI7HMP64.js → chunk-IEAWRHMV.js} +2 -2
  8. package/dist/{chunk-DTLMBF4W.js → chunk-M6PGYMBD.js} +15 -8
  9. package/dist/{chunk-F66VCBLA.js → chunk-PICUHQEM.js} +51 -2
  10. package/dist/{chunk-KPGNZLZL.js → chunk-RPGFHESR.js} +1 -1
  11. package/dist/{chunk-7FROMOZM.js → chunk-SWA5M5XX.js} +68 -544
  12. package/dist/{chunk-JDAOA5KP.js → chunk-TRSGPS27.js} +39 -14
  13. package/dist/{engine-TER6FDBP.js → engine-QNMFDIBV.js} +3 -2
  14. package/dist/{external-edit-service-CAGENHDJ.js → external-edit-service-4MEGPT4D.js} +4 -3
  15. package/dist/{grants-service-2OTFS7EH.js → grants-service-24GLUBY4.js} +2 -2
  16. package/dist/index.js +911 -263
  17. package/dist/invited-user-AM6A3WGO.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-2EWN7WWO.js} +7 -6
  20. package/dist/{stewardship-service-S22V2JKG.js → stewardship-service-TDZUC2Q6.js} +4 -3
  21. package/dist/{version-service-X4FAG2OZ.js → version-service-C3JTZLJH.js} +4 -3
  22. package/dist/web3/assets/ActivityTracePage-DzwWIjjx.js +1 -0
  23. package/dist/web3/assets/{AgentDocsPage-Dkhgb1hk.js → AgentDocsPage-ixP2UdAE.js} +1 -1
  24. package/dist/web3/assets/CollaboratorManager-C9VAXZ0h.js +1 -0
  25. package/dist/web3/assets/CollaboratorsTab-tq4ZTexH.js +1 -0
  26. package/dist/web3/assets/DocumentEditor-C2CP2fiE.js +36 -0
  27. package/dist/web3/assets/DocumentsTab-Mh7KxUoB.js +6 -0
  28. package/dist/web3/assets/ExternalEditsTab-B_CWKXZ_.js +1 -0
  29. package/dist/web3/assets/{MarkdownEditor-Dfk5uIE5.js → MarkdownEditor-CKh4WSn-.js} +136 -131
  30. package/dist/web3/assets/MarkdownEditor-ndtIx5x0.css +1 -0
  31. package/dist/web3/assets/NestPageHeader-Cizo8nUi.js +1 -0
  32. package/dist/web3/assets/NestView-EW-sraEb.js +73 -0
  33. package/dist/web3/assets/OverviewTab-CjidsMX_.js +6 -0
  34. package/dist/web3/assets/PersonCombobox-BKI7dff4.js +1 -0
  35. package/dist/web3/assets/{ReviewActions-BXDTDPp1.js → ReviewActions-CS8JXgXF.js} +2 -2
  36. package/dist/web3/assets/ReviewTab-CwVrXzw5.js +1 -0
  37. package/dist/web3/assets/StewardsTab-C-iP6gnh.js +1 -0
  38. package/dist/web3/assets/SubmitForReviewModal-Biq3m79i.js +1 -0
  39. package/dist/web3/assets/{alert-dialog-4SiLKlz8.js → alert-dialog-BvF3NFr7.js} +2 -2
  40. package/dist/web3/assets/{arrow-left-BAgIocLn.js → arrow-left-CWIsv-sG.js} +1 -1
  41. package/dist/web3/assets/backlinks-Cqcn7n1J.js +19 -0
  42. package/dist/web3/assets/boxes-ClbgQqG2.js +6 -0
  43. package/dist/web3/assets/{chevron-left-XR4ReJ9Z.js → chevron-left-CK_ZPFm9.js} +1 -1
  44. package/dist/web3/assets/{circle-check-CSkZUEFK.js → circle-check-Bw_aTXiH.js} +1 -1
  45. package/dist/web3/assets/{circle-x-in2o3aHU.js → circle-x-DkIG68H7.js} +1 -1
  46. package/dist/web3/assets/{code-xml-pPQgdctI.js → code-xml-BlR7EN6l.js} +1 -1
  47. package/dist/web3/assets/{corner-down-right-CuxZWscX.js → corner-down-right-DSVxK3tf.js} +1 -1
  48. package/dist/web3/assets/count-skeleton-DToLM74B.js +1 -0
  49. package/dist/web3/assets/{earth-CXOcThqn.js → earth-BXhVPvCR.js} +1 -1
  50. package/dist/web3/assets/{file-exclamation-point-CSphs1VD.js → file-exclamation-point-BevGZgtj.js} +1 -1
  51. package/dist/web3/assets/{folder-input-BAwK9tFL.js → folder-input-m8UBV4fA.js} +1 -1
  52. package/dist/web3/assets/{index-A0_ymcqL.js → index-BOVYgMvj.js} +1 -1
  53. package/dist/web3/assets/index-DVnaYqj9.css +1 -0
  54. package/dist/web3/assets/index-WYDmEOfO.js +404 -0
  55. package/dist/web3/assets/page-B6pkc9cd.js +2 -0
  56. package/dist/web3/assets/page-BJrFCKXB.js +1 -0
  57. package/dist/web3/assets/page-Bgy9Ng9p.js +1 -0
  58. package/dist/web3/assets/page-C2zoxumw.js +1 -0
  59. package/dist/web3/assets/page-CJWO06dN.js +1 -0
  60. package/dist/web3/assets/page-CptuAPJc.js +6 -0
  61. package/dist/web3/assets/page-DPY01SOj.js +1 -0
  62. package/dist/web3/assets/page-Dea2SblF.js +9 -0
  63. package/dist/web3/assets/page-Deq6IKiN.js +6 -0
  64. package/dist/web3/assets/page-Dfjg8p7Z.js +1 -0
  65. package/dist/web3/assets/{page-xzYqkBUP.js → page-Dh57L0RG.js} +5 -5
  66. package/dist/web3/assets/page-Dwbp4Seu.js +6 -0
  67. package/dist/web3/assets/{page-CUjuSs5Q.js → page-DzKwP5Ds.js} +3 -3
  68. package/dist/web3/assets/page-YxjrE5b5.js +45 -0
  69. package/dist/web3/assets/{page-title-BkqlUA0j.js → page-title-DVq4qvMR.js} +1 -1
  70. package/dist/web3/assets/{play-B8xE9j5f.js → play-CXhS6Pgq.js} +1 -1
  71. package/dist/web3/assets/{send-CvAgOUPG.js → send-DdfwnIrj.js} +1 -1
  72. package/dist/web3/assets/{settings-EY4A_DYv.js → settings-DKX0Q4gO.js} +1 -1
  73. package/dist/web3/assets/{share-2-vGmZUl90.js → share-2-CxmXRt58.js} +1 -1
  74. package/dist/web3/assets/{tag-Br_y1f00.js → tag-DeYC_mRv.js} +1 -1
  75. package/dist/web3/assets/{trash-2-B4hRXo-p.js → trash-2-B0HyPTnI.js} +1 -1
  76. package/dist/web3/assets/{user-plus-4CTYEkd3.js → user-plus-Ckm_Isyc.js} +1 -1
  77. package/dist/web3/assets/{x-qxt1WrUi.js → x-mNtADYHc.js} +1 -1
  78. package/dist/web3/assets/{zap-BcsKR0ef.js → zap-D2ifcnYb.js} +1 -1
  79. package/dist/web3/index.html +2 -2
  80. package/package.json +3 -2
  81. package/dist/client-D44ZHDTJ.js +0 -11
  82. package/dist/keys-CTOGAG3W.js +0 -20
  83. package/dist/web3/assets/ActivityTracePage-D2iIbEph.js +0 -1
  84. package/dist/web3/assets/CollaboratorManager-Bo81D--Q.js +0 -1
  85. package/dist/web3/assets/CollaboratorsTab-DRCitQuX.js +0 -1
  86. package/dist/web3/assets/DocumentEditor-DV2WHYbj.js +0 -36
  87. package/dist/web3/assets/DocumentsTab-BUxBCN8o.js +0 -6
  88. package/dist/web3/assets/ExternalEditsTab-kIBimZyE.js +0 -1
  89. package/dist/web3/assets/MarkdownEditor-CsVusP2d.css +0 -1
  90. package/dist/web3/assets/NestPageHeader-yOK-OIYZ.js +0 -1
  91. package/dist/web3/assets/NestView-DwIagxwJ.js +0 -68
  92. package/dist/web3/assets/OverviewTab-DFOFMVyE.js +0 -1
  93. package/dist/web3/assets/PersonCombobox-D350WlrJ.js +0 -1
  94. package/dist/web3/assets/ReasonDialog-CN3ECAnQ.js +0 -1
  95. package/dist/web3/assets/ReviewTab-CZewAYiz.js +0 -1
  96. package/dist/web3/assets/StewardsTab-AagNWcWS.js +0 -1
  97. package/dist/web3/assets/SubmitForReviewModal-BOZJxwRN.js +0 -1
  98. package/dist/web3/assets/backlinks-Cb8Uf8mw.js +0 -24
  99. package/dist/web3/assets/card-UDyMRcps.js +0 -1
  100. package/dist/web3/assets/count-skeleton-DBWCDhRy.js +0 -1
  101. package/dist/web3/assets/dates-BCxbm4_q.js +0 -1
  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
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,12 +91,12 @@ 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. |
97
98
  | `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. |
99
+ | `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
100
  | `SUBAGENT_MAX_DEPTH` | `8` | Max sub-agent nesting depth (clamped 1..32) — bounds the call tree's HEIGHT so it stays finite/haltable. |
100
101
  | `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
102
  | `RUN_MAX_STEPS` | `10000` | Max steps a single run may accumulate (clamped 10..100000) — bounds a runaway/hostile runner's step log. |
@@ -103,7 +104,10 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
103
104
  | `ANTHROPIC_API_KEY` | _(unset)_ | Server-wide default runner key for workflow-plane agent runs. Never returned by the API — the health endpoint reports presence only, and Settings shows a masked tail. |
104
105
  | `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
106
  | `MSTEAMS_WEBHOOK_URL` | _(unset — connector off)_ | Microsoft Teams incoming-webhook URL for the same governance events, posted as Adaptive Cards. `https://` only — the URL embeds a secret. Also editable from Settings in the UI. See [Microsoft Teams notifications](#microsoft-teams-notifications). |
107
+ | `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
108
  | `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. |
109
+ | `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. |
110
+ | `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
111
  | `NOTIFY_EMAIL_FROM` | _(unset)_ | From address for notification emails. |
108
112
  | `NOTIFY_EMAIL_TO` | _(unset)_ | Comma-separated recipients for notification emails. |
109
113
  | `NOTIFY_DEBOUNCE_MS` | `15000` | Per-nest buffer window before notifications flush; bursts collapse into one digest message. |
@@ -112,7 +116,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
112
116
 
113
117
  ## Microsoft Teams notifications
114
118
 
115
- Set `MSTEAMS_WEBHOOK_URL` (or paste the URL into **Settings → Notifications →
119
+ Set `MSTEAMS_WEBHOOK_URL` (or paste the URL into **Server settings → Notifications →
116
120
  Microsoft Teams notifications**) and the server posts the same governance
117
121
  events the Slack connector covers — review requested / approved / rejected,
118
122
  collaborator added, plus per-nest burst digests — to one Teams channel as
@@ -137,7 +141,7 @@ webhooks are now created with the **Workflows** (Power Automate) app:
137
141
  confirm the team + channel.
138
142
  3. Create the flow and **copy the HTTP POST URL** it shows (a
139
143
  `https://….logic.azure.com/…` or `https://….powerplatform.com/…` address).
140
- 4. Paste that URL into **Settings → Notifications → Microsoft Teams
144
+ 4. Paste that URL into **Server settings → Notifications → Microsoft Teams
141
145
  notifications** (or set `MSTEAMS_WEBHOOK_URL`).
142
146
  5. Save, then click **Send test** on the card to post a test message and
143
147
  confirm the channel receives it. (The button tests the *saved* URL — save
@@ -168,114 +172,566 @@ with `GET`/`PATCH`/`DELETE` on the same path.
168
172
 
169
173
  ## Single sign-on (OIDC)
170
174
 
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.
175
+ This section is the admin guide to single sign-on. It covers what SSO does,
176
+ how to set it up for each identity provider, what happens to existing accounts,
177
+ API keys and agents, and how to fix each sign-in error.
178
+
179
+ **In this section:** [Overview](#overview) ·
180
+ [Prerequisites](#prerequisites) · [Settings reference](#settings-reference) ·
181
+ [Microsoft Entra ID](#connect-microsoft-entra-id) · [Google](#connect-google) ·
182
+ [Okta](#connect-okta) · [Keycloak](#connect-keycloak) ·
183
+ [Any other OIDC provider](#connect-any-other-oidc-provider) ·
184
+ [User lifecycle](#user-lifecycle) ·
185
+ [API keys, agents and MCP](#how-sso-relates-to-api-keys-agents-and-mcp) ·
186
+ [Shared deployments](#hosted-and-shared-deployments) ·
187
+ [Department auto-tagging](#department-auto-tagging) ·
188
+ [Troubleshooting](#troubleshooting-sso-sign-in)
189
+
190
+ ### Overview
191
+
192
+ With SSO turned on, the login page shows one more button. Someone clicks it,
193
+ signs in at your identity provider (IdP), and comes back signed in to
194
+ ContextNest. The session is the same one a password login creates: an httpOnly
195
+ cookie that lasts 30 days.
196
+
197
+ - **Protocol:** OpenID Connect, authorization-code flow with PKCE (S256).
198
+ `GET /auth/oidc/login` sends the browser to your IdP.
199
+ `GET /auth/oidc/callback` gets the code back, redeems it, checks the ID token
200
+ (signature against the issuer's JWKS, issuer, audience, expiry, nonce) and
201
+ starts the session. Endpoint details are in `API.md → GET /auth/oidc/login`.
202
+ - **Providers:** any OIDC provider that meets the
203
+ [requirements below](#connect-any-other-oidc-provider). The settings page
204
+ has one-click presets for **Microsoft Entra ID** and **Google**. **Okta** and
205
+ **Keycloak** work as a custom issuer. SAML is not supported.
206
+ - **Button label:** "Sign in with Microsoft" when the issuer host is
207
+ `login.microsoftonline.com`, "Sign in with Google" for
208
+ `accounts.google.com`, and "Sign in with SSO" for anything else. The button
209
+ only appears when SSO is on **and** the issuer, client ID and client secret
210
+ are all set.
211
+ - **Where to configure:** **Admin → Server settings → Single sign-on** in the
212
+ left rail, or the `OIDC_*` environment variables. Changes made in the UI take
213
+ effect at once, without a restart.
214
+ - **Who can configure:** a server admin. That is the license admin or a
215
+ superadmin (granted on **Admin → Teammates**, or listed under `super_admins`
216
+ in `access.yaml`). In `AUTH_MODE=open` nobody signs in, so SSO has no effect
217
+ there. SSO is for `AUTH_MODE=key`, the default.
218
+ - **One setup per server:** there is a single issuer, client and domain
219
+ allowlist for the whole server. See
220
+ [Hosted and shared deployments](#hosted-and-shared-deployments).
221
+
222
+ SSO is separate from three other features with similar names:
223
+
224
+ - **Sign in with PromptOwl**, the device flow (`PROMPTOWL_SIGN_IN_GATE`).
225
+ - **One-click "Open Community" login** from PromptOwl (`GET /auth/sso`,
226
+ **Server settings → Community sites**).
227
+ - **Agent SSO** token exchange ([below](#agent-sso-token-exchange)).
228
+
229
+ Each has its own settings.
230
+
231
+ ### Prerequisites
232
+
233
+ 1. **An https address.** The issuer must be `https://` (the server refuses
234
+ anything else). Your ContextNest server should also be on https: the session
235
+ and flow cookies get the `Secure` flag on https, and most IdPs only accept an
236
+ `http://` redirect URI for `localhost`.
237
+ 2. **`PUBLIC_BASE_URL` set to that address.** Set it on
238
+ **Server settings → General → Public base URL**, or in the environment, e.g.
239
+ `https://nest.acme.com`, with no trailing path. The redirect URI is built
240
+ from it. If it is unset, the server uses the origin of the incoming request,
241
+ which behind a reverse proxy is whatever `Host` header the proxy forwards.
242
+ See the `PUBLIC_BASE_URL` row in the env var table for why you should always
243
+ pin it.
244
+ 3. **The redirect URI registered at your IdP, exactly:**
245
+
246
+ ```
247
+ <PUBLIC_BASE_URL>/auth/oidc/callback
248
+ ```
249
+
250
+ The Single sign-on tab shows this value with a **Copy** button. Before you
251
+ save, it is built from the Public base URL you have typed, or from this
252
+ browser's address if that field is empty. People must reach ContextNest at
253
+ this same address. The flow cookies are set on the host where sign-in
254
+ starts, so starting on a different host fails with `state_mismatch`.
255
+ 4. **Outbound access from the server to the IdP.** The server fetches
256
+ `<issuer>/.well-known/openid-configuration`, the JWKS and the token
257
+ endpoint. Each call times out after 10 seconds.
258
+ 5. **A server clock that is correct (NTP).** ID-token expiry is checked with
259
+ no clock tolerance.
260
+ 6. **A second way in while you test.** Keep password or PromptOwl sign-in on
261
+ until an SSO login has worked. The server will not let you turn off
262
+ password and PromptOwl sign-in unless SSO is on and fully configured, so
263
+ there is always a way in.
264
+
265
+ ### Settings reference
266
+
267
+ All of these are on **Admin → Server settings → Single sign-on**, in the
268
+ **Single sign-on (OIDC)** card. The exception is `PUBLIC_BASE_URL`, which is on
269
+ the **General** tab. One **Save** button, next to the page title, saves every
270
+ tab. If a value is set both in the environment and in the UI, see
271
+ [Runtime settings persistence](#runtime-settings-persistence) for which one
272
+ wins.
273
+
274
+ | Env var | UI field | Default | What it does |
275
+ |---|---|---|---|
276
+ | `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*. |
277
+ | `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). |
278
+ | `OIDC_CLIENT_ID` | **Client ID** | `""` | The client (application) ID from your IdP. The ID token's audience must equal it. |
279
+ | `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`). |
280
+ | `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. |
281
+ | `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). |
282
+ | `OIDC_DEPARTMENT_TAGGING` | **Department auto-tagging** | `false` | Adds `dept:<department>` to documents a user creates. See [Department auto-tagging](#department-auto-tagging). |
283
+ | `PUBLIC_BASE_URL` | **General → Public base URL** | `""` (request origin) | The base of the redirect URI and of the post-logout return address. |
284
+
285
+ The following are **fixed** and cannot be configured:
286
+
287
+ - Scopes: `openid email profile`.
288
+ - Response type: `code`, with PKCE `S256`.
289
+ - Client authentication: `client_secret_post`. `client_secret_basic` and
290
+ `private_key_jwt` are not supported.
291
+ - ID-token algorithm: `RS256` only.
292
+ - Flow cookies (state, nonce, PKCE verifier) last 10 minutes.
293
+ - Discovery documents are cached for 5 minutes.
294
+ - Calls to the IdP time out after 10 seconds.
295
+ - Sessions last 30 days.
296
+ - Rate limit: 100 requests per 15 minutes per client IP, counted separately for
297
+ `/auth/oidc/login` and `/auth/oidc/callback`.
298
+
299
+ The `/admin/settings` API names for the same fields are `oidc_enabled`,
300
+ `oidc_issuer`, `oidc_client_id`, `oidc_client_secret`, `oidc_allowed_domains`,
301
+ `oidc_auto_provision` and `oidc_department_tagging` (see `API.md`).
190
302
 
191
303
  ### Connect Microsoft Entra ID
192
304
 
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
305
+ 1. **Register an app.** In the Entra admin center, go to *Identity →
306
+ Applications → App registrations → New registration*. Name it, e.g.
307
+ "ContextNest", and leave the account type at *Accounts in this
308
+ organizational directory only* (single tenant).
309
+ 2. **Add the redirect URI.** In the registration, go to *Authentication → Add
310
+ a platform → Web* and paste the redirect URI from the Single sign-on tab
311
+ (`https://<your-server>/auth/oidc/callback`).
312
+ 3. **Create a client secret.** Go to *Certificates & secrets → New client
313
+ secret*. Copy the secret **Value**, not the Secret ID. It is shown only
314
+ once. Note when it expires: after that date, sign-in fails with
315
+ `exchange_failed` until you paste a new secret.
316
+ 4. **Collect the IDs.** On the app's *Overview* page, copy the **Application
202
317
  (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.
318
+ 5. **Fill in ContextNest.** On **Admin → Server settings → Single sign-on**,
319
+ click **Microsoft Entra ID** and paste the tenant ID when asked. The issuer
320
+ becomes `https://login.microsoftonline.com/<tenant-id>/v2.0`. Paste the
321
+ client ID and the secret value. You can also set **Allowed email domains**
322
+ to your organization's domains. Turn the switch **On** and **Save**. The
323
+ switch and the fields can go in the same save.
324
+ 6. **Test.** Open the login page in a private window. A **Sign in with
325
+ Microsoft** button should appear and take you through Entra.
326
+
327
+ Entra-specific notes:
328
+
329
+ - **Use the tenant ID (a GUID), not `common`, `organizations` or a domain
330
+ name.** The server requires the discovery document's `issuer` to match the
331
+ configured issuer exactly. For the multi-tenant endpoints, Entra's document
332
+ contains the placeholder `{tenantid}`, so the check fails and sign-in stops
333
+ with `discovery_failed`. Only single-tenant sign-in is supported.
334
+ - **Email claim.** Entra v2 ID tokens often have no `email` claim. The server
335
+ then uses `preferred_username` (the UPN). The address people sign in with
336
+ may therefore be their UPN, which may differ from their mail address.
337
+ Account matching uses this address (see [User lifecycle](#user-lifecycle)).
338
+ - **Only let assigned users in (optional).** Go to *Enterprise applications →
339
+ (your app) → Properties → Assignment required? → Yes*, then assign users or
340
+ groups. Entra then refuses anyone else. Depending on where Entra stops the
341
+ sign-in, the user sees either Entra's own error page or `provider_error`.
342
+ - **Sign-out:** Entra publishes an `end_session_endpoint`, so ContextNest
343
+ signs the user out of Entra as well (see
344
+ [Offboarding and sign-out](#revoking-access-offboarding)).
345
+
346
+ ### Connect Google
347
+
348
+ 1. **Choose the project.** In the Google Cloud console, pick (or create) the
349
+ project that will own the sign-in client.
350
+ 2. **Set up the consent screen.** Go to *APIs & Services → OAuth consent
351
+ screen*. Newer consoles call this *Google Auth Platform → Branding /
352
+ Audience*.
353
+ - **Internal:** only accounts in your Google Workspace organization can
354
+ sign in. This is the right choice for a company server.
355
+ - **External:** any Google account can sign in. While the app is in
356
+ *Testing*, only the test users you list can sign in.
357
+ 3. **Create the client.** Go to *APIs & Services → Credentials → Create
358
+ credentials → OAuth client ID* (newer consoles: *Google Auth Platform →
359
+ Clients → Create client*). Choose **Application type: Web application**.
360
+ 4. **Add the redirect URI.** Under **Authorized redirect URIs**, add the
361
+ redirect URI from the Single sign-on tab exactly, then click **Create**.
362
+ 5. **Copy the credentials.** Copy the **Client ID** and **Client secret**.
363
+ 6. **Fill in ContextNest.** On **Admin → Server settings → Single sign-on**,
364
+ click **Google**. The issuer becomes `https://accounts.google.com`. Paste
365
+ the client ID and secret, and **set Allowed email domains** to your
366
+ Workspace domains. Turn the switch **On**, **Save**, and test in a private
367
+ window. A **Sign in with Google** button should appear.
368
+
369
+ Google-specific notes:
370
+
371
+ - **Set Allowed email domains.** `accounts.google.com` will sign in *any*
372
+ Google account. With auto-provision on and no domain list, anyone with a
373
+ Google account gets an account on your server. The Single sign-on tab shows
374
+ a warning when that combination is set.
375
+ - **Sign-out:** Google has no `end_session_endpoint`. Signing out of
376
+ ContextNest ends the ContextNest session only. The user stays signed in to
377
+ Google.
378
+
379
+ ### Connect Okta
380
+
381
+ These are the standard steps in the Okta Admin Console. The Okta-side behavior
382
+ noted at the end has not been tested against a live Okta org, so check it on
383
+ yours.
384
+
385
+ 1. **Create the app integration.** Go to *Applications → Applications → Create
386
+ App Integration*. Choose **Sign-in method: OIDC - OpenID Connect** and
387
+ **Application type: Web Application**, then **Next**.
388
+ 2. **General settings.** Name it, e.g. "ContextNest". Under **Grant type**,
389
+ keep **Authorization Code** checked.
390
+ - **Sign-in redirect URIs:** the redirect URI from the Single sign-on tab,
391
+ e.g. `https://nest.acme.com/auth/oidc/callback`.
392
+ - **Sign-out redirect URIs:** your base URL with a trailing slash, e.g.
393
+ `https://nest.acme.com/`. This is the address ContextNest asks Okta to
394
+ return to after sign-out.
395
+ - **Assignments:** choose who may use the app (everyone, or selected
396
+ groups). **Save**.
397
+ 3. **Copy the credentials.** On the app's **General** tab, copy the **Client
398
+ ID** and a **Client secret**. **Client authentication** should be **Client
399
+ secret**, not *Public key / Private key*.
400
+ 4. **Find the issuer.** It is one of these:
401
+ - Your Okta org URL, e.g. `https://acme.okta.com`. This is the org
402
+ authorization server.
403
+ - A custom authorization server, e.g. `https://acme.okta.com/oauth2/default`
404
+ (*Security → API → Authorization Servers → Issuer URI*).
405
+
406
+ Either works, as long as the value you enter matches the `issuer` in
407
+ `<issuer>/.well-known/openid-configuration`. Open that URL in a browser to
408
+ check. If you use a custom domain, use the issuer the discovery document
409
+ shows.
410
+ 5. **Fill in ContextNest.** On **Admin → Server settings → Single sign-on**,
411
+ paste the issuer into **Issuer URL**. There is no preset, and the page
412
+ shows *Custom issuer*. Paste the **Client ID** and **Client secret**, set
413
+ **Allowed email domains** if you want, turn it **On** and **Save**. The
414
+ login page shows **Sign in with SSO**.
415
+
416
+ Okta notes that were **not verified against a live Okta org:**
417
+
418
+ - **Client authentication method.** ContextNest sends the secret in the
419
+ request body (`client_secret_post`). If sign-in fails with `exchange_failed`
420
+ and the server log shows `[oidc] token exchange failed with status 401`,
421
+ check the app's `token_endpoint_auth_method`. The Admin Console may not show
422
+ it, so use Okta's Apps API to set it to `client_secret_post` and try again.
423
+ - **Sign-out.** ContextNest sends `post_logout_redirect_uri` and `client_id`
424
+ to Okta's logout endpoint, but not `id_token_hint`. If Okta shows an error
425
+ page on sign-out, the ContextNest session has **already** ended (it is
426
+ deleted before the redirect). Only the Okta session is left open.
427
+
428
+ ### Connect Keycloak
429
+
430
+ These steps are for the current admin console (Keycloak 19 and later).
431
+
432
+ 1. **Create the client.** In your realm, go to *Clients → Create client*.
433
+ Choose **Client type: OpenID Connect** and enter a **Client ID**, e.g.
434
+ `contextnest`. **Next**.
435
+ 2. **Capability config.** Turn **Client authentication** **On**. This makes it
436
+ a confidential client, which ContextNest requires. Keep **Standard flow**
437
+ checked. **Next**.
438
+ 3. **Login settings.**
439
+ - **Valid redirect URIs:** the redirect URI from the Single sign-on tab.
440
+ - **Valid post logout redirect URIs:** your base URL with a trailing slash,
441
+ e.g. `https://nest.acme.com/`.
442
+
443
+ **Save**.
444
+ 4. **Copy the secret.** On the client's **Credentials** tab, keep **Client
445
+ Authenticator: Client Id and Secret** and copy the **Client Secret**.
446
+ 5. **Find the issuer.** It is `https://<keycloak-host>/realms/<realm>`. Older
447
+ Keycloak versions (before 17) add `/auth`:
448
+ `https://<keycloak-host>/auth/realms/<realm>`. Open
449
+ `<issuer>/.well-known/openid-configuration` and use its `issuer` value
450
+ exactly. If Keycloak runs behind a proxy without a correct hostname
451
+ setting, the document may show an internal or `http://` issuer. That fails
452
+ with `discovery_failed`, so fix Keycloak's hostname configuration first.
453
+ 6. **Fill in ContextNest.** Paste the issuer, client ID and client secret on
454
+ **Admin → Server settings → Single sign-on**, turn it **On** and **Save**.
455
+ The login page shows **Sign in with SSO**.
456
+
457
+ Keycloak-specific notes:
458
+
459
+ - **Verified email is required.** Keycloak includes `email_verified` in the ID
460
+ token, and ContextNest refuses a token where it is `false`
461
+ (`email_not_verified`). Users created in the admin console start with
462
+ *Email verified* **off**. Turn it on for each user, or have users verify
463
+ their address.
464
+ - **Users need an email address.** A user without one produces a token
465
+ ContextNest cannot use (`invalid_token`).
466
+ - **Lock down open registration.** If the realm allows self-registration,
467
+ anyone can create a Keycloak account with any address. Turn off *User
468
+ registration* or set **Allowed email domains**. See the
469
+ [trust assumption](#email-verification-trust-assumption).
470
+ - **Signature algorithm.** Keep the realm's default `RS256`. If the client's
471
+ *ID token signature algorithm* is set to anything else (`ES256`, `PS256`,
472
+ …), every login fails with `invalid_token`.
473
+ - **Department (optional).** For [department auto-tagging](#department-auto-tagging),
474
+ add a *User Attribute* mapper to the client's dedicated scope. Set the token
475
+ claim name to `department`, the claim type to String, and turn on *Add to
476
+ ID token*.
477
+
478
+ ### Connect any other OIDC provider
479
+
480
+ Any provider that meets all of these requirements works. Each one is checked by
481
+ the server, and a failure shows up as the error code given.
482
+
483
+ | Requirement | Error if not met |
484
+ |---|---|
485
+ | Issuer is `https://` and serves `<issuer>/.well-known/openid-configuration` | Refused when saving (UI), or `not_configured` (env) / `discovery_failed` |
486
+ | 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` |
487
+ | Authorization-code flow, with the client secret in the request body (`client_secret_post`). PKCE `S256` parameters are always sent. | `exchange_failed` |
488
+ | The token response includes an `id_token` | `exchange_failed` |
489
+ | 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` |
490
+ | The ID token has an email-shaped `email` claim, or failing that a `preferred_username` claim | `invalid_token` |
491
+ | `email_verified` is not `false`. A missing claim is accepted. | `email_not_verified` |
492
+
493
+ Optional claims the server uses: `name` (display name for new accounts) and
494
+ `department` (see [Department auto-tagging](#department-auto-tagging)). If the
495
+ discovery document has an `end_session_endpoint`, sign-out also ends the IdP
496
+ session.
497
+
498
+ Enter the issuer in **Issuer URL** (the page shows *Custom issuer*), fill in the
499
+ client ID and secret, and register the redirect URI with the provider.
500
+
501
+ ### User lifecycle
502
+
503
+ **How accounts are matched.** ContextNest links an SSO login to an account
504
+ **by email address only**. It does not store the IdP's subject ID (`sub`). The
505
+ address comes from `email`, or from `preferred_username` if `email` is missing.
506
+ It is trimmed and lowercased, and matched against existing accounts without
507
+ regard to case. As a result:
508
+
509
+ - **An existing password account with the same email is the same account.**
510
+ The first SSO login signs into it, with the same nests, teams, stewardship
511
+ roles, API keys and admin rights. SSO does not change or remove the
512
+ password, so the person can still sign in with it, unless you turn password
513
+ sign-in off (see [Making SSO the only way in](#making-sso-the-only-way-in)).
514
+ - **Invited people can use SSO straight away.** An invite (Teammates, a nest
515
+ share, a team, a steward assignment) creates the account and sends a
516
+ temporary password. If the invited email matches their IdP email, they can
517
+ click the SSO button instead. Any first sign-in clears the account's
518
+ *Invited* badge.
519
+ - **A changed email is a new account.** If someone's email or UPN changes at
520
+ the IdP, their next SSO login does not match the old account. With
521
+ auto-provision on, they get a new, empty account. With it off, they get
522
+ `not_invited`. Share their nests with the new address again, and remove the
523
+ old account on Teammates.
524
+ - **Admin rights belong to the account,** not to how it signed in. A
525
+ superadmin listed by email in `access.yaml` or granted on Teammates is still
526
+ an admin when signing in with SSO.
527
+
528
+ **Auto-provision on (the default).** The first successful SSO login by an
529
+ unknown email creates an account. The display name comes from the `name`
530
+ claim, and the password is random and unusable. A new account sees only
531
+ nests shared with it (directly or through a team) and nests whose visibility
532
+ is *Organization* or *Public*. The account
533
+ can sign in only through SSO, unless an admin sets a password with **Reset
534
+ password** on Teammates. Auto-provision also applies when **Manual sign-in**
535
+ is *Invite only*, because that setting governs email-and-password
536
+ registration, not SSO. Limit who can get in with **Allowed email domains**, or
537
+ by assigning the app to specific users or groups at the IdP.
538
+
539
+ **Auto-provision off (invite-only SSO).** Only emails that already have an
540
+ account can sign in. Everyone else is sent back with `not_invited`. Add people
541
+ first with **Admin → Teammates → Invite Teammate**, or by sharing a nest or
542
+ document with their email. They can then use the SSO button.
543
+
544
+ **Allowed email domains** apply to every SSO login, new and existing
545
+ accounts alike. An existing account whose domain is not on the list gets
546
+ `domain_not_allowed`. It can still use password or PromptOwl sign-in if those
547
+ are on.
548
+
549
+ #### Making SSO the only way in
550
+
551
+ On **Server settings → General**, set **Manual sign-in** to **Off** and
552
+ **Sign in with PromptOwl** to **Off**. The login page then shows only the SSO
553
+ button, and `POST /auth/login` / `POST /auth/register` return `403`. The server
554
+ refuses this combination unless SSO is on and has an issuer, client ID and
555
+ secret. It also refuses to turn SSO off or clear its settings while SSO is the
556
+ only way in.
557
+
558
+ Consider a break-glass option: set **Sign in with PromptOwl** to **Admins
559
+ only**. The license admin can then still sign in through `?admin=1` on the
560
+ login page if the IdP is down.
561
+
562
+ API keys keep working either way. They are not a sign-in method (see
563
+ [below](#how-sso-relates-to-api-keys-agents-and-mcp)).
217
564
 
218
- ### Department auto-tagging
565
+ ### Revoking access (offboarding)
219
566
 
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.
567
+ **Disabling someone at the IdP only blocks new SSO logins.** It does not
568
+ affect:
569
+
570
+ - ContextNest sessions they already have. These stay valid until they expire,
571
+ up to 30 days after login.
572
+ - Their **API keys**. These do not expire and never contact the IdP.
573
+ - Their password, if the account had one or an admin has set one, while
574
+ password sign-in is on.
575
+
576
+ To cut access completely, **remove the user in ContextNest**: go to
577
+ **Admin → Teammates**, find the person and click **Remove**. This deletes their
578
+ account, all their API keys, their active sessions, their stewardship roles
579
+ and their nest-collaborator grants in one step. Documents they wrote stay in
580
+ their nests. A few limits apply:
581
+
582
+ - Removal is refused while the user **owns** nests. Transfer or delete those
583
+ nests first.
584
+ - A server admin cannot be removed until their superadmin access is revoked.
585
+ - Nobody can remove their own account.
586
+
587
+ The full procedure has two steps:
588
+
589
+ 1. **Disable the user at the IdP.** Do this first. With auto-provision on, a
590
+ user who is still active at the IdP can sign in again after removal and
591
+ get a new, empty account.
592
+ 2. **Remove the user on Admin → Teammates.** This ends their sessions and
593
+ revokes their keys.
594
+
595
+ Bearers minted by [Agent SSO](#agent-sso-token-exchange) look up the user by
596
+ email on every request, so they stop working once the user is removed.
597
+
598
+ **Sign-out (RP-initiated logout).** When someone who signed in with SSO clicks
599
+ **Log out**, the app goes to `GET /auth/oidc/logout`. This deletes the
600
+ ContextNest session and cookies. Then, if the IdP publishes an
601
+ `end_session_endpoint` (Entra, Okta and Keycloak do; Google does not), it sends
602
+ the browser there with `post_logout_redirect_uri=<PUBLIC_BASE_URL>/` and
603
+ `client_id`, so the IdP session ends too. Without that, the next click on the
604
+ SSO button would sign the person straight back in. Register `<base URL>/` as a
605
+ sign-out / post-logout redirect URI at IdPs that require it (Okta, Keycloak).
606
+ If the IdP has no logout endpoint, or cannot be reached, the local session is
607
+ still cleared and the user lands on the login page.
608
+
609
+ ### How SSO relates to API keys, agents and MCP
610
+
611
+ SSO controls how **people sign in to the web app**. It does not control how
612
+ programs connect. Turning SSO on changes nothing for existing keys or agent
613
+ connections.
614
+
615
+ | Way in | Effect of turning on SSO |
616
+ |---|---|
617
+ | **Browser, email and password** | Keeps working. Controlled by **Manual sign-in** (Server settings → General). |
618
+ | **Browser, Sign in with PromptOwl** | Keeps working. Controlled by **Sign in with PromptOwl** (Server settings → General). |
619
+ | **Browser, SSO button** | New. Creates the same 30-day session as a password login. |
620
+ | **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. |
621
+ | **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. |
622
+ | **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). |
623
+ | **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. |
624
+
625
+ ### Hosted and shared deployments
626
+
627
+ The SSO settings apply to the **whole server**: one issuer, one client, one
628
+ domain list and one auto-provision setting for every user. There is no
629
+ per-organization or per-nest IdP. On a server that hosts several
630
+ organizations (`SHARED_DEPLOYMENT=true`, such as the PromptOwl-hosted service):
631
+
632
+ - Turning on SSO puts the same button in front of **every** user of the
633
+ server, and it signs in against one organization's IdP.
634
+ - With auto-provision on, anyone that IdP signs in (and the domain list allows)
635
+ gets an account on the shared server. That account can read every nest
636
+ whose visibility is *Organization* or *Public*, which on a shared server
637
+ means nests belonging to other customers.
638
+ - Only a server admin, meaning the operator, can change these settings.
639
+ Customers of a shared server cannot bring their own IdP.
640
+
641
+ An organization that needs its own IdP should run its own ContextNest server.
642
+ Per-organization identity (SAML / SCIM, a multi-tenant admin console) is in the
643
+ Enterprise edition (see `README.md`).
244
644
 
245
- ### Revoking access (offboarding)
645
+ ### Department auto-tagging
246
646
 
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).
647
+ With `OIDC_DEPARTMENT_TAGGING` on (**Admin → Server settings → Single sign-on →
648
+ Department auto-tagging**), every document a user **creates** is tagged
649
+ `dept:<slugified-department>` from their directory department. For example, a
650
+ user in *Customer Success* creates documents tagged `dept:customer-success`.
651
+ It applies to new documents only: edits never add or change the tag, and
652
+ existing documents are never tagged afterwards. Users without a department
653
+ (password accounts, or an IdP that does not send the claim) create untagged
654
+ documents, with no error.
655
+
656
+ The department comes from the **`department` claim** in the OIDC ID token. It
657
+ is saved on the user at every SSO login. A new value replaces the old one and a
658
+ missing claim clears it, so directory moves take effect at the user's next
659
+ sign-in. Whitespace is trimmed and collapsed, and the value is cut to 120
660
+ characters.
661
+
662
+ Microsoft Entra ID does **not** send the claim by default. Add it to the app
663
+ registration: *Token configuration → Add optional claim → Token type: **ID** →
664
+ select **department** → Add*. Grant the suggested Microsoft Graph permission if
665
+ asked, and make sure users' *Department* field is filled in in Entra. Other
666
+ IdPs work too, as long as they send a string `department` claim in the ID token
667
+ (e.g. a Keycloak user-attribute mapper, see [Keycloak](#connect-keycloak)).
668
+
669
+ > **Privacy note:** the `dept:<slug>` tag is part of the document's visible
670
+ > metadata. Anyone who can read the document (collaborators, shared nests,
671
+ > public nests) can see the creator's directory department. That is
672
+ > PII-adjacent organizational data. Think about this before turning it on for
673
+ > servers where documents are shared beyond the creator's own team or made
674
+ > public.
261
675
 
262
676
  ### Email-verification trust assumption
263
677
 
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`.
678
+ An ID token whose `email_verified` claim is **explicitly `false`** (the boolean
679
+ or the string `"false"`) is refused with `?sso_error=email_not_verified`. If
680
+ the claim is **missing**, the server trusts the email claim. Microsoft Entra ID
681
+ v2 tokens often leave it out, and both Entra and Google guarantee address
682
+ ownership, so refusing when it is missing would break the main providers.
683
+
684
+ This is a deliberate trust assumption. **If your IdP allows unverified,
685
+ self-registered emails (e.g. an open Keycloak realm), set Allowed email domains
686
+ and turn off self-registration at the IdP.** Otherwise anyone who can claim an
687
+ arbitrary email at your IdP can sign in as the local account with that email,
688
+ including an admin's.
689
+
690
+ ### Troubleshooting SSO sign-in
691
+
692
+ When an SSO sign-in fails, the browser lands on `/?sso_error=<code>`. The app
693
+ shows the message below as a toast, removes the parameter from the address bar
694
+ and shows the normal sign-in options. For the codes that involve the IdP, the
695
+ server log has the details: `[oidc] discovery failed: …`,
696
+ `[oidc] token exchange failed with status <n>`,
697
+ `[oidc] ID token validation failed: …` and
698
+ `[oidc] provisioning/session failed: …`.
699
+
700
+ | `sso_error` | Message the user sees | Cause | Fix |
701
+ |---|---|---|---|
702
+ | `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. |
703
+ | `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://`. |
704
+ | `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. |
705
+ | `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. |
706
+ | `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. |
707
+ | `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`. |
708
+ | `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. |
709
+ | `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. |
710
+ | `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). |
711
+ | `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. |
712
+ | `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. |
713
+ | `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. |
714
+
715
+ **Errors that appear at the IdP, not in ContextNest.** Some failures stop at
716
+ the IdP's own error page, and the browser never comes back. The most common
717
+ one is a redirect URI that is not registered exactly: Entra `AADSTS50011`,
718
+ Google `Error 400: redirect_uri_mismatch`, or Okta and Keycloak "invalid
719
+ redirect uri". Google also blocks, on its own page, users outside an *Internal*
720
+ consent screen's organization, and users who are not on the test list while
721
+ the app is in *Testing*. Copy the redirect URI from the Single sign-on tab again and
722
+ compare it character by character: scheme, host, port, path, and no trailing
723
+ slash. Check that it matches `PUBLIC_BASE_URL`.
724
+
725
+ **No SSO button on the login page.** The button appears only when SSO is on
726
+ *and* the issuer, client ID and secret are all set. The Single sign-on tab
727
+ warns *Enabled but not configured* when one is missing. The page reads this
728
+ state from `GET /health` (`oidc_enabled`, `oidc_issuer`).
729
+
730
+ **Other `sso_error` codes.** `not_supported`, `missing_ticket`,
731
+ `invalid_ticket`, `bad_audience`, `ticket_used` and `sign_in_restricted` come
732
+ from the PromptOwl one-click login (`GET /auth/sso`), not from OIDC. See
733
+ `API.md → GET /auth/sso` and **Server settings → Community sites**.
734
+ `rate_limited` and `service_error` are used by both flows.
279
735
 
280
736
  ---
281
737
 
@@ -288,8 +744,8 @@ RFC 8693-shaped. **Off by default and security-critical**: it trusts a token
288
744
  minted elsewhere and hands back access, so read this section before enabling it.
289
745
 
290
746
  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
747
+ `MS_*` / `GOOGLE_*` vars above, or from **Admin → Server settings → Single sign-on →
748
+ Agent SSO — token exchange** (server admins). It reuses `OIDC_ALLOWED_DOMAINS` and `OIDC_AUTO_PROVISION` — the
293
749
  same who-may-sign-in policy as browser login.
294
750
 
295
751
  **Who this is for — and who it is not.** The minted bearer is short-lived by
@@ -396,7 +852,7 @@ are printed to the server log.
396
852
 
397
853
  ### Runtime settings persistence
398
854
 
399
- Settings you change at runtime — everything on the **Settings page** (`/admin/settings`:
855
+ Settings you change at runtime — everything on **Admin → Server settings** (`/admin/settings`:
400
856
  sign-in gate, logo, base URL, upload limit, feature flags, OIDC SSO, Slack/Teams/SMTP connectors)
401
857
  plus the **installed license key** — are stored in the database (`server_settings`
402
858
  table), **not** in a `.env` file. This is deliberate: on Cloud Run the container