diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 0d888c1..9ab8646 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -1,5 +1,5 @@ /** - * GET /api/me — returns the authenticated user's identity and assigned color. + * GET /api/me — returns the authenticated user's identity, admin role, and provider setup status. * * Flow (normal — OIDC active): * 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie @@ -7,36 +7,72 @@ * 2. Derives displayName from OIDC claims (name → preferred_username → email → sub fallback) * then calls upsertUser(iss, sub, displayName) — writes on first visit, corrects a * previously blank displayName on subsequent visits (idempotent, keyed on iss+sub, D-10) - * 3. Returns { user: { id, displayName, color } } + * 3. Queries users.isAdmin and member_credentials existence for the resolved user + * 4. Returns { user: { id, displayName, color, isAdmin, needsProviderSetup } } * * Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production): * devAuthBypass() injects DEV_USER via c.set('user', DEV_USER). oidcAuthMiddleware * is NOT mounted in index.ts when the bypass is active, so getAuth(c) is never called. - * This handler reads c.get('user') first and short-circuits to return the dev identity - * directly, skipping the DB upsert. + * This handler reads c.get('user') first and short-circuits using the dev user's id, + * but STILL queries the DB for isAdmin (T-10-05: bypass skips OIDC, not the DB check) + * and member_credentials existence. + * + * Security (D-03, T-10-06): + * isAdmin is exposed for UX-only PWA nav gating — it is NOT the security boundary. + * The server enforces the role on every /api/admin/* request via requireAdmin (Plan 03). * * The OIDC session cookie is httpOnly + Secure + SameSite (T-02-03). - * No credential or refresh-token data is included in the response (T-02-04). + * No credential or refresh-token data is included in the response (T-02-04, T-10-07). */ import { Hono } from 'hono'; +import { eq } from 'drizzle-orm'; import { getAuth } from '../auth/middleware.js'; import { upsertUser, deriveDisplayName } from '../auth/user.js'; +import { db } from '../db/client.js'; +import { users, memberCredentials } from '../db/schema.js'; // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') import '../auth/devBypass.js'; export const meRouter = new Hono(); +/** + * Looks up isAdmin and needsProviderSetup for a given userId. + * Always reads from the DB — bypass only skips OIDC, not this check (T-10-05). + */ +async function resolveAdminAndSetupStatus(userId: number) { + const [userRow] = await db + .select({ isAdmin: users.isAdmin }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + const [cred] = await db + .select({ id: memberCredentials.id }) + .from(memberCredentials) + .where(eq(memberCredentials.userId, userId)) + .limit(1); + + return { + isAdmin: userRow?.isAdmin ?? false, + needsProviderSetup: !cred, + }; +} + meRouter.get('/', async (c) => { // Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active. - // Return the injected dev identity directly — no DB round-trip, no OIDC session needed. + // Use the injected dev identity's id for DB lookups — no OIDC session needed, + // but isAdmin and needsProviderSetup are still resolved from the DB (T-10-05). const devUser = c.get('user'); if (devUser) { + const { isAdmin, needsProviderSetup } = await resolveAdminAndSetupStatus(devUser.id); return c.json({ user: { id: devUser.id, displayName: devUser.displayName, color: devUser.color, + isAdmin, + needsProviderSetup, }, }); } @@ -64,11 +100,15 @@ meRouter.get('/', async (c) => { return c.json({ error: 'Could not resolve user' }, 500); } + const { isAdmin, needsProviderSetup } = await resolveAdminAndSetupStatus(user.id); + return c.json({ user: { id: user.id, displayName: user.displayName, color: user.color, + isAdmin, + needsProviderSetup, }, }); });