Skip to content

Accounts and cross-device synchronization

Accounts are optional. Anonymous visitors can open every public study route, use Study Hub AI, save locally, work offline, and export a backup without signing in. Signing in adds a private, account-scoped local partition and cloud synchronization; it never becomes a reader paywall or an email-domain restriction.

Current contract

  • Authentication uses Google OAuth through Supabase Auth. Any Google account accepted by the configured OAuth client may sign in; student.tue.nl is not required.
  • Supabase derives the user ID from the verified JWT. Browser requests never submit a trusted user ID, and row-level security prevents one account from reading or writing another account.
  • The exact configured OWNER_SUPABASE_USER_ID additionally authorizes publication and plugin administration. Ordinary Google accounts remain readers.
  • Anonymous, account A, and account B use different IndexedDB database names and localStorage keys. Signing out does not delete any partition.

The synchronized personal domains are progress events, annotations and their visibility, preferences and profile settings, page drawings, namespaced extension data, Study Hub AI conversations, source-grounded flashcard decks, durable AI workspace preferences, and recent AI references. Extension packages, runtime caches, retry metadata, active streams, and temporary composer text are not personal documents and are not synchronized.

Workspace trees, tabs, panes, splits, sidebar state, and other device-shaped layout remain local. They are intentionally allowed to differ between a laptop and phone. Manual backups may include device recovery data, but that does not make it a cloud domain.

Owner course drafts use a separate same-origin, owner-verified service route and service-role RPC. They are never sent through the reader RPC, and direct table/RPC access is denied to authenticated browser roles. Offline draft edits retain small account-scoped outbox metadata while the complete draft remains in its IndexedDB partition. All owner authorization uses the exact configured Supabase UUID, with no GitHub or Auth.js fallback.

Conflict resolution

The cloud is the store, not an arbiter. A mutable record is decided by whichever edit happened last, compared on the edit time the writing device recorded when it queued the mutation. A push never returns a conflict, so no reader is ever asked to choose between two versions of their own work. The device whose write lost is told its write did not land, pulls the winning copy on the next page, and converges.

Two consequences are deliberate. Editing the same record on two devices keeps only the later edit, and simultaneous edits are decided by the devices' clocks rather than by a merge. Progress events are immutable and keep their first write, so they cannot diverge at all and are unioned across devices.

First sign-in

Signing in unions this device with the cloud automatically. There is no question to answer: every record either side holds is kept, and any record both sides hold is decided by its last edit. The anonymous source partition is never deleted by a transfer. Removing it is a separate explicit local-data action.

Runtime behavior and recovery

Durable local writes commit first and queue an outbox mutation. One visible tab holds a renewable 15-second leader lease and performs pull, apply, push, then a final pull. Network loss leaves the outbox intact. Pull cursors advance only after a complete validated page is applied.

Settings reports the sync state and a pending count, and offers Sync now. A state of "Sync problem" means a transport or validation failure, never an edit that needs resolving. A normal Settings backup remains the portable disaster-recovery path, and Remove local data drops this device's account partition while keeping every cloud record.

Environment and deployment

Use the local Supabase stack for development and database tests. The free-tier deployment uses one hosted Supabase project and one Google Web OAuth client: validate that hosted project temporarily through the test app, then disable test sync before enabling the same project in production. Never leave account sync enabled on the test and production apps at the same time. The required public build values are:

ACCOUNT_SYNC_ENABLED=1
NEXT_PUBLIC_ACCOUNT_SYNC_ENABLED=1
NEXT_PUBLIC_SUPABASE_URL=https://PROJECT.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=...

The service role key and owner UUID are runtime-only:

SUPABASE_SERVICE_ROLE_KEY=...
OWNER_SUPABASE_USER_ID=...

Never put either secret behind NEXT_PUBLIC_, commit it, echo it, or record it in an acceptance report. scripts/vps/deploy.sh refuses an enabled deployment with incomplete account configuration. The Docker build receives only the boolean, project URL, and publishable key. Recreate the container after changing the environment because the browser-safe values are compiled into the Next.js build.

The Supabase database advisor has several intentional exceptions:

  • sync_mutations, owner_drafts, and owner_draft_mutations have RLS but no browser policy. Direct browser grants are revoked; they are reachable only through the audited definer RPCs.
  • push_sync_mutations and pull_sync_changes are authenticated SECURITY DEFINER RPCs by design. They pin search_path to empty, derive the user from auth.uid(), reject missing identities, and never accept a caller-supplied user ID.
  • Pull and retention indexes may be reported unused while the project has no real sync traffic. They support the cursor and cleanup access paths and must not be removed based on an empty-project scan.

Apply migrations to an explicit project ref and run npm run test:db locally first. Keep ACCOUNT_SYNC_ENABLED=0 in production while the test app passes two-account RLS, two-device, offline/reconnect, last-edit-wins, account deletion, and anonymous-use acceptance. After acceptance, switch test back to ACCOUNT_SYNC_ENABLED=0, remove its hosted Supabase values, and only then enable production. Keep both app callback URLs in the Supabase redirect allowlist so production cutover does not require changing the Google provider callback.

Verification

  1. npm run test:db passes all schema, RLS, and RPC pgTAP tests.
  2. npm run check and npm run build pass with account sync both disabled and enabled.
  3. Two test users cannot read each other's rows.
  4. Laptop and phone exchange every listed personal domain while retaining different layouts.
  5. Offline edits drain after reconnect, and the later edit of a record is the one that survives.
  6. Sign-out, local removal, account deletion, feature disable, and application rollback do not delete the anonymous partition or unrelated cloud records.