fix(tauri-app): Token an die AppView senden — und die Erneuerung reparieren
Die vier viewer-bezogenen AppView-Aufrufe (Timeline, Notifications, Count, Seen) senden jetzt das Access-JWT. Ohne Session gibt es einen sprechenden Fehler statt eines leeren Bearer-Headers. Dabei kam heraus, dass die automatische Token-Erneuerung noch nie funktioniert hat: isTokenInvalid() stieg mit `typeof e !== "object"` sofort aus, aber Tauri lehnt bei Commands mit Result<T, String> mit einem blanken String ab — der Zweig war seit seiner Einführung tot. Belegt per Mutationstest: mit der alten Zeile fallen acht der neuen Tests um. Die Prüfung liest den Fehlertext jetzt über einen Helfer, der Strings und Objekte behandelt. Dazu: der Badge-Poll bricht ab, wenn die Erneuerung endgültig scheitert, statt weiter gegen einen 401 zu laufen. 503 AuthUnavailable gilt dabei bewusst nicht als Auth-Fehler — die PDS kann kurz weg sein, der Poll soll das überdauern. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
co-authored by
Claude Opus 5
parent
ac18ff7a16
commit
9ee717bbc7
@@ -6,6 +6,8 @@
|
||||
fetchTimeline,
|
||||
fetchSearch,
|
||||
fetchPost,
|
||||
errorMessage,
|
||||
isAuthFailure,
|
||||
notificationCount,
|
||||
openExternalUrl,
|
||||
showError,
|
||||
@@ -377,6 +379,16 @@
|
||||
/// Pull the unread count for the NavRail badge. Swallows errors:
|
||||
/// the badge is ambient information, and a transient AppView hiccup
|
||||
/// shouldn't produce a toast every 5 seconds.
|
||||
///
|
||||
/// One class of error is *not* swallowed-and-retried, though. Since
|
||||
/// the AppView started requiring the access JWT on
|
||||
/// `/api/notifications/count`, a rejected token surfaces here — and
|
||||
/// by the time it does, `safeInvoke` has already spent its one
|
||||
/// automatic refresh. Retrying on a 5s timer would then be a request
|
||||
/// loop against a server that keeps answering 401/403 for as long as
|
||||
/// the app is open. So an auth failure stops the poll outright; the
|
||||
/// next successful login restarts it via the `session.subscribe`
|
||||
/// handler in `onMount`.
|
||||
async function refreshUnreadCount() {
|
||||
if (!currentUser) return;
|
||||
// While the notifications view is open the user is by definition
|
||||
@@ -386,8 +398,13 @@
|
||||
if (view === "notifications") return;
|
||||
try {
|
||||
unreadCount = await notificationCount(currentUser.did);
|
||||
} catch {
|
||||
/* ignore — keep the last known count */
|
||||
} catch (e) {
|
||||
if (isAuthFailure(e)) {
|
||||
console.warn("notification poll stopped: session rejected", e);
|
||||
stopPoll();
|
||||
return;
|
||||
}
|
||||
/* otherwise ignore — keep the last known count */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +437,7 @@
|
||||
if (fresh.length > 0) userPosts = [...fresh, ...userPosts];
|
||||
}
|
||||
} catch (e) {
|
||||
timelineError = String(e);
|
||||
timelineError = errorMessage(e);
|
||||
// Keep whatever we had on a transient failure.
|
||||
} finally {
|
||||
timelineLoading = false;
|
||||
@@ -440,7 +457,7 @@
|
||||
}
|
||||
timelineCursor = r.cursor;
|
||||
} catch (e) {
|
||||
timelineError = String(e);
|
||||
timelineError = errorMessage(e);
|
||||
} finally {
|
||||
timelineLoading = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
// The AppView auth contract, from the client's side.
|
||||
//
|
||||
// Same setup as `notifications.test.ts`: `@tauri-apps/api/core` is
|
||||
// mocked so no Tauri shell is needed, and every assertion is about the
|
||||
// exact sequence of commands we hand the Rust IPC layer.
|
||||
//
|
||||
// What's pinned here:
|
||||
// * the **token-renewal chain** — a `TokenInvalid` coming out of the
|
||||
// AppView (not the PDS) triggers exactly one `auth_refresh` + one
|
||||
// retry, for each of the four now-authenticated endpoints;
|
||||
// * that the chain fires for a **bare string** rejection, which is
|
||||
// what `invoke` actually rejects with for our `Result<T, String>`
|
||||
// commands — the shape the old `typeof e !== "object"` guard
|
||||
// silently skipped;
|
||||
// * that it fires **once**, never in a loop, and not at all when the
|
||||
// refresh itself fails or when the error isn't refreshable;
|
||||
// * that the **public** endpoints still work with no session at all
|
||||
// and never reach for a refresh.
|
||||
//
|
||||
// The error strings below are verbatim what the Rust side produces:
|
||||
// `appview_client.rs`'s `status_error()` formats
|
||||
// `"appview: {label} returned {status}: {body}"`, and `lib.rs`
|
||||
// stringifies that into the command's `Err(String)`. The Rust test
|
||||
// `token_invalid_code_survives_into_the_error_string` pins the other
|
||||
// half of the same contract.
|
||||
//
|
||||
// Run with:
|
||||
// npx vitest run src/lib/api/appview-auth.test.ts
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const invokeMock = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
isTauri: () => true,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
invokeMock.mockReset();
|
||||
});
|
||||
|
||||
/// Exactly what a Tauri command rejects with once the AppView has
|
||||
/// refused an expired access token: a bare string, because our
|
||||
/// commands are `Result<T, String>` and `invoke` rejects with the
|
||||
/// deserialised payload — not an `Error`.
|
||||
function appviewTokenInvalid(label: string): string {
|
||||
return (
|
||||
`appview: ${label} returned 401 Unauthorized: ` +
|
||||
`{"error":"TokenInvalid","message":"ExpiredSignature"}`
|
||||
);
|
||||
}
|
||||
|
||||
const FRESH_SESSION = {
|
||||
did: "did:plc:me",
|
||||
handle: "me.test",
|
||||
access_jwt: "fresh-access",
|
||||
refresh_jwt: "fresh-refresh",
|
||||
};
|
||||
|
||||
/// The four endpoints that grew an auth guard, each with the command
|
||||
/// name the Rust side registers, the AppView's label in the error
|
||||
/// string, a caller, and the payload the retry should resolve with.
|
||||
const AUTHED = [
|
||||
{
|
||||
name: "timeline_home",
|
||||
label: "timeline home",
|
||||
payload: { posts: [], cursor: null },
|
||||
call: async () => {
|
||||
const { fetchTimeline } = await import("./client");
|
||||
return fetchTimeline("did:plc:me");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fetch_notifications",
|
||||
label: "notifications",
|
||||
payload: { notifications: [], cursor: null },
|
||||
call: async () => {
|
||||
const { fetchNotifications } = await import("./client");
|
||||
return fetchNotifications("did:plc:me");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "notification_count",
|
||||
label: "notification count",
|
||||
payload: { count: 3 },
|
||||
call: async () => {
|
||||
const { notificationCount } = await import("./client");
|
||||
return notificationCount("did:plc:me");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mark_notifications_seen",
|
||||
label: "notifications seen",
|
||||
payload: { ok: true, updated: 2 },
|
||||
call: async () => {
|
||||
const { markNotificationsSeen } = await import("./client");
|
||||
return markNotificationsSeen("did:plc:me", "2026-09-09T10:00:00Z");
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe("AppView token renewal", () => {
|
||||
for (const ep of AUTHED) {
|
||||
it(`${ep.name}: a TokenInvalid from the AppView refreshes and retries once`, async () => {
|
||||
invokeMock
|
||||
// 1. the call, rejected by the AppView's auth guard
|
||||
.mockRejectedValueOnce(appviewTokenInvalid(ep.label))
|
||||
// 2. auth_refresh mints a new access JWT from the refresh JWT
|
||||
.mockResolvedValueOnce(FRESH_SESSION)
|
||||
// 3. the same call again, now with the fresh token
|
||||
.mockResolvedValueOnce(ep.payload);
|
||||
|
||||
await expect(ep.call()).resolves.toBeDefined();
|
||||
|
||||
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual([
|
||||
ep.name,
|
||||
"auth_refresh",
|
||||
ep.name,
|
||||
]);
|
||||
// The retry must repeat the *same* argument bag — a dropped
|
||||
// cursor or limit here would silently change what the user sees.
|
||||
expect(invokeMock.mock.calls[0][1]).toEqual(invokeMock.mock.calls[2][1]);
|
||||
});
|
||||
}
|
||||
|
||||
it("returns the retry's payload, not the failed first attempt", async () => {
|
||||
const { notificationCount } = await import("./client");
|
||||
invokeMock
|
||||
.mockRejectedValueOnce(appviewTokenInvalid("notification count"))
|
||||
.mockResolvedValueOnce(FRESH_SESSION)
|
||||
.mockResolvedValueOnce({ count: 7 });
|
||||
|
||||
await expect(notificationCount("did:plc:me")).resolves.toBe(7);
|
||||
});
|
||||
|
||||
it("fires for a bare-string rejection — the shape Tauri actually uses", async () => {
|
||||
// Regression guard. `invoke` rejects with the deserialised
|
||||
// `Err(String)` payload, i.e. a primitive string. A guard that
|
||||
// bails on anything that isn't an object never sees the code and
|
||||
// the retry silently never runs — the user's timeline just dies an
|
||||
// hour after login with no error anyone would connect to auth.
|
||||
const { fetchTimeline } = await import("./client");
|
||||
invokeMock
|
||||
.mockRejectedValueOnce(appviewTokenInvalid("timeline home"))
|
||||
.mockResolvedValueOnce(FRESH_SESSION)
|
||||
.mockResolvedValueOnce({ posts: [], cursor: null });
|
||||
|
||||
await expect(fetchTimeline("did:plc:me")).resolves.toEqual({
|
||||
posts: [],
|
||||
cursor: null,
|
||||
});
|
||||
expect(invokeMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("also fires when the error arrives as an Error object", async () => {
|
||||
const { fetchTimeline } = await import("./client");
|
||||
invokeMock
|
||||
.mockRejectedValueOnce(new Error(appviewTokenInvalid("timeline home")))
|
||||
.mockResolvedValueOnce(FRESH_SESSION)
|
||||
.mockResolvedValueOnce({ posts: [], cursor: null });
|
||||
|
||||
await expect(fetchTimeline("did:plc:me")).resolves.toBeDefined();
|
||||
expect(invokeMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries exactly once — a still-failing retry is not refreshed again", async () => {
|
||||
const { fetchNotifications } = await import("./client");
|
||||
invokeMock
|
||||
.mockRejectedValueOnce(appviewTokenInvalid("notifications"))
|
||||
.mockResolvedValueOnce(FRESH_SESSION)
|
||||
.mockRejectedValueOnce(appviewTokenInvalid("notifications"));
|
||||
|
||||
await expect(fetchNotifications("did:plc:me")).rejects.toThrow(
|
||||
/TokenInvalid/,
|
||||
);
|
||||
// Three calls, not five: no second refresh, no third attempt.
|
||||
expect(invokeMock).toHaveBeenCalledTimes(3);
|
||||
expect(invokeMock.mock.calls.filter((c) => c[0] === "auth_refresh")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("propagates the original error when the refresh itself fails", async () => {
|
||||
// The refresh JWT is good for 90 days, but it does eventually
|
||||
// expire (or get revoked). At that point there's nothing left to
|
||||
// do but surface the failure — retrying with the same dead token
|
||||
// would just be a second 401.
|
||||
const { notificationCount } = await import("./client");
|
||||
invokeMock
|
||||
.mockRejectedValueOnce(appviewTokenInvalid("notification count"))
|
||||
.mockRejectedValueOnce("refresh token expired");
|
||||
|
||||
await expect(notificationCount("did:plc:me")).rejects.toThrow(
|
||||
/TokenInvalid/,
|
||||
);
|
||||
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual([
|
||||
"notification_count",
|
||||
"auth_refresh",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not refresh on a 403 Forbidden — a new token wouldn't help", async () => {
|
||||
// The AppView returns this when the token is perfectly valid but
|
||||
// its `sub` doesn't match the `did` query parameter. Refreshing
|
||||
// mints another token for the same subject, so a retry is pure
|
||||
// waste.
|
||||
const { fetchTimeline } = await import("./client");
|
||||
invokeMock.mockRejectedValueOnce(
|
||||
'appview: timeline home returned 403 Forbidden: ' +
|
||||
'{"error":"Forbidden","message":"did does not match token subject"}',
|
||||
);
|
||||
|
||||
await expect(fetchTimeline("did:plc:someone-else")).rejects.toThrow(
|
||||
/Forbidden/,
|
||||
);
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not refresh when the shell says there is no session at all", async () => {
|
||||
// `require_access_jwt` in lib.rs. Nothing to refresh *from*, so the
|
||||
// message deliberately carries neither `TokenInvalid` nor
|
||||
// `ExpiredSignature`.
|
||||
const { fetchNotifications } = await import("./client");
|
||||
invokeMock.mockRejectedValueOnce(
|
||||
"not logged in: notifications requires a signed-in session",
|
||||
);
|
||||
|
||||
await expect(fetchNotifications("did:plc:me")).rejects.toThrow(
|
||||
/not logged in/,
|
||||
);
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats a 503 AuthUnavailable as transient, not as an auth failure", async () => {
|
||||
// The AppView answers 503 `AuthUnavailable` when it cannot reach
|
||||
// the PDS to fetch the verification key — it fails closed rather
|
||||
// than guessing. Our token is fine; the *server* is temporarily
|
||||
// unable to check it. So: no refresh (nothing is wrong with the
|
||||
// token), and `isAuthFailure` must stay false so the background
|
||||
// poll keeps trying instead of shutting itself down over an outage
|
||||
// that will resolve on its own.
|
||||
const { notificationCount, isAuthFailure } = await import("./client");
|
||||
const err =
|
||||
'appview: notification count returned 503 Service Unavailable: ' +
|
||||
'{"error":"AuthUnavailable","message":"could not fetch PDS key"}';
|
||||
|
||||
invokeMock.mockRejectedValueOnce(err);
|
||||
await expect(notificationCount("did:plc:me")).rejects.toThrow(
|
||||
/AuthUnavailable/,
|
||||
);
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
expect(isAuthFailure(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not refresh on a transient server error", async () => {
|
||||
const { notificationCount } = await import("./client");
|
||||
invokeMock.mockRejectedValueOnce(
|
||||
"appview: notification count returned 500 Internal Server Error: db down",
|
||||
);
|
||||
|
||||
await expect(notificationCount("did:plc:me")).rejects.toThrow(/500/);
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("an auth_* command never triggers a refresh (no login loop)", async () => {
|
||||
const { session } = await import("./client");
|
||||
invokeMock.mockRejectedValueOnce("TokenInvalid");
|
||||
|
||||
await expect(session.login("me.test", "pw")).rejects.toBeDefined();
|
||||
expect(invokeMock.mock.calls.map((c) => c[0])).toEqual(["auth_login"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("public AppView endpoints", () => {
|
||||
// These stay unauthenticated server-side, so they must keep working
|
||||
// with no session in the store: one invoke, no bearer token to fetch,
|
||||
// no refresh.
|
||||
const PUBLIC = [
|
||||
{
|
||||
name: "search",
|
||||
payload: { posts: [], q: "hi" },
|
||||
call: async () => (await import("./client")).fetchSearch("hi"),
|
||||
},
|
||||
{
|
||||
name: "profile_get",
|
||||
payload: {
|
||||
did: "did:plc:a",
|
||||
handle: "a.test",
|
||||
posts: [],
|
||||
followers: 0,
|
||||
following: 0,
|
||||
post_count: 0,
|
||||
},
|
||||
call: async () => (await import("./client")).fetchProfile("a.test"),
|
||||
},
|
||||
{
|
||||
name: "profile_get_by_did",
|
||||
payload: {
|
||||
did: "did:plc:a",
|
||||
handle: "a.test",
|
||||
posts: [],
|
||||
followers: 0,
|
||||
following: 0,
|
||||
post_count: 0,
|
||||
},
|
||||
call: async () => (await import("./client")).fetchProfileByDid("did:plc:a"),
|
||||
},
|
||||
{
|
||||
name: "post_get",
|
||||
payload: { post: null, thread: { parent: null, root: null } },
|
||||
call: async () =>
|
||||
(await import("./client")).fetchPost("at://did:plc:a/app.twi.post/1"),
|
||||
},
|
||||
{
|
||||
name: "fetch_thread",
|
||||
payload: { post: null, parents: [], root: null, replies: [] },
|
||||
call: async () =>
|
||||
(await import("./client")).fetchThread("at://did:plc:a/app.twi.post/1"),
|
||||
},
|
||||
{
|
||||
name: "fetch_followers",
|
||||
payload: { profiles: [], cursor: null },
|
||||
call: async () => (await import("./client")).fetchFollowers("did:plc:a"),
|
||||
},
|
||||
{
|
||||
name: "fetch_following",
|
||||
payload: { profiles: [], cursor: null },
|
||||
call: async () => (await import("./client")).fetchFollowing("did:plc:a"),
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const ep of PUBLIC) {
|
||||
it(`${ep.name} resolves without a session and without refreshing`, async () => {
|
||||
invokeMock.mockResolvedValueOnce(ep.payload);
|
||||
|
||||
await expect(ep.call()).resolves.toBeDefined();
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
expect(invokeMock.mock.calls[0][0]).toBe(ep.name);
|
||||
expect(
|
||||
invokeMock.mock.calls.some((c) => c[0] === "auth_refresh"),
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it("a public call's own failure surfaces untouched", async () => {
|
||||
const { fetchSearch } = await import("./client");
|
||||
invokeMock.mockRejectedValueOnce(
|
||||
"appview: search returned 400 Bad Request: q is required",
|
||||
);
|
||||
await expect(fetchSearch("")).rejects.toThrow(/q is required/);
|
||||
expect(invokeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthFailure / errorMessage", () => {
|
||||
it("recognises every shape the AppView's auth guard can answer with", async () => {
|
||||
const { isAuthFailure } = await import("./client");
|
||||
for (const msg of [
|
||||
'appview: timeline home returned 401 Unauthorized: {"error":"AuthMissing","message":"no bearer"}',
|
||||
'appview: notifications returned 401 Unauthorized: {"error":"TokenInvalid","message":"ExpiredSignature"}',
|
||||
'appview: notification count returned 403 Forbidden: {"error":"Forbidden"}',
|
||||
"not logged in: the home timeline requires a signed-in session",
|
||||
]) {
|
||||
expect(isAuthFailure(msg)).toBe(true);
|
||||
expect(isAuthFailure(new Error(msg))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not mistake a server or network failure for an auth failure", async () => {
|
||||
const { isAuthFailure } = await import("./client");
|
||||
expect(
|
||||
isAuthFailure("appview: notifications returned 500: db down"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthFailure("appview: failed to send timeline request"),
|
||||
).toBe(false);
|
||||
expect(isAuthFailure(null)).toBe(false);
|
||||
expect(isAuthFailure(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("swaps the raw 401 wire string for copy the user can act on", async () => {
|
||||
const { errorMessage } = await import("./client");
|
||||
const raw =
|
||||
'appview: notifications returned 401 Unauthorized: {"error":"TokenInvalid","message":"ExpiredSignature"}';
|
||||
expect(errorMessage(raw)).toBe(
|
||||
"Sitzung abgelaufen oder abgelehnt — bitte neu anmelden.",
|
||||
);
|
||||
// Anything else is shown verbatim: there's nothing better to say
|
||||
// about a 500 than what the server said.
|
||||
expect(errorMessage("appview: search returned 500: db down")).toContain(
|
||||
"500",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -59,14 +59,22 @@ export async function getAppviewUrl(): Promise<string> {
|
||||
* webview it falls through to a normal `invoke` call.
|
||||
*
|
||||
* **Auto-refresh on 401**: the access JWT expires after 1 hour. When
|
||||
* the PDS rejects our token with `TokenInvalid` (the rusty
|
||||
* `routes::auth` handlers return `{"error":"TokenInvalid",...}`),
|
||||
* we ask the Rust shell for a fresh access JWT via the
|
||||
* `auth_refresh` Tauri command. The Rust side reads the stored
|
||||
* refresh JWT (valid for 90 days) and rotates both. We retry
|
||||
* exactly once on the same `cmd` + `args`. The `auth_*` commands
|
||||
* themselves are skipped so a failing login doesn't trigger an
|
||||
* infinite refresh loop.
|
||||
* the PDS *or the AppView* rejects our token with `TokenInvalid`
|
||||
* (both return `{"error":"TokenInvalid",...}` — the PDS from its
|
||||
* `routes::auth` handlers, the AppView from the guard on
|
||||
* `/api/timeline/home`, `/api/notifications`,
|
||||
* `/api/notifications/count` and `/api/notifications/seen`), we ask
|
||||
* the Rust shell for a fresh access JWT via the `auth_refresh` Tauri
|
||||
* command. The Rust side reads the stored refresh JWT (valid for 90
|
||||
* days) and rotates both. We retry exactly once on the same `cmd` +
|
||||
* `args`. The `auth_*` commands themselves are skipped so a failing
|
||||
* login doesn't trigger an infinite refresh loop.
|
||||
*
|
||||
* The whole chain is string-matching, end to end: the AppView states
|
||||
* the code only in its JSON body, `appview_client.rs`'s
|
||||
* `status_error()` formats that body into the `anyhow` message, and
|
||||
* `lib.rs` stringifies it into the command's `Err(String)`. See the
|
||||
* Rust-side test `token_invalid_code_survives_into_the_error_string`.
|
||||
*/
|
||||
async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
if (!isTauri()) {
|
||||
@@ -82,17 +90,74 @@ async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promi
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalise whatever a rejected `invoke` handed us into a string.
|
||||
///
|
||||
/// This is not defensive padding — it is the difference between the
|
||||
/// retry chain working and not. Our Tauri commands are
|
||||
/// `Result<T, String>`, and `@tauri-apps/api`'s `invoke` rejects with
|
||||
/// the *deserialised* error payload, i.e. a bare JS **string**, not an
|
||||
/// `Error`. Anything that only reads `e.message` therefore sees
|
||||
/// nothing at all on the exact path that matters. Errors thrown
|
||||
/// locally (the browser-preview guard above, and the `Error` instances
|
||||
/// the tests use) still arrive as objects, so both shapes are handled.
|
||||
function errorText(e: unknown): string {
|
||||
if (typeof e === "string") return e;
|
||||
if (typeof e === "object" && e !== null) {
|
||||
const m = (e as { message?: unknown }).message;
|
||||
if (typeof m === "string") return m;
|
||||
}
|
||||
return String(e ?? "");
|
||||
}
|
||||
|
||||
/// Sniff out a `TokenInvalid` response from the Rust error string.
|
||||
/// Returns true when the error message looks like an expired/
|
||||
/// invalid JWT (the PDS uses a stable `"TokenInvalid"` code in its
|
||||
/// JSON error body, which `@tauri-apps/api/core` surfaces verbatim).
|
||||
/// Returns true when the error looks like an expired/invalid JWT —
|
||||
/// both the PDS and the AppView use a stable `"TokenInvalid"` code in
|
||||
/// their JSON error body, which travels verbatim through the Rust
|
||||
/// error message and out over the Tauri IPC boundary.
|
||||
function isTokenInvalid(e: unknown): boolean {
|
||||
if (typeof e !== "object" || e === null) return false;
|
||||
const msg = (e as { message?: string }).message ?? String(e);
|
||||
const msg = errorText(e);
|
||||
if (!msg) return false;
|
||||
return msg.includes("TokenInvalid") || msg.includes("ExpiredSignature");
|
||||
}
|
||||
|
||||
/// True when an error means "this call will not succeed until the user
|
||||
/// signs in again" — as opposed to a transient network/server hiccup.
|
||||
///
|
||||
/// Covers everything the AppView's auth guard can answer with
|
||||
/// (`AuthMissing` / `TokenInvalid` on 401, `Forbidden` on 403) plus the
|
||||
/// Rust shell's own "no session stored" message from
|
||||
/// `require_access_jwt`. Callers that poll in the background use this
|
||||
/// to *stop* polling: by the time one of these surfaces, `safeInvoke`
|
||||
/// has already spent its one refresh attempt, so retrying on a timer
|
||||
/// would just be a request loop against a server that keeps saying no.
|
||||
export function isAuthFailure(e: unknown): boolean {
|
||||
const msg = errorText(e);
|
||||
if (!msg) return false;
|
||||
return (
|
||||
msg.includes("AuthMissing") ||
|
||||
msg.includes("TokenInvalid") ||
|
||||
msg.includes("ExpiredSignature") ||
|
||||
msg.includes("Forbidden") ||
|
||||
msg.includes("not logged in")
|
||||
);
|
||||
}
|
||||
|
||||
/// User-facing copy for a failed call, in the app's German UI voice.
|
||||
///
|
||||
/// An auth failure gets a sentence naming the actual remedy. The raw
|
||||
/// string a view would otherwise render —
|
||||
/// `appview: timeline home returned 401 Unauthorized:
|
||||
/// {"error":"TokenInvalid","message":"ExpiredSignature"}` — is precise
|
||||
/// and completely unactionable for the person reading it. Everything
|
||||
/// else falls through verbatim: a network error or a 500 is worth
|
||||
/// showing as-is, since there is nothing better to say about it.
|
||||
export function errorMessage(e: unknown): string {
|
||||
if (isAuthFailure(e)) {
|
||||
return "Sitzung abgelaufen oder abgelehnt — bitte neu anmelden.";
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
did: string;
|
||||
handle: string;
|
||||
@@ -253,8 +318,10 @@ export type SearchResponse = {
|
||||
///
|
||||
/// `like_count` and `repost_count` are present when the post was
|
||||
/// found; they're `undefined` (or absent) for the "not in index"
|
||||
/// sentinel response (where `post` is null). AppView has no auth
|
||||
/// yet, so `viewer_liked` / `viewer_reposted` aren't returned.
|
||||
/// sentinel response (where `post` is null). `/api/post/{uri}` is one
|
||||
/// of the AppView's public endpoints — it takes no token and so has no
|
||||
/// viewer to resolve against, hence no `viewer_liked` /
|
||||
/// `viewer_reposted`. Use [`fetchThread`] with a `viewerDid` for those.
|
||||
export type ThreadResponse = {
|
||||
post: Post | null;
|
||||
thread: {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import Avatar from "./Avatar.svelte";
|
||||
import Skeleton from "./Skeleton.svelte";
|
||||
import {
|
||||
errorMessage,
|
||||
fetchNotifications,
|
||||
markNotificationsSeen,
|
||||
notificationIcon,
|
||||
@@ -71,7 +72,7 @@
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
error = errorMessage(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -89,7 +90,7 @@
|
||||
items = [...items, ...r.notifications.filter((n) => !seen.has(n.id))];
|
||||
cursor = r.cursor;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
error = errorMessage(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
|
||||
@@ -194,6 +194,48 @@ describe("NotificationsView actor navigation", () => {
|
||||
expect(onThreadClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows actionable copy when the AppView rejects the session", async () => {
|
||||
// Since `/api/notifications` grew an auth guard, this is what a
|
||||
// rejected token looks like by the time it reaches the view: the
|
||||
// AppView's JSON body, wrapped by `appview_client.rs`'s
|
||||
// `status_error()` and stringified across the Tauri IPC boundary.
|
||||
// `safeInvoke` has already spent its one refresh attempt getting
|
||||
// here, so the only thing left to tell the user is "log in again" —
|
||||
// rendering the raw wire string would be accurate and useless.
|
||||
fetchNotificationsMock.mockRejectedValue(
|
||||
'appview: notifications returned 401 Unauthorized: ' +
|
||||
'{"error":"TokenInvalid","message":"ExpiredSignature"}',
|
||||
);
|
||||
|
||||
app = mount(NotificationsView, {
|
||||
target,
|
||||
props: { did: "did:plc:me" },
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(target.textContent).toContain("bitte neu anmelden");
|
||||
expect(target.textContent).not.toContain("TokenInvalid");
|
||||
expect(target.textContent).not.toContain("401");
|
||||
// A failed load must not leave the spinner up or ack a page it
|
||||
// never rendered.
|
||||
expect(markNotificationsSeenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still shows a server error verbatim — there's nothing better to say", async () => {
|
||||
fetchNotificationsMock.mockRejectedValue(
|
||||
"appview: notifications returned 500 Internal Server Error: db down",
|
||||
);
|
||||
|
||||
app = mount(NotificationsView, {
|
||||
target,
|
||||
props: { did: "did:plc:me" },
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(target.textContent).toContain("500");
|
||||
expect(target.textContent).toContain("db down");
|
||||
});
|
||||
|
||||
it("opens the thread for a row that has a subject", async () => {
|
||||
fetchNotificationsMock.mockResolvedValue({
|
||||
notifications: [row()],
|
||||
|
||||
Reference in New Issue
Block a user