feat(10-02): extend /api/me with isAdmin + needsProviderSetup (D-03)

- dev-bypass path: DB lookup for users.isAdmin (T-10-05 bypass skips OIDC not DB)
- OIDC path: same resolveAdminAndSetupStatus helper after upsertUser
- needsProviderSetup: true when no member_credentials row, false when one exists
- no /api/me/credential POST added here (Plan 03)
This commit is contained in:
Lucas Berger
2026-06-13 14:37:38 -04:00
parent e5889df03e
commit 1adff61cec
+46 -6
View File
@@ -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): * Flow (normal — OIDC active):
* 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie * 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) * 2. Derives displayName from OIDC claims (name → preferred_username → email → sub fallback)
* then calls upsertUser(iss, sub, displayName) — writes on first visit, corrects a * 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) * 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): * Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production):
* devAuthBypass() injects DEV_USER via c.set('user', DEV_USER). oidcAuthMiddleware * 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. * 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 * This handler reads c.get('user') first and short-circuits using the dev user's id,
* directly, skipping the DB upsert. * 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). * 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 { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { getAuth } from '../auth/middleware.js'; import { getAuth } from '../auth/middleware.js';
import { upsertUser, deriveDisplayName } from '../auth/user.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') // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'; import '../auth/devBypass.js';
export const meRouter = new Hono(); 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) => { meRouter.get('/', async (c) => {
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active. // 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'); const devUser = c.get('user');
if (devUser) { if (devUser) {
const { isAdmin, needsProviderSetup } = await resolveAdminAndSetupStatus(devUser.id);
return c.json({ return c.json({
user: { user: {
id: devUser.id, id: devUser.id,
displayName: devUser.displayName, displayName: devUser.displayName,
color: devUser.color, color: devUser.color,
isAdmin,
needsProviderSetup,
}, },
}); });
} }
@@ -64,11 +100,15 @@ meRouter.get('/', async (c) => {
return c.json({ error: 'Could not resolve user' }, 500); return c.json({ error: 'Could not resolve user' }, 500);
} }
const { isAdmin, needsProviderSetup } = await resolveAdminAndSetupStatus(user.id);
return c.json({ return c.json({
user: { user: {
id: user.id, id: user.id,
displayName: user.displayName, displayName: user.displayName,
color: user.color, color: user.color,
isAdmin,
needsProviderSetup,
}, },
}); });
}); });