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.
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 format: ${adminUserId}.${expiresAtMs}.${hmacHex}
- Payload signed with HMAC-SHA256, keyed by
PAIRING_SECRETenv var. - Key imported once via Web Crypto
subtle.importKey, then cached in module scope. - Verification uses a constant-time byte comparison (
timingSafeEqual). 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. Failures are caught silently (e.g. missingPAIRING_SECRET) — the hook fails closed without crashing.
Hook chain position
... → csrfProtection → consentLoader → debugOwnerLoader → routeGuard → analyticsCollector
debugOwnerLoader runs after consentLoader (consent tier is available) and before routeGuard (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 | DELETE /api/pair/disconnect clears v10r_debug_owner cookie; future requests fail HMAC |
| Forged cookie | HMAC-SHA256 + constant-time compare; missing/wrong PAIRING_SECRET fails closed |
| Admin sees other users' events | Live feed filters on debug_owner_id = adminUserId — only the paired phone's events |
Required environment variable
PAIRING_SECRET=<≥32 random bytes, base64-encoded>
Generate with:
openssl rand -base64 32
Missing PAIRING_SECRET disables the pairing feature (cookie verification throws; debugOwnerLoader catches and sets debugOwnerId = null). No crash, no pairing.