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:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { use, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
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) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
if (password.length < 8) {
|
||||
setFormError("Password must be at least 8 characters.");
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
setFormError(PASSWORD_POLICY_MESSAGE);
|
||||
return;
|
||||
}
|
||||
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">
|
||||
Password updated
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Your password has been changed successfully.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-6">Your password has been changed successfully.</p>
|
||||
<button
|
||||
type="button"
|
||||
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="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Set a new password
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Choose a new password for your account.
|
||||
</p>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Set a new password</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">Choose a new password for your account.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -87,8 +82,8 @@ export default function ResetPasswordPage({ params }: { params: Promise<{ token:
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="At least 8 characters"
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, use } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
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 [done, setDone] = useState(false);
|
||||
|
||||
const { data: invite, isLoading, error: inviteError } = trpc.invite.getInvite.useQuery(
|
||||
{ token },
|
||||
{ retry: false },
|
||||
);
|
||||
const {
|
||||
data: invite,
|
||||
isLoading,
|
||||
error: inviteError,
|
||||
} = trpc.invite.getInvite.useQuery({ token }, { retry: false });
|
||||
|
||||
const acceptMutation = trpc.invite.acceptInvite.useMutation({
|
||||
onSuccess: () => setDone(true),
|
||||
@@ -26,8 +28,14 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
if (password.length < 8) { setFormError("Password must be at least 8 characters."); return; }
|
||||
if (password !== confirm) { setFormError("Passwords do not match."); return; }
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
setFormError(PASSWORD_POLICY_MESSAGE);
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setFormError("Passwords do not match.");
|
||||
return;
|
||||
}
|
||||
await acceptMutation.mutateAsync({ token, password });
|
||||
}
|
||||
|
||||
@@ -48,7 +56,8 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
|
||||
Invite link invalid or expired
|
||||
</h1>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,8 +91,8 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
|
||||
<div className="mb-6">
|
||||
<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">
|
||||
You have been invited as <strong>{invite.role}</strong> to CapaKraken.
|
||||
Set a password to activate your account (<span className="font-medium">{invite.email}</span>).
|
||||
You have been invited as <strong>{invite.role}</strong> to CapaKraken. Set a password to
|
||||
activate your account (<span className="font-medium">{invite.email}</span>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -103,8 +112,8 @@ export default function AcceptInvitePage({ params }: { params: Promise<{ token:
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="At least 8 characters"
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE } from "@capakraken/shared";
|
||||
import { createFirstAdmin } from "./actions.js";
|
||||
|
||||
export function SetupClient() {
|
||||
@@ -20,8 +21,8 @@ export function SetupClient() {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
if (password.length < 8) {
|
||||
setFormError("Password must be at least 8 characters.");
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
setFormError(PASSWORD_POLICY_MESSAGE);
|
||||
return;
|
||||
}
|
||||
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="w-full max-w-md rounded-2xl bg-white dark:bg-gray-900 shadow-lg p-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
First-run setup
|
||||
</h1>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">First-run setup</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Create the initial administrator account for CapaKraken.
|
||||
</p>
|
||||
@@ -125,8 +124,8 @@ export function SetupClient() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
placeholder="At least 8 characters"
|
||||
minLength={PASSWORD_MIN_LENGTH}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"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 }
|
||||
@@ -13,8 +18,14 @@ export async function createFirstAdmin(formData: {
|
||||
}): 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 < 8) return { error: "validation", message: "Password must be at least 8 characters." };
|
||||
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();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { PASSWORD_MIN_LENGTH, SystemRole } from "@capakraken/shared";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
|
||||
const SYSTEM_ROLE_LABELS: Record<SystemRole, string> = {
|
||||
@@ -129,7 +129,10 @@ export function UserCreateModal({
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
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"
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ describe("runtime env validation", () => {
|
||||
expect(
|
||||
getRuntimeEnvViolations({
|
||||
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",
|
||||
}),
|
||||
).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", () => {
|
||||
expect(
|
||||
getRuntimeEnvViolations({
|
||||
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",
|
||||
}),
|
||||
).toContain("AUTH_URL or NEXTAUTH_URL must use https in production.");
|
||||
|
||||
@@ -10,6 +10,29 @@ const DISALLOWED_PRODUCTION_SECRETS = new Set([
|
||||
"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>>;
|
||||
|
||||
function readEnvValue(env: RuntimeEnv, ...names: string[]): string | null {
|
||||
@@ -46,6 +69,17 @@ export function getRuntimeEnvViolations(env: RuntimeEnv = process.env): string[]
|
||||
violations.push(
|
||||
"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));
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
PASSWORD_MAX_LENGTH,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
PASSWORD_POLICY_MESSAGE,
|
||||
} from "@capakraken/shared";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc.js";
|
||||
@@ -78,7 +83,10 @@ export const authRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
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 }) => {
|
||||
|
||||
@@ -2,6 +2,11 @@ import { randomBytes } from "node:crypto";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
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 { getAppBaseUrl } from "../lib/app-base-url.js";
|
||||
import { sendEmail } from "../lib/email.js";
|
||||
@@ -114,7 +119,10 @@ export const inviteRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
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 }) => {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
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 { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
@@ -11,12 +16,12 @@ export const CreateUserInputSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
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({
|
||||
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({
|
||||
|
||||
@@ -25,7 +25,13 @@ export function averagePerWorkingDay(totalHours: number, workingDays: number): n
|
||||
}
|
||||
|
||||
export const DAY_KEYS: readonly (keyof WeekdayAvailability)[] = [
|
||||
"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
] as const;
|
||||
|
||||
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_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 = {
|
||||
monday: 8,
|
||||
tuesday: 8,
|
||||
@@ -60,7 +73,7 @@ export const DEFAULT_AVAILABILITY = {
|
||||
} as const;
|
||||
|
||||
export const VALUE_SCORE_WEIGHTS = {
|
||||
SKILL_DEPTH: 0.30,
|
||||
SKILL_DEPTH: 0.3,
|
||||
SKILL_BREADTH: 0.15,
|
||||
COST_EFFICIENCY: 0.25,
|
||||
CHARGEABILITY: 0.15,
|
||||
|
||||
Reference in New Issue
Block a user