Skip to content

Commit 577ea0a

Browse files
authored
Merge pull request #34 from codebridger/dev
Fix self-healing of stale auth token on translate and UI improvements in console-crane
2 parents 76553d0 + 1c9bc42 commit 577ea0a

12 files changed

Lines changed: 1147 additions & 127 deletions

CHANGELOG-DEV.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
1+
## [1.14.1-dev.2](https://github.com/codebridger/subturtle-extension-apps/compare/v1.14.1-dev.1...v1.14.1-dev.2) (2026-06-08)
2+
3+
4+
### Bug Fixes
5+
6+
* self-heal stale auth token on translate instead of failing ([6ef3766](https://github.com/codebridger/subturtle-extension-apps/commit/6ef3766e482c0c97476b4d8aac59fe2f92f7376b))
7+
8+
## [1.14.1-dev.1](https://github.com/codebridger/subturtle-extension-apps/compare/v1.14.0...v1.14.1-dev.1) (2026-06-08)
9+
10+
11+
### Bug Fixes
12+
13+
* **console-crane:** align bundle selector height with the Save button ([cf057a0](https://github.com/codebridger/subturtle-extension-apps/commit/cf057a095e85ac7d55824cafe5f06eeb2568d06a)), closes [#86exw6kme](https://github.com/codebridger/subturtle-extension-apps/issues/86exw6kme)
14+
* **console-crane:** isolate modal from host-page color/flex CSS leaks ([2b89736](https://github.com/codebridger/subturtle-extension-apps/commit/2b89736bcb6664f2ecb82ba902ef1bb5a83dcc61)), closes [#86exw6kme](https://github.com/codebridger/subturtle-extension-apps/issues/86exw6kme)
15+
16+
# [1.14.0-dev.2](https://github.com/codebridger/subturtle-extension-apps/compare/v1.14.0-dev.1...v1.14.0-dev.2) (2026-06-08)
17+
18+
19+
### Bug Fixes
20+
21+
* **console-crane:** isolate modal from host-page color/flex CSS leaks ([2b89736](https://github.com/codebridger/subturtle-extension-apps/commit/2b89736bcb6664f2ecb82ba902ef1bb5a83dcc61)), closes [#86exw6kme](https://github.com/codebridger/subturtle-extension-apps/issues/86exw6kme)
22+
123
# [1.14.0-dev.1](https://github.com/codebridger/subturtle-extension-apps/compare/v1.13.0...v1.14.0-dev.1) (2026-06-08)
224

325

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "subturtle-extension",
3-
"version": "1.14.0",
3+
"version": "1.14.1-dev.2",
44
"private": true,
55
"scripts": {
66
"dev": "webpack --watch",

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+
}

0 commit comments

Comments
 (0)