Skip to content

feat(pairing): denormalize user_email onto pairing_codes#45

Merged
fyoosh merged 4 commits intomainfrom
feat/pairing-email-denorm
Apr 30, 2026
Merged

feat(pairing): denormalize user_email onto pairing_codes#45
fyoosh merged 4 commits intomainfrom
feat/pairing-email-denorm

Conversation

@fyoosh
Copy link
Copy Markdown
Contributor

@fyoosh fyoosh commented Apr 30, 2026

Summary

Implements audit issue P4. Stamps the claimer's email onto pairing_codes.user_email at claim time so checkPairingStatus can return it without a gotrue round-trip per poll. Removes the auth.admin.getUserById call from the device-poll hot path.

The email is sourced from safeGetSession() in the route handler, so the round-trip is eliminated entirely (not just shifted from poll-time to claim-time as the audit originally suggested).

Test plan

  • npx vitest run — 268/268 pass (claimPairingCode signature update propagated; new defensive test for null user_email)
  • npm run check — 0 errors
  • supabase db reset --local at CLI 2.95.4 — 40 migrations apply clean
  • Migration-smoke CI gate triggers (touches supabase/migrations/**)

Notes for reviewer

  • claim_pairing_atomic signature changes 3-arg → 4-arg. Migration DROP IF EXISTS the old signature before CREATE of the new — Postgres treats overloads as distinct objects. Existing GRANTs from 20260430000002 die with the drop; new GRANT for the 4-arg signature ships in this migration.
  • rollback_claim_pairing clears user_email alongside claimed and user_id for symmetry. Signature unchanged → grant in 20260430000004 carries over.
  • Stale email window is bounded by the 5-minute pairing TTL. See commit message for full archeology.

Removes a gotrue round-trip from the device-status poll path. Devices
poll /api/pair/status/* every 3 s; on the success path the prior shape
did:

  1. SELECT claimed/expires_at/user_id FROM pairing_codes
  2. supabase.auth.admin.getUserById(user_id)  -- gotrue round-trip

Step (2) existed solely to fetch users.email so the device could show
the claimer's address in the Cloud submenu for confirmation. Stamping
user_email onto pairing_codes at claim time collapses (2) into (1).

## Why the denorm is safe

Stale only if the user mutates their auth email AFTER claim but BEFORE
the device finishes polling — bounded window (max 5 min, the pairing
TTL) and harmless for the one-time confirmation UX (the user sees the
email they had at claim time, which is the email they expect).

## Why the email comes from the session, not auth.admin

Audit P4 originally suggested an `auth.admin.getUserById` call inside
claim_pairing_atomic (claim-time, not poll-time). The route handler
already has `user.email` from `safeGetSession()`, so we feed it through
directly — saves the gotrue round-trip entirely instead of just
shifting it. `user.email` can be null/undefined for some auth flows;
we coerce to "" at the route boundary so the column gets a stable
string (NOT NULL violation can't fire because the column is nullable
anyway, but the contract is cleaner this way).

## RPC signature change

`claim_pairing_atomic(p_user_id, p_pairing_id, p_token_hash)` →
`claim_pairing_atomic(p_user_id, p_pairing_id, p_token_hash, p_user_email)`.
Postgres treats overloaded signatures as distinct objects, so the
migration DROPs the 3-arg version explicitly before CREATE-ing the
4-arg version. Existing GRANT in 20260430000002 was tied to the 3-arg
signature and dies with the DROP; new GRANT for 4-arg lives in this
migration. CLI is pinned >= 2.95.4 (PR #43) so the function name's
"atomic" substring no longer trips the v2.90 parser bug — bare
REVOKE+GRANT statements are safe.

## Rollback symmetry

`rollback_claim_pairing` updated to clear `user_email` alongside
`claimed` and `user_id` so the row's denorm fields stay consistent with
the claim flag. Signature unchanged → existing GRANT in
20260430000004 survives.

## Test plan

- [x] `npx vitest run` — 268/268 pass (claimPairingCode signature
  update propagated through 12 tests; new defensive test for the
  `user_email IS NULL` case in checkPairingStatus)
- [x] `npm run check` — 0 errors
- [x] `supabase db reset --local` at CLI 2.95.4 — 40 migrations apply
  clean including 20260430000006

Refs: docs/audits/2026-04-29-server-helpers.md issue P4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 30, 2026

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

Project Deployment Actions Updated (UTC)
web Ready Ready Preview, Comment Apr 30, 2026 7:57pm

fyoosh added 3 commits April 30, 2026 20:54
Address review feedback on the email denorm:

- Switch claimPairingCode to an options-bag signature
  ({ userId, userEmail, code }) so adjacent same-type string args
  cannot be silently swapped at the call site. WS-B will add more
  denorm fields here; the bag scales without further re-ordering.
- Type userEmail as `string | null` and pass `user.email ?? null`
  from the route. Empty string is a bad sentinel for "unknown
  email"; NULL expresses it honestly through the stack. Operators
  wiring phone-only or OAuth-without-email-scope auth keep working.
- Collapse the dual `?? ""` fallback to a single conversion site
  in checkPairingStatus; pairing_codes.user_email being NULL is
  now a documented, valid state rather than a defensive defence.
- Note in claim/+server.ts that `user.email` must come from the
  server-validated session, never from the request body — locks
  the security-adjacent wiring against future refactors.
- Trim two over-long comments to align with the project rule of
  comments only when WHY is non-obvious.
- Test polish: introduce a callClaim helper to defang signature
  drift across 12 repeated invocations (the wire-shape test keeps
  its explicit literals so a future arg-order change is loud); set
  user_email: null on the unclaimed mock to mirror real DB shape.
- Backfill user_email for any pairing_codes rows already claimed
  before the migration applies. Self-heals within 5 minutes via
  TTL regardless, but the UPDATE closes the seam for a deploy
  that lands during an active pairing window.
- Replace `CREATE OR REPLACE FUNCTION claim_pairing_atomic` with
  plain `CREATE FUNCTION` after the `DROP IF EXISTS` of the
  3-arg signature. The 4-arg version is brand new — `OR REPLACE`
  obscured intent and would silently swallow a re-run that found
  the function still present.
- Update the user_email COMMENT to acknowledge that NULL post-claim
  is a valid steady state (operator may wire phone or OAuth-no-email
  auth flows). The read side continues to surface NULL as empty
  string at the API boundary.
- Point the rollback_claim_pairing redeclaration at the canonical
  asymmetry rationale in 20260430000004_create_rollback_claim_pairing_fn.sql
  so a contributor reading 000006 first via grep does not reinvent
  the "do NOT delete the device row" reasoning.
Cover the two wiring guarantees that the unit-tested business
logic in src/lib/server/pairing.ts cannot enforce alone:

- The route handler MUST forward `user.email` from the
  server-validated session, NEVER from the request body. A future
  refactor that read body.email would let a malicious browser
  display any email on the device's pairing-confirmation screen
  (security-adjacent UX). The test passes a body with a different
  email and asserts the session email wins.
- A session user without an email (phone signup, OAuth without
  email scope) flows through as `null`, not as `""`. This is the
  hand-off point for the read-side NULL → "" conversion in
  checkPairingStatus.
- Plus a 401 baseline so the auth gate is regression-proof.

Adopts the existing tests/routes/transfer-confirm.test.ts mocking
pattern (vi.mock the route's deps before importing it).
@fyoosh fyoosh merged commit 8fd58ea into main Apr 30, 2026
3 checks passed
@fyoosh fyoosh deleted the feat/pairing-email-denorm branch April 30, 2026 20:00
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