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
+37
View File
@@ -242,6 +242,43 @@ async def list_devices():
return jsonify({"devices": [_serialize_device(d) for d in rows]})
@bp.delete("/devices/self")
@login_required
async def revoke_own_device():
"""Revoke the device token presented on THIS request.
What makes "unlink" on a native client actually stop its access. The client
can't use the id-keyed route below, because it doesn't reliably know its own
device id: a token pasted from the web app arrives without one, and `/me`
describes the user, not the device row. Identifying the row by the presented
token needs nothing the caller doesn't already hold, so it works for both ways
a client can be linked.
Declared above the `<device_id>` rule for reading order only — Werkzeug ranks a
static rule ahead of a converter regardless of registration order.
"""
token = _bearer_token()
if token is None:
# A session-cookie caller holds no device token, so "revoke the one I'm
# using" is meaningless rather than merely unauthorized. The web app
# revokes by id.
return jsonify({"error": "no device token was presented"}), 400
async with session_scope() as db:
row = await db.scalar(
select(DeviceToken).where(
DeviceToken.token_hash == hash_token(token),
# Owner-scoped like every other device route. The hash already pins
# a single row; the guarantee shouldn't rest on one column.
DeviceToken.user_id == g.user_id,
)
)
if row is None:
return jsonify({"error": "not found"}), 404
await db.delete(row)
await db.commit()
return jsonify({"ok": True})
@bp.delete("/devices/<device_id>")
@login_required
async def revoke_device(device_id: str):