OnTrack works fully on one device with zero setup. This file documents what's needed to turn on real cross-device accounts and exactly how that architecture works.
I have no network access in the environment I built this in — the
Supabase host is explicitly blocked by an egress proxy
(x-deny-reason: host_not_allowed), and there's no local Postgres. That
means I could not:
- Run
supabase-schema.sqlagainst your project - Deploy the
accountEdge Function - Execute a single real auth/RLS/sync test against your actual database
Everything below is built and reasoned through carefully, and everything that's testable without a network call (the entire local/offline path — profile creation, onboarding, habit/completion persistence, the access screen flows, graceful failure when the cloud isn't reachable) has been tested directly in a browser. The cloud path has not been executed against a live project. See "What you need to verify" below.
The previous version used Supabase anonymous auth as the identity
behind an Access Code. That doesn't work for cross-device login: an
anonymous session belongs to one browser, and there is no supported way
for a different browser to "become" that same anonymous user. It also
called auth.admin.createSession(...), which isn't a documented,
guaranteed-available Admin API — exactly the kind of thing that could
silently break.
This pass replaces both:
- Every OnTrack account is a real, non-anonymous Supabase Auth user,
identified by a system-generated email the person never sees or types
(e.g.
8f2c1e40-...@accounts.ontrack.internal). They only ever see their Access Code. - Access Codes are never stored in plaintext.
profiles.access_code_hashstores a SHA-256 hash. The plaintext code is shown to the user exactly once (at creation or regeneration) and then never again — OnTrack itself can't display it later, by design. - Cross-device login uses only documented, current Supabase Auth Admin
APIs:
auth.admin.createUser,auth.admin.generateLink, andauth.admin.getUserById, called from an Edge Function holding the service_role key. The client never receives that key — it receives a one-timetoken_hash, which it exchanges for a real session itself via the publicsupabase.auth.verifyOtp()API. No email is ever actually sent;generateLinkonly mints the token.
Device Edge Function "account" Database
------ ------------------------ --------
"+ New profile" (local) (not involved) localStorage only
"Connect to the cloud" ───POST {action:"create"}──▶
generate access code
hash it (SHA-256)
auth.admin.createUser(email)
insert profiles row
auth.admin.generateLink()
◀── {accessCode, email, tokenHash} ───
supabase.auth.verifyOtp(email, tokenHash) → real session
push local data to Supabase (checked per-table)
"Use Access Code" ───POST {action:"redeem", code}──▶
rate-limit check (per IP)
hash the code, look up profiles
auth.admin.getUserById()
auth.admin.generateLink()
◀── {email, tokenHash} ───
supabase.auth.verifyOtp(email, tokenHash) → real session
fetch full profile state from Supabase, hydrate DataStore
Profile → "Regenerate code" ───POST {action:"regenerate"} + Bearer JWT──▶
verify JWT → caller's own user id only
generate + hash new code, update row
◀── {accessCode} ───
RLS on every table (using (auth.uid() = user_id)) means a normal client
request — authenticated or not — can never look up a profile by access
code; only the Edge Function (service_role, bypasses RLS) can, and it
only returns a session token, never another user's data directly.
init() in app.js is sequenced, not fire-and-forget:
- Check
isSupabaseConfigured()(all ofurl,anonKey,accountFunctionUrlmust be set). - If configured, check for an existing Supabase session
(
auth.getSession()). - If a session exists, fetch the full cloud profile and hydrate
statebefore the app renders anything — the splash screen shows "Loading your account…" during this. The cloud session always wins over whatever local profile was previously active on the device. - If the fetch fails, a clear error screen appears (retry, or sign out and use the device locally) — it never silently falls back to showing stale local data as if it were current.
- If there's no cloud session, the app falls back to local/offline mode exactly as before.
SupabaseAdapter.pushFullState() checks the response of every table
write individually and returns { ok, failedTables }. DataStore
records the outcome in syncStatus, and Profile → "Access code &
account" shows it honestly: "Synced," or "Not fully synced — <tables>
didn't save to the cloud yet. It's safe in this browser and will retry."
The app never claims a save succeeded when it didn't.
"Connect to the cloud" in Profile (only shown for a local-only profile):
- Calls
action: "create"to get a real cloud identity + Access Code. - Establishes a session for it.
- Pushes the existing local profile's data (habits, completions, priorities, friction, skills, weekly reviews, challenges, settings) under the new cloud identity, checking every table write.
- Only on full success does the local UI switch over to the cloud identity. On any failure, the local profile is left completely untouched, signs back out of the half-created cloud session, and shows the error — nothing is deleted, nothing is silently declared migrated.
localStorage.clear() is never used anywhere in this flow.
Clicking a different local profile in the picker calls
ensureCloudSignedOutIfSwitchingIdentity() first, which signs out of
Supabase if the current cloud session belongs to a different account
than the one being switched to. This prevents the local UI from ever
showing one profile while Supabase still thinks a different user is
authenticated.
- Run
supabase-schema.sqlin the SQL Editor. It's idempotent and safe to re-run; if this project ever had the earlier plaintextaccess_codecolumn, the script hashes existing values intoaccess_code_hashbefore dropping the old column — no codes are silently invalidated. - Enable email auth stays default-on; you do NOT need to enable Anonymous Sign-ins for this version (that requirement is gone).
- Deploy the Edge Function:
(
supabase login supabase link --project-ref sefhzmllopzmdiwfedgz supabase functions deploy account supabase secrets set SUPABASE_SERVICE_ROLE_KEY=YOUR_SERVICE_ROLE_KEYSUPABASE_URLis provided automatically;ACCOUNT_EMAIL_DOMAINis optional — defaults toaccounts.ontrack.internal, which never needs to receive real mail since no email is ever actually sent.) - Fill in
supabase-config.jswith the deployed function's URL:(accountFunctionUrl: 'https://sefhzmllopzmdiwfedgz.supabase.co/functions/v1/account'urlandanonKeyare already filled in with the credentials you gave me.) - Reload. Profile will now offer "Connect to the cloud."
auth.admin.generateLink({ type: 'magiclink', email })returningdata.properties.hashed_tokenin the exact shape the Edge Function expects, on your project's current GoTrue version.supabase.auth.verifyOtp({ email, token_hash, type: 'magiclink' })successfully establishing a session client-side from that token.- RLS: create two accounts and confirm neither can read the other's
rows (should be structurally guaranteed by the
auth.uid() = user_idpolicies, but I could not run this against your actual database). - The full cross-device flow end-to-end: create on Device A, redeem the same code on Device B, confirm identical data appears, make a change on B, confirm it appears back on A.
- Rate limiting: confirm
access_code_attemptsactually throttles repeated bad codes from one IP as expected.
- A profile created with "+ New Profile" stays local-only until someone explicitly clicks "Connect to the cloud." This is intentional — trying the app never requires a network call.
- Push notifications are not implemented.
sw.jsonly caches the app shell for offline loads and installability; Settings says so rather than implying delivery works.