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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user