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.
This commit is contained in:
2026-07-22 20:21:25 -04:00
parent e446eb1775
commit ff956be80f
29 changed files with 2008 additions and 365 deletions

View File

@@ -5,7 +5,7 @@ import type { Row } from "@libsql/client/web";
import { SignJWT, jwtVerify } from "jose";
import { env } from "~/env/server";
import { ConnectionFactory } from "./db-connections";
import { AUTH_CONFIG, expiryToSeconds, getAccessTokenExpiry } from "~/config";
import { AUTH_CONFIG, LINEAGE_CONFIG, expiryToSeconds, getAccessTokenExpiry } from "~/config";
export const authCookieName = "auth_token";
@@ -78,6 +78,43 @@ export async function getAuthPayloadFromEvent(
return verifyAuthToken(token);
}
/**
* Verify a Lineage game (mobile-app) email JWT.
*
* p8-005: Lineage tokens are signed with the dedicated `LINEAGE_JWT_SECRET`
* (NOT the web `JWT_SECRET_KEY`) and carry distinct `iss: "lineage"` /
* `aud: "lineage-app"` claims. Enforcing the issuer + audience here guarantees
* that a token minted by the web app (which uses a different secret and no
* lineage claims) can never authenticate against a Lineage-protected endpoint,
* even if the two secrets were accidentally shared.
*/
export async function verifyLineageAuthToken(
token: string
): Promise<LineageAuthTokenPayload | null> {
try {
const secret = new TextEncoder().encode(env.LINEAGE_JWT_SECRET);
const { payload } = await jwtVerify(token, secret, {
issuer: LINEAGE_CONFIG.JWT_ISSUER,
audience: LINEAGE_CONFIG.JWT_AUDIENCE
});
if (!payload.userId) {
return null;
}
return {
userId: payload.userId as string,
email: (payload.email as string | null) ?? null
};
} catch (error) {
console.error("Lineage auth token verification failed:", error);
return null;
}
}
type LineageAuthTokenPayload = {
userId: string;
email: string | null;
};
export async function issueAuthToken({
event,
userId,
@@ -194,7 +231,9 @@ export async function validateLineageRequest({
const { provider, email } = userRow;
if (provider === "email") {
try {
const payload = await verifyAuthToken(auth_token);
// p8-005: Lineage email JWTs are signed with the dedicated
// LINEAGE_JWT_SECRET and enforce lineage issuer/audience claims.
const payload = await verifyLineageAuthToken(auth_token);
if (!payload || email !== payload.email) {
return false;
}