@promptowl/contextnest-community 1.9.0 → 1.11.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.
package/CONFIGURATION.md CHANGED
@@ -56,16 +56,26 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
56
56
  | `DB_SSL_CA` | `""` | Path to a CA certificate (PEM) for verify-ca/verify-full TLS when `DB_SSL=true`. |
57
57
  | `AUTH_MODE` | `key` | `key` or `open`. See above. |
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
- | `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 `ENV_FILE_PATH`. |
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`. |
61
62
  | `OFFICIAL_COMMUNITY_SSO_SECRET` | `""` | **Official deployment only — leave unset on self-hosted.** Shared HMAC secret enabling the one-click "Open Community" SSO auto-login from PromptOwl. Must exactly match the same-named var on PromptOwl. When unset, `GET /auth/sso` returns `404` and the feature is disabled; self-hosted users keep using the manual device-code flow. |
62
- | `PUBLIC_BASE_URL` | `""` | This server's canonical external URL (e.g. `https://community.promptowl.ai`). Checked against the SSO ticket's `aud` claim so a ticket minted for this server can't be replayed against another. Only relevant when `OFFICIAL_COMMUNITY_SSO_SECRET` is set; when unset, the audience check is skipped. |
63
- | `ENV_FILE_PATH` | `$DATA_ROOT/.env` | Path to the `.env` file the license install flow writes `PROMPTOWL_KEY` into (alongside existing vars), and which the server also reads at boot. Defaults **under `DATA_ROOT`** so the browser License Setup Page persists durably in containers — `$cwd` is `/app` in the official image (root-owned, discarded on container recreate), which silently lost the key. Override only if your writable, persisted `.env` lives elsewhere. In containers, providing `PROMPTOWL_KEY` directly via the environment also works and is read at boot. |
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 only when `OFFICIAL_COMMUNITY_SSO_SECRET` is set; when unset, the audience check is skipped. (2) **Set this whenever `OIDC_ENABLED=true`.** It's the base for the OIDC `redirect_uri` sent to your IdP and replayed at token exchange. Unset, that URI is derived from the incoming request's `Host` header — fine when your proxy overwrites `Host` with a trusted value, but a proxy that passes an attacker-supplied `Host` through would feed it straight into the redirect URI. Setting this pins the value regardless of what the proxy forwards. (Your IdP's own redirect-URI allowlist is a second line of defence, not a substitute.) |
64
+ | `OIDC_ENABLED` | `false` | Turn on generic OIDC single sign-on (`GET /auth/oidc/login` / `GET /auth/oidc/callback`). Requires `OIDC_ISSUER`, `OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET` the login-page button only appears once all three are set. Also editable from Settings Single sign-on. See [Single sign-on (OIDC)](#single-sign-on-oidc). |
65
+ | `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
+ | `OIDC_CLIENT_ID` | `""` | Application (client) ID from your IdP app registration. |
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
+ | `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
+ | `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
+ | `OIDC_DEPARTMENT_TAGGING` | `false` | Auto-tag newly **created** documents with the creator's directory department: `dept:<slugified-department>` (lowercase, spaces → dashes, e.g. `dept:customer-success`) is appended to the document's tags, deduped against user-supplied tags. Applies on create only — never on update, never retroactively — and a user without a stored department is a silent no-op. The department is captured from the OIDC `department` ID-token claim on every SSO login (a login without the claim clears it, so directory moves propagate), so this is only meaningful when your IdP emits that claim — see [Department auto-tagging](#department-auto-tagging). Also editable from Settings → Single sign-on. |
71
+ | `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`. |
64
72
  | `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
65
73
  | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
74
+ | `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. |
66
75
  | `CORS_ORIGINS` | `*` in open mode; `http://localhost:5173,http://localhost:3838` in key mode | Comma-separated allowlist. Set to `*` to allow any origin (**only** safe in open mode — in key mode with Bearer tokens this enables CSRF). |
67
76
  | `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. |
68
77
  | `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. |
78
+ | `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`. |
69
79
  | `FEATURE_WORKFLOW_PLANE` | _(unset — off)_ | Enables the workflow plane: typed edges, edge-type registry, governed runs. Optional feature; also toggleable from Settings. |
70
80
  | `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. |
71
81
  | `SUBAGENT_MAX_DEPTH` | `8` | Max sub-agent nesting depth (clamped 1..32) — bounds the call tree's HEIGHT so it stays finite/haltable. |
@@ -79,6 +89,119 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
79
89
 
80
90
  ---
81
91
 
92
+ ## Single sign-on (OIDC)
93
+
94
+ Generic OpenID Connect sign-in against any spec-compliant identity provider —
95
+ Microsoft Entra ID and Google are the first-class presets in **Settings →
96
+ Single sign-on**. The server runs a standard authorization-code flow with PKCE:
97
+ `GET /auth/oidc/login` redirects to your IdP, `GET /auth/oidc/callback` verifies
98
+ the returned ID token (issuer, audience, nonce, signature against the issuer's
99
+ JWKS) and starts a normal browser session. Users are looked up by email;
100
+ unknown emails are created automatically when `OIDC_AUTO_PROVISION` is on.
101
+
102
+ All seven `OIDC_*` values are also editable at runtime from **Settings → Single
103
+ sign-on** (superadmin only) — no restart needed. The Settings page shows the
104
+ exact **redirect URI** to register with your IdP:
105
+
106
+ ```
107
+ <PUBLIC_BASE_URL or server origin>/auth/oidc/callback
108
+ ```
109
+
110
+ Set `PUBLIC_BASE_URL` when the server sits behind a reverse proxy so the
111
+ redirect URI is derived from the canonical address rather than the incoming
112
+ Host header.
113
+
114
+ ### Connect Microsoft Entra ID
115
+
116
+ 1. **Register an app.** Entra admin center → *Identity → Applications → App
117
+ registrations → New registration*. Name it (e.g. "ContextNest"), leave the
118
+ account type at *Accounts in this organizational directory only*.
119
+ 2. **Add the redirect URI.** In the registration: *Authentication → Add a
120
+ platform → Web*, and paste the redirect URI shown on the ContextNest
121
+ Settings page (`https://<your-server>/auth/oidc/callback`).
122
+ 3. **Create a client secret.** *Certificates & secrets → New client secret*.
123
+ Copy the secret **value** (not the ID) immediately — it's shown once.
124
+ 4. **Collect the IDs.** On the app's *Overview* page copy the **Application
125
+ (client) ID** and the **Directory (tenant) ID**.
126
+ 5. **Fill in ContextNest.** Settings → Single sign-on: click **Microsoft Entra
127
+ ID**, paste the tenant ID when prompted (issuer becomes
128
+ `https://login.microsoftonline.com/<tenant>/v2.0`), then paste the client
129
+ ID and the secret value. Optionally restrict **Allowed email domains** to
130
+ your org's domain. Save, then flip the toggle **On** and save again (or in
131
+ the same save).
132
+ 6. **Test.** Open the login page in a private window — a **Sign in with
133
+ Microsoft** button appears and round-trips through Entra. Entra accounts
134
+ without an `email` claim fall back to `preferred_username` (the UPN).
135
+
136
+ For **Google**: create an OAuth client ID (type *Web application*) in the
137
+ Google Cloud console, add the same redirect URI under *Authorized redirect
138
+ URIs*, and use the **Google** preset (issuer `https://accounts.google.com`)
139
+ with that client ID/secret.
140
+
141
+ ### Department auto-tagging
142
+
143
+ With `OIDC_DEPARTMENT_TAGGING` on (Settings → Single sign-on → **Department
144
+ auto-tagging**), every document a user **creates** is tagged
145
+ `dept:<slugified-department>` from their directory department — e.g. a user in
146
+ *Customer Success* creates docs tagged `dept:customer-success`. Create-only:
147
+ edits never add or change the tag, and existing documents are never
148
+ retro-tagged. Users without a department (password accounts, or an IdP that
149
+ doesn't emit the claim) create untagged documents — never an error.
150
+
151
+ The department is read from the **`department` claim** in the OIDC ID token
152
+ and stored on the user at every SSO login: a new value updates it, an absent
153
+ claim clears it, so directory moves propagate on the user's next sign-in.
154
+
155
+ Microsoft Entra ID does **not** emit the claim by default — add it to the app
156
+ registration: *Token configuration → Add optional claim → Token type: **ID**
157
+ → select **department** → Add* (grant the suggested Microsoft Graph
158
+ permission if prompted), and make sure the users' *Department* field is
159
+ populated in Entra. Other IdPs work too as long as they emit a string
160
+ `department` claim in the ID token (e.g. a Keycloak user-attribute mapper).
161
+
162
+ > **Privacy note:** the `dept:<slug>` tag becomes part of the document's
163
+ > visible metadata — anyone who can read the document (collaborators, shared
164
+ > nests, public nests) can see the creator's directory department. That's
165
+ > PII-adjacent organizational data; consider this before enabling on servers
166
+ > where documents are shared beyond the creator's own team or made public.
167
+
168
+ ### Revoking access (offboarding)
169
+
170
+ Disabling a user **at the IdP** only blocks *new* sign-ins — an existing
171
+ ContextNest session stays valid until it expires (30 days). To cut access
172
+ immediately, **remove the user in ContextNest** (Settings → users): that wipes
173
+ all of their active sessions at once. Real offboarding is therefore two steps —
174
+ disable at the IdP *and* remove in ContextNest — with the ContextNest step being
175
+ the one that ends live sessions.
176
+
177
+ When a user signs themselves out, ContextNest performs an **RP-initiated
178
+ logout**: if the IdP advertises an `end_session_endpoint` (Entra, Okta,
179
+ Keycloak do; Google does not), the browser is bounced through it so the IdP
180
+ session ends too and the next "Sign in with SSO" click doesn't silently
181
+ re-authenticate. Where the IdP has no logout endpoint, the local session is
182
+ cleared and the user lands back on the login page (the IdP session persists —
183
+ that's the IdP's own timeout to manage).
184
+
185
+ ### Email-verification trust assumption
186
+
187
+ An ID token whose `email_verified` claim is **explicitly `false`** is refused
188
+ (`?sso_error=email_not_verified`). When the claim is **absent**, the server
189
+ trusts the email claim — Microsoft Entra ID v2 tokens frequently omit it, and
190
+ both Entra and Google guarantee address ownership, so refusing on absence
191
+ would break the primary providers. This is a deliberate trust assumption:
192
+ **when your IdP allows unverified self-registered emails (e.g. an open
193
+ Keycloak realm), configure `oidc_allowed_domains` and disable self-registration
194
+ at the IdP** — otherwise anyone able to assert an arbitrary email at your IdP
195
+ could sign in as the matching local account.
196
+
197
+ Sign-in failures bounce back to the app as `/?sso_error=<code>` and surface as
198
+ a toast; codes: `disabled`, `not_configured`, `discovery_failed`,
199
+ `provider_error`, `state_mismatch`, `exchange_failed`, `invalid_token`,
200
+ `email_not_verified`, `domain_not_allowed`, `not_invited`, `rate_limited`,
201
+ `service_error`.
202
+
203
+ ---
204
+
82
205
  ## Database backends
83
206
 
84
207
  The governance/auth metadata (users, sessions, nests registry, stewards, reviews,
@@ -103,6 +226,31 @@ Postgres backend starts empty.
103
226
  > shared storage — e.g. mount a GCS bucket via Cloud Storage FUSE, or use a
104
227
  > persistent volume — otherwise documents will disappear on redeploy/scale-in.
105
228
 
229
+ ### Runtime settings persistence
230
+
231
+ Settings you change at runtime — everything on the **Settings page** (`/admin/settings`:
232
+ sign-in gate, logo, base URL, upload limit, feature flags, OIDC SSO, Slack/SMTP connectors)
233
+ plus the **installed license key** — are stored in the database (`server_settings`
234
+ table), **not** in a `.env` file. This is deliberate: on Cloud Run the container
235
+ filesystem is ephemeral, so a file-based value was wiped by every rebuild, and each
236
+ horizontally-scaled instance had its own filesystem, so a change on one never
237
+ reached the others. The database (Cloud SQL in production) is durable **and** shared,
238
+ so a UI change persists across rebuilds and is seen by every instance. Rows are
239
+ loaded into the process environment at boot.
240
+
241
+ **Precedence between the deploy environment and a UI change.** Each stored row
242
+ remembers the deploy-env value in effect when it was written. At boot:
243
+
244
+ - If the deploy env value for that key is **unchanged**, the stored UI change wins
245
+ (your Settings-page edit survives an identical rebuild).
246
+ - If the deploy env value **changed** (you redeployed with a new value for that
247
+ variable), the deploy env wins and the stored row is re-synced to it.
248
+
249
+ So you can always override any setting from the deploy config, while UI edits made
250
+ to keys you don't set in the environment stay put. Clearing a setting in the UI
251
+ writes a tombstone, so a value you removed is not resurrected from the environment
252
+ on the next boot.
253
+
106
254
  ### Cloud Run + Cloud SQL (PostgreSQL)
107
255
 
108
256
  Attach the Cloud SQL instance to the service (`--add-cloudsql-instances`) so the
package/README.md CHANGED
@@ -105,7 +105,8 @@ For redistribution, hosted-service, OEM, or regulated-industry licensing, contac
105
105
  | Steward version revert | ✅ | ✅ |
106
106
  | MCP server for AI agents | ✅ | ✅ |
107
107
  | Centralized multi-tenant admin console | — | ✅ |
108
- | SSO / SAML / SCIM | | ✅ |
108
+ | Single sign-on (OIDC Entra ID, Google, Okta, Keycloak) | | ✅ |
109
+ | SAML / SCIM provisioning | — | ✅ |
109
110
  | Audit log streaming | — | ✅ |
110
111
  | Policy transforms (redaction, summarization) | — | ✅ |
111
112
  | Priority support and SLA | — | ✅ |
@@ -28,8 +28,9 @@ var canonicalEnvLoaded = null;
28
28
  var slackUrlWarned = false;
29
29
  var emailFromWarned = false;
30
30
  var emailToWarned = false;
31
+ var oidcIssuerWarned = false;
31
32
  function isEmailish(v) {
32
- return /^[^\s@]+@[^\s@]+$/.test(v) && !/[\r\n]/.test(v);
33
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) && !/[\r\n]/.test(v);
33
34
  }
34
35
  function isEmailListish(v) {
35
36
  return v.split(",").every((e) => isEmailish(e.trim()));
@@ -124,6 +125,28 @@ var config = {
124
125
  get PROMPTOWL_KEY() {
125
126
  return process.env.PROMPTOWL_KEY || "";
126
127
  },
128
+ /**
129
+ * Whether importing/syncing teams FROM PromptOwl is offered. Default OFF
130
+ * (only an explicit "true" enables) — an operator opts the PO-team surface
131
+ * in from Settings or env. When off, the /teams/promptowl routes 404 and the
132
+ * UI hides the panel. Independent of PROMPTOWL_SIGN_IN_GATE: a user may sign
133
+ * in with PromptOwl yet not be allowed to pull their PO teams in.
134
+ */
135
+ get PROMPTOWL_TEAMS_ENABLED() {
136
+ return process.env.PROMPTOWL_TEAMS_ENABLED === "true";
137
+ },
138
+ /** Vercel automation-bypass secret for PO calls; unset = no bypass. */
139
+ get PROMPTOWL_BYPASS_SECRET() {
140
+ return process.env.PROMPTOWL_BYPASS_SECRET || "";
141
+ },
142
+ /** Outbound headers for PO fetches; bypass header only when secret set. */
143
+ get PROMPTOWL_FETCH_HEADERS() {
144
+ const h = { "Content-Type": "application/json" };
145
+ if (this.PROMPTOWL_BYPASS_SECRET) {
146
+ h["x-vercel-protection-bypass"] = this.PROMPTOWL_BYPASS_SECRET;
147
+ }
148
+ return h;
149
+ },
127
150
  /**
128
151
  * Shared secret for one-click SSO auto-login from PromptOwl (PO).
129
152
  *
@@ -164,6 +187,98 @@ var config = {
164
187
  const v = (process.env.PROMPTOWL_SIGN_IN_GATE || "open").trim().toLowerCase();
165
188
  return v === "admin-only" || v === "disabled" ? v : "open";
166
189
  },
190
+ /**
191
+ * Manual (email + password) sign-in. Three modes, independent of
192
+ * `PROMPTOWL_SIGN_IN_GATE` (the two methods are controlled separately):
193
+ * "open" — anyone may log in and self-register (default).
194
+ * "invite-only" — existing + admin-invited users may log in and an invited
195
+ * placeholder may set its password ("claim"), but brand-new
196
+ * self-registration is refused. Use this when the admin
197
+ * provisions accounts (invite / share / steward) and users
198
+ * shouldn't be able to create their own.
199
+ * "disabled" — no email/password sign-in at all (PromptOwl only).
200
+ * Enforced at POST /auth/login and POST /auth/register. The /admin/settings
201
+ * validator refuses "disabled" while PromptOwl is also disabled, so a server
202
+ * can never be left with no way to sign in.
203
+ */
204
+ get MANUAL_SIGN_IN() {
205
+ const v = (process.env.MANUAL_SIGN_IN || "open").trim().toLowerCase();
206
+ return v === "invite-only" || v === "disabled" ? v : "open";
207
+ },
208
+ /**
209
+ * Generic OIDC single sign-on (Microsoft Entra ID, Google, any spec-
210
+ * compliant IdP). Off by default; superadmin-configurable at runtime via
211
+ * /admin/settings. When enabled AND fully configured (issuer + client id +
212
+ * client secret), GET /auth/oidc/login starts an authorization-code + PKCE
213
+ * flow and GET /auth/oidc/callback finishes it with a normal browser
214
+ * session — exactly like a password login.
215
+ */
216
+ get OIDC_ENABLED() {
217
+ return process.env.OIDC_ENABLED === "true";
218
+ },
219
+ /**
220
+ * OIDC issuer URL, e.g. "https://login.microsoftonline.com/<tenant>/v2.0"
221
+ * or "https://accounts.google.com". https only — tokens and the client
222
+ * secret ride this trust root, so a plaintext issuer is refused (warn once,
223
+ * treated as unset). Trailing slash stripped so discovery-doc comparison is
224
+ * canonical.
225
+ */
226
+ get OIDC_ISSUER() {
227
+ const raw = (process.env.OIDC_ISSUER || "").trim().replace(/\/$/, "");
228
+ if (!raw) return "";
229
+ let ok = false;
230
+ try {
231
+ ok = new URL(raw).protocol === "https:";
232
+ } catch {
233
+ ok = false;
234
+ }
235
+ if (!ok) {
236
+ if (!oidcIssuerWarned) {
237
+ oidcIssuerWarned = true;
238
+ console.warn(
239
+ "[config] OIDC_ISSUER rejected: must be a valid https:// URL. OIDC sign-in disabled."
240
+ );
241
+ }
242
+ return "";
243
+ }
244
+ return raw;
245
+ },
246
+ get OIDC_CLIENT_ID() {
247
+ return (process.env.OIDC_CLIENT_ID || "").trim();
248
+ },
249
+ get OIDC_CLIENT_SECRET() {
250
+ return (process.env.OIDC_CLIENT_SECRET || "").trim();
251
+ },
252
+ /**
253
+ * Comma-separated email-domain allowlist for OIDC sign-in (e.g.
254
+ * "acme.com, contractors.acme.com"). Empty = any domain the IdP asserts.
255
+ * Entries are normalized to lowercase; matching is on the part after the
256
+ * final "@" of the asserted email.
257
+ */
258
+ get OIDC_ALLOWED_DOMAINS() {
259
+ return (process.env.OIDC_ALLOWED_DOMAINS || "").split(",").map((d) => d.trim().toLowerCase()).filter(Boolean);
260
+ },
261
+ /**
262
+ * Auto-provision (JIT-create) an unknown user on first successful OIDC
263
+ * sign-in. Default true. When false, only emails that already have a user
264
+ * row (invited / registered) may sign in via OIDC — everyone else bounces
265
+ * with ?sso_error=not_invited.
266
+ */
267
+ get OIDC_AUTO_PROVISION() {
268
+ return process.env.OIDC_AUTO_PROVISION !== "false";
269
+ },
270
+ /**
271
+ * Auto-tag content created by SSO users with their directory department:
272
+ * when on, node CREATE appends `dept:<slugified-department>` to the new
273
+ * node's tags (never on update, never retroactively). The department is
274
+ * captured from the OIDC `department` claim at login (see oidc.ts), so
275
+ * this is only meaningful alongside an IdP that emits that claim — but
276
+ * there is no hard dependency on OIDC_ENABLED. Default off; superadmin-
277
+ * togglable at runtime via /admin/settings.
278
+ */
279
+ get OIDC_DEPARTMENT_TAGGING() {
280
+ return process.env.OIDC_DEPARTMENT_TAGGING === "true";
281
+ },
167
282
  /**
168
283
  * Path to the .env file the server reads its config from and the license
169
284
  * install flow persists PROMPTOWL_KEY into. Defaults UNDER DATA_ROOT (not
@@ -181,6 +296,18 @@ var config = {
181
296
  get TELEMETRY_INTERVAL_MS() {
182
297
  return parseInt(process.env.TELEMETRY_INTERVAL_MS || "3600000", 10);
183
298
  },
299
+ /**
300
+ * Activity-trace retention window in days (the api_events table behind
301
+ * GET /admin/trace and GET /nests/:id/trace). 0 = keep forever (pruning is
302
+ * skipped entirely). Invalid / negative → default 14; capped at 3650 (ten
303
+ * years) so a typo can't schedule a prune cutoff in the distant past.
304
+ * Editable at runtime from the admin Settings page (/admin/settings).
305
+ */
306
+ get TRACE_RETENTION_DAYS() {
307
+ const n = parseInt(process.env.TRACE_RETENTION_DAYS || "14", 10);
308
+ if (!Number.isFinite(n) || n < 0) return 14;
309
+ return Math.min(n, 3650);
310
+ },
184
311
  /**
185
312
  * Optional custom logo URL shown in UI header + login screen.
186
313
  * Must be an absolute https://, http://, or data:image/… URL. Other
@@ -206,9 +333,44 @@ var config = {
206
333
  * This flag is also the future license-tier hook: gating a paid tier here
207
334
  * is a one-line change because every plane route checks it per request.
208
335
  */
336
+ /**
337
+ * Optional Slack incoming-webhook URL for team notifications (review
338
+ * submitted/approved/rejected). Empty/unset = connector off. https only —
339
+ * a webhook carries an implicit secret in its path, so it never travels
340
+ * plaintext. (Ported from development PR #105.)
341
+ */
342
+ get SLACK_WEBHOOK_URL() {
343
+ const raw = process.env.SLACK_WEBHOOK_URL?.trim();
344
+ if (!raw) return null;
345
+ if (!/^https:\/\//i.test(raw)) {
346
+ if (!slackUrlWarned) {
347
+ slackUrlWarned = true;
348
+ console.warn(
349
+ "[config] SLACK_WEBHOOK_URL rejected: must be an https:// URL. Slack notifications disabled."
350
+ );
351
+ }
352
+ return null;
353
+ }
354
+ return raw;
355
+ },
209
356
  get FEATURE_WORKFLOW_PLANE() {
210
357
  return process.env.FEATURE_WORKFLOW_PLANE === "true";
211
358
  },
359
+ /**
360
+ * Content-type toggles — soft-disable a node type server-wide. DEFAULT ON:
361
+ * missing/unset/anything-but-"false" = enabled, so existing deployments see
362
+ * zero behavior change. Only CREATION of the type is refused when disabled;
363
+ * existing nodes of that type still read/render/version (never data loss).
364
+ * Runnable types (agent/skill/tool) are NOT governed here — they stay gated
365
+ * by FEATURE_WORKFLOW_PLANE. Superadmin-editable at runtime via
366
+ * /admin/settings (type_artifact_enabled / type_table_enabled).
367
+ */
368
+ get TYPE_ARTIFACT_ENABLED() {
369
+ return process.env.TYPE_ARTIFACT_ENABLED !== "false";
370
+ },
371
+ get TYPE_TABLE_ENABLED() {
372
+ return process.env.TYPE_TABLE_ENABLED !== "false";
373
+ },
212
374
  /**
213
375
  * Sub-agent runs: lets a run spawn nested runs (an external runner calling
214
376
  * back into POST /run for a child agent). Gated SEPARATELY from the base
@@ -238,6 +400,13 @@ var config = {
238
400
  const n = parseInt(process.env.RUN_MAX_STEPS || "10000", 10);
239
401
  return Number.isFinite(n) ? Math.min(1e5, Math.max(10, n)) : 1e4;
240
402
  },
403
+ /** Max concurrently-RUNNING root (depth-0) runs per nest. The subagent
404
+ * depth/fan-out caps bound a single tree; this bounds how many trees a
405
+ * caller can start at once, so root triggers can't flood the nest. */
406
+ get RUN_MAX_CONCURRENT_ROOTS() {
407
+ const n = parseInt(process.env.RUN_MAX_CONCURRENT_ROOTS || "50", 10);
408
+ return Number.isFinite(n) ? Math.min(1e3, Math.max(1, n)) : 50;
409
+ },
241
410
  get AUTH_MODE() {
242
411
  return process.env.AUTH_MODE || "key";
243
412
  },
@@ -295,26 +464,6 @@ var config = {
295
464
  }
296
465
  return raw;
297
466
  },
298
- /**
299
- * Optional Slack incoming-webhook URL for team notifications (review
300
- * submitted/approved/rejected, nest shared). Empty/unset = connector off.
301
- * https only — a webhook carries an implicit secret in its path, so it
302
- * never travels plaintext.
303
- */
304
- get SLACK_WEBHOOK_URL() {
305
- const raw = process.env.SLACK_WEBHOOK_URL?.trim();
306
- if (!raw) return null;
307
- if (!/^https:\/\//i.test(raw)) {
308
- if (!slackUrlWarned) {
309
- slackUrlWarned = true;
310
- console.warn(
311
- "[config] SLACK_WEBHOOK_URL rejected: must be an https:// URL. Slack notifications disabled."
312
- );
313
- }
314
- return null;
315
- }
316
- return raw;
317
- },
318
467
  /**
319
468
  * Per-nest notification digest window in milliseconds. A burst of governance
320
469
  * events on one nest inside this window collapses into a single digest
@@ -1078,6 +1227,198 @@ function runMigrations(db) {
1078
1227
  })();
1079
1228
  recordMigration("018_subagent_runs");
1080
1229
  }
1230
+ if (!hasMigration("020_server_settings")) {
1231
+ db.transaction(() => {
1232
+ db.exec(`
1233
+ CREATE TABLE IF NOT EXISTS server_settings (
1234
+ key TEXT PRIMARY KEY,
1235
+ value TEXT,
1236
+ env_at_write TEXT,
1237
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1238
+ );
1239
+ `);
1240
+ recordMigration("020_server_settings");
1241
+ })();
1242
+ }
1243
+ if (!hasMigration("020_schedules")) {
1244
+ db.transaction(() => {
1245
+ db.exec(`
1246
+ CREATE TABLE IF NOT EXISTS schedules (
1247
+ id TEXT PRIMARY KEY,
1248
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1249
+ agent_node TEXT NOT NULL,
1250
+ every_minutes INTEGER NOT NULL CHECK(every_minutes >= 5),
1251
+ enabled INTEGER NOT NULL DEFAULT 1,
1252
+ last_run_at TEXT,
1253
+ created_by TEXT NOT NULL,
1254
+ created_at TEXT NOT NULL,
1255
+ updated_at TEXT NOT NULL
1256
+ );
1257
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_schedules_agent
1258
+ ON schedules(nest_id, agent_node);
1259
+ `);
1260
+ })();
1261
+ recordMigration("020_schedules");
1262
+ }
1263
+ if (!hasMigration("021_schedule_bounds")) {
1264
+ db.transaction(() => {
1265
+ db.exec(`
1266
+ ALTER TABLE schedules ADD COLUMN max_runs INTEGER;
1267
+ ALTER TABLE schedules ADD COLUMN runs_created INTEGER NOT NULL DEFAULT 0;
1268
+ `);
1269
+ })();
1270
+ recordMigration("021_schedule_bounds");
1271
+ }
1272
+ if (!hasMigration("022_tools_and_watchers")) {
1273
+ db.transaction(() => {
1274
+ db.exec(`
1275
+ CREATE TABLE IF NOT EXISTS nest_env (
1276
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1277
+ key TEXT NOT NULL,
1278
+ value TEXT NOT NULL,
1279
+ updated_by TEXT NOT NULL,
1280
+ updated_at TEXT NOT NULL,
1281
+ PRIMARY KEY (nest_id, key)
1282
+ );
1283
+ CREATE TABLE IF NOT EXISTS watchers (
1284
+ id TEXT PRIMARY KEY,
1285
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1286
+ node_id TEXT NOT NULL,
1287
+ user_email TEXT NOT NULL,
1288
+ created_by TEXT NOT NULL,
1289
+ created_at TEXT NOT NULL
1290
+ );
1291
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_watchers_unique
1292
+ ON watchers(nest_id, node_id, user_email);
1293
+ CREATE TABLE IF NOT EXISTS notifications (
1294
+ id TEXT PRIMARY KEY,
1295
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1296
+ user_email TEXT NOT NULL,
1297
+ kind TEXT NOT NULL,
1298
+ subject_id TEXT,
1299
+ message TEXT NOT NULL,
1300
+ created_at TEXT NOT NULL,
1301
+ read_at TEXT
1302
+ );
1303
+ CREATE INDEX IF NOT EXISTS idx_notifications_user
1304
+ ON notifications(user_email, read_at, created_at);
1305
+ `);
1306
+ })();
1307
+ recordMigration("022_tools_and_watchers");
1308
+ }
1309
+ if (!hasMigration("023_connectors")) {
1310
+ db.transaction(() => {
1311
+ db.exec(`
1312
+ CREATE TABLE IF NOT EXISTS connectors (
1313
+ id TEXT PRIMARY KEY,
1314
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1315
+ channel TEXT NOT NULL CHECK(channel IN ('slack', 'teams', 'webhook')),
1316
+ url TEXT NOT NULL, -- https://\u2026 or env:KEY
1317
+ events TEXT NOT NULL, -- JSON array of kinds, or ["*"]
1318
+ enabled INTEGER NOT NULL DEFAULT 1,
1319
+ created_by TEXT NOT NULL,
1320
+ created_at TEXT NOT NULL,
1321
+ updated_at TEXT NOT NULL
1322
+ );
1323
+ CREATE INDEX IF NOT EXISTS idx_connectors_nest ON connectors(nest_id);
1324
+ `);
1325
+ })();
1326
+ recordMigration("023_connectors");
1327
+ }
1328
+ if (!hasMigration("024_trigger_hooks")) {
1329
+ db.transaction(() => {
1330
+ db.exec(`
1331
+ CREATE TABLE IF NOT EXISTS trigger_hooks (
1332
+ id TEXT PRIMARY KEY, -- the token (hk_\u2026), capability auth
1333
+ nest_id TEXT NOT NULL REFERENCES nests(id) ON DELETE CASCADE,
1334
+ agent_node TEXT NOT NULL,
1335
+ preset TEXT NOT NULL CHECK(preset IN ('slack', 'teams', 'webhook')),
1336
+ enabled INTEGER NOT NULL DEFAULT 1,
1337
+ fire_count INTEGER NOT NULL DEFAULT 0,
1338
+ last_fired_at TEXT,
1339
+ created_by TEXT NOT NULL,
1340
+ created_at TEXT NOT NULL
1341
+ );
1342
+ CREATE INDEX IF NOT EXISTS idx_hooks_nest ON trigger_hooks(nest_id);
1343
+ `);
1344
+ })();
1345
+ recordMigration("024_trigger_hooks");
1346
+ }
1347
+ if (!hasMigration("025_run_claims")) {
1348
+ db.transaction(() => {
1349
+ db.exec(`
1350
+ ALTER TABLE runs ADD COLUMN claimed_by TEXT;
1351
+ ALTER TABLE runs ADD COLUMN claimed_at TEXT;
1352
+ `);
1353
+ })();
1354
+ recordMigration("025_run_claims");
1355
+ }
1356
+ if (!hasMigration("026_api_events_nest_index")) {
1357
+ db.transaction(() => {
1358
+ db.exec(`
1359
+ CREATE INDEX IF NOT EXISTS idx_api_events_nest_ts ON api_events(nest_id, id);
1360
+ `);
1361
+ })();
1362
+ recordMigration("026_api_events_nest_index");
1363
+ }
1364
+ if (!hasMigration("027_teams")) {
1365
+ db.transaction(() => {
1366
+ db.exec(`
1367
+ CREATE TABLE IF NOT EXISTS teams (
1368
+ id TEXT PRIMARY KEY,
1369
+ name TEXT NOT NULL,
1370
+ owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
1371
+ members TEXT NOT NULL DEFAULT '[]', -- JSON [{userId, email, role}]
1372
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
1373
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
1374
+ );
1375
+ CREATE INDEX IF NOT EXISTS idx_teams_owner ON teams(owner_id);
1376
+ `);
1377
+ const nestColsNow = db.prepare("PRAGMA table_info(nests)").all().map((c) => c.name);
1378
+ if (!nestColsNow.includes("shared_teams")) {
1379
+ db.exec("ALTER TABLE nests ADD COLUMN shared_teams TEXT NOT NULL DEFAULT '[]'");
1380
+ }
1381
+ })();
1382
+ recordMigration("027_teams");
1383
+ }
1384
+ if (!hasMigration("028_user_department")) {
1385
+ db.transaction(() => {
1386
+ const userColsNow = db.prepare("PRAGMA table_info(users)").all().map((c) => c.name);
1387
+ if (!userColsNow.includes("department")) {
1388
+ db.exec("ALTER TABLE users ADD COLUMN department TEXT");
1389
+ }
1390
+ })();
1391
+ recordMigration("028_user_department");
1392
+ }
1393
+ if (!hasMigration("029_super_admins")) {
1394
+ db.transaction(() => {
1395
+ db.exec(`
1396
+ CREATE TABLE IF NOT EXISTS super_admins (
1397
+ email TEXT PRIMARY KEY, -- stored lowercased
1398
+ granted_by TEXT NOT NULL, -- email of the granting admin
1399
+ granted_at TEXT NOT NULL DEFAULT (datetime('now'))
1400
+ );
1401
+ `);
1402
+ })();
1403
+ recordMigration("029_super_admins");
1404
+ }
1405
+ if (!hasMigration("031_team_source")) {
1406
+ db.transaction(() => {
1407
+ const teamCols = db.prepare("PRAGMA table_info(teams)").all().map((c) => c.name);
1408
+ if (!teamCols.includes("source")) {
1409
+ db.exec("ALTER TABLE teams ADD COLUMN source TEXT");
1410
+ }
1411
+ if (!teamCols.includes("external_id")) {
1412
+ db.exec("ALTER TABLE teams ADD COLUMN external_id TEXT");
1413
+ }
1414
+ db.exec(`
1415
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_teams_external
1416
+ ON teams(owner_id, source, external_id)
1417
+ WHERE external_id IS NOT NULL;
1418
+ `);
1419
+ })();
1420
+ recordMigration("031_team_source");
1421
+ }
1081
1422
  }
1082
1423
  function mergeCaseCollidingUsers(db) {
1083
1424
  const groups = db.prepare(
@@ -1295,7 +1636,7 @@ async function initDb() {
1295
1636
  if (config.DB_DRIVER === "postgres") {
1296
1637
  const { Pool } = await import("pg");
1297
1638
  const { PostgresAdapter } = await import("./adapter.postgres-YOODX2BI.js");
1298
- const { runPostgresMigrations } = await import("./migrations.postgres-ORSV7UFJ.js");
1639
+ const { runPostgresMigrations } = await import("./migrations.postgres-BRXZY2GE.js");
1299
1640
  const pool = new Pool(buildPgConfig());
1300
1641
  adapter = new PostgresAdapter(pool);
1301
1642
  await runPostgresMigrations(adapter);