- playwright-cli verified: Members tab shows tappable rows, no retired buttons - Row tap opens 'Edit member' sheet; per-section saves keep sheet open - 'Add member' trigger opens 'Add member' sheet in create mode - Profile save fires 'Profile saved.' toast; sheet stays open (D-05) - eslint + prettier + typecheck + vitest (275 tests) all pass - Fix pre-existing prettier drift in docs/*, CLAUDE.md, README.md, api/admin.ts
504 lines
21 KiB
TypeScript
504 lines
21 KiB
TypeScript
/**
|
|
* Admin router — role-gated admin API surface (ADMIN-01/02/03).
|
|
*
|
|
* Security contract:
|
|
* - adminRouter.use('*', requireAdmin) is the FIRST statement (Pitfall 9 / T-10-08).
|
|
* This guard runs before ANY route handler, so no admin route is reachable by non-admins.
|
|
* - POST /credentials uses noEchoHook: NEVER returns Zod's result.error (which contains
|
|
* .received = the submitted password). Returns { error: 'Invalid request' } only (T-10-09).
|
|
* - validateEncryptAndStoreCredential from credentialSync.ts is the ONLY place
|
|
* createFastmailClient + fetchCalendars + encryptPassword + upsert live (D-07).
|
|
* - No console.log of request bodies or passwords in any handler (T-10-10).
|
|
*
|
|
* Routes:
|
|
* GET /api/admin/members → list members + credential status + isAdmin (UI-SPEC Surface 2)
|
|
* POST /api/admin/members → create local member: users row + local_credentials (AUTH-LOCAL-07)
|
|
* PATCH /api/admin/members/:id → update member profile: displayName and/or isAdmin (Plan 20-01)
|
|
* POST /api/admin/members/:id/password → admin reset local member password (AUTH-LOCAL-08)
|
|
* POST /api/admin/credentials → validate+encrypt+store for any member (ADMIN-01)
|
|
* GET /api/admin/calendars → list synced calendars (UI-SPEC Surface 5)
|
|
* PUT /api/admin/calendars/:id/shared → exclusive is_shared designation (ADMIN-02)
|
|
*
|
|
* Mounted in index.ts: app.route('/api/admin', adminRouter)
|
|
*/
|
|
|
|
import { Hono } from 'hono';
|
|
import type { Context } from 'hono';
|
|
import { zValidator } from '@hono/zod-validator';
|
|
import { z } from 'zod';
|
|
import { eq, sql } from 'drizzle-orm';
|
|
import { db } from '../db/client.js';
|
|
import { users, memberCredentials, calendars, appConfig, localCredentials } from '../db/schema.js';
|
|
import { requireAdmin } from '../lib/requireAdmin.js';
|
|
import { isValidIanaTimezone, resolveHouseholdTimezone } from '../lib/householdTimezone.js';
|
|
import {
|
|
validateEncryptAndStoreCredential,
|
|
CredentialValidationError,
|
|
} from '../broker/credentialSync.js';
|
|
import { hashPassword } from '../auth/localCredentials.js';
|
|
import { resetLoginAttempts } from './localAuth.js';
|
|
import { COLOR_PALETTE } from '../auth/user.js';
|
|
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
|
import '../auth/devBypass.js';
|
|
|
|
export const adminRouter = new Hono();
|
|
|
|
// Pitfall 9: requireAdmin MUST be the first statement on the router.
|
|
// All sub-routes are protected — no path can be reached without passing this guard.
|
|
adminRouter.use('*', requireAdmin);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Zod schema + no-echo hook for credential routes (T-10-09 / Pitfall 7)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const credentialSchema = z.object({
|
|
userId: z.number().int().positive(),
|
|
providerType: z.literal('caldav'),
|
|
fastmailEmail: z.string().email().max(256),
|
|
appPassword: z.string().min(1).max(500),
|
|
});
|
|
|
|
// Timezone schema — no noEchoHook needed (timezone strings are non-sensitive, T-18-06)
|
|
const timezoneSchema = z.object({
|
|
timezone: z
|
|
.string()
|
|
.min(1)
|
|
.max(64)
|
|
.refine(isValidIanaTimezone, { message: 'Invalid IANA timezone identifier' }),
|
|
});
|
|
|
|
/**
|
|
* noEchoHook: NEVER return result.error from zValidator for credential routes.
|
|
* Zod's error object contains issues[].received which echoes the submitted value
|
|
* (the app password) — returning it would violate T-10-09 (Pitfall 7).
|
|
* Always return { error: 'Invalid request' } 400, no other fields.
|
|
*/
|
|
const noEchoHook = (result: { success: boolean }, c: Context) => {
|
|
if (!result.success) {
|
|
return c.json({ error: 'Invalid request' }, 400);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* WR-07: strictly parse a positive-integer route param. parseInt('12abc', 10) returns 12
|
|
* and passes an isNaN guard, silently accepting malformed ids. Number('12abc') is NaN, so
|
|
* Number.isInteger(Number(raw)) rejects trailing garbage. Returns null for anything that is
|
|
* not a whole positive integer (empty, '12abc', '1.5', '-3', '0', etc.) so the caller can 400.
|
|
*/
|
|
function parsePositiveIntParam(raw: string | undefined): number | null {
|
|
if (raw === undefined || raw.trim() === '') return null;
|
|
const n = Number(raw);
|
|
if (!Number.isInteger(n) || n <= 0) return null;
|
|
return n;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/admin/members
|
|
//
|
|
// Returns all household members with their credential status.
|
|
// Feeds UI-SPEC Surface 2 (member list with rotate-credential affordance).
|
|
// Includes hasLocalCredential (AUTH-LOCAL-17) alongside existing hasCredential.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
adminRouter.get('/members', async (c) => {
|
|
const rows = await db
|
|
.select({
|
|
id: users.id,
|
|
displayName: users.displayName,
|
|
color: users.color,
|
|
isAdmin: users.isAdmin, // Plan 20-01: feeds editor admin toggle initial state (D-02)
|
|
credentialId: memberCredentials.id,
|
|
localCredId: localCredentials.id, // LEFT JOIN — null when no local_credentials row
|
|
})
|
|
.from(users)
|
|
.leftJoin(memberCredentials, eq(memberCredentials.userId, users.id))
|
|
.leftJoin(localCredentials, eq(localCredentials.userId, users.id)); // AUTH-LOCAL-17
|
|
|
|
const members = rows.map((row) => ({
|
|
id: row.id,
|
|
displayName: row.displayName,
|
|
color: row.color,
|
|
isAdmin: row.isAdmin, // Plan 20-01
|
|
hasCredential: row.credentialId !== null,
|
|
hasLocalCredential: row.localCredId !== null, // AUTH-LOCAL-17
|
|
}));
|
|
|
|
return c.json({ members });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /api/admin/members
|
|
//
|
|
// Admin creates a new local member: inserts a users row and a local_credentials
|
|
// row with a hashed initial password in a single transaction (AUTH-LOCAL-07).
|
|
// Security:
|
|
// - noEchoHook: never echoes Zod errors containing the submitted password (T-19-06)
|
|
// - requireAdmin: already enforced by adminRouter.use('*', requireAdmin) (T-19-05)
|
|
// - db.transaction: rolls back both inserts on username conflict (T-19-10)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const createMemberSchema = z.object({
|
|
displayName: z.string().min(1).max(256),
|
|
username: z.string().min(1).max(128),
|
|
initialPassword: z.string().min(8),
|
|
});
|
|
|
|
adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => {
|
|
const { displayName, username, initialPassword } = c.req.valid('json');
|
|
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
|
|
|
|
// Assign the first palette color not already in use (mirrors upsertUser color logic)
|
|
const usedRows = await db.select({ color: users.color }).from(users);
|
|
const usedColors = new Set(usedRows.map((r) => r.color));
|
|
const color =
|
|
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
|
|
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
|
|
|
|
// WR-03: hash the initial password BEFORE opening the transaction so the (now async,
|
|
// threadpool) scrypt work does not hold the DB transaction open for its duration.
|
|
const initialPasswordHash = await hashPassword(initialPassword);
|
|
|
|
try {
|
|
let newUserId: number;
|
|
|
|
// T-19-10: atomic transaction — both inserts succeed or both roll back
|
|
await db.transaction(async (tx) => {
|
|
// Insert the new users row (no oidcIss/oidcSub — local-only member)
|
|
const [inserted] = await tx
|
|
.insert(users)
|
|
.values({
|
|
displayName,
|
|
color,
|
|
isAdmin: false,
|
|
claimed: false, // no OIDC identity bound yet
|
|
})
|
|
.$returningId();
|
|
newUserId = inserted.id;
|
|
|
|
// Insert local_credentials row with hashed initial password
|
|
// If username is already in use, the UNIQUE constraint fires here and rolls back
|
|
await tx.insert(localCredentials).values({
|
|
userId: newUserId,
|
|
username,
|
|
passwordHash: initialPasswordHash,
|
|
});
|
|
});
|
|
|
|
// Return the new user's id (the PWA uses it to navigate to the member)
|
|
return c.json({ id: newUserId! }, 201);
|
|
} catch (err) {
|
|
// Username uniqueness violation — UNIQUE constraint on local_credentials.username.
|
|
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
|
|
const isDup =
|
|
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
|
|
(err != null &&
|
|
typeof err === 'object' &&
|
|
'code' in err &&
|
|
(err as { code?: string }).code === 'ER_DUP_ENTRY') ||
|
|
(err != null &&
|
|
typeof err === 'object' &&
|
|
'cause' in err &&
|
|
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
|
|
if (isDup) {
|
|
return c.json({ error: 'Username already in use' }, 409);
|
|
}
|
|
// Unexpected errors — log message only, never the body or password
|
|
console.error(
|
|
'[admin/POST /members] Unexpected error:',
|
|
err instanceof Error ? err.message : String(err),
|
|
);
|
|
return c.json({ error: 'Service unavailable' }, 503);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PATCH /api/admin/members/:id
|
|
//
|
|
// Updates a member's displayName and/or isAdmin flag (Plan 20-01, D-02, D-03).
|
|
// Security:
|
|
// - requireAdmin: inherited from adminRouter.use('*', requireAdmin) (D-02 — no second guard)
|
|
// - noEchoHook: applied for consistency with other admin write routes (T-20-04)
|
|
// - D-03 last-admin guard: rejects isAdmin=false when target is the sole remaining admin (T-20-02)
|
|
// - parsePositiveIntParam: rejects malformed ids (T-20-03)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const updateMemberSchema = z.object({
|
|
displayName: z.string().min(1).max(256).optional(),
|
|
isAdmin: z.boolean().optional(),
|
|
});
|
|
|
|
adminRouter.patch('/members/:id', zValidator('json', updateMemberSchema, noEchoHook), async (c) => {
|
|
const targetId = parsePositiveIntParam(c.req.param('id'));
|
|
if (targetId === null) {
|
|
return c.json({ error: 'Invalid member id' }, 400);
|
|
}
|
|
|
|
const { displayName, isAdmin } = c.req.valid('json');
|
|
// T-20-04: NEVER log request body
|
|
|
|
// Verify the target user exists (404 if not)
|
|
const [target] = await db
|
|
.select({ id: users.id, isAdmin: users.isAdmin })
|
|
.from(users)
|
|
.where(eq(users.id, targetId))
|
|
.limit(1);
|
|
|
|
if (!target) {
|
|
return c.json({ error: 'Member not found' }, 404);
|
|
}
|
|
|
|
// D-03 last-admin guard: reject demotion of the only remaining admin (T-20-02)
|
|
if (isAdmin === false && target.isAdmin) {
|
|
const [{ count }] = await db
|
|
.select({ count: sql<number>`COUNT(*)` })
|
|
.from(users)
|
|
.where(eq(users.isAdmin, true));
|
|
if (Number(count) <= 1) {
|
|
return c.json({ error: 'Cannot remove the last admin' }, 409);
|
|
}
|
|
}
|
|
|
|
// Build a partial set() from whichever fields are present
|
|
const updates: { displayName?: string; isAdmin?: boolean } = {};
|
|
if (displayName !== undefined) updates.displayName = displayName;
|
|
if (isAdmin !== undefined) updates.isAdmin = isAdmin;
|
|
|
|
try {
|
|
await db.update(users).set(updates).where(eq(users.id, targetId));
|
|
return c.json({ ok: true }, 200);
|
|
} catch (err) {
|
|
console.error(
|
|
'[admin/PATCH /members/:id] Unexpected error:',
|
|
err instanceof Error ? err.message : String(err),
|
|
);
|
|
return c.json({ error: 'Service unavailable' }, 503);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /api/admin/members/:id/password
|
|
//
|
|
// Admin resets a local member's password without knowing the current one (AUTH-LOCAL-08).
|
|
// Security:
|
|
// - noEchoHook: never echoes Zod errors (T-19-06)
|
|
// - requireAdmin: enforced by adminRouter.use('*', requireAdmin) (T-19-05)
|
|
// - No current password required — admin-only capability
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const resetPasswordSchema = z.object({
|
|
newPassword: z.string().min(8),
|
|
});
|
|
|
|
adminRouter.post(
|
|
'/members/:id/password',
|
|
zValidator('json', resetPasswordSchema, noEchoHook),
|
|
async (c) => {
|
|
const targetId = parsePositiveIntParam(c.req.param('id'));
|
|
if (targetId === null) {
|
|
return c.json({ error: 'Invalid member id' }, 400);
|
|
}
|
|
|
|
const { newPassword } = c.req.valid('json');
|
|
// T-19-06: NEVER log newPassword or the request body
|
|
|
|
// Verify the target user has a local_credentials row (404 if not).
|
|
// Also read the username so we can clear any login lockout for it (CR-04).
|
|
const [credRow] = await db
|
|
.select({ id: localCredentials.id, username: localCredentials.username })
|
|
.from(localCredentials)
|
|
.where(eq(localCredentials.userId, targetId))
|
|
.limit(1);
|
|
|
|
if (!credRow) {
|
|
return c.json({ error: 'Member not found or has no local credential' }, 404);
|
|
}
|
|
|
|
try {
|
|
await db
|
|
.update(localCredentials)
|
|
.set({ passwordHash: await hashPassword(newPassword) })
|
|
.where(eq(localCredentials.userId, targetId));
|
|
|
|
// CR-04: an admin password reset must immediately clear any rate-limit / lockout
|
|
// state for this username, so a locked-out member regains access at once rather than
|
|
// waiting for the TTL. The lockout is keyed on username (not member id).
|
|
resetLoginAttempts(credRow.username);
|
|
|
|
return c.json({ ok: true }, 200);
|
|
} catch (err) {
|
|
console.error(
|
|
'[admin/POST /members/:id/password] Unexpected error:',
|
|
err instanceof Error ? err.message : String(err),
|
|
);
|
|
return c.json({ error: 'Service unavailable' }, 503);
|
|
}
|
|
},
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /api/admin/credentials
|
|
//
|
|
// Admin rotates (or sets for the first time) a member's Fastmail app password.
|
|
// Validates against CalDAV (PROPFIND) before storing.
|
|
// Uses the shared validateEncryptAndStoreCredential helper — no duplicated logic here.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
adminRouter.post('/credentials', zValidator('json', credentialSchema, noEchoHook), async (c) => {
|
|
const { userId, fastmailEmail, appPassword, providerType } = c.req.valid('json');
|
|
// T-10-10: NEVER log appPassword or c.req.valid('json') here
|
|
|
|
try {
|
|
await validateEncryptAndStoreCredential(userId, fastmailEmail, appPassword, providerType);
|
|
} catch (err) {
|
|
if (err instanceof CredentialValidationError) {
|
|
// T-10-09: map validation failure to generic 400 — no echo of password or Zod details
|
|
return c.json({ error: 'Invalid request' }, 400);
|
|
}
|
|
// Unexpected errors (DB failure, etc.) — log message only, no credential data
|
|
console.error(
|
|
'[admin/POST /credentials] Unexpected error:',
|
|
err instanceof Error ? err.message : String(err),
|
|
);
|
|
return c.json({ error: 'Service unavailable' }, 503);
|
|
}
|
|
|
|
return c.json({ ok: true }, 200);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/admin/calendars
|
|
//
|
|
// Lists all synced calendars. Feeds UI-SPEC Surface 5 (shared-calendar picker).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
adminRouter.get('/calendars', async (c) => {
|
|
const rows = await db
|
|
.select({
|
|
id: calendars.id,
|
|
displayName: calendars.displayName,
|
|
isShared: calendars.isShared,
|
|
})
|
|
.from(calendars);
|
|
|
|
return c.json({ calendars: rows });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PUT /api/admin/calendars/:id/shared
|
|
//
|
|
// Exclusively marks one calendar as is_shared=true (ADMIN-02).
|
|
// Clears is_shared on any prior shared calendar first (D-06 single-select).
|
|
// Pattern 7: two sequential UPDATE statements.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
adminRouter.put('/calendars/:id/shared', async (c) => {
|
|
const targetId = parsePositiveIntParam(c.req.param('id'));
|
|
if (targetId === null) {
|
|
return c.json({ error: 'Invalid calendar id' }, 400);
|
|
}
|
|
|
|
// CR-01: verify the target exists and flip the shared lane atomically.
|
|
// Without the existence check + transaction, a bad/stale id would clear the
|
|
// current shared calendar in step 1 and update 0 rows in step 2 — silently
|
|
// leaving the household with NO shared calendar while still returning ok.
|
|
const found = await db.transaction(async (tx) => {
|
|
const [target] = await tx
|
|
.select({ id: calendars.id })
|
|
.from(calendars)
|
|
.where(eq(calendars.id, targetId))
|
|
.limit(1);
|
|
if (!target) return false;
|
|
|
|
// Step 1: Clear is_shared on any currently-shared calendar
|
|
await tx.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true));
|
|
// Step 2: Set is_shared on the target calendar (D-06 single-select)
|
|
await tx.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId));
|
|
return true;
|
|
});
|
|
|
|
if (!found) {
|
|
return c.json({ error: 'Calendar not found' }, 404);
|
|
}
|
|
|
|
return c.json({ ok: true }, 200);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/admin/config/timezone
|
|
//
|
|
// Returns the stored household timezone and whether it has been explicitly set
|
|
// (D-01, D-04, D-06). isExplicitlySet: false when no row is in app_config
|
|
// (the response still includes the D-06 fallback as the timezone value).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
adminRouter.get('/config/timezone', async (c) => {
|
|
const [row] = await db
|
|
.select({ value: appConfig.value })
|
|
.from(appConfig)
|
|
.where(eq(appConfig.key, 'household_timezone'))
|
|
.limit(1);
|
|
|
|
const isExplicitlySet = row?.value != null;
|
|
// IN-01/IN-02: reuse the row we just SELECTed and let the centralized accessor apply
|
|
// the D-06 fallback — no second app_config round-trip, single source for the policy.
|
|
const timezone = resolveHouseholdTimezone(row?.value ?? null);
|
|
|
|
return c.json({ timezone, isExplicitlySet });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PUT /api/admin/config/timezone
|
|
//
|
|
// Validates the IANA timezone string (server-side, T-18-04) and upserts the
|
|
// household_timezone key in app_config (D-01, D-04).
|
|
// Uses onDuplicateKeyUpdate — appConfig.key is the PK so this is a true upsert.
|
|
// No noEchoHook: timezone strings are non-sensitive (T-18-06).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
adminRouter.put('/config/timezone', zValidator('json', timezoneSchema), async (c) => {
|
|
const { timezone } = c.req.valid('json');
|
|
|
|
await db
|
|
.insert(appConfig)
|
|
.values({ key: 'household_timezone', value: timezone })
|
|
.onDuplicateKeyUpdate({ set: { value: timezone } });
|
|
|
|
return c.json({ ok: true }, 200);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /api/admin/config/timezone/seed
|
|
//
|
|
// Seeds household_timezone ONLY when currently unset (D-02 first-run / D-03
|
|
// no-overwrite). Used by the Phase 12 setup wizard and the Phase 18 auto-detect
|
|
// flow to store the browser-detected IANA zone without clobbering an admin's
|
|
// explicit choice.
|
|
//
|
|
// Always returns 200 with { ok: true, seeded: <bool> }.
|
|
// Uses a single INSERT IGNORE: when the household_timezone row already exists the
|
|
// insert is silently ignored (existing value untouched — D-03 no-overwrite) and
|
|
// cannot 500 on the PK constraint under a concurrent seed/PUT. `seeded` is derived
|
|
// from the result's affectedRows so it reflects what the DB actually did, accurately
|
|
// even under a genuine concurrent race (WR-02).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
adminRouter.post('/config/timezone/seed', zValidator('json', timezoneSchema), async (c) => {
|
|
const { timezone } = c.req.valid('json');
|
|
|
|
// WR-02: always run the INSERT and let the DB be the source of truth, instead of a
|
|
// pre-flight SELECT whose result could be stale under a concurrent race (two racers
|
|
// both observing an empty table and both returning seeded:true). INSERT IGNORE on
|
|
// MariaDB reports affectedRows === 1 for a real insert and 0 when the row already
|
|
// exists (ignored, value preserved — D-03), so deriving `seeded` from affectedRows
|
|
// is accurate: only the racer whose INSERT actually wrote the row gets seeded:true.
|
|
// `timezone` is interpolated via drizzle's parameterized sql template (bound param,
|
|
// not string concatenation) and is already validated as an IANA zone by timezoneSchema.
|
|
const result = (await db.execute(
|
|
sql`INSERT IGNORE INTO ${appConfig} (${sql.identifier('key')}, ${sql.identifier('value')}) VALUES ('household_timezone', ${timezone})`,
|
|
)) as unknown as [{ affectedRows: number }, unknown];
|
|
|
|
const seeded = result[0].affectedRows === 1;
|
|
|
|
return c.json({ ok: true, seeded }, 200);
|
|
});
|