feat(10-03): implement credentialSync helper, adminRouter, and self-service /api/me/credential
credentialSync.ts:
- validateEncryptAndStoreCredential(userId, email, appPassword, providerType) — single
shared validate→encrypt→store→initial-sync path used by BOTH admin and self-service
- createFastmailClient + fetchCalendars wrapped in ONE try/catch: any failure throws
CredentialValidationError (routes map to { error: 'Invalid request' } 400)
- appPassword never logged or echoed (T-10-10)
- encryptPassword (AES-256-GCM) applied before DB write (T-10-11)
- fire-and-forget initial sync via loadClientForUser + syncCalendar (Pitfall 5)
admin.ts:
- adminRouter.use('*', requireAdmin) FIRST (Pitfall 9 / T-10-08)
- GET /members: users LEFT JOIN member_credentials → hasCredential boolean
- POST /credentials: noEchoHook + validateEncryptAndStoreCredential (T-10-09)
- GET /calendars: calendar list (UI-SPEC Surface 5)
- PUT /calendars/:id/shared: exclusive is_shared update (ADMIN-02, D-06)
index.ts:
- app.route('/api/admin', adminRouter) mounted in route block
me.ts:
- POST /credential: member self-service, always currentUserId (Pitfall 6 / T-10-12)
- meCredentialSchema (no userId field), meNoEchoHook, calls shared helper
- All 17 new admin tests pass; 270 total pass; tsc --noEmit clean
This commit is contained in:
@@ -26,11 +26,18 @@
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Context } from 'hono';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { z } from 'zod';
|
||||
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';
|
||||
import {
|
||||
validateEncryptAndStoreCredential,
|
||||
CredentialValidationError,
|
||||
} from '../broker/credentialSync.js';
|
||||
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||
import '../auth/devBypass.js';
|
||||
|
||||
@@ -59,6 +66,25 @@ async function resolveAdminAndSetupStatus(userId: number) {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth helper — resolves the current user id from dev-bypass or OIDC session.
|
||||
// Per project convention: duplicated per router (not extracted to shared module).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function resolveUserId(c: Context): Promise<number | null> {
|
||||
const devUser = c.get('user') as { id: number } | undefined;
|
||||
if (devUser) return devUser.id;
|
||||
|
||||
const auth = await getAuth(c);
|
||||
if (!auth) return null;
|
||||
|
||||
const iss = (auth.iss as string | undefined) ?? '';
|
||||
const sub = auth.sub ?? '';
|
||||
const displayName = deriveDisplayName(auth);
|
||||
const user = await upsertUser(iss, sub, displayName);
|
||||
return user?.id ?? null;
|
||||
}
|
||||
|
||||
meRouter.get('/', async (c) => {
|
||||
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
|
||||
// Use the injected dev identity's id for DB lookups — no OIDC session needed,
|
||||
@@ -112,3 +138,64 @@ meRouter.get('/', async (c) => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /api/me/credential — member self-service credential endpoint (D-07)
|
||||
//
|
||||
// Security contract (T-10-12 Pitfall 6):
|
||||
// - ALWAYS writes to currentUserId from the session — NEVER a body userId.
|
||||
// - Any body.userId field is IGNORED — this endpoint cannot cross-write.
|
||||
// - Uses the SAME shared validateEncryptAndStoreCredential helper as admin path.
|
||||
// - Does NOT require requireAdmin — any authenticated member can set their own credential.
|
||||
// - Failure (any Zod or validation failure) returns { error: 'Invalid request' } 400
|
||||
// with NO echoed password (noEchoHook + CredentialValidationError → 400).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const meCredentialSchema = z.object({
|
||||
// D-07: no userId field — body userId is not accepted (Pitfall 6)
|
||||
providerType: z.literal('caldav'),
|
||||
fastmailEmail: z.string().email().max(256),
|
||||
appPassword: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
/**
|
||||
* noEchoHook for /api/me/credential: NEVER echo Zod error details (T-10-09 / Pitfall 7).
|
||||
*/
|
||||
const meNoEchoHook = (result: { success: boolean }, c: Context) => {
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Invalid request' }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
meRouter.post(
|
||||
'/credential',
|
||||
zValidator('json', meCredentialSchema, meNoEchoHook),
|
||||
async (c) => {
|
||||
// Pitfall 6: ALWAYS resolve currentUserId from the session — never from the body.
|
||||
const currentUserId = await resolveUserId(c);
|
||||
if (!currentUserId) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const { fastmailEmail, appPassword, providerType } = c.req.valid('json');
|
||||
// T-10-10: NEVER log appPassword or c.req.valid('json') here
|
||||
|
||||
try {
|
||||
// D-07: identical validate→encrypt→store→sync path as admin, but always with
|
||||
// currentUserId (not a body userId). Admin passes the target member's userId;
|
||||
// self-service passes the authenticated session userId. Same helper, same argument order.
|
||||
await validateEncryptAndStoreCredential(currentUserId, fastmailEmail, appPassword, providerType);
|
||||
} catch (err) {
|
||||
if (err instanceof CredentialValidationError) {
|
||||
return c.json({ error: 'Invalid request' }, 400);
|
||||
}
|
||||
console.error(
|
||||
'[me/POST /credential] Unexpected error:',
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return c.json({ error: 'Service unavailable' }, 503);
|
||||
}
|
||||
|
||||
return c.json({ ok: true }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user