Skip to content
Merged
22 changes: 22 additions & 0 deletions CHANGELOG-DEV.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
## [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)


### Bug Fixes

* self-heal stale auth token on translate instead of failing ([6ef3766](https://github.com/codebridger/subturtle-extension-apps/commit/6ef3766e482c0c97476b4d8aac59fe2f92f7376b))

## [1.14.1-dev.1](https://github.com/codebridger/subturtle-extension-apps/compare/v1.14.0...v1.14.1-dev.1) (2026-06-08)


### Bug Fixes

* **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)
* **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)

# [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)


### Bug Fixes

* **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)

# [1.14.0-dev.1](https://github.com/codebridger/subturtle-extension-apps/compare/v1.13.0...v1.14.0-dev.1) (2026-06-08)


Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "subturtle-extension",
"version": "1.14.0",
"version": "1.14.1-dev.2",
"private": true,
"scripts": {
"dev": "webpack --watch",
Expand Down
267 changes: 267 additions & 0 deletions src/common/helper/auth-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import { authentication } from "@modular-rest/client";
import { sendMessage } from "./massage";
import { StoreUserTokenMessage } from "../types/messaging";
import { debug, error, warn } from "./log";

/**
* Auth-recovery helper β€” the three pieces of "self-heal a dead session token"
* live together here because they're only ever useful as a unit:
*
* isAuthError(err) β€” is this failure a stale/invalid token?
* reauthAnonymously()β€” if so, how do we recover a session?
* withAuthRetry(run) β€” the policy that wires the two: retry once on recovery.
*
* Why this matters: anonymous users get purged server-side, so a token
* persisted in chrome.storage.sync can outlive its user and start returning
* "User not found" on every call β€” leaving the user stuck (e.g. on
* "Translation failed") with no way out short of a full reload.
*/

// ---------------------------------------------------------------------------
// Detection
// ---------------------------------------------------------------------------

/**
* We can only inspect the *body* the server sent, never the HTTP status:
* modular-rest's HTTPClient.request discards `error.response.status` and
* re-throws only `error.response.data` wrapped as `{ hasError, error }`
* (node_modules/@modular-rest/client/dist/class/http.js). So detection is
* string-based against the response body.
*
* Two thrown shapes reach callers:
* - functionProvider.run re-throws the raw server body, e.g. "User not found".
* - dataProvider.* let the wrapper through, e.g. { hasError, error: "..." }.
* extractMessage flattens both into one searchable string.
*
* The patterns below were derived from the actual modular-rest server source
* (auth middleware β†’ JWT verify β†’ user lookup), not guessed. On every
* auth-gated route (/function/run, /data-provider/*) the server reports auth
* failures via Koa `ctx.throw`, so the BODY is a bare string: either the thrown
* message, or β€” when the message is undefined β€” Koa's default reason phrase.
* Verified failure β†’ body mapping:
* - token valid but user row purged β†’ 412 "User not found"
* - missing / empty Authorization β†’ 401 "authentication is required"
* - lacks permission β†’ 403 "access denied"
* - invalid/expired/wrong-sig/malformed token β†’ 412 "Precondition Failed"
* ^ the jwt reason ("jwt expired" / "invalid signature" / "jwt malformed")
* is SWALLOWED server-side: JWT.verify rejects with the message as a
* *string*, so `err.message` is undefined and ctx.throw(412, undefined)
* falls back to Koa's reason phrase. "Precondition Failed" is therefore
* the ONLY on-the-wire signal for the most common stale-token case β€”
* and on these routes a bare "precondition failed" body can only come
* from the auth middleware (validation 412s carry a JSON body instead),
* so matching it does not risk a false positive.
*
* NOTE: /user/login & /user/loginAnonymous failures arrive as
* {status:"error", e:{}} (the Error serializes to {} β€” message lost). Those are
* deliberately NOT matched: login is an explicit user action and must not be
* silently retried as anonymous. If the upstream server stops swallowing the
* jwt message, the raw jsonwebtoken phrases below will start matching too.
*/
const AUTH_ERROR_PATTERNS = [
// Bare-string bodies the server emits via ctx.throw on auth-gated routes.
"user not found", // 412 β€” token valid, user row purged (the reported bug)
"authentication", // 401 "authentication is required" (missing/empty header)
"access denied", // 403 β€” authenticated but lacks permission
"precondition failed", // 412 β€” invalid/expired/wrong-sig token (reason swallowed)

// Raw jsonwebtoken messages β€” reach the client verbatim via /verify/token,
// and would reach the auth path too if the server stops swallowing them.
"jwt expired",
"jwt malformed",
"jwt not active",
"invalid signature",
"invalid token",
"jwt", // catch-all for other jsonwebtoken phrases ("jwt must be provided", …)

// Defensive nets β€” not emitted by this server today, but cheap and guard the
// modular-rest client's own throws / intermediary proxies / gateways.
"unauthorized",
"forbidden",
"token doesn't", // client-side: "Token doesn't find on local machine"
];

function extractMessage(err: unknown, depth = 0): string {
if (err == null || depth > 3) return "";
if (typeof err === "string") return err;
if (typeof err === "number" || typeof err === "boolean") return String(err);
if (typeof err === "object") {
const o = err as Record<string, unknown>;
// The fields modular-rest / our own throws use to carry a message.
return [o.error, o.message, o.detail, o.reason, o.e, o.status]
.map((v) => extractMessage(v, depth + 1))
.filter(Boolean)
.join(" ");
}
return "";
}

/**
* True when the error body matches a known auth/token-failure phrase.
*
* Deliberately conservative: business errors ("limit reached", "not enough
* credit") must NOT match, or we'd needlessly churn a perfectly good session
* β€” and a registered user could get silently degraded to anonymous.
*/
export function isAuthError(err: unknown): boolean {
const text = extractMessage(err).toLowerCase();
if (!text) return false;
return AUTH_ERROR_PATTERNS.some((pattern) => text.includes(pattern));
}

// ---------------------------------------------------------------------------
// Recovery
// ---------------------------------------------------------------------------

/**
* Establish a fresh anonymous session and persist its token to the background
* so every bundle on the page reuses it. Returns true once a usable session is
* in place.
*
* Persisting matters: subsequent mounts (other bundles on the same page, the
* popup, page reloads) reuse this token instead of each calling
* /user/loginAnonymous and stranding the previous anonymous user β€” which the
* server then 412s / "User not found"s on the next call. The write goes to
* chrome.storage.sync (cross-context) via the background script.
*
* Callers:
* 1. modular-rest.ts loginWithLastSession's fallback β€” first-session
* bootstrap when no valid stored token exists.
* 2. withAuthRetry (via recoverSession) β€” a previously-valid token went stale
* mid-session.
*
* SINGLE-FLIGHT: concurrent callers are coalesced into ONE /user/loginAnonymous
* and all reuse its token. This is essential β€” when a stored token is dead, a
* page typically fires several failing requests at once (the word-detail modal
* runs a simple + a detailed translation plus bundle look-ups together), and
* without coalescing each one would mint and strand its own anonymous user,
* producing the "constant loginAnonymous calls" storm. A re-auth that starts
* after the in-flight one settles is a fresh login (the guard resets).
*
* modular-rest.ts is the one that additionally refreshes the reactive isLogin
* ref via updateIsLogin after calling this.
*/
let inflightReauth: Promise<boolean> | null = null;

export function reauthAnonymously(): Promise<boolean> {
if (inflightReauth) return inflightReauth;
inflightReauth = performAnonymousReauth().finally(() => {
inflightReauth = null;
});
return inflightReauth;
}

async function performAnonymousReauth(): Promise<boolean> {
try {
await authentication.loginAsAnonymous();
debug("Subturtle anonymous login succeeded", authentication.isLogin);

const token = authentication.getToken;
if (token) {
try {
await sendMessage(new StoreUserTokenMessage(token));
} catch (err) {
error(
"Subturtle: persisting anonymous token to background failed",
err
);
}
}

return authentication.isLogin;
} catch (err) {
// Raw console.error (not the [Subturtle]-prefixing helper) to preserve the
// exact message the anon-fallback has always logged β€” pinned by
// tests/auth-anon-flow.test.ts.
console.error("Subturtle anonymous login failed", err);
return false;
}
}

// ---------------------------------------------------------------------------
// Recovery strategy (late-bound)
// ---------------------------------------------------------------------------

/**
* The recovery withAuthRetry runs when it sees an auth error. Defaults to the
* bare reauthAnonymously (fresh anon token only). modular-rest.ts overrides it
* at init via setSessionRecovery with a system-wide recovery that ALSO tears
* the dead session down (logout broadcast + profile/isLogin/analytics reset)
* before re-establishing anonymous β€” see modular-rest.ts `recoverSession`.
*
* Late binding (rather than importing logout from the plugin) is deliberate:
* - the plugin already imports reauthAnonymously from THIS module, so a
* direct back-import would be circular; and
* - this module must stay side-effect-free so translate.service β€” and the
* many UI components importing it β€” don't drag the plugin's content-script
* side effects (GlobalOptions, chrome listeners, …) into their import graph
* or tests. The plugin is loaded by every bundle, so the override is always
* applied in production; code paths that never load it (some unit tests)
* fall back to the bare anonymous recovery, which is sufficient there.
*/
let sessionRecovery: () => Promise<boolean> = reauthAnonymously;

export function setSessionRecovery(recover: () => Promise<boolean>): void {
sessionRecovery = recover;
}

/**
* Single-flight wrapper around the installed recovery. A burst of failing
* requests (the word-detail modal fires several at once) must trigger ONE
* recovery, not one per request β€” otherwise the registered-user path would run
* logout() repeatedly and the anonymous path would still funnel through the
* (already coalesced) reauthAnonymously. All concurrent failures await the same
* recovery and then each retries its own call.
*/
let inflightRecovery: Promise<boolean> | null = null;

function recoverOnce(): Promise<boolean> {
if (inflightRecovery) return inflightRecovery;
inflightRecovery = Promise.resolve(sessionRecovery()).finally(() => {
inflightRecovery = null;
});
return inflightRecovery;
}

// ---------------------------------------------------------------------------
// Retry policy
// ---------------------------------------------------------------------------

/**
* Run a modular-rest call and, if it fails because the session token is
* stale/invalid, recover the session and retry the call once.
*
* Reusable across services β€” any call that depends on a valid session token
* can wrap itself in this to self-heal a dead token instead of surfacing a
* hard failure:
*
* import { withAuthRetry } from "@/common/helper/auth-recovery";
* const data = await withAuthRetry(() => functionProvider.run({ ... }));
*
* Recovery is whatever setSessionRecovery installed (system-wide logout +
* anonymous re-auth in production). A registered user whose token is genuinely
* dead is cleanly downgraded to anonymous across the extension.
*
* Guarantees:
* - Only retries auth-shaped errors (isAuthError is conservative), so genuine
* failures β€” network, rate limit, business errors β€” surface unchanged.
* - Retries at most ONCE, and only if recovery actually produced a usable
* session, so it can never loop.
*/
export async function withAuthRetry<T>(run: () => Promise<T>): Promise<T> {
try {
return await run();
} catch (err) {
if (!isAuthError(err)) throw err;

warn(
"Request hit an auth error; recovering session and retrying once.",
err
);

const recovered = await recoverOnce();
if (!recovered) throw err;

return await run();
}
}
Loading