Skip to content

Commit 6ef3766

Browse files
navidshadclaude
andcommitted
fix: self-heal stale auth token on translate instead of failing
A purged/invalid token in chrome.storage.sync made every translation fail with "User not found" (and other token-dependent calls 412) until a full reload. Requests now detect auth-shaped failures, recover the session, and retry once. - isAuthError: detect token failures from the response body (the client discards the HTTP status). Patterns are grounded in the actual modular-rest server output, including the swallowed "Precondition Failed" body for invalid/expired/wrong-signature tokens. - withAuthRetry: reusable wrapper that recovers + retries once on an auth error; wired into TranslateService's three function/run calls. - recoverSession: tears a dead registered session down system-wide via logout() (profile/isLogin/analytics reset + broadcast) then re-auths anonymous; anonymous sessions just re-auth (contained). - single-flight recovery: concurrent failures share ONE loginAnonymous instead of each minting and stranding an anonymous user. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b1c5a13 commit 6ef3766

6 files changed

Lines changed: 889 additions & 76 deletions

File tree

src/common/helper/auth-recovery.ts

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import { authentication } from "@modular-rest/client";
2+
import { sendMessage } from "./massage";
3+
import { StoreUserTokenMessage } from "../types/messaging";
4+
import { debug, error, warn } from "./log";
5+
6+
/**
7+
* Auth-recovery helper — the three pieces of "self-heal a dead session token"
8+
* live together here because they're only ever useful as a unit:
9+
*
10+
* isAuthError(err) — is this failure a stale/invalid token?
11+
* reauthAnonymously()— if so, how do we recover a session?
12+
* withAuthRetry(run) — the policy that wires the two: retry once on recovery.
13+
*
14+
* Why this matters: anonymous users get purged server-side, so a token
15+
* persisted in chrome.storage.sync can outlive its user and start returning
16+
* "User not found" on every call — leaving the user stuck (e.g. on
17+
* "Translation failed") with no way out short of a full reload.
18+
*/
19+
20+
// ---------------------------------------------------------------------------
21+
// Detection
22+
// ---------------------------------------------------------------------------
23+
24+
/**
25+
* We can only inspect the *body* the server sent, never the HTTP status:
26+
* modular-rest's HTTPClient.request discards `error.response.status` and
27+
* re-throws only `error.response.data` wrapped as `{ hasError, error }`
28+
* (node_modules/@modular-rest/client/dist/class/http.js). So detection is
29+
* string-based against the response body.
30+
*
31+
* Two thrown shapes reach callers:
32+
* - functionProvider.run re-throws the raw server body, e.g. "User not found".
33+
* - dataProvider.* let the wrapper through, e.g. { hasError, error: "..." }.
34+
* extractMessage flattens both into one searchable string.
35+
*
36+
* The patterns below were derived from the actual modular-rest server source
37+
* (auth middleware → JWT verify → user lookup), not guessed. On every
38+
* auth-gated route (/function/run, /data-provider/*) the server reports auth
39+
* failures via Koa `ctx.throw`, so the BODY is a bare string: either the thrown
40+
* message, or — when the message is undefined — Koa's default reason phrase.
41+
* Verified failure → body mapping:
42+
* - token valid but user row purged → 412 "User not found"
43+
* - missing / empty Authorization → 401 "authentication is required"
44+
* - lacks permission → 403 "access denied"
45+
* - invalid/expired/wrong-sig/malformed token → 412 "Precondition Failed"
46+
* ^ the jwt reason ("jwt expired" / "invalid signature" / "jwt malformed")
47+
* is SWALLOWED server-side: JWT.verify rejects with the message as a
48+
* *string*, so `err.message` is undefined and ctx.throw(412, undefined)
49+
* falls back to Koa's reason phrase. "Precondition Failed" is therefore
50+
* the ONLY on-the-wire signal for the most common stale-token case —
51+
* and on these routes a bare "precondition failed" body can only come
52+
* from the auth middleware (validation 412s carry a JSON body instead),
53+
* so matching it does not risk a false positive.
54+
*
55+
* NOTE: /user/login & /user/loginAnonymous failures arrive as
56+
* {status:"error", e:{}} (the Error serializes to {} — message lost). Those are
57+
* deliberately NOT matched: login is an explicit user action and must not be
58+
* silently retried as anonymous. If the upstream server stops swallowing the
59+
* jwt message, the raw jsonwebtoken phrases below will start matching too.
60+
*/
61+
const AUTH_ERROR_PATTERNS = [
62+
// Bare-string bodies the server emits via ctx.throw on auth-gated routes.
63+
"user not found", // 412 — token valid, user row purged (the reported bug)
64+
"authentication", // 401 "authentication is required" (missing/empty header)
65+
"access denied", // 403 — authenticated but lacks permission
66+
"precondition failed", // 412 — invalid/expired/wrong-sig token (reason swallowed)
67+
68+
// Raw jsonwebtoken messages — reach the client verbatim via /verify/token,
69+
// and would reach the auth path too if the server stops swallowing them.
70+
"jwt expired",
71+
"jwt malformed",
72+
"jwt not active",
73+
"invalid signature",
74+
"invalid token",
75+
"jwt", // catch-all for other jsonwebtoken phrases ("jwt must be provided", …)
76+
77+
// Defensive nets — not emitted by this server today, but cheap and guard the
78+
// modular-rest client's own throws / intermediary proxies / gateways.
79+
"unauthorized",
80+
"forbidden",
81+
"token doesn't", // client-side: "Token doesn't find on local machine"
82+
];
83+
84+
function extractMessage(err: unknown, depth = 0): string {
85+
if (err == null || depth > 3) return "";
86+
if (typeof err === "string") return err;
87+
if (typeof err === "number" || typeof err === "boolean") return String(err);
88+
if (typeof err === "object") {
89+
const o = err as Record<string, unknown>;
90+
// The fields modular-rest / our own throws use to carry a message.
91+
return [o.error, o.message, o.detail, o.reason, o.e, o.status]
92+
.map((v) => extractMessage(v, depth + 1))
93+
.filter(Boolean)
94+
.join(" ");
95+
}
96+
return "";
97+
}
98+
99+
/**
100+
* True when the error body matches a known auth/token-failure phrase.
101+
*
102+
* Deliberately conservative: business errors ("limit reached", "not enough
103+
* credit") must NOT match, or we'd needlessly churn a perfectly good session
104+
* — and a registered user could get silently degraded to anonymous.
105+
*/
106+
export function isAuthError(err: unknown): boolean {
107+
const text = extractMessage(err).toLowerCase();
108+
if (!text) return false;
109+
return AUTH_ERROR_PATTERNS.some((pattern) => text.includes(pattern));
110+
}
111+
112+
// ---------------------------------------------------------------------------
113+
// Recovery
114+
// ---------------------------------------------------------------------------
115+
116+
/**
117+
* Establish a fresh anonymous session and persist its token to the background
118+
* so every bundle on the page reuses it. Returns true once a usable session is
119+
* in place.
120+
*
121+
* Persisting matters: subsequent mounts (other bundles on the same page, the
122+
* popup, page reloads) reuse this token instead of each calling
123+
* /user/loginAnonymous and stranding the previous anonymous user — which the
124+
* server then 412s / "User not found"s on the next call. The write goes to
125+
* chrome.storage.sync (cross-context) via the background script.
126+
*
127+
* Callers:
128+
* 1. modular-rest.ts loginWithLastSession's fallback — first-session
129+
* bootstrap when no valid stored token exists.
130+
* 2. withAuthRetry (via recoverSession) — a previously-valid token went stale
131+
* mid-session.
132+
*
133+
* SINGLE-FLIGHT: concurrent callers are coalesced into ONE /user/loginAnonymous
134+
* and all reuse its token. This is essential — when a stored token is dead, a
135+
* page typically fires several failing requests at once (the word-detail modal
136+
* runs a simple + a detailed translation plus bundle look-ups together), and
137+
* without coalescing each one would mint and strand its own anonymous user,
138+
* producing the "constant loginAnonymous calls" storm. A re-auth that starts
139+
* after the in-flight one settles is a fresh login (the guard resets).
140+
*
141+
* modular-rest.ts is the one that additionally refreshes the reactive isLogin
142+
* ref via updateIsLogin after calling this.
143+
*/
144+
let inflightReauth: Promise<boolean> | null = null;
145+
146+
export function reauthAnonymously(): Promise<boolean> {
147+
if (inflightReauth) return inflightReauth;
148+
inflightReauth = performAnonymousReauth().finally(() => {
149+
inflightReauth = null;
150+
});
151+
return inflightReauth;
152+
}
153+
154+
async function performAnonymousReauth(): Promise<boolean> {
155+
try {
156+
await authentication.loginAsAnonymous();
157+
debug("Subturtle anonymous login succeeded", authentication.isLogin);
158+
159+
const token = authentication.getToken;
160+
if (token) {
161+
try {
162+
await sendMessage(new StoreUserTokenMessage(token));
163+
} catch (err) {
164+
error(
165+
"Subturtle: persisting anonymous token to background failed",
166+
err
167+
);
168+
}
169+
}
170+
171+
return authentication.isLogin;
172+
} catch (err) {
173+
// Raw console.error (not the [Subturtle]-prefixing helper) to preserve the
174+
// exact message the anon-fallback has always logged — pinned by
175+
// tests/auth-anon-flow.test.ts.
176+
console.error("Subturtle anonymous login failed", err);
177+
return false;
178+
}
179+
}
180+
181+
// ---------------------------------------------------------------------------
182+
// Recovery strategy (late-bound)
183+
// ---------------------------------------------------------------------------
184+
185+
/**
186+
* The recovery withAuthRetry runs when it sees an auth error. Defaults to the
187+
* bare reauthAnonymously (fresh anon token only). modular-rest.ts overrides it
188+
* at init via setSessionRecovery with a system-wide recovery that ALSO tears
189+
* the dead session down (logout broadcast + profile/isLogin/analytics reset)
190+
* before re-establishing anonymous — see modular-rest.ts `recoverSession`.
191+
*
192+
* Late binding (rather than importing logout from the plugin) is deliberate:
193+
* - the plugin already imports reauthAnonymously from THIS module, so a
194+
* direct back-import would be circular; and
195+
* - this module must stay side-effect-free so translate.service — and the
196+
* many UI components importing it — don't drag the plugin's content-script
197+
* side effects (GlobalOptions, chrome listeners, …) into their import graph
198+
* or tests. The plugin is loaded by every bundle, so the override is always
199+
* applied in production; code paths that never load it (some unit tests)
200+
* fall back to the bare anonymous recovery, which is sufficient there.
201+
*/
202+
let sessionRecovery: () => Promise<boolean> = reauthAnonymously;
203+
204+
export function setSessionRecovery(recover: () => Promise<boolean>): void {
205+
sessionRecovery = recover;
206+
}
207+
208+
/**
209+
* Single-flight wrapper around the installed recovery. A burst of failing
210+
* requests (the word-detail modal fires several at once) must trigger ONE
211+
* recovery, not one per request — otherwise the registered-user path would run
212+
* logout() repeatedly and the anonymous path would still funnel through the
213+
* (already coalesced) reauthAnonymously. All concurrent failures await the same
214+
* recovery and then each retries its own call.
215+
*/
216+
let inflightRecovery: Promise<boolean> | null = null;
217+
218+
function recoverOnce(): Promise<boolean> {
219+
if (inflightRecovery) return inflightRecovery;
220+
inflightRecovery = Promise.resolve(sessionRecovery()).finally(() => {
221+
inflightRecovery = null;
222+
});
223+
return inflightRecovery;
224+
}
225+
226+
// ---------------------------------------------------------------------------
227+
// Retry policy
228+
// ---------------------------------------------------------------------------
229+
230+
/**
231+
* Run a modular-rest call and, if it fails because the session token is
232+
* stale/invalid, recover the session and retry the call once.
233+
*
234+
* Reusable across services — any call that depends on a valid session token
235+
* can wrap itself in this to self-heal a dead token instead of surfacing a
236+
* hard failure:
237+
*
238+
* import { withAuthRetry } from "@/common/helper/auth-recovery";
239+
* const data = await withAuthRetry(() => functionProvider.run({ ... }));
240+
*
241+
* Recovery is whatever setSessionRecovery installed (system-wide logout +
242+
* anonymous re-auth in production). A registered user whose token is genuinely
243+
* dead is cleanly downgraded to anonymous across the extension.
244+
*
245+
* Guarantees:
246+
* - Only retries auth-shaped errors (isAuthError is conservative), so genuine
247+
* failures — network, rate limit, business errors — surface unchanged.
248+
* - Retries at most ONCE, and only if recovery actually produced a usable
249+
* session, so it can never loop.
250+
*/
251+
export async function withAuthRetry<T>(run: () => Promise<T>): Promise<T> {
252+
try {
253+
return await run();
254+
} catch (err) {
255+
if (!isAuthError(err)) throw err;
256+
257+
warn(
258+
"Request hit an auth error; recovering session and retrying once.",
259+
err
260+
);
261+
262+
const recovered = await recoverOnce();
263+
if (!recovered) throw err;
264+
265+
return await run();
266+
}
267+
}

