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
+42 -3
View File
@@ -9,6 +9,7 @@ import {
updates as updateBridge,
type Compatibility,
type ProbeResult,
type RevokeOutcome,
type SyncStatus,
type UpdateChannel,
type UpdateStatus,
@@ -45,6 +46,9 @@ const syncing = ref(false);
const syncError = ref("");
const lastResult = ref("");
/** Set only when unlinking left the token valid server-side (see revokeWarning). */
const unlinkWarning = ref("");
const linked = computed(() => status.value?.linked === true);
/** Only offer to connect once a probe has said the server is usable. */
@@ -205,20 +209,41 @@ async function syncNow() {
}
}
/**
* Advice for an unlink whose server-side revoke didn't land — empty when it did.
* Never a toast: a toast disappears, and "your token is still live" is exactly the
* kind of thing someone comes back to this screen to check.
*/
function revokeWarning(outcome: RevokeOutcome): string {
if (outcome.status === "unsupported") {
return "This server is older than in-app sign-out, so this device's token had to be left in place. Revoke it in the web app under Account → Linked devices.";
}
if (outcome.status === "failed") {
return `${outcome.reason} Until it's revoked, this device's token still works — you can revoke it in the web app under Account → Linked devices.`;
}
return "";
}
async function disconnect() {
if (
!window.confirm(
"Stop syncing with this server?\n\nYour notes stay on this device, and the copy on the server is left alone. The device token remains valid until you revoke it on the server under Account → Linked devices.",
"Stop syncing with this server?\n\nYour notes stay on this device, and the copy on the server is left alone. This device's access token is revoked, so it can't be used to reach the server again.",
)
) {
return;
}
try {
status.value = await syncBridge.unlink();
const result = await syncBridge.unlink();
status.value = result.status;
linkedAs.value = "";
degraded.value = [];
lastResult.value = "";
ui.showToast("Disconnected. This device now works offline only.");
unlinkWarning.value = revokeWarning(result.revoked);
ui.showToast(
result.revoked.status === "revoked"
? "Disconnected, and this device is signed out on the server."
: "Disconnected. This device now works offline only.",
);
} catch (e) {
ui.showToast(String((e as Error)?.message ?? e));
}
@@ -292,6 +317,20 @@ onMounted(refresh);
<!-- Not linked: the normal resting state, deliberately not framed as a problem -->
<template v-else>
<!-- The one exception to that framing: an unlink whose server-side revoke
didn't land leaves a live credential behind, and the person who unlinked
to retire a machine has to be told plainly rather than by a toast. -->
<section
v-if="unlinkWarning"
class="mb-6 rounded-xl border border-amber-300 bg-amber-50 p-4 dark:border-amber-500/40 dark:bg-amber-500/10"
role="alert"
>
<p class="text-sm font-medium text-amber-800 dark:text-amber-300">
This device's token is still valid on the server
</p>
<p class="mt-1 text-sm text-amber-700 dark:text-amber-300/80">{{ unlinkWarning }}</p>
</section>
<section
class="mb-6 rounded-xl border border-neutral-200 p-4 dark:border-neutral-800"
aria-live="polite"