Skip to content

Add data-transfer opt-out flow (Supabase + SendGrid) - #9

Open
RubenSousaDinis wants to merge 13 commits into
mainfrom
feat/data-transfer-opt-out
Open

Add data-transfer opt-out flow (Supabase + SendGrid)#9
RubenSousaDinis wants to merge 13 commits into
mainfrom
feat/data-transfer-opt-out

Conversation

@RubenSousaDinis

@RubenSousaDinis RubenSousaDinis commented Aug 14, 2026

Copy link
Copy Markdown
Member

Summary

Public opt-out flow for the Talent Protocol shutdown. A recipient of the transfer notice visits /data-opt-out, types their email, receives a tokenized confirmation email, clicks through, and presses an explicit Confirm opt-out button. Confirmed opt-outs are excluded from the data handed to the acquiring company.

Backed by a dedicated Supabase database (records + extra_emails exported from talent-api, opt_outs written here) and SendGrid for the confirmation email. Pages are bare and Talent-branded — no app chrome — since recipients arrive from an email and shouldn't meet unfamiliar navigation while making a consent decision.

Design properties

  • Anti-enumeration — the request endpoint returns a byte-identical {"success":true} whether or not the email matches, including on cooldown, already-confirmed, insert races, send failures, upstream outages, and an internal 8s deadline. It never reveals whether an address is in the transferring cohort.
  • Tokens — 256-bit random, SHA-256 digest at rest, raw token only ever in the emailed link; 30-day TTL, 2-minute resend cooldown.
  • No mutation on GET — the confirm page renders only; the opt-out is recorded on an explicit POST behind a button, so email scanners that prefetch links can't opt someone out.
  • Idempotent and one-way — re-confirming returns the original timestamp; an already-confirmed row is never reported as expired; there is no un-opt-out path.
  • Keys stay server-sideSUPABASE_SECRET_KEY / SENDGRID_API_KEY are read only in route handlers, never logged, never NEXT_PUBLIC_. RLS is deny-all so only the service key reaches the data.

Deployment prerequisites

  • SUPABASE_SECRET_KEY and SENDGRID_API_KEY in the Vercel project (SUPABASE_URL optional; defaults to the project URL)
  • A Vercel Firewall rate rule on /api/opt-out — this is the only abuse control; without it an attacker can drive unbounded SendGrid sends
  • Domain pointed at this project; supabase/schema.sql applied (already done on the live project)

Test plan

  • npm run test — 485/485 across 31 files; typecheck and lint clean on all touched files
  • Verified end-to-end locally against the live Supabase project: an unknown address returns the identical success body with no email sent

🤖 Generated with Claude Code

RubenSousaDinis and others added 3 commits August 13, 2026 22:57
Talent Protocol is shutting down; builders can opt out of having their
data carried over to the successor company. talent-api already
implements the flow end-to-end but requires a server-side X-API-KEY on
every request, so this adds thin proxy routes (/api/opt-out/request,
/confirm, /status) that hold the key server-side and relay talent-api's
status/body verbatim, plus an injectable-fetch, never-throwing client lib
(src/lib/opt-out.ts) and two new route builders for the upcoming pages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ates

Fixes final-review findings: proxy routes now forward the visitor's IP
(first x-forwarded-for hop) upstream as X-Client-IP so talent-api's
rate limiter keys per visitor instead of this app's shared egress IPs;
the confirm page no longer tells visitors with a transient failure
(429/502/503/timeout) that their link expired, giving 'unavailable' its
own retry-capable state distinct from genuine 'invalid' (422) results;
requestOptOut gets a dedicated 'rate-limited' variant for 429 with its
own copy; async result panels on both pages get role="status" for
screen readers; plus a few trivial UX polish items (reset-to-form
affordance, clearing stale results on validation errors, consistent
font-mono email styling).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
the-final-app Ready Ready Preview Aug 14, 2026 5:17pm

Request Review

