August 8, 2026 — PWA home-icon unlock, gate login, new-device email
Problems reported
- Opening the installed PWA (home icon) after unlocking in a browser tab asked for the license again and registered a new device.
- Gate option to sign in with email/password instead of the key did not work (often unreachable on short viewports; weak error path).
Root causes
phvac-device-idandphvac-licenselived only in localStorage. Unlock cookie only stored deviceId and could not restore a missing license payload. Tab vs home-icon (or partial clear) could mint a new UUID → servernewDevice: true.getDeviceId()catch path returned a random id without persisting it.- License gate panel was flex-centered with no scroll; email/password block sat below the fold on phone / standalone chrome.
- Failed password sign-in could fall through to
signUpand confuse errors. - No owner email when a device was first logged on the license.
Fixes (client)
device-id.js— dual-write device id to localStorage + long-lived cookie; read LS → cookie → create; never return unpersisted random id when cookie has a valid UUID.license-client.js— dual-write unlocked license payload tophvac-license-cachecookie(s); hydrate LS from cookie; strongerisDeviceUnlocked().pwa-entry.js— same dual-read for homepage CTA routing.pwa-acquire.css+license-gate.js— scrollable gate; login errors under the form; no sign-up on invalid credentials; clearer device_limit copy.manifest.json—start_url→/app/index.htm.- Help:
help.htm,help/license-auth.htm(storage, email alert, troubleshooting).
Fixes (server)
_shared/email.ts—sendNewDeviceEmail(Resend; no license key in body; password-reset CTA).validate-license— afterregister_license_device, ifnewDevice === trueand license has email, send alert best-effort (mail failure never blocks unlock). Response includesnewDevice.- Email only once per device id per license (INSERT path). Re-open /
last_seenupdates do not email.
Deploy edge function separately: supabase functions deploy validate-license --workdir _pwa-build (or full deploy-functions.ps1). Requires RESEND_API_KEY (same as license-key mail).
Storage keys (troubleshooting)
localStorage.phvac-device-id+ cookiephvac-device-id— stable UUID for this browser profilelocalStorage.phvac-license— JSON cache (key, email, status, deviceId, unlocked, validatedAt)- Cookie
phvac-license-cache(optional.n/.0… chunks) — mirror of unlocked payload - Cookie
phvac-license-unlocked— deviceId mirror when unlocked - Supabase auth session — separate (LS + optional remember cookies / HttpOnly bridge)
Detailed logic flow — app open / gate
Entry: any app page loads license-gate.js → checkAccess().
1. getDeviceId()
a. valid UUID in localStorage? → use it; re-sync cookie if different
b. else valid UUID in cookie phvac-device-id? → write LS; use it
c. else create UUID; write LS + cookie
2. getCachedLicense()
a. valid JSON in localStorage.phvac-license? → use it
b. else parse cookie phvac-license-cache (+ chunks) → re-seed LS; use it
c. else null
3. isDeviceUnlocked()?
- false if status === revoked
- true if licenseKey present, deviceId matches (or no cached deviceId),
and unlocked === true | undefined (legacy)
- else if unlock cookie deviceId === current id AND cache has key → true
- else false
4. If unlocked:
- hideGate(); dispatch license-ready
- if online and needsRevalidation (24h): background validateLicense
(only hard-locks on confirmed revoked)
5. Else if cache has licenseKey and online:
- validateLicense(key) → registers device server-side
- valid → cache unlocked; hideGate
- device_limit / revoked / etc. → showGate + message
6. Else if online: trySessionRestore()
- supabase.auth.getSession()
- recover-license (Bearer user JWT) → licenseKey for account email
- unlockFromLicenseResult → validateLicense (registers device)
7. Else showGate (key form + email/password block)
Detailed logic flow — unlock with license key
User submits #license-gate-form
→ validateLicense(key, { allowOffline: false })
deviceId = getDeviceId()
deviceLabel = "Chrome on Windows" (etc.)
POST Edge validate-license { licenseKey, deviceId, deviceLabel }
Server validate-license:
1. normalize key + device UUID
2. load licenses row
3. revoked → { valid:false, reason:revoked }
4. RPC register_license_device(p_license_key, p_device_id, p_device_label)
- existing row for device_id → UPDATE last_seen → { newDevice: false }
- else if count >= 5 → exception device_limit_exceeded
- else INSERT → { newDevice: true, deviceCount }
5. if newDevice === true AND email → sendNewDeviceEmail (best-effort)
6. return { valid:true, licenseKey, email, newDevice, deviceCount, ... }
Client on valid:
cacheLicense({ ..., unlocked: true })
→ LS + unlock cookie + license-cache cookie
hideGate(); dispatchReady
Detailed logic flow — unlock with email + password
User submits #license-gate-login-form
→ setRememberDevice(checkbox)
→ supabase.auth.signInWithPassword({ email, password })
if invalid credentials → showLoginError (no signUp attempt)
if session:
recoverLicenseWithSession(session)
POST recover-license with Authorization: Bearer access_token
server: user email → active license row → return key (never email-only)
unlockFromLicenseResult(recovered)
validateLicense(key) // same device register + optional new-device email
on success → unlocked durable cache
UI: gate is scrollable; #license-gate-login-error under the form
Password path uses email (not a separate username).
New-device email rules
- Send only when RPC returns
newDevice: true(first log of that device_id on the license). - Do not send on known device revalidate, app re-open, or failed registration (device_limit, invalid key).
- Body: device label, slot count, “This wasn’t you? Reset password” →
/login.htm?forgot=1&email=…&next=/app/index.htm. No license key in the alert.
Troubleshooting checklist
- Home icon asks for key again — DevTools → Application: check
phvac-device-idandphvac-licensein LS and cookies. If device id differs from Settings server list, cookies were cleared or different profile/origin. - Two devices for same phone — expected only across profiles/browsers; same profile after v1.1.5 should share one id. Remove extra in Settings on a trusted device.
- Password login does nothing — scroll the gate; look for login error under the form; confirm online (Supabase JS from CDN); wrong password shows invalid credentials.
- device_limit — free a slot in Settings elsewhere, or use a browser already registered.
- No security email on new device — redeploy
validate-license; check Resend secrets; confirm responsenewDevice: trueonly on first register. - Email every open — should not happen; if it does, client is minting a new device id each time (LS+cookie both missing/failing).
Files touched
js/device-id.js,license-client.js,pwa-entry.jsapp/license-gate.js,manifest.json,sw.js(v101)styles/pwa-acquire.csssupabase/functions/_shared/email.ts,validate-license/index.tsapp/help.htm,help/license-auth.htm- Public:
bugs/summaries/summary.august.8.2026.htm,bugs/index.htm