security: align client password policy with server, enforce AUTH_SECRET length + entropy (#56)

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>
This commit is contained in:
2026-04-17 14:56:43 +02:00
parent 805bb0464f
commit 01c45d0344
12 changed files with 163 additions and 44 deletions
@@ -2,6 +2,7 @@
import { use, useState } from "react"; import { use, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) { export default function ResetPasswordPage({ params }: { params: Promise<{ token: string }> }) {
@@ -21,8 +22,8 @@ export default function ResetPasswordPage({ params }: { params: Promise<{ token:
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
if (password.length < 8) { if (password.length < PASSWORD_MIN_LENGTH) {
setFormError("Password must be at least 8 characters."); setFormError(PASSWORD_POLICY_MESSAGE);
return; return;
} }
if (password !== confirm) { if (password !== confirm) {
@@ -40,9 +41,7 @@ export default function ResetPasswordPage({ params }: { params: Promise<{ token:
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2"> <h1 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
Password updated Password updated
</h1> </h1>
<p className="text-sm text-gray-500 mb-6"> <p className="text-sm text-gray-500 mb-6">Your password has been changed successfully.</p>
Your password has been changed successfully.
</p>
<button <button
type="button" type="button"
onClick={() => router.push("/auth/signin")} onClick={() => router.push("/auth/signin")}
@@ -59,12 +58,8 @@ export default function ResetPasswordPage({ params }: { params: Promise<{ token:
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4"> <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8"> <div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100"> <h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Set a new password</h1>
Set a new password <p className="mt-1 text-sm text-gray-500">Choose a new password for your account.</p>
</h1>
<p className="mt-1 text-sm text-gray-500">
Choose a new password for your account.
</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -87,8 +82,8 @@ export default function ResetPasswordPage({ params }: { params: Promise<{ token:
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
minLength={8} minLength={PASSWORD_MIN_LENGTH}
placeholder="At least 8 characters" placeholder={`At least ${PASSWORD_MIN_LENGTH} characters`}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/> />
</div> </div>
+20 -11
View File
@@ -2,6 +2,7 @@
import { useState, use } from "react"; import { useState, use } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js"; import { trpc } from "~/lib/trpc/client.js";
export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) { export default function AcceptInvitePage({ params }: { params: Promise<{ token: string }> }) {
@@ -13,10 +14,11 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const { data: invite, isLoading, error: inviteError } = trpc.invite.getInvite.useQuery( const {
{ token }, data: invite,
{ retry: false }, isLoading,
); error: inviteError,
} = trpc.invite.getInvite.useQuery({ token }, { retry: false });
const acceptMutation = trpc.invite.acceptInvite.useMutation({ const acceptMutation = trpc.invite.acceptInvite.useMutation({
onSuccess: () => setDone(true), onSuccess: () => setDone(true),
@@ -26,8 +28,14 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
if (password.length < 8) { setFormError("Password must be at least 8 characters."); return; } if (password.length < PASSWORD_MIN_LENGTH) {
if (password !== confirm) { setFormError("Passwords do not match."); return; } setFormError(PASSWORD_POLICY_MESSAGE);
return;
}
if (password !== confirm) {
setFormError("Passwords do not match.");
return;
}
await acceptMutation.mutateAsync({ token, password }); await acceptMutation.mutateAsync({ token, password });
} }
@@ -48,7 +56,8 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
Invite link invalid or expired Invite link invalid or expired
</h1> </h1>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{inviteError?.message ?? "This invite link is no longer valid. Please request a new invitation from your administrator."} {inviteError?.message ??
"This invite link is no longer valid. Please request a new invitation from your administrator."}
</p> </p>
</div> </div>
</div> </div>
@@ -82,8 +91,8 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
<div className="mb-6"> <div className="mb-6">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Accept invitation</h1> <h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Accept invitation</h1>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
You have been invited as <strong>{invite.role}</strong> to CapaKraken. You have been invited as <strong>{invite.role}</strong> to CapaKraken. Set a password to
Set a password to activate your account (<span className="font-medium">{invite.email}</span>). activate your account (<span className="font-medium">{invite.email}</span>).
</p> </p>
</div> </div>
@@ -103,8 +112,8 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
minLength={8} minLength={PASSWORD_MIN_LENGTH}
placeholder="At least 8 characters" placeholder={`At least ${PASSWORD_MIN_LENGTH} characters`}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/> />
</div> </div>
+6 -7
View File
@@ -2,6 +2,7 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
import { createFirstAdmin } from "./actions.js"; import { createFirstAdmin } from "./actions.js";
export function SetupClient() { export function SetupClient() {
@@ -20,8 +21,8 @@ export function SetupClient() {
e.preventDefault(); e.preventDefault();
setFormError(null); setFormError(null);
if (password.length < 8) { if (password.length < PASSWORD_MIN_LENGTH) {
setFormError("Password must be at least 8 characters."); setFormError(PASSWORD_POLICY_MESSAGE);
return; return;
} }
if (password !== confirmPassword) { if (password !== confirmPassword) {
@@ -73,9 +74,7 @@ export function SetupClient() {
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4"> <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950 p-4">
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8"> <div className="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100"> <h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">First-run setup</h1>
First-run setup
</h1>
<p className="mt-1 text-sm text-gray-500"> <p className="mt-1 text-sm text-gray-500">
Create the initial administrator account for CapaKraken. Create the initial administrator account for CapaKraken.
</p> </p>
@@ -125,8 +124,8 @@ export function SetupClient() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
minLength={8} minLength={PASSWORD_MIN_LENGTH}
placeholder="At least 8 characters" placeholder={`At least ${PASSWORD_MIN_LENGTH} characters`}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/> />
</div> </div>
+13 -2
View File
@@ -1,6 +1,11 @@
"use server"; "use server";
import { prisma } from "@capakraken/db"; import { prisma } from "@capakraken/db";
import { SystemRole } from "@capakraken/db"; import { SystemRole } from "@capakraken/db";
import {
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
PASSWORD_POLICY_MESSAGE,
} from "@capakraken/shared";
export type SetupResult = export type SetupResult =
| { success: true } | { success: true }
@@ -13,8 +18,14 @@ export async function createFirstAdmin(formData: {
}): Promise<SetupResult> { }): Promise<SetupResult> {
// Validate // Validate
if (!formData.name.trim()) return { error: "validation", message: "Name is required." }; if (!formData.name.trim()) return { error: "validation", message: "Name is required." };
if (!formData.email.includes("@")) return { error: "validation", message: "Valid email required." }; if (!formData.email.includes("@"))
if (formData.password.length < 8) return { error: "validation", message: "Password must be at least 8 characters." }; 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 // TOCTOU guard — check again inside the action
const count = await prisma.user.count(); const count = await prisma.user.count();
@@ -1,4 +1,4 @@
import { SystemRole } from "@capakraken/shared"; import { PASSWORD_MIN_LENGTH, SystemRole } from "@capakraken/shared";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = { const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
@@ -129,7 +129,10 @@ export function UserCreateModal({
type="button" type="button"
onClick={onSubmit} onClick={onSubmit}
disabled={ disabled={
isPending || !state.name.trim() || !state.email.trim() || state.password.length < 8 isPending ||
!state.name.trim() ||
!state.email.trim() ||
state.password.length < PASSWORD_MIN_LENGTH
} }
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
> >
+24 -2
View File
@@ -10,7 +10,7 @@ describe("runtime env validation", () => {
expect( expect(
getRuntimeEnvViolations({ getRuntimeEnvViolations({
NODE_ENV: "production", NODE_ENV: "production",
NEXTAUTH_SECRET: "super-long-random-secret", NEXTAUTH_SECRET: "super-long-random-secret-with-enough-entropy-abc123",
NEXTAUTH_URL: "https://capakraken.example.com", NEXTAUTH_URL: "https://capakraken.example.com",
}), }),
).toEqual([]); ).toEqual([]);
@@ -49,11 +49,33 @@ describe("runtime env validation", () => {
); );
}); });
it("rejects an auth secret shorter than the minimum length in production", () => {
expect(
getRuntimeEnvViolations({
NODE_ENV: "production",
NEXTAUTH_SECRET: "short-but-random-xyz", // 20 chars
NEXTAUTH_URL: "https://capakraken.example.com",
}),
).toContain("AUTH_SECRET or NEXTAUTH_SECRET must be at least 32 characters in production.");
});
it("rejects a long-but-low-entropy auth secret in production", () => {
expect(
getRuntimeEnvViolations({
NODE_ENV: "production",
NEXTAUTH_SECRET: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 38 a's
NEXTAUTH_URL: "https://capakraken.example.com",
}),
).toContain(
"AUTH_SECRET or NEXTAUTH_SECRET entropy is too low; generate with `openssl rand -base64 32`.",
);
});
it("rejects non-https auth urls in production", () => { it("rejects non-https auth urls in production", () => {
expect( expect(
getRuntimeEnvViolations({ getRuntimeEnvViolations({
NODE_ENV: "production", NODE_ENV: "production",
NEXTAUTH_SECRET: "super-long-random-secret", NEXTAUTH_SECRET: "super-long-random-secret-with-enough-entropy-abc123",
NEXTAUTH_URL: "http://capakraken.example.com", NEXTAUTH_URL: "http://capakraken.example.com",
}), }),
).toContain("AUTH_URL or NEXTAUTH_URL must use https in production."); ).toContain("AUTH_URL or NEXTAUTH_URL must use https in production.");
+34
View File
@@ -10,6 +10,29 @@ const DISALLOWED_PRODUCTION_SECRETS = new Set([
"ci-test-secret-minimum-32-chars-xx", "ci-test-secret-minimum-32-chars-xx",
]); ]);
// A cryptographically generated secret (openssl rand -base64 32 / -hex 32)
// has ≥ 32 ASCII characters and high Shannon entropy (≥ 4 bits per char
// for base64, ≥ 4 for hex). Values below these thresholds are either
// too short to resist offline brute force of the JWT signature, or are
// low-entropy strings like "password1234567890123456789012345678" that
// pass a simple length check but are trivially guessable.
const MIN_AUTH_SECRET_LENGTH = 32;
const MIN_AUTH_SECRET_SHANNON_ENTROPY = 3.5;
function shannonEntropy(value: string): number {
if (value.length === 0) return 0;
const counts = new Map<string, number>();
for (const ch of value) {
counts.set(ch, (counts.get(ch) ?? 0) + 1);
}
let entropy = 0;
for (const count of counts.values()) {
const p = count / value.length;
entropy -= p * Math.log2(p);
}
return entropy;
}
type RuntimeEnv = Partial<Record<string, string | undefined>>; type RuntimeEnv = Partial<Record<string, string | undefined>>;
function readEnvValue(env: RuntimeEnv, ...names: string[]): string | null { function readEnvValue(env: RuntimeEnv, ...names: string[]): string | null {
@@ -46,6 +69,17 @@ export function getRuntimeEnvViolations(env: RuntimeEnv = process.env): string[]
violations.push( violations.push(
"AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production.", "AUTH_SECRET or NEXTAUTH_SECRET must not use a known development placeholder in production.",
); );
} else {
if (authSecret.length < MIN_AUTH_SECRET_LENGTH) {
violations.push(
`AUTH_SECRET or NEXTAUTH_SECRET must be at least ${MIN_AUTH_SECRET_LENGTH} characters in production.`,
);
}
if (shannonEntropy(authSecret) < MIN_AUTH_SECRET_SHANNON_ENTROPY) {
violations.push(
"AUTH_SECRET or NEXTAUTH_SECRET entropy is too low; generate with `openssl rand -base64 32`.",
);
}
} }
violations.push(...getDevBypassViolations(env)); violations.push(...getDevBypassViolations(env));
+13 -1
View File
@@ -67,7 +67,19 @@ publicProcedure
- Admin settings reads expose only presence flags (`hasApiKey`, `hasSmtpPassword`, `hasGeminiApiKey`) instead of returning secret values to the browser, and those flags also reflect environment-backed runtime overrides - Admin settings reads expose only presence flags (`hasApiKey`, `hasSmtpPassword`, `hasGeminiApiKey`) instead of returning secret values to the browser, and those flags also reflect environment-backed runtime overrides
- The admin settings mutation no longer persists new secret values into `SystemSettings`; secret inputs must be provisioned through environment or a deployment-time secret manager, and legacy database copies can be cleared explicitly - The admin settings mutation no longer persists new secret values into `SystemSettings`; secret inputs must be provisioned through environment or a deployment-time secret manager, and legacy database copies can be cleared explicitly
- The admin UI now exposes runtime secret source/status plus an explicit "clear legacy DB secrets" cleanup path so operators can complete the migration without direct database writes - The admin UI now exposes runtime secret source/status plus an explicit "clear legacy DB secrets" cleanup path so operators can complete the migration without direct database writes
- Production startup now validates Auth.js runtime configuration and refuses to boot if `AUTH_SECRET`/`NEXTAUTH_SECRET` is missing, left on a known development placeholder, or paired with a non-HTTPS public auth URL - Production startup now validates Auth.js runtime configuration and refuses to boot if `AUTH_SECRET`/`NEXTAUTH_SECRET` is missing, left on a known development placeholder, paired with a non-HTTPS public auth URL, shorter than 32 characters, or failing a Shannon-entropy check (≥ 3.5 bits/char)
- User passwords: minimum 12 characters, maximum 128 characters; single `PASSWORD_MIN_LENGTH` / `PASSWORD_MAX_LENGTH` constant (`@capakraken/shared/constants`) is imported by every client-side pre-submit validator and server-side Zod schema — prevents client/server policy drift
#### Secret rotation
- **`AUTH_SECRET` / `NEXTAUTH_SECRET`** is the signing key for all JWT session cookies. Rotation forces every user to re-authenticate on their next request.
- Generate replacement: `openssl rand -base64 32`
- Deploy path:
1. Update the secret in the deployment secret store (not in repo).
2. Roll all application containers — existing JWTs signed under the old key fail verification and the user is redirected to sign-in.
3. There is no multi-key transition window: this is a hard cut on purpose, because a compromised signing key must be retired immediately.
- Recommended cadence: quarterly, or immediately on suspected compromise.
- **`POSTGRES_PASSWORD`** rotation is coordinated across postgres container init, the app container's `DATABASE_URL`, and any external replication consumers — follow the deployment runbook.
### Anonymization ### Anonymization
+9 -1
View File
@@ -1,4 +1,9 @@
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import {
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
PASSWORD_POLICY_MESSAGE,
} from "@capakraken/shared";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "../trpc.js"; import { createTRPCRouter, publicProcedure } from "../trpc.js";
@@ -78,7 +83,10 @@ export const authRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
token: z.string().min(1), token: z.string().min(1),
password: z.string().min(12, "Password must be at least 12 characters.").max(128), password: z
.string()
.min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE)
.max(PASSWORD_MAX_LENGTH),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
+9 -1
View File
@@ -2,6 +2,11 @@ import { randomBytes } from "node:crypto";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { SystemRole } from "@capakraken/db"; import { SystemRole } from "@capakraken/db";
import {
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
PASSWORD_POLICY_MESSAGE,
} from "@capakraken/shared";
import { createTRPCRouter, adminProcedure, publicProcedure } from "../trpc.js"; import { createTRPCRouter, adminProcedure, publicProcedure } from "../trpc.js";
import { getAppBaseUrl } from "../lib/app-base-url.js"; import { getAppBaseUrl } from "../lib/app-base-url.js";
import { sendEmail } from "../lib/email.js"; import { sendEmail } from "../lib/email.js";
@@ -114,7 +119,10 @@ export const inviteRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
token: z.string(), token: z.string(),
password: z.string().min(12, "Password must be at least 12 characters.").max(128), password: z
.string()
.min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE)
.max(PASSWORD_MAX_LENGTH),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@@ -1,4 +1,9 @@
import { Prisma } from "@capakraken/db"; import { Prisma } from "@capakraken/db";
import {
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
PASSWORD_POLICY_MESSAGE,
} from "@capakraken/shared";
import { PermissionOverrides, SystemRole, resolvePermissions } from "@capakraken/shared/types"; import { PermissionOverrides, SystemRole, resolvePermissions } from "@capakraken/shared/types";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
@@ -11,12 +16,12 @@ export const CreateUserInputSchema = z.object({
email: z.string().email(), email: z.string().email(),
name: z.string().min(1), name: z.string().min(1),
systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER), systemRole: z.nativeEnum(SystemRole).default(SystemRole.USER),
password: z.string().min(12).max(128), password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH),
}); });
export const SetUserPasswordInputSchema = z.object({ export const SetUserPasswordInputSchema = z.object({
userId: z.string(), userId: z.string(),
password: z.string().min(12, "Password must be at least 12 characters").max(128), password: z.string().min(PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE).max(PASSWORD_MAX_LENGTH),
}); });
export const UpdateUserRoleInputSchema = z.object({ export const UpdateUserRoleInputSchema = z.object({
+15 -2
View File
@@ -25,7 +25,13 @@ export function averagePerWorkingDay(totalHours: number, workingDays: number): n
} }
export const DAY_KEYS: readonly (keyof WeekdayAvailability)[] = [ export const DAY_KEYS: readonly (keyof WeekdayAvailability)[] = [
"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const; ] as const;
export function normalizeCityName(cityName?: string | null): string | null { export function normalizeCityName(cityName?: string | null): string | null {
@@ -51,6 +57,13 @@ export const BUDGET_WARNING_THRESHOLDS = {
export const DEFAULT_WORKING_HOURS_PER_DAY = 8; export const DEFAULT_WORKING_HOURS_PER_DAY = 8;
export const DEFAULT_OPENAI_MODEL = "gpt-5.4"; export const DEFAULT_OPENAI_MODEL = "gpt-5.4";
// Single source of truth for password policy. Server-side Zod schemas and
// client-side pre-submit validators must both import these so divergence
// (e.g. client allowing 8 chars when server requires 12) cannot recur.
export const PASSWORD_MIN_LENGTH = 12;
export const PASSWORD_MAX_LENGTH = 128;
export const PASSWORD_POLICY_MESSAGE = `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`;
export const DEFAULT_AVAILABILITY = { export const DEFAULT_AVAILABILITY = {
monday: 8, monday: 8,
tuesday: 8, tuesday: 8,
@@ -60,7 +73,7 @@ export const DEFAULT_AVAILABILITY = {
} as const; } as const;
export const VALUE_SCORE_WEIGHTS = { export const VALUE_SCORE_WEIGHTS = {
SKILL_DEPTH: 0.30, SKILL_DEPTH: 0.3,
SKILL_BREADTH: 0.15, SKILL_BREADTH: 0.15,
COST_EFFICIENCY: 0.25, COST_EFFICIENCY: 0.25,
CHARGEABILITY: 0.15, CHARGEABILITY: 0.15,