Purpose
Admin tests the public site on a real phone without a second login. The phone's anonymous pageviews appear live on the PC dashboard's Live Activity feed, attributed to the admin's identity. The phone never logs in — attribution happens through a short-lived pairing code and an HMAC-signed cookie.
Flow
- Admin visits the analytics dashboard on PC and requests a new pairing code (
POST /api/admin/analytics/pair). - Server generates a 6-digit code and returns it alongside a QR code (SVG, server-rendered) and the full URL
/pair/<code>. - Admin scans the QR or reads the code aloud; phone navigates to
/pair/<code>. - Server atomically claims the code: sets
consumed_at, stamps the phone's_v10r_sidsession row withpaired_admin_user_id+paired_at, and sets thev10r_debug_ownerHMAC cookie. - Phone is redirected to
/. - All subsequent pageviews from that phone carry
debug_owner_id = adminUserIdinanalytics.events.
Schema
analytics.pairing_codes
| Column | Type | Notes |
|---|---|---|
code |
text PK |
6 digits in [2-9]. PG CHECK enforces format. |
admin_user_id |
text FK → auth.user |
Cascades on user delete. |
created_at |
timestamptz |
Default now. |
expires_at |
timestamptz |
10 minutes after creation. |
consumed_at |
timestamptz |
Null until claimed. |
consumed_by_session_id |
text FK → sessions |
Set null on session delete. |
attempt_count |
integer |
Incremented on every claim attempt. PG CHECK ≤ 5. |
analytics.events — debug_owner_id text nullable column, with partial index (debug_owner_id, id) WHERE debug_owner_id IS NOT NULL.
analytics.sessions — paired_admin_user_id text nullable, paired_at timestamptz nullable. Pairing tag is cleared by the cleanup job after 2h. A separate debug_owner_id column on sessions is the PERMANENT attribution copy: stamped on write, never cleared, and it is what every aggregate excludes on — the 2h cap bounds live streaming, not the exclusion of the operator's own traffic from the numbers.
Consent boundary (since 2026-08): the phone-side claim no longer mints _v10r_sid unconditionally. With analytics consent it uses/mints the cookie session as before; without it, it tags the cookieless daily session id instead — no terminal storage is written below the consent tier. Caveat: the cookieless id rotates at UTC midnight, so a pairing spanning midnight stops tagging the phone's new session for the remainder of the cap.
Code design
- Alphabet:
23456789— no0,1,O,Ito eliminate visual ambiguity. - Code length: 6 digits → ~16.7M combinations, enough for the 10-minute TTL window.
- TTL: 10 minutes (unconsumed). Enforced by both the
WHERE expires_at > nowquery predicate and the cleanup job. - Attempt cap: 5 tries. Checked via a
WHERE attempt_count < 5predicate on the atomicUPDATE. Subsequent reads still incrementattempt_countagainst brute-force. - Code generation retries on PK collision (up to 5 attempts), then raises.
claimPairingCode()is a singleUPDATE ... RETURNING— atomic, no TOCTOU gap.
Cookie
Name: v10r_debug_owner
Attributes: HttpOnly; Secure; SameSite=Lax; Max-Age=7200 (2h)
Value: a signed ticket — security/ticket.ts over { adminUserId }, in the format base64url(payload).base64url(hmac).
- Keyed by the
pairingOwnersubkey, derived fromBETTER_AUTH_SECRETvia HMAC domain separation.PAIRING_SECRETis the optional rotation override, not a required input. - HMAC-SHA256 via
node:crypto, verified withtimingSafeEqual. The signature is checked before the payload is parsed, so untrusted structure is never interpreted unauthenticated. - Expiry is signed alongside the fields and enforced by
verifyTicket, so a client cannot extend its own validity. - base64url is encoding, not encryption: the admin's user id is readable by anyone who can read the cookie. It is
HttpOnly, and the cookie grants attribution rather than authority. debugOwnerLoaderinhooks.server.tsverifies the cookie on every request. If verification fails or the cookie is expired, the cookie is cleared andevent.locals.debugOwnerIdis set tonull. The hook still catches, so any crypto failure fails closed without crashing.
Hook chain position
... → csrfProtection → sessionPopulate → consentLoader → debugOwnerLoader → devRouteGuard → analyticsCollector
debugOwnerLoader runs after consentLoader (consent tier is available) and before devRouteGuard (admin pages also get a populated debugOwnerId). analyticsCollector reads event.locals.debugOwnerId and passes it to recordEvent() / upsertSession().
Cleanup
analyticsCleanup() at src/lib/server/jobs/analytics-cleanup.ts handles three sweeps on each run:
| Target | Condition | Retention |
|---|---|---|
| Unconsumed pairing codes | consumed_at IS NULL AND expires_at < now - 1h |
1h grace after expiry |
| Consumed pairing codes | consumed_at < now - 7d |
7 days |
| Paired session tags | paired_at IS NOT NULL AND paired_at < now - 2h |
2h hard cap — clears paired_admin_user_id + paired_at |
The 2h session cap is a privacy guardrail: admin re-pairs if longer coverage is needed.
Threat model and limits
| Concern | Mitigation |
|---|---|
| Code brute-force | 5-attempt cap + 10-min TTL; cap enforced at DB level (PG CHECK) |
| Stale attribution | 2h hard cap; cleanup job untags sessions automatically |
| Admin revokes pairing | revokePairing() marks code consumed, untags all sessions for that admin |
| Phone self-disconnects | POST /api/pair/disconnect clears v10r_debug_owner cookie; future requests fail HMAC |
| Forged cookie | HMAC-SHA256 + constant-time compare over a purpose-separated subkey; a ticket minted for any other purpose fails the MAC |
| Admin sees other users' events | By default the live feed shows all recent site traffic and tags paired rows (isPaired). The phone-attribution guarantee is the debug_owner_id stamping, not a feed restriction. Selecting the paired filter restricts the feed to debug_owner_id = adminUserId. |
Environment
No required variable. The signing key is derived from BETTER_AUTH_SECRET, which the app already refuses to boot without, so pairing works in any environment that can serve a request at all.
PAIRING_SECRET remains available as an optional override when this one key should rotate independently of the auth secret:
PAIRING_SECRET=<≥32 random bytes, base64-encoded> # optional
Generate with:
openssl rand -base64 32
Rotating it ends every paired phone session immediately; they re-pair by scanning a new code.