Phase 10 — Admin Role & Settings (ADMIN-01/02/03) #17

Merged
luckberg merged 25 commits from gsd/phase-10-admin-role-settings into main 2026-06-13 17:10:47 -04:00
Showing only changes of commit 1adff61cec - Show all commits
+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):
* 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,
},
});
});