Move the marketing landing page content to the new route group
served at , and convert the legacy
route into a 308 permanent
redirect to the new subdomain.
- Add hosting the migrated marketing page.
- Extract shared landing content and the redirect target into
, with unit tests asserting the
redirect destination.
- Extract shared SubdomainNavList used by both subdomain bars
- Add branded Typewriter header to MainLeftBarContent
- Update BACK_TO_FRENO label to 'back to freno.me'
Refactor LeftBar/RightBarContent into main-site and subdomain variants.
Subdomain sites render a simplified, brand-colored shell iterating a new
pure nav-config module (NAV_CONFIG), with a "back to freno.me" affordance
and inline SVG icon resolver. The main site retains its bespoke bars
(Recent Posts, auth-aware nav, admin links, widgets) unchanged.
Add nav-config.ts as an import-free, unit-testable module defining the
per-site link sets (with external/href/auth-gating/icon contract), plus
filterNavByAuth helper and BACK_TO_FRENO link.
Extract pure resolvePageHeadMeta helper that derives title suffix,
canonical URL, and OpenGraph fallbacks from the active site config and
router pathname. PageHead now reads useSite() + useLocation() to
render per-subdomain metadata (e.g. " | Nessa", canonical on
nessa.freno.me) instead of hardcoding " | Michael Freno".
- Always emit a canonical link (explicit override wins; otherwise
https://<site.domain><pathname>)
- Default og:image to the site ogDefaultImage when not provided
- Add unit tests covering all five sites, canonical derivation, and
OpenGraph fallback behavior
Refactor sitemap.xml route to read the Host header, determine the active
site, and produce a sitemap scoped to that site's routes with canonical
URLs. Extracts route definitions and XML generation into shared modules.
- Add Subdomain Routing section to AGENTS.md with route placement, site context,
API routing, and auth boundary guidance for future tasks
- Add scripts/verify-subdomains.sh for post-deployment verification of DNS, HTTPS,
appcast regression, and SSL certificates
- Document Google Domains CNAME records for all four subdomains
- Document Vercel project domain additions
- Document vercel.json rewrite architecture (api pass-through ordering)
- Include verification checklist and auth boundary notes
Resolve the active site from the request Host header on the server and
expose it through a SiteContext provider so the app can vary rendering
per subdomain. Serialize the resolved site into the document shell
(data-site attribute + window.__SITE__) so client hydration matches the
server render. Add host-based Vercel rewrites mapping each subdomain to
its site root, and include unit tests for the site resolution logic.
The webhook handler now derives emailVerified from the primary email's
Clerk verification status (1 = verified, 0 = unverified) and writes it
on both user.created upserts and user.updated mutations. This aligns
manual test-user creation (scripts/create_test_user) with webhook-created
users, which previously diverged (manual set emailVerified=1, webhook
did not set it at all).
- resolveEmailVerified(): new helper reads the primary email's
verification.status from the Clerk payload.
- INSERT/UPDATE SQL now includes emailVerified in the column list.
- Tests assert emailVerified=1 for verified emails and emailVerified=0
for unverified emails.
- src/server/nessa-auth.ts: replace jose HS256 sign/verify with Clerk
session JWT verification via @clerk/backend verifyToken (RS256/JWKS).
signNessaToken removed — frontend now supplies Clerk session tokens.
- src/server/api/utils.ts: createTRPCContext verifies Clerk JWT, resolves
ctx.nessaUserId via SELECT id FROM users WHERE clerkUserId=? on the
shared NessaConnectionFactory. Lookup miss throws typed UNAUTHORIZED
(webhook has not run yet). Invalid/expired tokens are swallowed; the
enforceNessaUser middleware rejects null nessaUserId.
- src/server/api/routers/nessa-community-authz.test.ts: add clerkUserId
lookup tests (seeded match, missing row, mismatched id, local≠clerk).
- src/server/nessa-auth.test.ts: verifyNessaToken unit tests with mocked
@clerk/backend (valid sub, missing sub, malformed/expired/wrong-signature
rejection) plus static audit that signNessaToken is gone.
- src/server/clerk-user-webhook.ts + src/routes/api/clerk-webhook.ts:
Clerk user.created/user.updated webhook handler (Svix signature
verification, idempotent upsert by clerkUserId, lazy ALTER TABLE
migration) with full test suite.
- src/server/api/routers/nessa.ts: remove legacy register/login/google/
apple sign-in mutations (Clerk is now the sole identity provider).
- src/env/server.ts: add NESSA_CLERK_SECRET, NESSA_CLERK_JWT_ISSUER,
NESSA_CLERK_WEBHOOK_SECRET; NESSA_JWT_SECRET moved to optional.
- package.json: add @clerk/backend, svix; lineage/auth.test.ts and
nessa-ownership.test.ts: add Clerk env vars to env mocks.
- .env.example: document Clerk config vars and rotation.
- delete nessa-google-oauth.test.ts (Google auth removed).
ctx.nessaUserId remains the local users.id — router bodies are untouched.
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.