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.
Replace the per-Vercel-instance in-memory Map rate-limit cache with an
atomic shared store backed by the existing Turso RateLimit table, so limits
hold across all instances/redeploys and cannot be bypassed by distributing
brute-force attempts across instances (audit finding p8-010, MEDIUM).
- checkRateLimit now performs a single atomic round-trip:
INSERT ... ON CONFLICT(identifier) DO UPDATE ... RETURNING count, reset_at
with window-reset semantics (CASE WHEN reset_at < now THEN 1 ELSE count+1).
- The DB is now the primary source of truth (no longer a fire-and-forget
fallback). The per-instance Map is reduced to a short-TTL local cache used
ONLY to fast-fail already-blocked identifiers (cuts DB load during brute-
force storms); it can never let a request bypass the limit.
- ensureRateLimitSchema() creates the table + a UNIQUE identifier index so
ON CONFLICT upserts are well-defined; added RateLimit to db/create.ts.
- resetLoginRateLimits / clearRateLimitStore invalidate the local cache.
- getClientIP now trusts proxy headers in non-development environments
(production + test); local dev stays strict against header spoofing.
- bunfig.toml defines import.meta.env.SSR=true so the server-only env guard
loads under 'bun test'.
- Tests: await clearRateLimitStore in beforeEach (fixes a race where an
un-awaited clear let leftover rows corrupt the next upsert); unique test
identifiers; realistic remote-shared-store perf bounds; new p8-010
distributed-store tests (restart-survival, multi-instance aggregation,
no bypass by alternating instances).
Enforce requireClubMembership on social.getPost, addComment, comments,
like, unlike, challenges.leave, and challenges.submitProgress so private
club content is not readable/actionable by non-members (was IDOR).
Extract the membership helpers (requireClubMembership,
resolveClubIdFromPost, resolveClubIdFromChallenge) into a shared
dependency-free module (nessa-community-authz.ts) so all membership-gated
endpoints use one implementation and the libsql connection surface is
typed uniformly. Each post/challenge endpoint now resolves the owning
clubId first (NOT_FOUND if the resource is missing) then gates on it.
Add regression tests (nessa-community-authz.test.ts) covering: non-member
FORBIDDEN vs member allowed for all 7 endpoints' resolve→require sequences,
NOT_FOUND for missing post/challenge, and a join→allowed→leave→blocked
integration.