# BUG-03 Diagnosis: Founder is `role='member'` on own instance

**Diagnosed:** 2026-04-11
**Plan:** Phase 1.5 Plan 01.5-02 Task T1
**Status:** Root cause confirmed, fix scope identified

---

## Root cause (confirmed, not speculation)

`app/lib/auth.js:108` — the NextAuth `signIn` callback's new-user INSERT hardcodes `role: 'member'` for every new user created by any OAuth provider (GitHub, Google, OIDC):

```js
INSERT INTO users (id, org_id, email, name, image, provider, provider_account_id, role, created_at, last_login_at)
VALUES (${userId}, 'org_default', ${user.email || ''}, ${user.name || null}, ${user.image || null}, ${account.provider}, ${account.providerAccountId}, 'member', ${now}, ${now})
```

**There is no first-user detection, no bootstrap promotion, no "is this a fresh instance?" check.** Every developer who deploys DashClaw via the Vercel 1-click button and signs in via any OAuth provider gets created as `role='member'` and has no path to approve actions in the `/approvals` UI without a manual SQL update.

The `session` callback at line 181 then reads this role verbatim:

```js
session.user.role = token.role || 'member';
```

Which feeds directly into the `/approvals` page check at `app/approvals/page.js:92`:

```js
const isAdmin = session?.user?.role === 'admin';
```

When this is false, the banner renders at line 119:

```jsx
{!isAdmin && (
  <Banner icon={ShieldAlert} tone="warning" title="Read-only access">
    Only administrators can approve or deny actions. You are currently viewing as a member.
  </Banner>
)}
```

## Hypotheses tested and eliminated

| Hypothesis | Verdict | Evidence |
|---|---|---|
| (a) DB default `role` column is `member`, bootstrap never overrides | **CONFIRMED** | `app/lib/auth.js:108` hardcodes `'member'` in the INSERT, no conditional |
| (b) Org mismatch — session org ≠ API key org, role lookup fails | Eliminated | `jwt` callback at `app/lib/auth.js:137` reads `u.role` directly from `users` table by user ID, not via org-scoped lookup. Role resolves from `u.role` regardless of current org |
| (c) Broken first-user detection (conditional that never fires) | Eliminated | There is no first-user detection logic to break. The feature simply doesn't exist yet |
| (d) `3dcb43dc` JWT org-resolution regression (Lief LAN/CSP port) | **ELIMINATED** | `3dcb43dc` changed the `dashclaw-local-session` cookie's orgId in `app/api/auth/local/route.js`, not the NextAuth user role. The approvals page uses `useSession()` (NextAuth), not the local-session cookie. The local route's role is hardcoded to `'admin'` on line 42 and was not touched by `3dcb43dc`. **Independently, the role claim in `3dcb43dc` is unchanged** — only the orgId field was modified. `3dcb43dc` is fully cleared |
| (e) Something else | N/A | (a) is the confirmed cause |

## Auth system topology (for context)

DashClaw has TWO authentication surfaces:

1. **NextAuth (`app/api/auth/[...nextauth]/route.js` + `app/lib/auth.js`)** — GitHub/Google/OIDC providers. Creates users in the `users` table. This is what Wes used (he has a GitHub/Google/OIDC-provisioned account, not the local-admin password).
2. **Local admin password (`app/api/auth/local/route.js`)** — a custom bearer-token JWT in the `dashclaw-local-session` cookie. Hardcoded `role: 'admin'`. This is the "self-host-without-OAuth" path.

The `/approvals` page uses `useSession()` from `next-auth/react`, which reads the NextAuth session — **not** the local-session cookie. So:

