@promptowl/contextnest-community 1.10.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
@@ -60,7 +60,14 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
60
60
  | `PROMPTOWL_SIGN_IN_GATE` | `open` | Restrict "Sign in with PromptOwl". `open` = anyone may; `admin-only` = only the license owner (admin) may, everyone else uses email/password (admin opens the login page with `?admin=1`); `disabled` = nobody may. Enforced server-side at `POST /auth/promptowl` and surfaced on the health endpoint. Unknown values fall back to `open`. |
61
61
  | `MANUAL_SIGN_IN` | `open` | Email + password sign-in mode. `open` = anyone may log in and self-register a new account; `invite-only` = existing/invited users may log in but brand-new self-registration returns `403` (the admin provisions accounts via invite/share/steward and shares the password — there is no self-service "set password", which would be account takeover without email verification); `disabled` = no email/password sign-in at all (`POST /auth/login` and `POST /auth/register` return `403`). Independent of `PROMPTOWL_SIGN_IN_GATE`, so the two methods are controlled separately (e.g. `invite-only` manual + `admin-only` PromptOwl). Also settable from Settings → General. The server refuses `disabled` while PromptOwl sign-in is also `disabled` (that would leave no way to log in). Unknown values fall back to `open`. |
62
62
  | `OFFICIAL_COMMUNITY_SSO_SECRET` | `""` | **Official deployment only — leave unset on self-hosted.** Shared HMAC secret enabling the one-click "Open Community" SSO auto-login from PromptOwl. Must exactly match the same-named var on PromptOwl. When unset, `GET /auth/sso` returns `404` and the feature is disabled; self-hosted users keep using the manual device-code flow. |
63
- | `PUBLIC_BASE_URL` | `""` | This server's canonical external URL (e.g. `https://community.promptowl.ai`). 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
+ | `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. |
64
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`. |
65
72
  | `TELEMETRY_ENABLED` | `"true"` (set to `"false"` to disable) | Batched, anonymized usage events sent to PromptOwl. Off disables the loop entirely. |
66
73
  | `TELEMETRY_INTERVAL_MS` | `3600000` (1 hour) | How often buffered telemetry is flushed to PromptOwl. |
@@ -68,6 +75,7 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
68
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). |
69
76
  | `MAX_BODY_BYTES` | `10485760` (10 MB) | Reject requests whose `Content-Length` exceeds this. Prevents giant-payload DoS. |
70
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`. |
71
79
  | `FEATURE_WORKFLOW_PLANE` | _(unset — off)_ | Enables the workflow plane: typed edges, edge-type registry, governed runs. Optional feature; also toggleable from Settings. |
72
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. |
73
81
  | `SUBAGENT_MAX_DEPTH` | `8` | Max sub-agent nesting depth (clamped 1..32) — bounds the call tree's HEIGHT so it stays finite/haltable. |
@@ -81,6 +89,119 @@ The server prints a loud warning at startup when `AUTH_MODE=open` is active.
81
89
 
82
90
  ---
83
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
+
84
205
  ## Database backends
85
206
 
86
207
  The governance/auth metadata (users, sessions, nests registry, stewards, reviews,
@@ -108,7 +229,7 @@ Postgres backend starts empty.
108
229
  ### Runtime settings persistence
109
230
 
110
231
  Settings you change at runtime — everything on the **Settings page** (`/admin/settings`:
111
- sign-in gate, logo, base URL, upload limit, feature flags, Slack/SMTP connectors)
232
+ sign-in gate, logo, base URL, upload limit, feature flags, OIDC SSO, Slack/SMTP connectors)
112
233
  plus the **installed license key** — are stored in the database (`server_settings`
113
234
  table), **not** in a `.env` file. This is deliberate: on Cloud Run the container
114
235
  filesystem is ephemeral, so a file-based value was wiped by every rebuild, and each
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,6 +28,7 @@ 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
33
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) && !/[\r\n]/.test(v);
33
34
  }
@@ -124,6 +125,16 @@ 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
+ },
127
138
  /** Vercel automation-bypass secret for PO calls; unset = no bypass. */
128
139
  get PROMPTOWL_BYPASS_SECRET() {
129
140
  return process.env.PROMPTOWL_BYPASS_SECRET || "";
@@ -194,6 +205,80 @@ var config = {
194
205
  const v = (process.env.MANUAL_SIGN_IN || "open").trim().toLowerCase();
195
206
  return v === "invite-only" || v === "disabled" ? v : "open";
196
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
+ },
197
282
  /**
198
283
  * Path to the .env file the server reads its config from and the license
199
284
  * install flow persists PROMPTOWL_KEY into. Defaults UNDER DATA_ROOT (not
@@ -271,6 +356,21 @@ var config = {
271
356
  get FEATURE_WORKFLOW_PLANE() {
272
357
  return process.env.FEATURE_WORKFLOW_PLANE === "true";
273
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
+ },
274
374
  /**
275
375
  * Sub-agent runs: lets a run spawn nested runs (an external runner calling
276
376
  * back into POST /run for a child agent). Gated SEPARATELY from the base
@@ -1261,6 +1361,64 @@ function runMigrations(db) {
1261
1361
  })();
1262
1362
  recordMigration("026_api_events_nest_index");
1263
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
+ }
1264
1422
  }
1265
1423
  function mergeCaseCollidingUsers(db) {
1266
1424
  const groups = db.prepare(
@@ -1478,7 +1636,7 @@ async function initDb() {
1478
1636
  if (config.DB_DRIVER === "postgres") {
1479
1637
  const { Pool } = await import("pg");
1480
1638
  const { PostgresAdapter } = await import("./adapter.postgres-YOODX2BI.js");
1481
- const { runPostgresMigrations } = await import("./migrations.postgres-4XYY3CTF.js");
1639
+ const { runPostgresMigrations } = await import("./migrations.postgres-BRXZY2GE.js");
1482
1640
  const pool = new Pool(buildPgConfig());
1483
1641
  adapter = new PostgresAdapter(pool);
1484
1642
  await runPostgresMigrations(adapter);