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:
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user