typescript-express-dead-route-implementation
Root cause: OpenAPI spec advertised PATCH /auth/me for the web profile Save action, but src/routes/auth.ts only registered GET /me. The service (updateUser) and Postgres repo already existed — the route, validation, and error mapping were the missing layers. A secondary bug: the repo's dynamic SET clause silently dropped grade/ageBand, so even when a PATCH was wired up, those fields would validate but never persist.
Layer 1 — Zod schema (src/schemas/auth.ts): strict object so unknown keys 400 instead of being silently ignored; nullable fields so null can clear values:
export const updateProfileSchema = z.object({
username: z.string().min(3).max(32).optional(),
displayName: z.string().min(1).max(64).optional(),
grade: z.string().max(16).nullable().optional(), // null = clear
ageBand: z.enum(['child', 'teen', 'adult']).nullable().optional(),
}).strict();
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
Layer 2 — PATCH route (src/routes/auth.ts): the only missing HTTP layer. Same authenticate + validate pipeline as GET:
router.get('/me', authenticate, async (req, res) => {
res.json(await userService.getById(req.user.id));
});
// NEW — matches OpenAPI; fixes web profile Save 404
router.patch('/me', authenticate, validate(updateProfileSchema), async (req, res) => {
res.json(await userService.updateUser(req.user.id, req.body));
});
Layer 3 — Service (src/services/userService.ts): 404 if the principal no longer exists, then delegate:
async updateUser(id: string, input: UpdateProfileInput) {
const existing = await this.repo.findById(id);
if (!existing) throw new NotFoundError('User not found');
return this.repo.updateUser(id, input);
}
Layer 4 — Postgres repo (src/repositories/userRepo.ts): whitelisted dynamic SET clause (fixes the silent grade/ageBand drop — the old builder used a hardcoded column list that omitted them, so params and columns drifted), plus FK/unique code mapping without leaking SQL text (err.detail/err.message are logged server-side only):
const COLUMN_MAP: Record<string, string> = {
username: 'username', displayName: 'display_name',
grade: 'grade', ageBand: 'age_band',
};
const PG_FK_VIOLATION = '23503';
const PG_UNIQUE_VIOLATION = '23505';
async updateUser(id: string, fields: UpdateProfileInput) {
const cols: string[] = []; const values: unknown[] = [];
for (const [key, value] of Object.entries(fields)) {
if (!(key in COLUMN_MAP) || value === undefined) continue; // whitelist only; count matches placeholders
cols.push(`${COLUMN_MAP[key]} = $${values.length + 1}`);
values.push(value);
}
if (cols.length === 0) return this.findById(id); // no-op update
values.push(id);
const sql = `UPDATE users SET ${cols.join(', ')} WHERE id = $${values.length} RETURNING *`;
try {
const { rows } = await this.pool.query(sql, values);
return rows[0] ?? null;
} catch (err) {
if (isPgError(err) && err.code) {
if (err.code === PG_FK_VIOLATION) throw new NotFoundError('Referenced record not found'); // bad grade/school ref
if (err.code === PG_UNIQUE_VIOLATION) throw new ConflictError('Username already taken');
log.error('pg update failed', { code: err.code, detail: err.detail }); // no client leak
}
throw err;
}
}
Error middleware maps NotFoundError → 404, ConflictError → 409, ZodError → 400, everything else → 500 with a generic message (never the raw pg error).
**Verification runs:**
- `npm test` — suite grew by **8 tests**, all green.
- Live probes against the running server (web profile Save path):
- `PATCH /auth/me` without token → **401** (authenticate rejects)
- `PATCH /auth/me` `{unknownField: 1}` → **400** (`.strict()` + zod)
- `PATCH /auth/me` `{displayName: "New"}` with valid token → **200** with updated row (was **404** before the fix)
- `PATCH /auth/me` duplicate username → **409** (23505 mapped)
- `PATCH /auth/me` invalid `grade` reference → **404** (23503 mapped)
**Edge cases covered:**
1. **No-op update** (empty/`{}` body) → returns current user 200, no `UPDATE` emitted.
2. **`grade`/`ageBand` persistence** — regression test asserts both columns are written; the old builder silently dropped them (whitelist drift), now covered by `RETURNING *` assertions.
3. **Clearing fields** — `null` vs `undefined` distinguished: `null` writes `NULL`, `undefined` omits the column.
4. **SQL injection / param drift** — column names come only from `COLUMN_MAP` whitelist, never user input; placeholders are generated from the same key scan, so count always matches params.
5. **No SQL text leak** — 409/404 responses contain only the friendly message; `err.detail` (which includes the offending row/constraint text) is server-logged, not returned.
6. **FK vs unique disambiguation** — 23503 → 404 and 23505 → 409 exercised with real DB constraints, not stubs.
7. **GET /auth/me regression** — existing route and its tests still pass after the router edit.{"model": "deepseek-v4-flash", "problem_class": "typescript-express-dead-route-implementation", "result": "passed", "tests": 8}