RubenSousaDinis and others added 8 commits August 14, 2026 11:04
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
firstName, to, and confirmUrl were interpolated raw into the HTML mail
part. firstName comes from records.name (free text from a legacy DB
export), so any &, <, >, ", or ' there could produce malformed HTML and
break the confirm link. Add a local escapeHtml helper and apply it only
to the html branch; the text/plain branch stays raw since escaping it
would show users literal entity sequences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address task-3 review findings: race post-validation work against an
8s deadline that resolves to the same success body, so a stalled
Supabase/SendGrid call (unlike a fast connection-refused) can no
longer let a platform timeout distinguish a matched email from an
unmatched one. Treat an unparseable last_sent_at as within cooldown
(fail closed, not open). Switch test isolation to resetAllMocks and
restore the 400 body assertions. Fix a stale comment in
supabase-admin.ts left over from an earlier insertOptOut contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- test/opt-out-api-routes.test.ts: replace the hardcoded 2026-09-13
  expires_at default in optOutRow() with a value relative to now, so the
  fixture stops silently flipping three tests to the expired-token branch
  after that date. Also fixes the now-flaky re-mint test this uncovered,
  by giving its `existing` row an explicitly past expires_at instead of
  relying on two same-millisecond Date.now() calls to differ.
- src/lib/opt-out.ts, src/app/data-opt-out/page.tsx: rewrite comments that
  still described the deleted talent-api proxy module to describe the
  current Supabase + SendGrid backend instead.
- README.md: correct the "Ground rules" line that claimed zero
  server-side secrets/state; the temporary opt-out flow holds
  SUPABASE_SECRET_KEY, SENDGRID_API_KEY, and real opt-out row state.
- supabase/schema.sql: bring the file back in sync with the live project
  — lowercase-enforcing check constraints and plain-email indexes on
  records/extra_emails to match the actual eq. query shape.
- src/app/api/opt-out/request/route.ts: pin the function-duration
  assumption with an explicit `maxDuration = 30` and reword the deadline
  comment to reference it instead of an unstated platform limit.
- src/lib/opt-out.ts: document why the 429 branch is not dead code (a
  planned Vercel Firewall rate rule on /api/opt-out reaches it), per
  review guidance not to prune it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The data-transfer opt-out flow (/data-opt-out and its confirm/[token]
subtree) is reached from an email, not app navigation. Showing the Open
Builder Score navbar/footer there put an unfamiliar product brand and
distracting links in front of someone deciding whether to opt out —
exactly the shape people are trained to read as phishing.

A nested layout can't remove markup the root layout renders directly
around {children}, and giving these two routes their own root layout
would mean dropping app/layout.tsx as the app's single root, which is
far more invasive than warranted here. Instead, SiteChrome gates on
pathname in the one place Header/Footer were already being rendered:
normal routes keep the existing shell, and the opt-out routes get a
bare shell with just the Talent wordmark, keeping the app's fonts,
colors, and theme tokens untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@RubenSousaDinis RubenSousaDinis changed the title Add data-transfer opt-out flow (pages + API proxy) Add data-transfer opt-out flow (Supabase + SendGrid) Aug 14, 2026
optout.talentprotocol.com now points at this Vercel project, so it was
serving the whole Open Builder Score app. Add a proxy.ts that, only on
that exact host, allows the /data-opt-out pages, /api/opt-out/* routes,
and the static assets they need, redirects a bare / to the opt-out form
on the same host (so a half-remembered URL lands on the form instead of
bouncing away from it), and sends everything else off to
talentprotocol.com with a non-cacheable 307. Every other host is
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix the ready state's email/text concatenation bug with explicit {' '}
spacing (transform-agnostic, not a CSS margin patch), lift the email out
of the sentence flow into its own prominent block, and raise the primary
sentence's contrast/size while keeping the "can't be undone" caveat
present but secondary. Same spacing pattern fixed in already-confirmed
and confirmed where it recurred.

Give the confirmed state a "Go to talentprotocol.com" button and a
10-second auto-redirect with a visible, ticking countdown. The interval
lives in a framework-free startRedirectCountdown helper so it's unit
testable without a component-render harness (none exists in this repo).
Screen readers get exactly two announcements — the initial notice and a
3-second final warning — never one per tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant