Skip to content

fix(server,db): per-user pending oauth_account uniqueness + clean conflict errors#1121

Merged
buremba merged 2 commits into
mainfrom
feat/auth-profiles-pending-conflict
May 28, 2026
Merged

fix(server,db): per-user pending oauth_account uniqueness + clean conflict errors#1121
buremba merged 2 commits into
mainfrom
feat/auth-profiles-pending-conflict

Conversation

@buremba
Copy link
Copy Markdown
Member

@buremba buremba commented May 28, 2026

Summary

Two related fixes for the GitHub-connector page toast duplicate key value violates unique constraint "auth_profiles_pending_unique":

  1. Root cause: partial-index shape was wrong. auth_profiles_pending_unique was keyed on (org, connector_key, profile_kind, provider) WHERE status='pending_auth' — no caller dimension. For oauth_account (user-personal), that blocked two members in the same org from running parallel OAuth flows for the same connector. New migration drops it and adds auth_profiles_pending_oauth_account_unique keyed on (org, connector_key, provider, created_by) WHERE status='pending_auth' AND profile_kind='oauth_account'. Other kinds (oauth_app, env, interactive, browser_session) are written status='active' from creation in practice, so the old per-kind bound was unused for them; the typed 23505 catch stays as a safety net.

  2. Defensive translation of the remaining collision case. createAuthProfile catches 23505 on the new index, looks up the colliding row, and throws PendingAuthConflictError extends ToolUserError(409) carrying the existing profile — so callers get a structured error instead of a raw PG message bubbling to a toast. handleCreateAuthProfile does the same pre-INSERT lookup when no slug is supplied, so the happy-path UI repeat-click silently reuses the user's own pending row + mints a fresh connect token. findPendingAuthProfile now takes createdBy and filters by it for oauth_account.

restToolProxy's httpStatus cast widened to allow 403/409/422.

Test plan

  • New integration suite pending-auth-conflict.test.ts (4 cases):
    • No-slug repeat reuses the existing pending row (idempotent), exactly one row in auth_profiles.
    • Explicit different slug while a pending row exists → friendly error: containing the existing slug; no "duplicate key" / no constraint name leaked.
    • Two users in the same org → both reach pending_auth in parallel; per-user dedup reuses each user's own row.
    • Direct createAuthProfile collision → PendingAuthConflictError with httpStatus=409 and existing populated.
  • Full connector integration suite (src/__tests__/integration/connectors/) — 59 tests pass.
  • make review BASE=origin/main — typecheck/unit/integration all green; pi verdict bug_free 86, simplicity 88, slop 0, bugs 0, 0 blockers, tests_adequate=true, behavior_change_risk=medium.
  • Owed before merge: boot lobu run against this branch, seed an orphan pending_auth row, drive the connector page in the Chrome extension to confirm the new friendly message / silent reuse. Not yet done — was the gap I called out earlier.
  • Manual prod follow-up: SELECT the existing orphan pending row in lobu-crm org and clear it once verified unreferenced.

Summary by CodeRabbit

  • New Features

    • Multiple users within the same organization can now initiate parallel OAuth authentication flows without conflicts.
  • Improvements

    • OAuth authentication flows are now idempotent—repeating the same request reuses the existing pending authorization without creating duplicates.
  • Bug Fixes

    • Enhanced error messages for authentication conflicts with clearer, user-friendly notifications instead of technical error details.

Review Change Stack

…y error

The partial unique index `auth_profiles_pending_unique` rejects a second
pending oauth_account profile per (org, connector, kind, provider). The
UI's "create OAuth account" click omits `slug` and bypassed
`handleCreateAuthProfile`'s existing slug-keyed idempotent reuse, so a
repeat click leaked `duplicate key value violates unique constraint
"auth_profiles_pending_unique"` into a toast.

- `createAuthProfile` now catches 23505 on that index, looks up the
  colliding row, and throws a `PendingAuthConflictError` (extends
  `ToolUserError`, HTTP 409) carrying the existing row.
- `handleCreateAuthProfile` does the same tuple lookup pre-INSERT when
  no slug was supplied, so the happy path silently reuses the pending
  row + issues a fresh connect token. The try/catch closes the
  SELECT/INSERT race window.
