sync: unlinking a device now revokes its token on the server (issue 2110)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 40s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m13s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m9s
Desktop (Tauri) / Update manifest (push) Successful in 4s

Unlink was local-only. It cleared the server URL, token and cursor from the
device, and left the bearer token valid on the server indefinitely — so someone
who unlinked because the laptop was being sold or handed on believed they had
revoked access when they hadn't.

The blocker was identification, not intent: a token pasted from the web app
never carried a device id, and /api/auth/me describes the user, not the device
row, so DELETE /devices/<id> could only ever have worked for one of the two ways
this app can be linked. DELETE /api/auth/devices/self keys off the token in the
Authorization header instead, which the caller always holds — one route that
works for both paths, owner-scoped like the rest, and no local schema change.

Unlinking is never blocked on the network. Wanting to stop syncing is a local
decision, so the revoke is attempted first, its outcome carried back, and the
link cleared either way. When the token survives — server unreachable, or older
than the route — the Sync screen says so in place, with where to revoke it. A
toast would have been the wrong shape for that: it disappears, and this is
exactly what someone returns to the screen to check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-16 10:31:06 -04:00
co-authored by Claude Opus 5
parent edf52da97f
commit 2cfe049f9c
7 changed files with 237 additions and 12 deletions
+18 -1
View File
@@ -125,6 +125,22 @@ export interface SyncOutcome {
status: SyncStatus;
}
/**
* What happened to this device's token on the SERVER when unlinking. Unlinking
* always succeeds locally, so this is the only part that can disappoint — and the
* user who unlinked to retire a machine is exactly who needs to be told.
*/
export type RevokeOutcome =
| { status: "revoked" }
| { status: "unsupported" }
| { status: "failed"; reason: string }
| { status: "skipped" };
export interface UnlinkResult {
status: SyncStatus;
revoked: RevokeOutcome;
}
/** Either a password login or a token pasted from the web app's Linked devices. */
export interface LinkInput {
url: string;
@@ -138,7 +154,8 @@ export const sync = {
/** Ask who's at an address without committing to anything. */
probe: (url: string) => invoke<ProbeResult>("sync_probe", { url }),
link: (input: LinkInput) => invoke<LinkResult>("sync_link", { input }),
unlink: () => invoke<SyncStatus>("sync_unlink"),
/** Stops syncing AND revokes this device's token server-side; see UnlinkResult. */
unlink: () => invoke<UnlinkResult>("sync_unlink"),
status: () => invoke<SyncStatus>("sync_status"),
/**
* One full cycle: push, then pull. There is deliberately no bare "pull" — pulling