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:
Lucas Berger
2026-06-13 14:54:23 -04:00
parent 037a7ed4c1
commit d2f6d5d77b
4 changed files with 360 additions and 0 deletions
+87
View File
@@ -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);
},
);