- New integration test covers no-slug repeat (idempotent), slug-different
  collision (clean error, no PG text leaked), and direct collision
  (PendingAuthConflictError with the existing row attached).

Does not redesign the index itself; including `created_by` (so two users
in the same org can start parallel OAuth flows) is a follow-up.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

📝 Walkthrough

Walkthrough

This PR introduces per-user pending auth conflict handling for OAuth profile creation. A new database constraint, error model, and detection logic prevent duplicate in-flight OAuth flows for the same user while permitting multiple users to flow in parallel. Changes include database migration, PendingAuthConflictError class, utility lookup logic, tool integration for graceful reuse/error handling, API status code support, and integration tests.

Changes

Per-user pending auth conflict handling

Layer / File(s) Summary
Database constraint and error model
db/migrations/20260528120000_auth_profiles_pending_per_user.sql, packages/server/src/utils/auth-profiles.ts
Migration replaces global pending uniqueness with per-user (created_by) constraint for oauth_account profiles. PendingAuthConflictError class encapsulates conflicting rows; findPendingAuthProfile detects collisions against the new index.
createAuthProfile conflict handling
packages/server/src/utils/auth-profiles.ts
Provider normalization (lowercase), INSERT error handling for the oauth_account partial unique index, and throwing PendingAuthConflictError with the existing conflicting row instead of leaking constraint details.
Tool-level pending profile reuse
packages/server/src/tools/admin/manage_auth_profiles.ts
Imports PendingAuthConflictError, reuses existing pending profiles when no slug provided (per-user dedup via findPendingAuthProfile), and wraps createAuthProfile in try/catch to return user-facing error messages.
API status code support
packages/server/src/rest-api.ts
ToolUserError response typing expanded to allow 409 Conflict status code alongside 400, 403, 404, and 422.
Integration tests
packages/server/src/__tests__/integration/connectors/pending-auth-conflict.test.ts
Tests verify idempotency (reuse without slug), slug conflict with friendly error, per-user parallel flows with individual reuse, and direct PendingAuthConflictError inspection (409 status, existing row, no constraint leakage).

Sequence Diagram(s)

sequenceDiagram
  participant UI
  participant manageAuthProfiles
  participant findPendingAuthProfile
  participant createAuthProfile
  participant Database
  UI->>manageAuthProfiles: create_auth_profile action
  manageAuthProfiles->>findPendingAuthProfile: lookup existing pending for (org, connector, provider, user)
  findPendingAuthProfile->>Database: query auth_profiles status=pending_auth
  alt existing pending found
    findPendingAuthProfile-->>manageAuthProfiles: return existing row
    manageAuthProfiles-->>UI: reuse (idempotent)
  else no existing pending
    findPendingAuthProfile-->>manageAuthProfiles: null
    manageAuthProfiles->>createAuthProfile: insert new pending profile
    createAuthProfile->>Database: INSERT auth_profiles (normalized provider)
    alt conflict detected
      Database-->>createAuthProfile: unique constraint error
      createAuthProfile->>findPendingAuthProfile: find conflicting row
      findPendingAuthProfile-->>createAuthProfile: existing row
      createAuthProfile-->>manageAuthProfiles: throw PendingAuthConflictError
      manageAuthProfiles-->>UI: { error: "already pending authorization" }
    else no conflict
      Database-->>createAuthProfile: success
      createAuthProfile-->>manageAuthProfiles: new row
      manageAuthProfiles-->>UI: new profile
    end
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A rabbit hops through OAuth flows,
Per user now the conflict goes,
No duplicate pending states in flight,
While parallel users share the night—
409 when collisions rise so dear! 🌙

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: fixing pending oauth_account uniqueness per-user and improving error handling.
Description check ✅ Passed The description comprehensively covers the summary, test plan, and notes with specific implementation details and testing outcomes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/auth-profiles-pending-conflict

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.

Warning

Review ran into problems

🔥 Problems

Stopped waiting for pipeline failures after 30000ms. One of your pipelines takes longer than our 30000ms fetch window to run, so review may not consider pipeline-failure results for inline comments if any failures occurred after the fetch window. Increase the timeout if you want to wait longer or run a @coderabbit review after the pipeline has finished.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter
Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

