Skip to content

feat(checkout): /claim/<token> page for guest buyers, magic link + passkey sign-in - #124

Merged
ralyodio merged 1 commit into
mainfrom
fix/claim-page
Oct 21, 2026
Merged

ralyodio merged 1 commit into
mainfrom
fix/claim-page

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Paid guest checkouts (#122/#123) email a /claim/<token> link, but there was no page behind it, so a paying guest could not get the episode.

What

  • /claim/: checks the link, then attaches the purchase to the signed-in account, or signs the buyer in or up by emailed magic link (prefilled with the payment email) or passkey and finishes the claim on return. It polls until the worker grants the entitlement, then opens /play?mode=campaign&episode=<id>.
  • Errors: used, expired, still confirming, refunded and forged links each get a plain explanation. Expired and invalid links get a "send me a fresh link" form (by the old link or by the payment email). The reply is the same either way, and mail only goes to the address that paid.
  • API: POST /checkout/claim/status and POST /checkout/claim/resend. POST /checkout/claim now refuses dead links with a code (409/410/400) and answers 200 with playUrl when the purchase is already yours. Tokens go in POST bodies, never URL paths, so they stay out of access logs.
  • Token design kept: HMAC claim token, 7-day TTL. It is single-use because the order gets a user_id, and the worker still re-checks and attaches atomically (user_id is null).
  • House auth: Better Auth magic-link and passkey plugins. Accounts created that way get a generated display name. New passkeys table, migration 0001_passkeys (already applied to prod; additive). The login page now offers link and passkey first, and the account page can add a passkey.

Tests

  • bunx vitest run: 45 files, all pass (new: describeClaim, API claim/status/resend, display-name defaults, claim page copy). typecheck and eslint are clean.
  • Ran the full stack locally (prod Docker image, fresh Postgres + Redis): seeded a paid guest order, then status claimable, magic link, verify, redirect to /claim/<token>?auto=1, claim 202, worker, claimed_by_you, entitlement active. Re-claim answers 200 + playUrl, and a stranger sees "claimed".

🤖 Generated with Claude Code

…sskey sign-in

A paid guest checkout emails a /claim/<token> link, but no page existed, so a
guest who paid could not get the episode.

- /claim/<token>: asks the API what the link is, then attaches the purchase
  to the signed-in account, or signs the buyer in or up by emailed magic link
  (prefilled with the payment email) or passkey and finishes the claim on
  return (?auto=1), polls until the worker grants the entitlement, and opens
  /play?mode=campaign&episode=<id>.
- Clear states for used, expired, pending, refunded and forged links, with a
  "send me a fresh link" form (by old link or by payment email; same answer
  either way, mail only goes to the address that paid).
- API: POST /checkout/claim/status, POST /checkout/claim/resend; POST
  /checkout/claim now refuses dead links with a code (409/410/400) instead of
  queueing them, and answers 200 + playUrl when it is already yours. Tokens
  travel in POST bodies, never URL paths.
- entitlements: describeClaim + readClaimTokenIgnoringExpiry (pure, tested).
- auth: Better Auth magic-link and passkey plugins (house auth); accounts
  created that way get a generated display name. passkeys table, migration
  0001_passkeys (applied to prod). Login page offers link + passkey first;
  account page can add a passkey.
- Worker: magic-link email; claim email says the link lasts 7 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ralyodio
ralyodio merged commit de13d5a into main Oct 21, 2026
5 checks passed
@ralyodio
ralyodio deleted the fix/claim-page branch October 21, 2026 12:34
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

1 finding(s)

MEDIUM: 1

Severity Rule Location
MEDIUM js-unescaped-html-sink apps/site/app/_components/pit-footer.tsx:17

Snippets are redacted; ThreatCrush never prints matched credential material.

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