src/common/services/translate.service.ts

Lines changed: 43 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from "../../console-crane/modules/word-detail/types";
1515
import { LanguageDetector } from "../helper/language-detection";
1616
import { useSettingsStore } from "../store/settings";
17+
import { withAuthRetry } from "../helper/auth-recovery";
1718

1819
// Cache interface for translation results
1920
interface TranslationCache {
@@ -124,18 +125,20 @@ export class TranslateService {
124125

125126
// If not cached, fetch from API
126127
try {
127-
const result = await functionProvider.run<string>({
128-
name: "translateWithContext",
129-
args: {
130-
translationType: "simple",
131-
sourceLanguage: "auto",
132-
targetLanguage: this.languageTitle,
133-
phrase: text,
134-
context: context || "",
135-
// For the server-side translation_requested analytics event.
136-
userId: authentication.user?.id,
137-
},
138-
});
128+
const result = await withAuthRetry(() =>
129+
functionProvider.run<string>({
130+
name: "translateWithContext",
131+
args: {
132+
translationType: "simple",
133+
sourceLanguage: "auto",
134+
targetLanguage: this.languageTitle,
135+
phrase: text,
136+
context: context || "",
137+
// For the server-side translation_requested analytics event.
138+
userId: authentication.user?.id,
139+
},
140+
})
141+
);
139142

140143
// Cache the result
141144
this.cacheResult(cacheKey, result);
@@ -163,18 +166,20 @@ export class TranslateService {
163166

164167
// If not cached, fetch from API
165168
try {
166-
const data = await functionProvider.run<LanguageLearningData>({
167-
name: "translateWithContext",
168-
args: {
169-
translationType: "detailed",
170-
sourceLanguage: "auto",
171-
targetLanguage: this.languageTitle,
172-
phrase: text,
173-
context: context || "",
174-
// For the server-side translation_requested analytics event.
175-
userId: authentication.user?.id,
176-
},
177-
});
169+
const data = await withAuthRetry(() =>
170+
functionProvider.run<LanguageLearningData>({
171+
name: "translateWithContext",
172+
args: {
173+
translationType: "detailed",
174+
sourceLanguage: "auto",
175+
targetLanguage: this.languageTitle,
176+
phrase: text,
177+
context: context || "",
178+
// For the server-side translation_requested analytics event.
179+
userId: authentication.user?.id,
180+
},
181+
})
182+
);
178183

179184
// Add context and phrase to the result
180185
data.context = context;
@@ -203,18 +208,20 @@ export class TranslateService {
203208
currentChunks?: Chunk[];
204209
history?: { role: "user" | "assistant"; text: string }[];
205210
}): Promise<TranslationAdvice> {
206-
return functionProvider.run<TranslationAdvice>({
207-
name: "translationAdvice",
208-
args: {
209-
phrase: params.phrase,
210-
context: params.context || "",
211-
message: params.message,
212-
currentChunks: params.currentChunks || [],
213-
history: params.history || [],
214-
sourceLanguage: "auto",
215-
targetLanguage: this.languageTitle,
216-
},
217-
});
211+
return withAuthRetry(() =>
212+
functionProvider.run<TranslationAdvice>({
213+
name: "translationAdvice",
214+
args: {
215+
phrase: params.phrase,
216+
context: params.context || "",
217+
message: params.message,
218+
currentChunks: params.currentChunks || [],
219+
history: params.history || [],
220+
sourceLanguage: "auto",
221+
targetLanguage: this.languageTitle,
222+
},
223+
})
224+
);
218225
}
219226

220227
async translateByDictionaryapi(word: string) {

0 commit comments

Comments
 (0)