fix(12): IN-02 guard /setup/complete against skipping the credential step

Without a prerequisite check, an operator could call POST /api/setup/complete
directly, setting setup_complete=true with no admin user or credential row,
leaving no recovery path without manual DB surgery.

Add an inner join check for an unclaimed user with an associated credential;
return 422 if absent. Update /complete tests to seed the prerequisite for
the success path and add an explicit 422 regression test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-15 16:20:55 -04:00
co-authored by Claude Sonnet 4.6
parent d9dfe72aab
commit 3babbfa20e
2 changed files with 34 additions and 2 deletions
+17 -2
View File
@@ -27,9 +27,9 @@ 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 { eq, sql, and, isNull } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users, appConfig } from '../db/schema.js';
import { users, appConfig, memberCredentials } from '../db/schema.js';
import { isSetupLocked } from '../lib/setupGuard.js';
import { COLOR_PALETTE } from '../auth/user.js';
import {
@@ -352,6 +352,21 @@ setupRouter.post('/complete', async (c) => {
const locked = await isSetupLocked();
if (locked) return c.json({ error: 'Setup already complete' }, 423);
// IN-02: Guard against skipping the credential step — require an unclaimed user
// with an associated credential before locking setup. Without this check an operator
// could call /complete directly, producing a state where setup_complete=true but no
// admin user exists: first OIDC login creates a non-admin with no credential.
const [unclaimedWithCred] = await db
.select({ id: users.id })
.from(users)
.innerJoin(memberCredentials, eq(memberCredentials.userId, users.id))
.where(and(isNull(users.oidcIss), eq(users.claimed, false)))
.limit(1);
if (!unclaimedWithCred) {
return c.json({ error: 'Cannot lock setup: no credential configured' }, 422);
}
await db
.insert(appConfig)
.values({ key: 'setup_complete', value: 'true' })