The old `auth_profiles_pending_unique` was keyed on
(org, connector_key, profile_kind, provider) with no caller dimension.
For `oauth_account` — which is user-personal — that blocked two
members in the same org from running parallel OAuth flows for the
same connector: User B's INSERT collided with User A's in-flight
pending row.

Replace with `auth_profiles_pending_oauth_account_unique` keyed on
(org, connector_key, provider, created_by) filtered to
status='pending_auth' AND profile_kind='oauth_account'. Other kinds
are written status='active' from creation in practice (oauth_app,
env via upsertConnectorAuthProfiles; browser_session via worker-api),
so the old per-kind bound was unused for them; createAuthProfile's
typed 23505 catch stays as the safety net if any future caller pushes
a pending row through.

findPendingAuthProfile takes createdBy and filters by it for
oauth_account. handleCreateAuthProfile passes ctx.userId. New
integration test asserts two users in the same org can both reach
pending_auth in parallel and that per-user dedup still reuses each
user's own row.
@buremba
Copy link
Copy Markdown
Member Author

buremba commented May 28, 2026

bug_free 86, simplicity 88, slop 0, bugs 0, 0 blockers

Typecheck, unit, and integration logs all passed. Reviewed migration/code paths and ran git diff --check. [env] Extra psql index inspection could not run because DATABASE_URL was not exported/in .env for this shell; no suite failures observed.

Full verdict JSON
{
  "bug_free_confidence": 86,
  "bugs": 0,
  "slop": 0,
  "simplicity": 88,
  "blockers": [],
  "change_type": "fix",
  "behavior_change_risk": "medium",
  "tests_adequate": true,
  "suggested_fixes": [],
  "notes": "Typecheck, unit, and integration logs all passed. Reviewed migration/code paths and ran git diff --check. [env] Extra psql index inspection could not run because DATABASE_URL was not exported/in .env for this shell; no suite failures observed.",
  "categories": {
    "src": 202,
    "tests": 283,
    "docs": 0,
    "config": 0,
    "deps": 0,
    "migrations": 35,
    "ci": 0,
    "generated": 0
  }
}

Local review gate — branch protection can require the pi-review commit status. See docs/REVIEW_SCHEMA.md.

@buremba buremba changed the title fix(server): clean error for pending oauth_account collisions fix(server,db): per-user pending oauth_account uniqueness + clean conflict errors May 28, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/server/src/utils/auth-profiles.ts`:
- Around line 395-418: The catch block currently rethrows the raw Postgres error
when isUniqueViolation(..., 'auth_profiles_pending_oauth_account_unique') is
true but findPendingAuthProfile(...) returns null, leaking constraint details;
update the handler in the catch of the INSERT to, after calling
findPendingAuthProfile with params (organizationId, connectorKey, profileKind
'oauth_account', provider normalizedProvider, createdBy), throw a
PendingAuthConflictError(existing) if existing is found, otherwise throw a new,
generic conflict error (e.g., new Error('pending auth profile conflict')) or a
dedicated domain error without exposing DB constraint names so callers do not
receive raw PG details; keep the existing checks for params.profileKind,
params.connectorKey, and normalizedProvider and ensure all branches end with
either PendingAuthConflictError or the sanitized error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c8ebea8c-ec5d-448f-910b-50bb7ae9461c

📥 Commits

Reviewing files that changed from the base of the PR and between 449f93e and 874856a.

📒 Files selected for processing (5)
  • db/migrations/20260528120000_auth_profiles_pending_per_user.sql
  • packages/server/src/__tests__/integration/connectors/pending-auth-conflict.test.ts
  • packages/server/src/rest-api.ts
  • packages/server/src/tools/admin/manage_auth_profiles.ts
  • packages/server/src/utils/auth-profiles.ts

Comment on lines +395 to +418
} catch (err) {
// Partial unique index `auth_profiles_pending_oauth_account_unique`
// enforces one pending oauth_account row per (org, connector_key,
// provider, created_by). Translate a raw 23505 into a structured error
// carrying the existing row so callers can recover (idempotent reuse)
// or surface a clean message instead of leaking the constraint name.
if (isUniqueViolation(err, 'auth_profiles_pending_oauth_account_unique')) {
if (
params.profileKind === 'oauth_account' &&
params.connectorKey !== null &&
normalizedProvider !== null
) {
const existing = await findPendingAuthProfile({
organizationId: params.organizationId,
connectorKey: params.connectorKey,
profileKind: 'oauth_account',
provider: normalizedProvider,
createdBy: params.createdBy ?? null,
});
if (existing) throw new PendingAuthConflictError(existing);
}
}
throw err;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Edge case: constraint violation with no existing row still leaks raw PG error.

If isUniqueViolation matches but findPendingAuthProfile returns null (the colliding row was deleted between the INSERT failure and the SELECT), the original err is re-thrown at line 417, leaking the constraint name. Wrap with a generic message when existing is null.

Proposed fix
       if (isUniqueViolation(err, 'auth_profiles_pending_oauth_account_unique')) {
         if (
           params.profileKind === 'oauth_account' &&
           params.connectorKey !== null &&
           normalizedProvider !== null
         ) {
           const existing = await findPendingAuthProfile({
             organizationId: params.organizationId,
             connectorKey: params.connectorKey,
             profileKind: 'oauth_account',
             provider: normalizedProvider,
             createdBy: params.createdBy ?? null,
           });
-          if (existing) throw new PendingAuthConflictError(existing);
+          if (existing) {
+            throw new PendingAuthConflictError(existing);
+          }
+          // Row vanished between constraint-check and lookup; surface a generic
+          // conflict so we never leak the raw constraint name to the caller.
+          throw new ToolUserError(
+            'A pending auth profile for this connector already exists. Please retry.',
+            409
+          );
         }
       }
       throw err;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/utils/auth-profiles.ts` around lines 395 - 418, The catch
