Skip to content

security: eliminate anonymous write contamination in pilot events and apply tracking#347

Open
ctol3r wants to merge 1 commit into
mainfrom
wave/anon-write-extinction
Open

security: eliminate anonymous write contamination in pilot events and apply tracking#347
ctol3r wants to merge 1 commit into
mainfrom
wave/anon-write-extinction

Conversation

@ctol3r
Copy link
Copy Markdown
Owner

@ctol3r ctol3r commented May 12, 2026

Summary

Closes the brief "eliminate anonymous write contamination" against the two routes named in the request. After this PR, no durable write may occur on these surfaces without an attributable Clerk-authenticated actor.

Surface Before After
POST /api/pilot-ops/events (web proxy) anonymous session passed through with empty user id 401 with {error:'unauthenticated', detail:'session.userId required …'} before any upstream call
POST /api/pilot-ops/events (backend) trusted x-clerk-user-id header verbatim from proxy requireClerkUserId second-layer guard rejects empty/missing
POST /api/track/apply (web proxy) route did not exist new Clerk-authenticated proxy for APPLY_CLICKED; 401 on anonymous; emits actor_id to the backend learning/track endpoint
POST /api/learning/track (backend) accepted any caller requires x-clerk-user-id header; rejects empty; attaches actor_id to event metadata
useTrackEvent (client) sent APPLY_CLICKED anonymously routes APPLY_CLICKED through /api/track/apply with credentials:include so the Clerk session cookie is attached

Attribution continuity contract

After the patch:

  1. Frontend gate (apps/web/app/api/{pilot-ops/events,track/apply}/route.ts) — checks session.userId on every POST; 401 before any backend call.
  2. Backend gate (apps/api/backend/src/routes/{pilotOps,learningTrack}.ts) — second layer requires the x-clerk-user-id header; rejects empty.
  3. Actor on the event — every durable event row carries actor_id equal to the Clerk user id. Replay attribution can reconstruct which actor wrote which event for any historical run.
  4. Replay lineage — unchanged. replay.runId / replay.lineageKey derivation (feat(replay): canonical deterministic replay identity (Wave 10) #343) is orthogonal: the actor identity is additional attribution, not a replacement.
  5. Snapshot ownership — unchanged. The event row's actor_id lives alongside the existing snapshot ownership fields; no rename.

Files

File Change
apps/web/app/api/pilot-ops/events/route.ts +11 lines — 401 gate before proxy
apps/web/app/api/track/apply/route.ts new (100 lines) — auth-enforcing Clerk proxy for APPLY_CLICKED
apps/web/lib/learning/useTrackEvent.ts +45 lines — route APPLY_CLICKED through new endpoint with credentials:include
apps/web/__tests__/pilot-ops-events-route.test.ts +50 lines — 401 contract assertions
apps/api/backend/src/routes/pilotOps.ts +7 lines — requireClerkUserId second-layer guard
apps/api/backend/src/routes/learningTrack.ts +28 lines — header-required + actor_id metadata

Truth rules

  • No banned strings introduced (scan CLEAN)
  • No new claims about external verification; only enforces actor identity

Validation

  • Targeted vitest: 2/2 passing (__tests__/pilot-ops-events-route.test.ts)
  • Full web build: pnpm turbo run build --filter @vitalcv/web13/13 tasks, 35s
  • Truth-contract scan: CLEAN

Anonymous actor extinction verdict

Both routes now reject anonymous writes at TWO independent layers (web proxy + backend handler). No durable event row can be written without a Clerk-authenticated session. Existing replay-identity infrastructure (#343) is preserved verbatim; this PR layers actor attribution on top.

Notes

  • The brief referenced /api/pilot/events; the canonical route is /api/pilot-ops/events. Both layers are hardened.
  • The brief referenced /api/track/apply; that exact path is created by this PR.

… apply tracking

- /api/pilot-ops/events (frontend): enforce session.userId -> 401 before proxy
- /api/pilot-ops/events (backend): requireClerkUserId second-layer guard
- /api/track/apply: new auth-enforcing Next.js proxy for APPLY_CLICKED
- /api/learning/track (backend): require x-clerk-user-id header, attach actor_id to metadata
- useTrackEvent: route APPLY_CLICKED through /api/track/apply with credentials:include
- tests: assert 401 contract for anonymous pilot event writes

No durable write may occur without attributable actor continuity.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vcv-web Ready Ready Preview, Comment May 12, 2026 5:24pm
vitalcv Ready Ready Preview, Comment May 12, 2026 5:24pm

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 82b6540c46

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +41 to +45
if (!actorId) {
return void res.status(401).json({
error: 'Unauthorized',
error_description: 'x-clerk-user-id header is required. Route all tracking calls through an authenticated proxy.',
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep non-APPLY tracking events routable after auth gate

POST /api/learning/track now unconditionally returns 401 when x-clerk-user-id is missing, but this commit only adds an authenticated proxy for APPLY_CLICKED; useTrackEvent still sends other frontend events (PROFILE_VIEWED, EMPLOYER_VIEWED, JOB_VIEWED, etc.) directly to /api/learning/track without that header, so those events are silently dropped in production and analytics/experiments lose data for all non-apply interactions.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants