← Home

August 8, 2026 — PWA home-icon unlock, gate login, new-device email

Path: + validate-license · Site v1.1.5 · SW cache v101

Problems reported

Root causes

Fixes (client)

Fixes (server)

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)

Detailed logic flow — app open / gate

Entry: any app page loads license-gate.jscheckAccess().

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

Troubleshooting checklist

Files touched

Public changelog