block currently rethrows the raw Postgres error when isUniqueViolation(...,
'auth_profiles_pending_oauth_account_unique') is true but
findPendingAuthProfile(...) returns null, leaking constraint details; update the
handler in the catch of the INSERT to, after calling findPendingAuthProfile with
params (organizationId, connectorKey, profileKind 'oauth_account', provider
normalizedProvider, createdBy), throw a PendingAuthConflictError(existing) if
existing is found, otherwise throw a new, generic conflict error (e.g., new
Error('pending auth profile conflict')) or a dedicated domain error without
exposing DB constraint names so callers do not receive raw PG details; keep the
existing checks for params.profileKind, params.connectorKey, and
normalizedProvider and ensure all branches end with either
PendingAuthConflictError or the sanitized error.

@buremba
Copy link
Copy Markdown
Member Author

buremba commented May 28, 2026

Live e2e — done

Booted lobu run (Node 22, embedded PG, this branch) on :8806 with the new migration applied. Seeded org_e2e_test with an active oauth_app profile + an orphan pending_auth oauth_account (slug='github-account-orphan', provider='github', created_by='user_e2e_a') to reproduce the user's screenshot conditions exactly.

Migration verified live

$ psql -c "SELECT indexname, indexdef FROM pg_indexes WHERE tablename='auth_profiles' AND indexname LIKE '%pending%';"
auth_profiles_pending_oauth_account_unique
  CREATE UNIQUE INDEX ... ON auth_profiles (organization_id, connector_key, provider, created_by)
  WHERE ((status = 'pending_auth') AND (profile_kind = 'oauth_account'))

Old auth_profiles_pending_unique is gone. Per-user shape in place.

🔴 RED — pre-fix behavior (raw INSERT bypassing the catch)

INSERT INTO auth_profiles (org, slug, connector_key, profile_kind, status, provider, created_by, ...)
VALUES ('org_e2e_test', 'github-account-different', 'github', 'oauth_account', 'pending_auth', 'github', 'user_e2e_a', ...);

ERROR:  duplicate key value violates unique constraint "auth_profiles_pending_oauth_account_unique"
DETAIL: Key (organization_id, connector_key, provider, created_by)=(org_e2e_test, github, github, user_e2e_a) already exists.

This is exactly the shape of message that leaked into the user's toast on prod.

🟢 GREEN case 1 — no-slug repeat → silent reuse via HTTP

$ curl -X POST .../api/e2e-test/manage_auth_profiles \
    -H "authorization: Bearer $TOKEN" \
    -d '{"action":"create_auth_profile","connector_key":"github","profile_kind":"oauth_account","display_name":"GitHub Final Account"}'

