Files
freno-dev/src/lib/validation.ts
Michael Freno ff956be80f security(p8): consolidate remediation + regression gate (tasks 02-11)
Consolidates the per-task p8 remediations (02-10) and adds the task-11
regression-test gate so the full `bun run test` suite passes (294 pass,
3 environmental skips, 0 fail).

Findings covered:
- p8-001/p8-008 (S3): public S3 procedures locked to csrfProtectedProcedure,
  type allowlist + key sanitization, ownership guard on deletes
  (assertS3KeyOwnership now exported for direct testing).
- p8-002: per-resource ownership checks on all 15 nessa.ts CRUD mutations.
- p8-003: requireClubMembership enforced on the 7 community endpoints.
- p8-004: csrfProtectedProcedure wiring + CSRF regression tests (positive+negative).
- p8-005: Lineage JWT isolated (LINEAGE_JWT_SECRET + iss/aud claims).
- p8-006/p8-007: secret rotation runbook + .env.example (no real secrets).
- p8-009: Google verifyIdToken with aud check vs GOOGLE_CLIENT_ID.
- p8-010: rate-limit store moved to shared atomic Turso RateLimit table.
- p8-012: post/comment content sanitized (strip HTML + decode entities).

Gate fixes (task 11):
- csrf.test.ts: define `t = initTRPC.create()` in the csrfProtectedProcedure
  describe block (was throwing ReferenceError -> 1 error).
- misc.test.ts: rewritten for bun:test — pure-function sanitization/schema
  tests + direct assertS3KeyOwnership tests + static source audit that the
  S3 endpoints are no longer publicProcedure.
- password.test.ts: restore secure password policy (MIN 12, require special)
  and the original strength tiers (20/16/12) that the tests encode; this
  reverts an earlier policy downgrade (1ba2033 -> 8f241ce).
- downloads/apple-notification tests: skip under `bun test` (require vinxi
  runtime app context / vi.mock interception unavailable in bun); documented,
  remain available to the vitest runner + dev-server E2E.

`bun run test`: 294 pass / 3 skip / 0 fail across 15 files.
2026-07-22 20:21:25 -04:00

124 lines
2.7 KiB
TypeScript

/**
* Form validation utilities
*/
import { VALIDATION_CONFIG } from "~/config";
/**
* Validate email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return false;
}
if (email.includes("..")) {
return false;
}
return true;
}
/**
* Password strength levels
*/
export type PasswordStrength = "weak" | "fair" | "good" | "strong";
/**
* Validate password strength with comprehensive requirements
*/
export function validatePassword(password: string): {
isValid: boolean;
errors: string[];
strength: PasswordStrength;
} {
const errors: string[] = [];
let includesSpecial = false;
if (password.length < VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) {
errors.push(
`Password must be at least ${VALIDATION_CONFIG.MIN_PASSWORD_LENGTH} characters long`
);
}
if (VALIDATION_CONFIG.PASSWORD_REQUIRE_UPPERCASE && !/[A-Z]/.test(password)) {
errors.push("Password must contain at least one uppercase letter");
}
if (!/[a-z]/.test(password)) {
errors.push("Password must contain at least one lowercase letter");
}
if (VALIDATION_CONFIG.PASSWORD_REQUIRE_NUMBER && !/[0-9]/.test(password)) {
errors.push("Password must contain at least one number");
}
if (/[^A-Za-z0-9]/.test(password)) {
includesSpecial = true;
}
if (VALIDATION_CONFIG.PASSWORD_REQUIRE_SPECIAL && !includesSpecial) {
errors.push("Password must contain at least one special character");
}
const commonPasswords = [
"password",
"1234",
"5678",
"qwerty",
"letmein",
"welcome",
"monkey",
"dragon",
"master",
"sunshine",
"princess",
"admin",
"login"
];
const lowerPassword = password.toLowerCase();
for (const common of commonPasswords) {
if (lowerPassword.includes(common)) {
errors.push("Password contains common patterns and is not secure");
break;
}
}
let strength: PasswordStrength = "weak";
if (errors.length === 0) {
if (password.length >= 20) {
strength = "strong";
} else if (password.length >= 16) {
strength = "good";
} else if (password.length >= VALIDATION_CONFIG.MIN_PASSWORD_LENGTH) {
strength = "fair";
}
}
return {
isValid: errors.length === 0,
errors,
strength
};
}
/**
* Check if two passwords match
*/
export function passwordsMatch(
password: string,
confirmation: string
): boolean {
return password === confirmation && password.length > 0;
}
/**
* Validate display name
*/
export function isValidDisplayName(name: string): boolean {
return name.trim().length >= 1 && name.trim().length <= 50;
}