- A user who signed in via OAuth (Wes's path): role comes from DB, gets the `'member'` hardcode → BUG-03 fires
- A user who signed in via local admin password: role is hardcoded `'admin'` in the JWT, but... `useSession()` doesn't read this cookie, so the approvals page still wouldn't see `role='admin'` even in the local-password path. **This means local-admin users are ALSO affected by BUG-03**, via a different mechanism

This diagnosis is scoped to the OAuth-user case (Wes's path). Fixing the OAuth-user case covers the Vercel-1-click-then-sign-in-with-GitHub scenario, which is the primary deploy flow. The local-admin case is tracked as a separate follow-up in Notes below.

## Where Wes's user record currently lives

Not queried live from the DB in this diagnosis (would require `.env` DATABASE_URL access). But from the code trace, Wes's user record is:

- `id`: `usr_<some-uuid>`
- `org_id`: `'org_default'`
- `provider`: whichever OAuth provider he used (GitHub most likely given issue #46 activity)
- `role`: `'member'` ← **the bug**
- `created_at`: timestamp of his first signin

The one-off promotion script will find his record by email and update `role` to `'admin'`.

## Proposed fix (for T2)

### Change 1: First-user auto-promotion in `signIn` callback

`app/lib/auth.js`, around the `INSERT` at line 108, add a `SELECT COUNT(*) FROM users` check before the insert. If 0 users exist, this new signin is the instance's first user and gets `role='admin'`. Otherwise it gets `role='member'` (current behavior).

```js
// First-user promotion: the operator of a fresh DashClaw instance must be admin of
// their own instance. Without this, every self-hosted deploy creates a member-only
// first user who cannot approve actions in the /approvals UI. Fixes BUG-03.
const countResult = await sql`SELECT COUNT(*)::int AS count FROM users`;
const isFirstUser = Number(countResult[0].count) === 0;
const newUserRole = isFirstUser ? 'admin' : 'member';
```

Then use `${newUserRole}` in place of the hardcoded `'member'` in the INSERT.

There is a small race window if two users sign up simultaneously for the first time. On a single-operator self-hosted deploy this is vanishingly rare. Documented explicitly in an inline comment.

### Change 2: One-off promotion script for existing users

`scripts/promote-founder-to-admin.mjs` — idempotent, takes email as CLI arg, updates the `users` table. Follows the `scripts/migrate-api-keys-compat.mjs` pattern (ESM, uses `getSql`, exits 0/1 based on result).

### Change 3: Regression test

`__tests__/unit/bootstrap-first-user-admin.test.js` with three cases:
- First user (count=0) → `role='admin'`
- Subsequent user (count=5) → `role='member'`
- Existing user re-login → `signIn` takes the `UPDATE` branch, not `COUNT + INSERT`

### NOT in scope for this fix

- **Local-admin password path's dual-session issue** (noted above): the local-password cookie isn't read by `useSession()`, so local-admin users also can't see admin access on `/approvals`. This is a separate bug that requires either (a) bridging the two session systems, or (b) making `useSession()` aware of the local-session cookie, or (c) refactoring the approvals page to check both. Tracked as a follow-up in `01.5-02-SUMMARY.md`.
- **Silent failure anti-pattern in `signIn` catch block** (lines 112-116): if DB upsert fails, `signIn` returns true anyway. This is a "graceful degradation" that arguably violates CLAUDE.md's no-silent-failures rule. Not in scope for BUG-03 but worth flagging as tech debt.
- **Role column constraints in schema**: is `role` an enum in Postgres? A free text column? Should it be an enum? The fix works regardless of the column type, but making it an enum would prevent future typos. Not in scope.

## Files to be modified in T2

Updated `files_modified` for the plan frontmatter (was TBD):

- `app/lib/auth.js` — add first-user COUNT check + conditional role in signIn callback
- `scripts/promote-founder-to-admin.mjs` — NEW file, one-off promotion script
- `__tests__/unit/bootstrap-first-user-admin.test.js` — NEW file, regression test

No route files, no UI files, no schema changes, no migrations. Surgical.

---

*Diagnosis complete: 2026-04-11*
*Root cause: hardcoded `role='member'` in signIn callback's INSERT, with no first-user detection*
*`3dcb43dc` cleared as not-the-cause*