{
  "action": "create_auth_profile",
  "auth_profile": {
    "id": 2,
    "slug": "github-account-orphan",      ← reuses the orphan
    "status": "pending_auth",
    "provider": "github",
    "created_by": "user_e2e_a",
    ...
  },
  "connect_url": "http://localhost:8787/connect/<token>/oauth/start",
  "connect_token": "w0N3gycS3a1WoO5I1VWlN1RrRbyw7X3jUksZw30BiYk"
}

Server reuses the existing pending row and mints a fresh connect token so the user can resume the abandoned OAuth flow. No new row created, no error surfaced.

🟢 GREEN case 2 — explicit different slug → friendly conflict response

$ curl -X POST .../api/e2e-test/manage_auth_profiles \
    -H "authorization: Bearer $TOKEN" \
    -d '{"action":"create_auth_profile","connector_key":"github","profile_kind":"oauth_account","display_name":"Another","slug":"a-different-slug"}'

HTTP/1.1 200 OK
{"error":"An oauth_account auth profile for connector 'github' (github) is already pending authorization (slug: 'github-account-orphan'). Finish authorizing it or delete it before creating a new one."}
  • ✅ No duplicate key value.
  • ✅ No auth_profiles_pending_oauth_account_unique.
  • ✅ Existing slug github-account-orphan named in the message so the user knows what to resume / clear.

Row-state assertions after both calls

$ psql -c "SELECT id, slug, status FROM auth_profiles WHERE organization_id='org_e2e_test';"
 id |         slug          |    status    
----+-----------------------+--------------
  1 | github-app            | active
  2 | github-account-orphan | pending_auth
(2 rows)

Still exactly the two seeded rows — no orphan duplication. Connect tokens: one fresh token issued by Case 1 against auth_profile_id=2; Case 2 minted none (short-circuited).

Cross-user parallel flows (proven by integration suite, not re-driven over HTTP)

The new per-user index also unblocks the cross-user case (User A's pending OAuth no longer blocks User B's). That path is covered by pending-auth-conflict.test.ts > two users in the same org can run parallel pending oauth_account flows (passing). Did not re-drive over HTTP because device-token minting for a second user requires more better-auth fixture juggling than the test contract is worth — the database-level constraint is what changed, and the integration test exercises both the constraint and handleCreateAuthProfile's per-user lookup.

Prod migration sanity

$ kubectl exec ... -- psql -c "SELECT count(*), count(*) FILTER (WHERE status='pending_auth' AND profile_kind='oauth_account') FROM auth_profiles;"
 rows | pending_oauth_account
------+-----------------------
   34 |                     2

Both pending oauth_account rows are owned by the same user but for different connectors (github + x), so neither violates the new per-user index. Table is tiny (34 rows total) — DROP+CREATE INDEX is microseconds, no need for CREATE INDEX CONCURRENTLY.

The orphan that caused the user's original toast (org_lobucrm, github-account, 2026-05-26) is one of those two; after merge + deploy, a single repeat click of "Create OAuth account" will silently reuse it and issue a fresh connect token to resume the OAuth flow — no manual prod cleanup required.

@buremba buremba merged commit 8ec3a81 into main May 28, 2026
22 of 24 checks passed
@buremba buremba deleted the feat/auth-profiles-pending-conflict branch May 28, 2026 15:55
buremba added a commit that referenced this pull request May 28, 2026
…t guard (#1138)

Stability-audit follow-up. Adds CI guards so two recently-changed schema
invariants can't silently regress in a future migration:

- migration-invariants.test.ts: asserts the per-user pending oauth_account
  unique index from #1121 exists (and the old org-wide index is gone), plus a
  functional contract test — a user's second parallel pending OAuth flow
  collides while a distinct user's is allowed. Also asserts event_embeddings
  carries the embedding_model stamp column (#1069/#1080).
- embedding-model-literal.test.ts: the legacy-stamp backfill migration
  hard-codes the model literal; this fails if it ever drifts from
  DEFAULT_EMBEDDING_MODEL, which would silently re-open the full-corpus recall
  regression. Exports DEFAULT_EMBEDDING_MODEL for the assertion.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants