01c45d0344
Client-side validators (reset-password, invite-accept, first-admin setup, user-create modal) previously checked password.length < 8 while every server-side Zod schema required .min(12). External API consumers (or a confused browser UI) could get past the client check but fail at the tRPC boundary — or worse, quietly under-enforce policy compared to what admins expect. Fix: introduce PASSWORD_MIN_LENGTH (12) and PASSWORD_MAX_LENGTH (128) in @capakraken/shared and import them from every pre-submit client validator and every server Zod schema. Single source of truth; drift becomes a compile error rather than a security finding. Also hardens the AUTH_SECRET runtime check: in addition to the existing placeholder-blacklist, production startup now rejects secrets shorter than 32 chars OR with Shannon entropy below 3.5 bits/char. That covers low-entropy-but-long values like "aaaa..." (38 chars, entropy 0) which would have passed the previous checks. Documented the rotation process for AUTH_SECRET + POSTGRES_PASSWORD in docs/security-architecture.md §3. Verified: - pnpm test:unit — 396 files / 1922 tests passed - pnpm --filter @capakraken/web exec tsc --noEmit — clean - pnpm --filter @capakraken/api exec tsc --noEmit — clean Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
56 lines
1.6 KiB
TypeScript
56 lines
1.6 KiB
TypeScript
"use server";
|
|
import { prisma } from "@capakraken/db";
|
|
import { SystemRole } from "@capakraken/db";
|
|
import {
|
|
PASSWORD_MAX_LENGTH,
|
|
PASSWORD_MIN_LENGTH,
|
|
PASSWORD_POLICY_MESSAGE,
|
|
} from "@capakraken/shared";
|
|
|
|
export type SetupResult =
|
|
| { success: true }
|
|
| { error: "alreadySetup" | "emailTaken" | "validation"; message?: string };
|
|
|
|
export async function createFirstAdmin(formData: {
|
|
name: string;
|
|
email: string;
|
|
password: string;
|
|
}): Promise<SetupResult> {
|
|
// Validate
|
|
if (!formData.name.trim()) return { error: "validation", message: "Name is required." };
|
|
if (!formData.email.includes("@"))
|
|
return { error: "validation", message: "Valid email required." };
|
|
if (
|
|
formData.password.length < PASSWORD_MIN_LENGTH ||
|
|
formData.password.length > PASSWORD_MAX_LENGTH
|
|
) {
|
|
return { error: "validation", message: PASSWORD_POLICY_MESSAGE };
|
|
}
|
|
|
|
// TOCTOU guard — check again inside the action
|
|
const count = await prisma.user.count();
|
|
if (count > 0) return { error: "alreadySetup" };
|
|
|
|
const { hash } = await import("@node-rs/argon2");
|
|
const passwordHash = await hash(formData.password);
|
|
|
|
try {
|
|
await prisma.user.create({
|
|
data: {
|
|
email: formData.email.toLowerCase().trim(),
|
|
name: formData.name.trim(),
|
|
passwordHash,
|
|
systemRole: SystemRole.ADMIN,
|
|
isActive: true,
|
|
},
|
|
});
|
|
return { success: true };
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
if (message.includes("Unique constraint") || message.includes("unique")) {
|
|
return { error: "emailTaken" };
|
|
}
|
|
throw err;
|
|
}
|
|
}
|