Delete Account and Revoke Device Access
This document describes how account deletion and device revoke work, and how other devices are reset gracefully when the user deletes their account or revokes a device from elsewhere.
Policy: Per project rules (see AGENTS.md / CLAUDE.md), the frontend never hard deletes; the backend uses hard delete only where required. Account deletion is one of those cases—the backend permanently removes the user and related data; the frontend only triggers it via DELETE /v1/account.
Overview
- Delete account: The user can permanently delete their account from Settings > Preferences. All data is removed on the backend. Other signed-in devices must reset locally so they don’t crash when PowerSync syncs empty data.
- Revoke device: In Settings > Devices, the user sees a list of devices that have signed in. They can revoke a device; that device is then signed out and its local data is cleared on next sync (or as soon as it sees the revoked state).
Both flows rely on the same graceful reset: disable sync, clear localStorage, reset the app directory (DB and related files), and reload. The user lands on a clean, signed-out state.
User Flows
Delete Account
- User goes to Settings > Preferences and chooses “Delete my account” (with confirmation).
- Frontend calls
DELETE /v1/accountwith the current auth token. Backend deletes the user and all related data (settings, chats, models, devices, etc.). - On other devices that were signed in:
- When PowerSync tries to refresh the token, the backend returns 410 Gone with
code: 'ACCOUNT_DELETED'. - The frontend treats this as “credentials invalid” and runs the reset flow (see below).
- Alternatively, PowerSync may sync DELETE operations and empty the local DB; without the 410 path, that could cause crashes. The 410 path triggers a full reset before or when that happens.
- When PowerSync tries to refresh the token, the backend returns 410 Gone with
Revoke Device
- User goes to Settings > Devices, sees a list of devices (name, last seen, “This device”, “Revoked”).
- User chooses “Revoke” on another device (with confirmation). Frontend calls
POST /v1/account/devices/:id/revoke. - Backend sets
revoked_aton that device row (soft revoke). PowerSync syncs the updateddevicestable to all clients. - On the revoked device:
- Immediate: The app watches the current device’s row via React Query (
getDevice(deviceId)). When the synced row hasrevoked_atset, the app runs the same reset flow. - On token refresh: When PowerSync refreshes the token, the backend returns 403 Forbidden with
code: 'DEVICE_DISCONNECTED'. The connector dispatches the same “credentials invalid” event and the app resets.
- Immediate: The app watches the current device’s row via React Query (
So revoke is visible either as soon as the devices row syncs or when the next token refresh returns 403.
Backend
PowerSync Token Endpoint (GET /powersync/token)
- Authenticated (session)
If the request includesX-Device-ID:- The backend checks the
devicesrow for that id. Ifrevoked_atis set, it returns 403 with{ code: 'DEVICE_DISCONNECTED' }and does not issue a token. - Otherwise it issues a PowerSync JWT and upserts the device (id, user_id, name, last_seen, created_at).
- The backend checks the
- Bearer token only (e.g. PowerSync credential refresh)
Backend resolves the session from the Bearer token, then looks up the user:- If the user no longer exists (account deleted), it returns 410 Gone with
{ code: 'ACCOUNT_DELETED' }. - Otherwise it returns 401 (e.g. invalid/expired token).
- If the user no longer exists (account deleted), it returns 410 Gone with
So:
- 410 = account deleted (client should reset).
- 403 with
DEVICE_DISCONNECTED= this device was revoked (client should reset). - 401 = generic auth failure (e.g. token refresh in the future, not necessarily a full reset).
Revoke Device Endpoint (POST /v1/account/devices/:id/revoke)
- Requires an authenticated user (session).
- Sets
revoked_atto the current timestamp for the deviceidthat belongs to the current user. - Returns 204 on success (idempotent for already-revoked devices).
Devices Table
- Backend:
devicestable withid,user_id,name,last_seen,created_at,revoked_at. Synced via PowerSync. - Frontend: Same schema in the local DB;
devicesis in the PowerSync schema so it syncs. Used for the Settings > Devices list and for “current device revoked?” checks.
Frontend
Credentials-Invalid Handling
When the app should reset (account deleted or device revoked), it runs a single flow:
setSyncEnabled(false)– disconnect from PowerSync.localStorage.clear()– remove auth token and device id (and any other local state).resetAppDir()– clear the app directory (DB and related files).window.location.reload()– reload so the app starts from a clean, signed-out state.
This is triggered in two ways:
-
Event
powersyncCredentialsInvalid
The PowerSync connector dispatches this when:- The token request returns 410 (account deleted), or
- The token request returns 403 with body
code: 'DEVICE_DISCONNECTED'(and a token was sent).
So any token refresh that gets 410 or 403 (revoked) leads to reset.
-
Devices table (current device revoked)
usePowerSyncCredentialsInvalidListeneruses React Query withgetDevice(deviceId)and query key['devices', deviceId]. When thedevicestable is invalidated (e.g. by PowerSync sync), the query refetches. If the current device’s row hasrevoked_atset, the hook runs the same reset flow. That gives an immediate reset as soon as the revoked state syncs, without waiting for the next token refresh.
Auth Token and Device ID
- Auth token: Stored in
localStorageunder a fixed key. Not synced. Cleared on reset vialocalStorage.clear(). - Device id: Stored in
localStorageto identify this device. Sent asX-Device-ID(and optionalX-Device-Name) on PowerSync token requests so the backend can register/update the device and enforce revoke.
Settings > Devices Page
- Lists devices from the local DB (synced
devicestable) viagetAllDevices()and React Query key['devices']. - Shows name, last seen, “This device” for the current device, and “Revoked” when
revoked_atis set. - “Revoke” is shown only for other, non-revoked devices; it calls
POST /v1/account/devices/:id/revokeand then invalidates['devices']so the list updates after sync.
Summary
| Action | Where | Backend / sync behavior | Other device behavior |
|---|---|---|---|
| Delete account | Preferences | User and data deleted; 410 on token refresh | Reset when 410 received or when devices table / sync reflects deletion |
| Revoke device | Devices | Set revoked_at; 403 on that device’s token refresh |
Revoked device resets when it sees revoked_at (useQuery) or gets 403 on refresh |
Both paths trigger the same reset: disable sync, clear localStorage, reset app dir, reload.