@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 +151 -3
- package/README.md +2 -1
- package/dist/{chunk-DPHV6Q26.js → chunk-3M7677XW.js} +363 -22
- package/dist/{chunk-BYS4HDME.js → chunk-F7EKYNXG.js} +634 -70
- package/dist/{chunk-3JTODC3Y.js → chunk-GUNJTORH.js} +7 -0
- package/dist/{chunk-HRQWNRVI.js → chunk-JHIU6RZU.js} +1 -1
- package/dist/{chunk-VC3QNHHB.js → chunk-XB7T3THE.js} +685 -6
- package/dist/{chunk-TIO5XOFD.js → chunk-ZMSU7BCC.js} +2 -2
- package/dist/{client-GI74NPIW.js → client-YUOEVUFU.js} +1 -1
- package/dist/{grants-service-MDLRPMZR.js → grants-service-PS5RMMEA.js} +3 -3
- package/dist/index.js +3602 -742
- package/dist/{migrations.postgres-ORSV7UFJ.js → migrations.postgres-BRXZY2GE.js} +149 -3
- package/dist/{review-service-A3XFQTJH.js → review-service-DPN26C47.js} +7 -5
- package/dist/{stewardship-service-3KCVVXCW.js → stewardship-service-7BHI2YXB.js} +4 -3
- package/dist/{version-service-6J4OWQL6.js → version-service-YQIIQ7FN.js} +2 -2
- package/dist/web3/assets/index-BiGiarBx.css +1 -0
- package/dist/web3/assets/index-DekN5OmU.js +1026 -0
- package/dist/web3/index.html +2 -2
- package/package.json +4 -2
- package/dist/web3/assets/index-CHavHPIT.css +0 -1
- package/dist/web3/assets/index-DyvOBH92.js +0 -934
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 `
|
|
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
|
|
63
|
-
| `
|
|
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
|
-
|
|
|
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-
|
|
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);
|