((resolve) => {
+ releaseRefresh = resolve
+ })
+ const fetchSpy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input)
+ if (url.endsWith("/api/v1/auth/refresh")) {
+ refreshHits += 1
+ // Hold the refresh open so both callers are parked on the shared
+ // in-flight promise before it resolves.
+ await refreshGate
+ return new Response(
+ JSON.stringify({ access_token: "fresh-access", refresh_token: "rotated-refresh" }),
+ { status: 200, headers: { "content-type": "application/json" } }
+ )
+ }
+ const auth = new Headers(init?.headers).get("Authorization") ?? ""
+ if (auth === "Bearer stale-access") {
+ return new Response("unauthorized", { status: 401 })
+ }
+ return new Response(JSON.stringify({ ok: true }), {
+ status: 200,
+ headers: { "content-type": "application/json" }
+ })
+ })
+ vi.stubGlobal("fetch", fetchSpy as any)
+
+ const { bgRequest } = await importProxy()
+ const call = () =>
+ bgRequest<{ ok: boolean }>({
+ path: "/api/v1/notes/search/" as unknown as `/${string}`,
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: { q: "hello" }
+ })
+
+ const a = call()
+ const b = call()
+
+ // Let both requests reach the (gated) refresh before releasing it.
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ releaseRefresh()
+
+ const [ra, rb] = await Promise.all([a, b])
+ expect(ra).toEqual({ ok: true })
+ expect(rb).toEqual({ ok: true })
+ // Two concurrent 401s → exactly ONE refresh network call.
+ expect(refreshHits).toBe(1)
+ })
+})
diff --git a/apps/packages/ui/src/services/__tests__/persona-stream.test.ts b/apps/packages/ui/src/services/__tests__/persona-stream.test.ts
index d2dc0e4cfb..5857a90abd 100644
--- a/apps/packages/ui/src/services/__tests__/persona-stream.test.ts
+++ b/apps/packages/ui/src/services/__tests__/persona-stream.test.ts
@@ -30,39 +30,75 @@ describe("buildPersonaWebSocketUrl", () => {
})
})
- it("uses the webui origin for quickstart websocket urls", () => {
+ it("uses the webui origin for quickstart websocket urls and keeps auth out of the url", () => {
process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE = "quickstart"
- const url = buildPersonaWebSocketUrl({
+ const { url, protocols } = buildPersonaWebSocketUrl({
serverUrl: "http://127.0.0.1:8000/",
authMode: "single-user",
apiKey: "abc123",
accessToken: ""
})
- expect(url).toBe("ws://127.0.0.1:8080/api/v1/persona/stream?api_key=abc123")
+ expect(url).toBe("ws://127.0.0.1:8080/api/v1/persona/stream")
+ expect(url).not.toContain("abc123")
+ expect(protocols).toEqual(["bearer", "abc123"])
})
- it("builds api-key websocket url for single-user mode", () => {
- const url = buildPersonaWebSocketUrl({
+ it("carries the api key in the subprotocol for single-user mode (not the url)", () => {
+ const { url, protocols } = buildPersonaWebSocketUrl({
serverUrl: "http://127.0.0.1:8000/",
authMode: "single-user",
apiKey: "abc123",
accessToken: ""
})
- expect(url).toBe("ws://127.0.0.1:8000/api/v1/persona/stream?api_key=abc123")
+ expect(url).toBe("ws://127.0.0.1:8000/api/v1/persona/stream")
+ expect(url).not.toContain("api_key")
+ expect(url).not.toContain("abc123")
+ expect(protocols).toEqual(["bearer", "abc123"])
})
- it("builds token websocket url for multi-user mode", () => {
- const url = buildPersonaWebSocketUrl({
+ it("carries the jwt in the subprotocol for multi-user mode (not the url)", () => {
+ const { url, protocols } = buildPersonaWebSocketUrl({
serverUrl: "https://example.com",
authMode: "multi-user",
apiKey: "",
accessToken: "jwt-token"
})
- expect(url).toBe("wss://example.com/api/v1/persona/stream?token=jwt-token")
+ expect(url).toBe("wss://example.com/api/v1/persona/stream")
+ expect(url).not.toContain("token=")
+ expect(url).not.toContain("jwt-token")
+ expect(protocols).toEqual(["bearer", "jwt-token"])
+ })
+
+ it("falls back to the query-string token when the api key is not subprotocol-safe", () => {
+ // A user-set custom key with RFC 6455 separators (`/`, `=`) can't be a
+ // WebSocket subprotocol value; falling back keeps persona voice working
+ // instead of throwing at `new WebSocket(url, ["bearer", key])`.
+ const { url, protocols } = buildPersonaWebSocketUrl({
+ serverUrl: "http://127.0.0.1:8000/",
+ authMode: "single-user",
+ apiKey: "weird/key=with+seps",
+ accessToken: ""
+ })
+
+ expect(protocols).toEqual([])
+ expect(url).toContain("api_key=")
+ expect(url).toContain(encodeURIComponent("weird/key=with+seps"))
+ })
+
+ it("keeps a token-safe key (token_urlsafe / hex style) in the subprotocol", () => {
+ const { url, protocols } = buildPersonaWebSocketUrl({
+ serverUrl: "http://127.0.0.1:8000/",
+ authMode: "single-user",
+ apiKey: "abc-123_XYZ",
+ accessToken: ""
+ })
+
+ expect(protocols).toEqual(["bearer", "abc-123_XYZ"])
+ expect(url).not.toContain("abc-123_XYZ")
})
it("throws when auth secret is missing for selected auth mode", () => {
diff --git a/apps/packages/ui/src/services/__tests__/voice-conversation.test.ts b/apps/packages/ui/src/services/__tests__/voice-conversation.test.ts
index 080ebfb3c7..f57dd3eb29 100644
--- a/apps/packages/ui/src/services/__tests__/voice-conversation.test.ts
+++ b/apps/packages/ui/src/services/__tests__/voice-conversation.test.ts
@@ -223,9 +223,11 @@ describe("voice conversation contract", () => {
resolveProvider
})
+ // TASK-12106: the token is sent as an {type:"auth"} first frame, never in the URL.
expect(result.websocketUrl).toBe(
- "ws://127.0.0.1:8000/api/v1/audio/chat/stream?token=secret-token"
+ "ws://127.0.0.1:8000/api/v1/audio/chat/stream"
)
+ expect(result.websocketUrl).not.toContain("secret-token")
expect(result.llm).toEqual({})
expect(result.tts).toEqual({
model: "kokoro",
diff --git a/apps/packages/ui/src/services/background-proxy.ts b/apps/packages/ui/src/services/background-proxy.ts
index 8758d445fb..75aa031196 100644
--- a/apps/packages/ui/src/services/background-proxy.ts
+++ b/apps/packages/ui/src/services/background-proxy.ts
@@ -19,6 +19,11 @@ import type {
PathOrUrl,
UpperLower
} from "@/services/tldw/openapi-guard"
+import {
+ isAbsoluteUrlAllowlisted,
+ isSameOriginAbsoluteUrlForConfiguredServer,
+ parseHttpOrigin
+} from "@/utils/absolute-url-guard"
const ERROR_LOG_THROTTLE_MS = 15_000
const RATE_LIMIT_LOG_THROTTLE_MS = 60_000
@@ -30,7 +35,15 @@ const STREAM_QUEUE_DRAIN_BATCH_LIMIT = 32
const STREAM_QUEUE_DRAIN_SLICE_MS = 12
const SAFE_RUNTIME_MESSAGE_TIMEOUT_MS = 3_000
const UNSAFE_RUNTIME_MESSAGE_TIMEOUT_FLOOR_MS = 5_000
-const DEFAULT_UNSAFE_RUNTIME_MESSAGE_TIMEOUT_MS = 10_000
+// The MV3 worker only replies to an unsafe (write) request once the whole
+// server operation finishes, so this messaging-ack timeout must cover the
+// longest normal generation/ingest (non-stream chat, media kickoff, export)
+// rather than the ~10s it used to be — a 10s cap killed in-flight writes that
+// the worker had actually completed, losing the result. A genuinely dead
+// worker still rejects fast (connection errors), so this only affects the
+// slow-but-alive case. Keep it above the generation request-timeout default.
+const DEFAULT_UNSAFE_RUNTIME_MESSAGE_TIMEOUT_MS = 130_000
+const DEFAULT_UPLOAD_RUNTIME_MESSAGE_TIMEOUT_MS = 130_000
const ABSOLUTE_URL_BLOCK_ERROR =
"Direct stream fallback is allowed only for allowlisted absolute URLs."
const BACKEND_UNREACHABLE_PATTERN =
@@ -62,18 +75,6 @@ const normalizeKnownPathQuirks = (rawPath: P): P => {
const isAudioStudioArtifactMediaPath = (path: string): boolean =>
/\/api\/v1\/audio-studio\/projects\/[^/?#]+\/artifacts\/[^/?#]+\/media(?:[?#]|$)/.test(path)
-const parseHttpOrigin = (value: unknown): string | null => {
- const raw = String(value || "").trim()
- if (!raw) return null
- try {
- const parsed = new URL(raw)
- if (!/^https?:$/i.test(parsed.protocol)) return null
- return parsed.origin.toLowerCase()
- } catch {
- return null
- }
-}
-
const normalizeExpectedStatuses = (statuses: unknown): Set => {
if (!Array.isArray(statuses)) return new Set()
return new Set(
@@ -89,69 +90,10 @@ const normalizeExpectedStatuses = (statuses: unknown): Set => {
)
}
-const toAllowlistEntries = (value: unknown): string[] => {
- if (Array.isArray(value)) {
- return value
- .map((entry) => String(entry || "").trim())
- .filter((entry) => entry.length > 0)
- }
- if (typeof value === "string") {
- const trimmed = value.trim()
- if (!trimmed) return []
- if (!trimmed.includes(",")) return [trimmed]
- return trimmed
- .split(",")
- .map((entry) => entry.trim())
- .filter((entry) => entry.length > 0)
- }
- return []
-}
-
-const configuredServerOrigin = (cfg: Record | null): string | null => {
- return parseHttpOrigin(cfg?.serverUrl)
-}
-
-const absoluteOriginAllowlistFromConfig = (
- cfg: Record | null
-): Set => {
- const out = new Set()
- const serverOrigin = configuredServerOrigin(cfg)
- if (serverOrigin) out.add(serverOrigin)
- for (const entry of toAllowlistEntries(cfg?.absoluteUrlAllowlist)) {
- const parsedOrigin = parseHttpOrigin(entry)
- if (parsedOrigin) out.add(parsedOrigin)
- }
- return out
-}
-
-const isAbsoluteUrlAllowlisted = (
- absoluteUrl: string,
- cfg: Record | null
-): boolean => {
- try {
- const target = new URL(absoluteUrl)
- if (!/^https?:$/i.test(target.protocol)) return false
- const allowlistedOrigins = absoluteOriginAllowlistFromConfig(cfg)
- return allowlistedOrigins.has(target.origin.toLowerCase())
- } catch {
- return false
- }
-}
-
-const isSameOriginAbsoluteUrlForConfiguredServer = (
- absoluteUrl: string,
- cfg: Record | null
-): boolean => {
- const serverOrigin = configuredServerOrigin(cfg)
- if (!serverOrigin) return false
- try {
- const target = new URL(absoluteUrl)
- if (!/^https?:$/i.test(target.protocol)) return false
- return target.origin.toLowerCase() === serverOrigin
- } catch {
- return false
- }
-}
+// Origin-allowlist / same-origin helpers (parseHttpOrigin,
+// isAbsoluteUrlAllowlisted, isSameOriginAbsoluteUrlForConfiguredServer) are
+// imported from the canonical utils/absolute-url-guard module — behaviour is
+// unchanged (this file's copies were byte-for-byte identical to the guard's).
const extractHttpStatus = (value: unknown): number | null => {
const statusCandidate = (value as { status?: unknown } | null)?.status
@@ -354,6 +296,20 @@ const createAbortError = (
return abortError
}
+type StreamInterruptedError = Error & { code?: string; interrupted?: true }
+
+// Surfaced when a non-idempotent streamed request (chat completions,
+// complete-v2) loses its extension port before/around the first token. We must
+// NOT replay it (that would double-generate and persist a duplicate message),
+// so instead we raise a clear error the caller can show as an interruption.
+const createStreamInterruptedError = (message: string): StreamInterruptedError => {
+ const error = new Error(message) as StreamInterruptedError
+ error.name = "StreamInterruptedError"
+ error.code = "STREAM_INTERRUPTED"
+ error.interrupted = true
+ return error
+}
+
const shouldNotifyBackendUnavailable = (entry: {
method: string
path: string
@@ -442,6 +398,82 @@ export interface BgRequestInit<
// the promise settles it is removed, so caching/staleness semantics are unchanged.
const inFlightGetRequests = new Map>()
+type DirectRuntimeStorage = Pick<
+ ReturnType,
+ "get" | "set"
+>
+
+// Module-level single-flight for the web/direct fallback token refresh. Mirrors
+// the extension worker's `refreshInFlight`: concurrent 401s (a common pattern
+// when many components refetch on a page load) trigger exactly ONE refresh
+// instead of a stampede that would each spend and rotate the refresh token,
+// persisting a dead one.
+let webRefreshInFlight: Promise | null = null
+
+const refreshAuthDirect = async (
+ storage: DirectRuntimeStorage
+): Promise => {
+ if (!webRefreshInFlight) {
+ webRefreshInFlight = (async () => {
+ const cfg =
+ ((await storage.get("tldwConfig").catch(() => null)) as
+ | Record
+ | null) || null
+ const refreshToken = String((cfg?.refreshToken as string) || "").trim()
+ // Signal failure (throw) rather than resolving silently: request-core
+ // treats a resolved refreshAuth as success and would retry with the stale
+ // token. Throwing makes it mark the refresh as failed so a still-401 retry
+ // surfaces "Session expired" instead of masking the failure.
+ if (!refreshToken) {
+ throw new Error("Token refresh failed: no refresh token available")
+ }
+ const resp = await tldwRequest(
+ {
+ path: "/api/v1/auth/refresh",
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: { refresh_token: refreshToken },
+ noAuth: true
+ },
+ { getConfig: () => storage.get("tldwConfig").catch(() => null) }
+ )
+ const tokens = (resp?.ok ? resp.data : null) as
+ | { access_token?: string; refresh_token?: string }
+ | null
+ if (!tokens?.access_token) {
+ throw new Error(
+ `Token refresh failed: ${resp?.error || `no access token in refresh response (status ${resp?.status ?? "unknown"})`}`
+ )
+ }
+ const latest =
+ ((await storage.get("tldwConfig").catch(() => null)) as
+ | Record
+ | null) ||
+ cfg ||
+ {}
+ await storage.set("tldwConfig", {
+ ...latest,
+ accessToken: tokens.access_token,
+ refreshToken:
+ tokens.refresh_token ||
+ (latest?.refreshToken as string) ||
+ refreshToken
+ })
+ })().finally(() => {
+ webRefreshInFlight = null
+ })
+ }
+ await webRefreshInFlight
+}
+
+// Runtime for the web/direct fallback. Supplies a working `refreshAuth` so
+// request-core's 401 refresh-and-retry runs in the browser (not just inside the
+// extension worker), and single-flights it across concurrent callers.
+const createDirectRuntime = (storage: DirectRuntimeStorage) => ({
+ getConfig: () => storage.get("tldwConfig").catch(() => null),
+ refreshAuth: () => refreshAuthDirect(storage)
+})
+
export async function bgRequest<
T = any,
P extends PathOrUrl = AllowedPath,
@@ -612,7 +644,7 @@ async function bgRequestImpl<
abortSignal,
responseType
},
- { getConfig: () => storage.get("tldwConfig").catch(() => null) }
+ createDirectRuntime(storage)
)
}
const resolveArrayBufferResponse = async (
@@ -665,7 +697,7 @@ async function bgRequestImpl<
abortSignal,
responseType
},
- { getConfig: () => storage.get("tldwConfig").catch(() => null) }
+ createDirectRuntime(storage)
)
if (!resp?.ok) {
const msg = formatErrorMessage(
@@ -865,7 +897,7 @@ async function bgRequestImpl<
abortSignal,
responseType
},
- { getConfig: () => storage.get("tldwConfig").catch(() => null) }
+ createDirectRuntime(storage)
)
if (!resp?.ok) {
const msg = formatErrorMessage(
@@ -1238,6 +1270,24 @@ export async function* bgStream<
return
}
+ // Derive the connection (time-to-first-token) timeout from config instead of a
+ // hard-coded 5s. Time-to-first-byte over 5s is normal for large prompts, RAG,
+ // or a cold local model, and a premature disconnect used to replay the whole
+ // request. Reuse the stream idle-timeout budget (chat default 45s).
+ const streamStorage = createSafeStorage()
+ const streamCfg =
+ (await streamStorage
+ .get>("tldwConfig")
+ .catch(() => null)) || null
+ const connectionTimeoutMs = deriveStreamIdleTimeout(
+ streamCfg,
+ String(path),
+ Number(streamIdleTimeoutMs)
+ )
+ // Only idempotent (GET/HEAD/OPTIONS) streams may be replayed via direct fetch
+ // after a transport loss. Non-idempotent generation POSTs must not be re-sent.
+ const methodAllowsStreamReplay = isSafeFallbackMethod(method)
+
// Extension port-based streaming with connection-time and connection-establish fallback.
let port: ReturnType
try {
@@ -1255,15 +1305,15 @@ export async function* bgStream<
let firstDataReceived = false
let connectionTimedOut = false
- // Connection timeout - if no data arrives within 5s, fall back to direct fetch
- const CONNECTION_TIMEOUT_MS = 5000
+ // Connection timeout - if no first token arrives within the derived window,
+ // give up on the port. Whether we may then replay depends on idempotency.
const connectionTimer = setTimeout(() => {
if (!firstDataReceived && !done) {
connectionTimedOut = true
done = true
try { port.disconnect() } catch {}
}
- }, CONNECTION_TIMEOUT_MS)
+ }, connectionTimeoutMs)
const onMessage = (msg: any) => {
if (msg?.event === 'data') {
@@ -1340,10 +1390,18 @@ export async function* bgStream<
sliceStartedAt = Date.now()
}
}
- // If connection timed out before receiving any data, fall back to direct fetch
+ // If connection timed out before receiving any data, only idempotent
+ // requests may be replayed via direct fetch. The worker may already be
+ // generating server-side for a non-idempotent POST, so replaying it would
+ // double-generate and persist a duplicate message — surface a timeout error.
if (connectionTimedOut) {
- yield* bgStreamDirect({ path, method, headers, body, streamIdleTimeoutMs, abortSignal })
- return
+ if (methodAllowsStreamReplay) {
+ yield* bgStreamDirect({ path, method, headers, body, streamIdleTimeoutMs, abortSignal })
+ return
+ }
+ throw createStreamInterruptedError(
+ `Stream connection timed out after ${connectionTimeoutMs}ms without a first token`
+ )
}
const shouldFallbackAfterEarlyError =
!firstDataReceived &&
@@ -1351,8 +1409,17 @@ export async function* bgStream<
Boolean(error) &&
(isExtensionTransportFailure(error) || !hasHttpStatus(error))
if (shouldFallbackAfterEarlyError) {
- yield* bgStreamDirect({ path, method, headers, body, streamIdleTimeoutMs, abortSignal })
- return
+ // Same rule for an early transport failure: replay idempotent requests
+ // only; never re-send a non-idempotent generation POST.
+ if (methodAllowsStreamReplay) {
+ yield* bgStreamDirect({ path, method, headers, body, streamIdleTimeoutMs, abortSignal })
+ return
+ }
+ throw createStreamInterruptedError(
+ error instanceof Error
+ ? error.message
+ : String(error || "Stream transport interrupted")
+ )
}
const shouldGracefullyEndAfterPartialStreamError =
firstDataReceived &&
@@ -1425,8 +1492,14 @@ export async function bgUpload 0 ? timeoutMs : 60000
+ // Add timeout to extension messaging for uploads. The worker only acks
+ // after the upload (and any synchronous processing kickoff) completes, so
+ // a short cap would abort large-but-progressing uploads the worker is
+ // still finishing.
+ const resolvedTimeout =
+ typeof timeoutMs === "number" && timeoutMs > 0
+ ? timeoutMs
+ : DEFAULT_UPLOAD_RUNTIME_MESSAGE_TIMEOUT_MS
const uploadTimeout = Math.max(5000, resolvedTimeout)
const uploadPromise = browser.runtime.sendMessage({
type: 'tldw:upload',
@@ -1526,7 +1599,7 @@ export async function bgUpload storage.get("tldwConfig").catch(() => null) }
+ createDirectRuntime(storage)
)
if (!resp?.ok) {
const msg = formatErrorMessage(
diff --git a/apps/packages/ui/src/services/persona-stream.ts b/apps/packages/ui/src/services/persona-stream.ts
index 6e9dfec55c..21f29cb473 100644
--- a/apps/packages/ui/src/services/persona-stream.ts
+++ b/apps/packages/ui/src/services/persona-stream.ts
@@ -1,30 +1,85 @@
import type { TldwConfig } from "@/services/tldw/TldwApiClient"
import { resolveBrowserWebSocketBase } from "@/services/tldw/browser-websocket"
+export type PersonaWebSocketConnection = {
+ url: string
+ /**
+ * Values passed as the second `new WebSocket(url, protocols)` argument. The
+ * browser sends these as the `Sec-WebSocket-Protocol` request header.
+ */
+ protocols: string[]
+}
+
+/**
+ * Build the persona-stream WebSocket URL plus the auth subprotocols.
+ *
+ * TASK-12106: the auth credential is NO LONGER placed in the URL query string
+ * (it would leak into server access logs, proxy logs, and browser history).
+ * Instead it is carried in the WebSocket subprotocol as `["bearer", ]`,
+ * which the backend parses from `Sec-WebSocket-Protocol`
+ * (persona.py:3705-3712: splits on ",", requires parts[0]=="bearer" and uses
+ * parts[1] as the token; only consulted when no Authorization header is set).
+ * `_should_treat_bearer_as_api_key` (persona.py:3663-3679) then maps a
+ * single-user / non-JWT bearer onto the API-key path server-side.
+ *
+ * NEEDS LIVE-SERVER VALIDATION before merge:
+ * - The server does not echo the offered subprotocol; confirm the browser
+ * still completes the handshake against a running backend.
+ *
+ * Subprotocol values must be RFC 6455 tokens. Default tldw keys (secrets
+ * token_urlsafe / token_hex) and JWTs are token-safe, but a user-set custom API
+ * key containing separators (space, `,`, `/`, `=` ...) would make
+ * `new WebSocket(url, ["bearer", key])` throw. For that case we fall back to the
+ * legacy query-string token (so persona voice keeps working) rather than crash.
+ */
+
+// RFC 6455 token charset (valid WebSocket subprotocol characters).
+const WS_SUBPROTOCOL_TOKEN_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
+
+const isSubprotocolSafe = (value: string): boolean =>
+ value.length > 0 && WS_SUBPROTOCOL_TOKEN_RE.test(value)
+
export const buildPersonaWebSocketUrl = (
config: Pick
-): string => {
+): PersonaWebSocketConnection => {
const serverUrl = String(config.serverUrl || "").trim()
if (!serverUrl) {
throw new Error("tldw server is not configured")
}
const base = resolveBrowserWebSocketBase(serverUrl)
- const params = new URLSearchParams()
+ let credential: string
if (config.authMode === "multi-user") {
- const token = String(config.accessToken || "").trim()
- if (!token) {
+ credential = String(config.accessToken || "").trim()
+ if (!credential) {
throw new Error("Not authenticated. Please log in under Settings.")
}
- params.set("token", token)
} else {
- const apiKey = String(config.apiKey || "").trim()
- if (!apiKey) {
+ credential = String(config.apiKey || "").trim()
+ if (!credential) {
throw new Error("API key missing. Update Settings -> tldw server.")
}
- params.set("api_key", apiKey)
}
- return `${base}/api/v1/persona/stream?${params.toString()}`
+ if (isSubprotocolSafe(credential)) {
+ return {
+ url: `${base}/api/v1/persona/stream`,
+ protocols: ["bearer", credential]
+ }
+ }
+
+ // Credential can't be sent as a WebSocket subprotocol without throwing; keep
+ // persona voice working via the legacy query-string token for this case.
+ if (typeof console !== "undefined") {
+ console.warn(
+ "[persona-stream] API key is not WebSocket-subprotocol-safe; falling back to query-string auth (the credential will appear in the connection URL). Use a token-safe API key to keep it out of the URL."
+ )
+ }
+ const params = new URLSearchParams()
+ params.set(config.authMode === "multi-user" ? "token" : "api_key", credential)
+ return {
+ url: `${base}/api/v1/persona/stream?${params.toString()}`,
+ protocols: []
+ }
}
diff --git a/apps/packages/ui/src/services/tldw/TldwApiClient.ts b/apps/packages/ui/src/services/tldw/TldwApiClient.ts
index e94aea8a78..7b45a42e05 100644
--- a/apps/packages/ui/src/services/tldw/TldwApiClient.ts
+++ b/apps/packages/ui/src/services/tldw/TldwApiClient.ts
@@ -130,9 +130,10 @@ const DEFAULT_SERVER_URL = "http://127.0.0.1:8000"
const CHARACTER_CACHE_TTL_MS = 5 * 60 * 1000
const CHAT_MESSAGES_CACHE_TTL_MS = 60 * 1000
const RAG_QUERY_MAX_LENGTH = 20000
-const CHAT_COMPLETION_ERROR_MESSAGE = "Chat completion failed."
-const CHAT_COMPLETION_ERRORS_MESSAGE =
- "One or more internal errors were suppressed."
+// Speech synthesis can take much longer than the 10s default request timeout
+// (e.g. long passages on local Kokoro), so give it a generous default that
+// callers can still override.
+const TTS_REQUEST_TIMEOUT_MS = 120000
const toRecordOrNull = (value: unknown): Record | null =>
value && typeof value === "object" && !Array.isArray(value)
@@ -153,69 +154,6 @@ const isSavedDegradedCharacterPersistError = (error: unknown): boolean => {
return detail?.code === "persist_validation_degraded" && detail?.saved === true
}
-const isSuspiciousChatCompletionString = (value: string): boolean =>
- /traceback|stack(?:\s*trace)?|exception|error|\/Users\/|[A-Za-z]:\\|\.py:\d+/i.test(
- value
- )
-
-const normalizeChatCompletionResponseBody = (
- value: unknown
-): Record | unknown[] => {
- if (typeof value === "string") {
- if (isSuspiciousChatCompletionString(value)) {
- return {
- error: CHAT_COMPLETION_ERROR_MESSAGE,
- errors: [CHAT_COMPLETION_ERRORS_MESSAGE]
- }
- }
- return { content: value }
- }
- const sanitized = sanitizeChatCompletionPayload(value)
- if (Array.isArray(sanitized)) {
- return sanitized
- }
- if (sanitized && typeof sanitized === "object") {
- return sanitized as Record
- }
- return { content: sanitized ?? "" }
-}
-
-const sanitizeChatCompletionPayload = (value: unknown): unknown => {
- if (typeof value === "string") {
- return isSuspiciousChatCompletionString(value)
- ? CHAT_COMPLETION_ERROR_MESSAGE
- : value
- }
- if (Array.isArray(value)) {
- return value.map((item) => sanitizeChatCompletionPayload(item))
- }
- if (value && typeof value === "object") {
- const sanitized: Record = {}
- for (const [key, item] of Object.entries(value)) {
- if (
- key === "details" ||
- key === "exception" ||
- key === "traceback" ||
- key === "stack" ||
- key === "stack_trace"
- ) {
- continue
- }
- if (key === "error" && item) {
- sanitized[key] = CHAT_COMPLETION_ERROR_MESSAGE
- continue
- }
- if (key === "errors" && item) {
- sanitized[key] = [CHAT_COMPLETION_ERRORS_MESSAGE]
- continue
- }
- sanitized[key] = sanitizeChatCompletionPayload(item)
- }
- return sanitized
- }
- return value
-}
-
const toOptionalNumber = (value: unknown): number | null => {
if (typeof value === "number" && Number.isFinite(value)) {
return value
@@ -2670,11 +2608,14 @@ export class TldwApiClientBase {
timeoutMs: options?.timeoutMs,
abortSignal: options?.signal
})
- // bgRequest returns parsed data; for non-streaming chat we expect a JSON structure or text. To keep existing consumers happy, wrap as Response-like
- // For simplicity, return a minimal object with json() and text()
+ // bgRequest throws on any non-2xx response, so a resolved value here is
+ // always a successful completion. Return the parsed body unmodified — the
+ // streaming path does not scrub content, and scrubbing successful replies
+ // that merely mention "error"/"exception" or include a file path was
+ // silently corrupting legitimate assistant content. Wrap as Response-like
+ // to keep existing consumers happy.
const data = res as any
- const safeData = normalizeChatCompletionResponseBody(data)
- return createJsonResponseLike(safeData, { status: 200 })
+ return createJsonResponseLike(data, { status: 200 })
}
async *streamChatCompletion(request: ChatCompletionRequest, options?: ChatCompletionStreamOptions): AsyncGenerator {
@@ -6505,9 +6446,10 @@ export class TldwApiClientBase {
extraParams?: Record
stream?: boolean
signal?: AbortSignal
+ timeoutMs?: number
}
): Promise {
- await this.ensureConfigForRequest(true)
+ const cfg = await this.ensureConfigForRequest(true)
const body: Record = { input: text, text }
if (options?.voice) body.voice = options.voice
if (options?.model) body.model = options.model
@@ -6542,12 +6484,19 @@ export class TldwApiClientBase {
return "audio/mpeg"
}
})()
+ const cfgTtsTimeout = Number((cfg as any)?.ttsRequestTimeoutMs)
+ const timeoutMs =
+ options?.timeoutMs ??
+ (Number.isFinite(cfgTtsTimeout) && cfgTtsTimeout > 0
+ ? cfgTtsTimeout
+ : TTS_REQUEST_TIMEOUT_MS)
const data = await this.request({
path: "/api/v1/audio/speech",
method: "POST",
headers: { Accept: accept },
body,
responseType: "arrayBuffer",
+ timeoutMs,
abortSignal: options?.signal
})
@@ -8009,6 +7958,19 @@ Object.assign(
webClipperMethods
)
+// createChatCompletion and synthesizeSpeech are implemented on
+// TldwApiClientBase and are intentionally excluded from the domain interface
+// via TldwDomainMethodOverride, so the base-class versions are the canonical
+// (type-visible) ones. The legacy duplicates in the chat-rag / models-audio
+// mixins were overwriting them at runtime, which (a) re-introduced the
+// non-streaming sanitizer that corrupts successful assistant replies and
+// (b) dropped the generous TTS timeout. Re-apply the base implementations so
+// runtime matches the declared types and the fixes take effect.
+Object.assign(TldwApiClient.prototype, {
+ createChatCompletion: TldwApiClientBase.prototype.createChatCompletion,
+ synthesizeSpeech: TldwApiClientBase.prototype.synthesizeSpeech
+})
+
// Also expose core helpers that domain files reference via `this`
export type TldwApiClientCore = TldwApiClient
diff --git a/apps/packages/ui/src/services/tldw/TldwAuth.ts b/apps/packages/ui/src/services/tldw/TldwAuth.ts
index 1546494229..f523e09534 100644
--- a/apps/packages/ui/src/services/tldw/TldwAuth.ts
+++ b/apps/packages/ui/src/services/tldw/TldwAuth.ts
@@ -45,6 +45,7 @@ const buildApiKeyValidationUrl = (serverUrl: string): string => {
export class TldwAuthService {
private refreshTimer: NodeJS.Timeout | null = null
+ private refreshInFlight: Promise | null = null
constructor() {
}
@@ -226,9 +227,23 @@ export class TldwAuthService {
}
/**
- * Refresh access token using refresh token
+ * Refresh access token using refresh token.
+ *
+ * Single-flighted: concurrent callers (e.g. the pre-expiry timer racing a 401
+ * refresh) share one in-flight request so the backend's rotating refresh
+ * token is not spent twice, which would persist a dead token.
*/
async refreshToken(): Promise {
+ if (this.refreshInFlight) {
+ return this.refreshInFlight
+ }
+ this.refreshInFlight = this.performTokenRefresh().finally(() => {
+ this.refreshInFlight = null
+ })
+ return this.refreshInFlight
+ }
+
+ private async performTokenRefresh(): Promise {
const config = await tldwClient.getConfig()
if (!config || !config.refreshToken) {
throw new Error('No refresh token available')
@@ -260,6 +275,30 @@ export class TldwAuthService {
return tokens
}
+ /**
+ * (Re-)arm the pre-expiry refresh timer after a page load.
+ *
+ * The timer set during login/verify/refresh is discarded on reload, so a
+ * multi-user user who reloads would otherwise never auto-refresh and every
+ * request would 401 once the access token expired. Safe to call repeatedly:
+ * it is a no-op in hosted mode, when a timer is already armed, or when no
+ * refresh token is present. When a refresh token exists but no timer is
+ * scheduled it performs a single refresh, which rotates the access token and
+ * re-arms the timer via setupTokenRefresh.
+ */
+ async initTokenRefresh(): Promise {
+ if (this.isHostedMode()) return
+ if (this.refreshTimer) return
+ const config = await tldwClient.getConfig()
+ if (!config || config.authMode !== 'multi-user') return
+ if (!config.refreshToken) return
+ try {
+ await this.refreshToken()
+ } catch (error) {
+ console.error('Token refresh on init failed:', error)
+ }
+ }
+
/**
* Get current user information
*/
diff --git a/apps/packages/ui/src/services/tldw/TldwChat.ts b/apps/packages/ui/src/services/tldw/TldwChat.ts
index 9d6e35e0fe..2fec326329 100644
--- a/apps/packages/ui/src/services/tldw/TldwChat.ts
+++ b/apps/packages/ui/src/services/tldw/TldwChat.ts
@@ -279,6 +279,9 @@ export interface TldwChatOptions {
jsonMode?: boolean
researchContext?: ChatResearchContext
chatDebugMetadata?: ChatRequestDebugMetadata
+ // Caller-owned AbortSignal (e.g. the UI Stop signal). When it fires, the
+ // internal per-call controller is aborted so the underlying request stops.
+ signal?: AbortSignal
}
export { getLastChatCompletionDebugSnapshot }
export type { ChatCompletionDebugSnapshot }
@@ -321,7 +324,11 @@ export interface ChatStreamChunk {
}
export class TldwChatService {
- private currentController: AbortController | null = null
+ // Per-call controllers are tracked here so that `cancelStream()` can act as a
+ // global "stop everything" without any single call clobbering another. Each
+ // `streamMessage` invocation owns its own controller (see below); we never
+ // share one controller across concurrent streams (Compare mode, double-send).
+ private activeControllers: Set = new Set()
/**
* Send a chat completion request
@@ -421,6 +428,26 @@ export class TldwChatService {
options: TldwChatOptions,
onChunk?: (chunk: ChatStreamChunk) => void
): AsyncGenerator {
+ // Per-call abort controller: concurrent streams must not cancel each other.
+ const controller = new AbortController()
+ this.activeControllers.add(controller)
+
+ // Combine the caller's signal (e.g. the UI Stop signal) with this call's
+ // controller so a Stop actually aborts the underlying request/transport,
+ // while internal timeouts abort only this call (not the caller's signal).
+ const callerSignal = options.signal
+ let detachCallerSignal: (() => void) | null = null
+ if (callerSignal) {
+ if (callerSignal.aborted) {
+ controller.abort()
+ } else {
+ const onCallerAbort = () => controller.abort()
+ callerSignal.addEventListener("abort", onCallerAbort, { once: true })
+ detachCallerSignal = () =>
+ callerSignal.removeEventListener("abort", onCallerAbort)
+ }
+ }
+
try {
await tldwClient.initialize()
const normalizedTools =
@@ -439,11 +466,6 @@ export class TldwChatService {
)
}
- // Cancel any existing stream
- this.cancelStream()
-
- // Create new abort controller
- this.currentController = new AbortController()
const cfg = (await tldwClient.getConfig().catch(() => null)) as
| {
chatRequestTimeoutMs?: number
@@ -507,14 +529,13 @@ export class TldwChatService {
30_000
)
const stream = tldwClient.streamChatCompletion(request, {
- signal: this.currentController.signal,
+ signal: controller.signal,
streamIdleTimeoutMs,
debugMetadata: options.chatDebugMetadata
})
let idleTimer: ReturnType | null = null
let startupTimer: ReturnType | null = null
- const controller = this.currentController
let sawVisibleProgress = false
let timeoutReason: "startup" | "idle" | null = null
@@ -610,18 +631,23 @@ export class TldwChatService {
}
throw new Error('Stream completion failed', { cause: error })
} finally {
- this.currentController = null
+ this.activeControllers.delete(controller)
+ detachCallerSignal?.()
}
}
/**
- * Cancel the current streaming request
+ * Cancel all in-flight streaming requests.
+ *
+ * This is a global "stop everything" used by external callers. New streams no
+ * longer auto-invoke this, so starting a stream never cancels other in-flight
+ * streams (Compare mode, double-send, regenerate-while-streaming).
*/
cancelStream(): void {
- if (this.currentController) {
- this.currentController.abort()
- this.currentController = null
+ for (const controller of this.activeControllers) {
+ controller.abort()
}
+ this.activeControllers.clear()
}
/**
diff --git a/apps/packages/ui/src/services/tldw/__tests__/TldwApiClient.sanitizer.test.ts b/apps/packages/ui/src/services/tldw/__tests__/TldwApiClient.sanitizer.test.ts
new file mode 100644
index 0000000000..74d15dbf0e
--- /dev/null
+++ b/apps/packages/ui/src/services/tldw/__tests__/TldwApiClient.sanitizer.test.ts
@@ -0,0 +1,132 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const mocks = vi.hoisted(() => ({
+ bgRequest: vi.fn()
+}))
+
+vi.mock("@/services/background-proxy", () => ({
+ bgRequest: (...args: unknown[]) => mocks.bgRequest(...args),
+ bgUpload: vi.fn(),
+ bgStream: vi.fn()
+}))
+
+vi.mock("@/utils/safe-storage", () => ({
+ createSafeStorage: () => ({
+ get: vi.fn(async () => null),
+ set: vi.fn(async () => undefined),
+ remove: vi.fn(async () => undefined)
+ }),
+ safeStorageSerde: {
+ serialize: (value: unknown) => value,
+ deserialize: (value: unknown) => value
+ }
+}))
+
+import { TldwApiClient } from "@/services/tldw/TldwApiClient"
+
+describe("TldwApiClient.createChatCompletion (non-streaming sanitizer)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("returns successful completion content verbatim even when it looks like an error", async () => {
+ const content =
+ "To handle this exception, wrap it in try/catch — see /Users/foo/bar.py:12"
+ const completion = {
+ id: "chatcmpl-1",
+ object: "chat.completion",
+ choices: [
+ {
+ index: 0,
+ message: { role: "assistant", content },
+ finish_reason: "stop"
+ }
+ ]
+ }
+ // bgRequest throws on non-2xx, so a resolved value is always a success body.
+ mocks.bgRequest.mockResolvedValueOnce(completion)
+
+ const client = new TldwApiClient()
+ const res = await client.createChatCompletion({
+ model: "gpt-test",
+ messages: [{ role: "user", content: "How do I handle errors?" }]
+ } as any)
+
+ const body = await res.json()
+ expect(body.choices[0].message.content).toBe(content)
+ // The suspicious substrings must survive untouched.
+ expect(body.choices[0].message.content).toContain("exception")
+ expect(body.choices[0].message.content).toContain("/Users/foo/bar.py:12")
+ expect(JSON.stringify(body)).not.toContain("Chat completion failed.")
+ })
+
+ it("preserves error-shaped keys inside successful assistant content", async () => {
+ const completion = {
+ choices: [
+ {
+ index: 0,
+ message: {
+ role: "assistant",
+ content: "Here is a stack trace and a traceback for you."
+ },
+ finish_reason: "stop"
+ }
+ ]
+ }
+ mocks.bgRequest.mockResolvedValueOnce(completion)
+
+ const client = new TldwApiClient()
+ const res = await client.createChatCompletion({
+ model: "gpt-test",
+ messages: [{ role: "user", content: "show me a trace" }]
+ } as any)
+
+ const body = await res.json()
+ expect(body.choices[0].message.content).toBe(
+ "Here is a stack trace and a traceback for you."
+ )
+ })
+})
+
+describe("TldwApiClient.synthesizeSpeech (timeout)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const configureClient = (client: TldwApiClient) => {
+ ;(client as any).config = {
+ serverUrl: "http://127.0.0.1:8000",
+ apiKey: "test-api-key-123",
+ authMode: "single-user"
+ }
+ }
+
+ const findSpeechCall = () =>
+ mocks.bgRequest.mock.calls
+ .map((call) => call[0] as any)
+ .find((init) => init?.path === "/api/v1/audio/speech")
+
+ it("uses a generous default timeout so long synthesis is not aborted", async () => {
+ mocks.bgRequest.mockResolvedValue(new ArrayBuffer(8))
+
+ const client = new TldwApiClient()
+ configureClient(client)
+ await client.synthesizeSpeech("Some long passage to render.")
+
+ const speechCall = findSpeechCall()
+ expect(speechCall).toBeTruthy()
+ expect(speechCall.timeoutMs).toBeGreaterThanOrEqual(120000)
+ })
+
+ it("lets callers override the timeout", async () => {
+ mocks.bgRequest.mockResolvedValue(new ArrayBuffer(8))
+
+ const client = new TldwApiClient()
+ configureClient(client)
+ await client.synthesizeSpeech("hi", { timeoutMs: 5000 } as any)
+
+ const speechCall = findSpeechCall()
+ expect(speechCall).toBeTruthy()
+ expect(speechCall.timeoutMs).toBe(5000)
+ })
+})
diff --git a/apps/packages/ui/src/services/tldw/__tests__/TldwAuth.refresh.test.ts b/apps/packages/ui/src/services/tldw/__tests__/TldwAuth.refresh.test.ts
new file mode 100644
index 0000000000..84de7d7102
--- /dev/null
+++ b/apps/packages/ui/src/services/tldw/__tests__/TldwAuth.refresh.test.ts
@@ -0,0 +1,123 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+const mocks = vi.hoisted(() => ({
+ bgRequest: vi.fn(),
+ emitSplashAfterLoginSuccess: vi.fn(),
+ getConfig: vi.fn(),
+ updateConfig: vi.fn(),
+ getCurrentUserProfile: vi.fn()
+}))
+
+vi.mock("@/services/background-proxy", () => ({
+ bgRequest: (...args: unknown[]) => mocks.bgRequest(...args)
+}))
+
+vi.mock("@/services/splash-events", () => ({
+ emitSplashAfterLoginSuccess: (...args: unknown[]) =>
+ mocks.emitSplashAfterLoginSuccess(...args)
+}))
+
+vi.mock("@/services/tldw/TldwApiClient", () => ({
+ tldwClient: {
+ getConfig: (...args: unknown[]) => mocks.getConfig(...args),
+ updateConfig: (...args: unknown[]) => mocks.updateConfig(...args),
+ getCurrentUserProfile: (...args: unknown[]) =>
+ mocks.getCurrentUserProfile(...args)
+ }
+}))
+
+const originalDeploymentMode = process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE
+
+describe("TldwAuthService token refresh single-flight", () => {
+ beforeEach(() => {
+ vi.resetModules()
+ delete process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE
+ mocks.bgRequest.mockReset()
+ mocks.getConfig.mockReset()
+ mocks.updateConfig.mockReset()
+ mocks.updateConfig.mockResolvedValue(undefined)
+ mocks.getConfig.mockResolvedValue({
+ authMode: "multi-user",
+ refreshToken: "refresh-token"
+ })
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ if (originalDeploymentMode === undefined) {
+ delete process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE
+ } else {
+ process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE = originalDeploymentMode
+ }
+ })
+
+ it("coalesces concurrent refreshToken() calls into one refresh request", async () => {
+ mocks.bgRequest.mockResolvedValue({
+ access_token: "fresh-access",
+ refresh_token: "rotated-refresh",
+ token_type: "bearer"
+ })
+
+ const { TldwAuthService } = await import("@/services/tldw/TldwAuth")
+ const auth = new TldwAuthService()
+
+ const [a, b] = await Promise.all([auth.refreshToken(), auth.refreshToken()])
+
+ expect(a).toBe(b)
+ const refreshCalls = mocks.bgRequest.mock.calls.filter(
+ (call) => (call[0] as { path?: string })?.path === "/api/v1/auth/refresh"
+ )
+ expect(refreshCalls).toHaveLength(1)
+ })
+
+ it("allows a fresh refresh once the previous one settles", async () => {
+ mocks.bgRequest.mockResolvedValue({
+ access_token: "fresh-access",
+ token_type: "bearer"
+ })
+
+ const { TldwAuthService } = await import("@/services/tldw/TldwAuth")
+ const auth = new TldwAuthService()
+
+ await auth.refreshToken()
+ await auth.refreshToken()
+
+ const refreshCalls = mocks.bgRequest.mock.calls.filter(
+ (call) => (call[0] as { path?: string })?.path === "/api/v1/auth/refresh"
+ )
+ expect(refreshCalls).toHaveLength(2)
+ })
+
+ it("initTokenRefresh arms the refresh timer when a valid refresh token is present", async () => {
+ vi.useFakeTimers()
+ mocks.bgRequest.mockResolvedValue({
+ access_token: "fresh-access",
+ refresh_token: "rotated-refresh",
+ token_type: "bearer",
+ expires_in: 1800
+ })
+
+ const { TldwAuthService } = await import("@/services/tldw/TldwAuth")
+ const auth = new TldwAuthService()
+
+ await auth.initTokenRefresh()
+ // A second init is a no-op because a timer is already armed.
+ await auth.initTokenRefresh()
+
+ const refreshCalls = mocks.bgRequest.mock.calls.filter(
+ (call) => (call[0] as { path?: string })?.path === "/api/v1/auth/refresh"
+ )
+ expect(refreshCalls).toHaveLength(1)
+ })
+
+ it("initTokenRefresh is a no-op without a refresh token", async () => {
+ mocks.getConfig.mockResolvedValue({ authMode: "multi-user" })
+
+ const { TldwAuthService } = await import("@/services/tldw/TldwAuth")
+ const auth = new TldwAuthService()
+
+ await auth.initTokenRefresh()
+
+ expect(mocks.bgRequest).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/packages/ui/src/services/tldw/__tests__/TldwChat.abort.test.ts b/apps/packages/ui/src/services/tldw/__tests__/TldwChat.abort.test.ts
new file mode 100644
index 0000000000..91cae725f1
--- /dev/null
+++ b/apps/packages/ui/src/services/tldw/__tests__/TldwChat.abort.test.ts
@@ -0,0 +1,124 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const mocks = vi.hoisted(() => ({
+ initialize: vi.fn(async () => {}),
+ getConfig: vi.fn(async () => null),
+ streamChatCompletion: vi.fn()
+}))
+
+vi.mock("../TldwApiClient", () => ({
+ tldwClient: {
+ initialize: (...args: unknown[]) => mocks.initialize(...args),
+ getConfig: (...args: unknown[]) => mocks.getConfig(...args),
+ streamChatCompletion: (...args: unknown[]) =>
+ mocks.streamChatCompletion(...args)
+ }
+}))
+
+import { TldwChatService } from "../TldwChat"
+
+const chunk = (content: string) => ({ choices: [{ delta: { content } }] })
+
+describe("TldwChatService abort lifecycle", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.initialize.mockResolvedValue(undefined)
+ mocks.getConfig.mockResolvedValue(null)
+ })
+
+ it("gives each streamMessage call its own controller so concurrent streams do not cancel each other", async () => {
+ const receivedSignals: AbortSignal[] = []
+ mocks.streamChatCompletion.mockImplementation(
+ async function* (_req: unknown, opts: { signal: AbortSignal }) {
+ receivedSignals.push(opts.signal)
+ yield chunk("a")
+ yield chunk("b")
+ }
+ )
+
+ const service = new TldwChatService()
+ const genA = service.streamMessage(
+ [{ role: "user", content: "A" }],
+ { model: "m", stream: true }
+ )
+ const genB = service.streamMessage(
+ [{ role: "user", content: "B" }],
+ { model: "m", stream: true }
+ )
+
+ // Enter both generator bodies so each registers its own controller.
+ await genA.next()
+ await genB.next()
+
+ expect(receivedSignals).toHaveLength(2)
+ expect(receivedSignals[0]).not.toBe(receivedSignals[1])
+ // Starting B must not abort A (the old code called `this.cancelStream()`).
+ expect(receivedSignals[0].aborted).toBe(false)
+ expect(receivedSignals[1].aborted).toBe(false)
+
+ // Drain both so their finally blocks run (clears internal timers).
+ await genA.next()
+ await genA.next()
+ await genB.next()
+ await genB.next()
+ })
+
+ it("aborts the internal request when the caller's signal fires", async () => {
+ let capturedSignal: AbortSignal | undefined
+ mocks.streamChatCompletion.mockImplementation(
+ async function* (_req: unknown, opts: { signal: AbortSignal }) {
+ capturedSignal = opts.signal
+ yield chunk("x")
+ yield chunk("y")
+ }
+ )
+
+ const service = new TldwChatService()
+ const caller = new AbortController()
+ const gen = service.streamMessage(
+ [{ role: "user", content: "hi" }],
+ { model: "m", stream: true, signal: caller.signal }
+ )
+
+ const first = await gen.next()
+ expect(first.value).toBe("x")
+ expect(capturedSignal?.aborted).toBe(false)
+
+ caller.abort()
+ // The caller's signal is threaded into this call's internal controller.
+ expect(capturedSignal?.aborted).toBe(true)
+
+ await expect(gen.next()).rejects.toThrow(/abort|cancel/i)
+ })
+
+ it("cancelStream aborts every in-flight stream (global stop everything)", async () => {
+ const receivedSignals: AbortSignal[] = []
+ mocks.streamChatCompletion.mockImplementation(
+ async function* (_req: unknown, opts: { signal: AbortSignal }) {
+ receivedSignals.push(opts.signal)
+ yield chunk("a")
+ yield chunk("b")
+ }
+ )
+
+ const service = new TldwChatService()
+ const genA = service.streamMessage(
+ [{ role: "user", content: "A" }],
+ { model: "m", stream: true }
+ )
+ const genB = service.streamMessage(
+ [{ role: "user", content: "B" }],
+ { model: "m", stream: true }
+ )
+ await genA.next()
+ await genB.next()
+
+ service.cancelStream()
+
+ expect(receivedSignals[0].aborted).toBe(true)
+ expect(receivedSignals[1].aborted).toBe(true)
+
+ await expect(genA.next()).rejects.toThrow(/abort|cancel/i)
+ await expect(genB.next()).rejects.toThrow(/abort|cancel/i)
+ })
+})
diff --git a/apps/packages/ui/src/services/tldw/__tests__/request-core.refresh-timeout.test.ts b/apps/packages/ui/src/services/tldw/__tests__/request-core.refresh-timeout.test.ts
new file mode 100644
index 0000000000..f140e4a67b
--- /dev/null
+++ b/apps/packages/ui/src/services/tldw/__tests__/request-core.refresh-timeout.test.ts
@@ -0,0 +1,159 @@
+import { afterEach, describe, expect, it, vi } from "vitest"
+import { deriveRequestTimeout, tldwRequest } from "@/services/tldw/request-core"
+
+const jsonResponse = (data: unknown, status = 200): Response =>
+ new Response(JSON.stringify(data), {
+ status,
+ headers: { "content-type": "application/json" }
+ })
+
+describe("deriveRequestTimeout generation defaults", () => {
+ it("defaults chat completions to a generation-appropriate timeout (not 10s)", () => {
+ const timeout = deriveRequestTimeout(null, "/api/v1/chat/completions")
+ expect(timeout).toBeGreaterThanOrEqual(120000)
+ })
+
+ it("defaults rag endpoints to a generation-appropriate timeout (not 10s)", () => {
+ const timeout = deriveRequestTimeout(null, "/api/v1/rag/search")
+ expect(timeout).toBeGreaterThanOrEqual(120000)
+ })
+
+ it("still honors an explicit chatRequestTimeoutMs override", () => {
+ const timeout = deriveRequestTimeout(
+ { chatRequestTimeoutMs: 20000 },
+ "/api/v1/chat/completions"
+ )
+ expect(timeout).toBe(20000)
+ })
+})
+
+describe("tldwRequest post-refresh retry", () => {
+ it("reuses the binary body (FormData) on the post-refresh retry instead of JSON.stringify", async () => {
+ const bodies: unknown[] = []
+ let refreshCalls = 0
+ const fetchFn = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
+ bodies.push(init?.body)
+ if (bodies.length === 1) {
+ return new Response("unauthorized", { status: 401 })
+ }
+ return jsonResponse({ ok: true })
+ }) as unknown as typeof fetch
+
+ const runtime = {
+ getConfig: async () => ({
+ serverUrl: "https://api.example.com",
+ authMode: "multi-user",
+ accessToken: refreshCalls > 0 ? "fresh-access" : "stale-access",
+ refreshToken: "refresh-token"
+ }),
+ refreshAuth: async () => {
+ refreshCalls += 1
+ },
+ fetchFn
+ }
+
+ const form = new FormData()
+ form.append("title", "example")
+
+ const resp = await tldwRequest(
+ {
+ path: "https://api.example.com/api/v1/media/add",
+ method: "POST",
+ body: form
+ },
+ runtime
+ )
+
+ expect(resp.ok).toBe(true)
+ expect(refreshCalls).toBe(1)
+ expect(bodies).toHaveLength(2)
+ // The retried request must send the SAME FormData instance, not "{}".
+ expect(bodies[0]).toBe(form)
+ expect(bodies[1]).toBe(form)
+ })
+})
+
+describe("tldwRequest timeout bounds", () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it("does not abort a >10s non-stream chat completion (uses the generation default)", async () => {
+ vi.useFakeTimers()
+ const fetchFn = vi.fn((_url: RequestInfo | URL, init?: RequestInit) => {
+ return new Promise((resolve, reject) => {
+ const signal = init?.signal
+ const timer = setTimeout(() => resolve(jsonResponse({ ok: true })), 15000)
+ signal?.addEventListener("abort", () => {
+ clearTimeout(timer)
+ const abortError = new Error("aborted")
+ abortError.name = "AbortError"
+ reject(abortError)
+ })
+ })
+ }) as unknown as typeof fetch
+
+ const runtime = {
+ getConfig: async () => ({ serverUrl: "https://api.example.com" }),
+ fetchFn
+ }
+
+ const pending = tldwRequest(
+ {
+ path: "https://api.example.com/api/v1/chat/completions",
+ method: "POST",
+ body: { messages: [] }
+ },
+ runtime
+ )
+
+ await vi.advanceTimersByTimeAsync(15001)
+ const resp = await pending
+ expect(resp.ok).toBe(true)
+ expect(resp.status).toBe(200)
+ })
+
+ it("bounds the body read so a stalled body does not hang forever", async () => {
+ vi.useFakeTimers()
+ const fetchFn = vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
+ const signal = init?.signal
+ return {
+ ok: true,
+ status: 200,
+ headers: new Headers({ "content-type": "application/json" }),
+ // Body read never resolves on its own — only the request timeout can
+ // unblock it by aborting the shared controller.
+ json: () =>
+ new Promise((_resolve, reject) => {
+ signal?.addEventListener("abort", () => {
+ const abortError = new Error("aborted")
+ abortError.name = "AbortError"
+ reject(abortError)
+ })
+ }),
+ text: () => Promise.resolve("")
+ } as unknown as Response
+ }) as unknown as typeof fetch
+
+ const runtime = {
+ getConfig: async () => ({ serverUrl: "https://api.example.com" }),
+ fetchFn
+ }
+
+ const pending = tldwRequest(
+ {
+ path: "https://api.example.com/api/v1/chat/completions",
+ method: "POST",
+ body: { messages: [] }
+ },
+ runtime
+ )
+
+ // Advance past the derived (generation) timeout; the body read must abort
+ // and resolve rather than hang.
+ await vi.advanceTimersByTimeAsync(120001)
+ const resp = await pending
+ expect(resp.status).toBe(200)
+ expect(resp.data).toBeNull()
+ })
+})
diff --git a/apps/packages/ui/src/services/tldw/__tests__/voice-conversation.test.ts b/apps/packages/ui/src/services/tldw/__tests__/voice-conversation.test.ts
new file mode 100644
index 0000000000..6c5109bc19
--- /dev/null
+++ b/apps/packages/ui/src/services/tldw/__tests__/voice-conversation.test.ts
@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest"
+
+import { buildVoiceConversationPreflight } from "@/services/tldw/voice-conversation"
+
+describe("buildVoiceConversationPreflight (TASK-12106 auth-out-of-url)", () => {
+ const baseInput = {
+ serverUrl: "http://127.0.0.1:8000",
+ token: "secret-token-123",
+ requestedModel: "",
+ ttsProvider: "tldw",
+ tldwTtsModel: "kokoro",
+ tldwTtsVoice: "af_heart",
+ tldwTtsSpeed: 1,
+ tldwTtsResponseFormat: "mp3",
+ voiceChatTtsMode: "stream" as const,
+ resolveProvider: () => undefined
+ }
+
+ it("keeps the auth token out of the websocket url", async () => {
+ const preflight = await buildVoiceConversationPreflight(baseInput)
+
+ expect(preflight.websocketUrl).toContain("/api/v1/audio/chat/stream")
+ expect(preflight.websocketUrl).not.toContain("token")
+ expect(preflight.websocketUrl).not.toContain("secret-token-123")
+ expect(preflight.websocketUrl).not.toContain("?")
+ })
+
+ it("still fails fast when the token is missing", async () => {
+ await expect(
+ buildVoiceConversationPreflight({ ...baseInput, token: "" })
+ ).rejects.toThrow(/Not authenticated/i)
+ })
+})
diff --git a/apps/packages/ui/src/services/tldw/domains/chat-rag.ts b/apps/packages/ui/src/services/tldw/domains/chat-rag.ts
index 88fa74c05a..f9167a091a 100644
--- a/apps/packages/ui/src/services/tldw/domains/chat-rag.ts
+++ b/apps/packages/ui/src/services/tldw/domains/chat-rag.ts
@@ -98,72 +98,10 @@ const buildSanitizedRagSearchError = (
return sanitizedError
}
-const CHAT_COMPLETION_ERROR_MESSAGE = "Chat completion failed."
-const CHAT_COMPLETION_ERRORS_MESSAGE =
- "One or more internal errors were suppressed."
-
-const isSuspiciousChatCompletionString = (value: string): boolean =>
- /traceback|stack(?:\s*trace)?|exception|error|\/Users\/|[A-Za-z]:\\|\.py:\d+/i.test(
- value
- )
-
-const normalizeChatCompletionResponseBody = (
- value: unknown
-): Record | unknown[] => {
- if (typeof value === "string") {
- if (isSuspiciousChatCompletionString(value)) {
- return {
- error: CHAT_COMPLETION_ERROR_MESSAGE,
- errors: [CHAT_COMPLETION_ERRORS_MESSAGE]
- }
- }
- return { content: value }
- }
- const sanitized = sanitizeChatCompletionPayload(value)
- if (Array.isArray(sanitized)) {
- return sanitized
- }
- if (sanitized && typeof sanitized === "object") {
- return sanitized as Record
- }
- return { content: sanitized ?? "" }
-}
-
-const sanitizeChatCompletionPayload = (value: unknown): unknown => {
- if (typeof value === "string") {
- return isSuspiciousChatCompletionString(value)
- ? CHAT_COMPLETION_ERROR_MESSAGE
- : value
- }
- if (Array.isArray(value)) {
- return value.map((item) => sanitizeChatCompletionPayload(item))
- }
- if (value && typeof value === "object") {
- const sanitized: Record = {}
- for (const [key, item] of Object.entries(value)) {
- if (
- key === "details" ||
- key === "exception" ||
- key === "traceback" ||
- key === "stack" ||
- key === "stack_trace"
- ) {
- continue
- }
- if (key === "error" && item) {
- sanitized[key] = CHAT_COMPLETION_ERROR_MESSAGE
- continue
- }
- if (key === "errors" && item) {
- sanitized[key] = [CHAT_COMPLETION_ERRORS_MESSAGE]
- continue
- }
- sanitized[key] = sanitizeChatCompletionPayload(item)
- }
- return sanitized
- }
- return value
-}
+// NOTE: the chat-completion "sanitizer" that used to live here corrupted successful
+// non-streaming replies (any content containing "error"/"exception"/a file path was
+// replaced with "Chat completion failed."). It was removed — bgRequest throws on non-2xx,
+// so createChatCompletion only ever sees success bodies. See FRONTEND_AUDIT.md (C1) / TASK-12091.
export const chatRagMethods = {
normalizeChatSummary(input: any): ServerChatSummary {
@@ -304,9 +242,12 @@ export const chatRagMethods = {
})
// bgRequest returns parsed data; for non-streaming chat we expect a JSON structure or text. To keep existing consumers happy, wrap as Response-like
// For simplicity, return a minimal object with json() and text()
+ // NOTE: bgRequest throws on non-2xx, so `res` here is always a SUCCESS body.
+ // Do NOT run it through the error-string "sanitizer" — that corrupts legitimate
+ // assistant replies that merely mention "error"/"exception"/a file path, and the
+ // streaming path never sanitized. See apps/FRONTEND_AUDIT.md (C1) / TASK-12091.
const data = res as any
- const safeData = normalizeChatCompletionResponseBody(data)
- return createJsonResponseLike(safeData, { status: 200 })
+ return createJsonResponseLike(data, { status: 200 })
},
async *streamChatCompletion(this: TldwApiClientCore, request: ChatCompletionRequest, options?: ChatCompletionStreamOptions): AsyncGenerator {
diff --git a/apps/packages/ui/src/services/tldw/domains/models-audio.ts b/apps/packages/ui/src/services/tldw/domains/models-audio.ts
index 4d2df9e15f..a6c53e6e70 100644
--- a/apps/packages/ui/src/services/tldw/domains/models-audio.ts
+++ b/apps/packages/ui/src/services/tldw/domains/models-audio.ts
@@ -753,9 +753,10 @@ export const modelsAudioMethods = {
extraParams?: Record
stream?: boolean
signal?: AbortSignal
+ timeoutMs?: number
}
): Promise {
- await this.ensureConfigForRequest(true)
+ const cfg = await this.ensureConfigForRequest(true)
const body: Record = { input: text, text }
if (options?.voice) body.voice = options.voice
if (options?.model) body.model = options.model
@@ -790,13 +791,21 @@ export const modelsAudioMethods = {
return "audio/mpeg"
}
})()
+ // TTS synthesis of more than a short paragraph routinely exceeds the 10s default
+ // request timeout; give it a generous, overridable timeout. See FRONTEND_AUDIT.md / TASK-12101.
+ const ttsTimeoutMs =
+ options?.timeoutMs ??
+ (Number((cfg as any)?.ttsRequestTimeoutMs) > 0
+ ? Number((cfg as any).ttsRequestTimeoutMs)
+ : 120000)
const data = await this.request({
path: "/api/v1/audio/speech",
method: "POST",
headers: { Accept: accept },
body,
responseType: "arrayBuffer",
- abortSignal: options?.signal
+ abortSignal: options?.signal,
+ timeoutMs: ttsTimeoutMs
})
const normalizeArrayBuffer = async (value: unknown): Promise => {
diff --git a/apps/packages/ui/src/services/tldw/request-core.ts b/apps/packages/ui/src/services/tldw/request-core.ts
index f20176125d..b7a3c7231c 100644
--- a/apps/packages/ui/src/services/tldw/request-core.ts
+++ b/apps/packages/ui/src/services/tldw/request-core.ts
@@ -9,6 +9,12 @@ import {
type BrowserSurface
} from "@/services/tldw/browser-networking"
import { getRuntimeSingleUserApiKeyOverride } from "@/services/tldw/runtime-auth-override"
+import {
+ ABSOLUTE_URL_BLOCK_ERROR,
+ isAbsoluteUrlAllowlisted as guardIsAbsoluteUrlAllowlisted,
+ isSameOriginAbsoluteUrlForConfiguredServer as guardIsSameOriginAbsoluteUrlForConfiguredServer,
+ type AllowlistWarnHooks
+} from "@/utils/absolute-url-guard"
export type TldwRequestPayload = {
path: PathOrUrl
@@ -35,8 +41,6 @@ export type BrowserRequestTransport = {
url: string
}
-const ABSOLUTE_URL_BLOCK_ERROR =
- "Absolute URL requests are blocked unless the request origin is explicitly allowlisted."
const REQUEST_LOG_PREFIX = "[tldw:request]"
const malformedConfigServerUrlWarnings = new Set()
const malformedAllowlistEntryWarnings = new Set()
@@ -61,6 +65,11 @@ const isMediaApiPath = (path: string): boolean => /\/api\/v1\/media(?:\/|\?|$)/.
const isFilesApiPath = (path: string): boolean => /\/api\/v1\/files(?:\/|\?|$)/.test(path)
const isSlidesApiPath = (path: string): boolean => /\/api\/v1\/slides(?:\/|\?|$)/.test(path)
const SLIDES_REQUEST_TIMEOUT_FLOOR_MS = 120000
+// LLM generation and RAG endpoints routinely run far longer than the generic
+// 10s request default. Using the short default aborts normal generations
+// mid-response and surfaces as a spurious "Network error". Default these paths
+// to a generation-appropriate timeout instead (still overridable via config).
+const GENERATION_REQUEST_TIMEOUT_DEFAULT_MS = 120000
const getCurrentBrowserSurface = (): BrowserSurface => {
if (typeof window === "undefined") {
@@ -97,14 +106,14 @@ export const deriveRequestTimeout = (
? Number(cfg.chatRequestTimeoutMs)
: Number(cfg?.requestTimeoutMs) > 0
? Number(cfg.requestTimeoutMs)
- : 10000
+ : GENERATION_REQUEST_TIMEOUT_DEFAULT_MS
}
if (p.includes("/api/v1/rag/")) {
return Number(cfg?.ragRequestTimeoutMs) > 0
? Number(cfg.ragRequestTimeoutMs)
: Number(cfg?.requestTimeoutMs) > 0
? Number(cfg.requestTimeoutMs)
- : 10000
+ : GENERATION_REQUEST_TIMEOUT_DEFAULT_MS
}
if (isMediaApiPath(p)) {
return Number(cfg?.mediaRequestTimeoutMs) > 0
@@ -143,24 +152,6 @@ export const parseRetryAfter = (headerValue?: string | null): number | null => {
return null
}
-const toAllowlistEntries = (value: unknown): string[] => {
- if (Array.isArray(value)) {
- return value
- .map((entry) => String(entry || "").trim())
- .filter((entry) => entry.length > 0)
- }
- if (typeof value === "string") {
- const trimmed = value.trim()
- if (!trimmed) return []
- if (!trimmed.includes(",")) return [trimmed]
- return trimmed
- .split(",")
- .map((entry) => entry.trim())
- .filter((entry) => entry.length > 0)
- }
- return []
-}
-
const warnMalformedServerUrl = (raw: string, error: unknown) => {
const key = raw.trim()
if (!key || malformedConfigServerUrlWarnings.has(key)) return
@@ -181,76 +172,30 @@ const warnMalformedAllowlistEntry = (raw: string, error: unknown) => {
)
}
-const parseConfiguredServerOrigin = (cfg: TldwConfigLike): string | null => {
- const configuredServerUrl = String(
- (cfg as Record | null)?.serverUrl || ""
- ).trim()
- if (!configuredServerUrl) return null
- try {
- const serverParsed = new URL(configuredServerUrl)
- if (!/^https?:$/i.test(serverParsed.protocol)) return null
- return serverParsed.origin.toLowerCase()
- } catch (error) {
- warnMalformedServerUrl(configuredServerUrl, error)
- return null
- }
-}
-
-const absoluteOriginAllowlistFromConfig = (cfg: TldwConfigLike): Set => {
- const out = new Set()
- const configuredServerUrl = String((cfg as Record | null)?.serverUrl || "").trim()
- if (configuredServerUrl) {
- try {
- const serverParsed = new URL(configuredServerUrl)
- if (/^https?:$/i.test(serverParsed.protocol)) {
- out.add(serverParsed.origin.toLowerCase())
- }
- } catch {
- // Ignore malformed configured server URL.
- }
- }
- const entries = toAllowlistEntries((cfg as Record | null)?.absoluteUrlAllowlist)
- for (const entry of entries) {
- try {
- const parsed = new URL(entry)
- if (/^https?:$/i.test(parsed.protocol)) {
- out.add(parsed.origin.toLowerCase())
- }
- } catch (error) {
- warnMalformedAllowlistEntry(entry, error)
- }
- }
- return out
+// The origin-allowlist / same-origin primitives live in the canonical
+// utils/absolute-url-guard module. request-core keeps its once-per-value
+// malformed-config warnings, so it passes those diagnostics through as hooks;
+// the actual allowlist/same-origin logic is not duplicated here.
+const requestCoreAllowlistWarnHooks: AllowlistWarnHooks = {
+ onMalformedServerUrl: warnMalformedServerUrl,
+ onMalformedAllowlistEntry: warnMalformedAllowlistEntry
}
const isSameOriginAbsoluteUrlForConfiguredServer = (
absoluteUrl: string,
cfg: TldwConfigLike
-): boolean => {
- const configuredServerOrigin = parseConfiguredServerOrigin(cfg)
- if (!configuredServerOrigin) return false
- try {
- const target = new URL(absoluteUrl)
- if (!/^https?:$/i.test(target.protocol)) return false
- return target.origin.toLowerCase() === configuredServerOrigin
- } catch {
- return false
- }
-}
+): boolean =>
+ guardIsSameOriginAbsoluteUrlForConfiguredServer(
+ absoluteUrl,
+ cfg,
+ requestCoreAllowlistWarnHooks
+ )
const isAbsoluteUrlAllowlisted = (
absoluteUrl: string,
cfg: TldwConfigLike
-): boolean => {
- try {
- const target = new URL(absoluteUrl)
- if (!/^https?:$/i.test(target.protocol)) return false
- const allowlistedOrigins = absoluteOriginAllowlistFromConfig(cfg)
- return allowlistedOrigins.has(target.origin.toLowerCase())
- } catch {
- return false
- }
-}
+): boolean =>
+ guardIsAbsoluteUrlAllowlisted(absoluteUrl, cfg, requestCoreAllowlistWarnHooks)
export const resolveBrowserRequestTransport = ({
config,
@@ -470,10 +415,11 @@ export const tldwRequest = async (
body: resolvedBody,
signal: controller.signal
})
- if (timeoutId) {
- clearTimeout(timeoutId)
- timeoutId = null
- }
+ // Headers have arrived; fetch() resolves before the body is read. Re-arm the
+ // timeout so the body read below is bounded too — otherwise a server that
+ // sends headers then stalls hangs forever despite the "timeout".
+ if (timeoutId) clearTimeout(timeoutId)
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs)
if (
!shouldSkipAuth &&
@@ -483,6 +429,11 @@ export const tldwRequest = async (
cfg?.refreshToken &&
runtime.refreshAuth
) {
+ // The first request is finished; stop its timer before refreshing/retrying.
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ timeoutId = null
+ }
let refreshSucceeded = false
try {
await runtime.refreshAuth()
@@ -505,13 +456,14 @@ export const tldwRequest = async (
resp = await fetchFn(url, {
method,
headers: retryHeaders,
- body: body ? (typeof body === "string" ? body : JSON.stringify(body)) : undefined,
+ // Reuse the binary-aware serialization from the first attempt. A plain
+ // JSON.stringify here corrupts FormData/Blob uploads into "{}".
+ body: resolvedBody,
signal: retryController.signal
})
- if (retryTimeoutId) {
- clearTimeout(retryTimeoutId)
- retryTimeoutId = null
- }
+ // Re-arm so the retry body read is bounded as well.
+ if (retryTimeoutId) clearTimeout(retryTimeoutId)
+ retryTimeoutId = setTimeout(() => retryController.abort(), timeoutMs)
if (!refreshSucceeded && resp.status === 401) {
return {
ok: false,
diff --git a/apps/packages/ui/src/services/tldw/voice-conversation.ts b/apps/packages/ui/src/services/tldw/voice-conversation.ts
index b69f3ac464..721db8a2e5 100644
--- a/apps/packages/ui/src/services/tldw/voice-conversation.ts
+++ b/apps/packages/ui/src/services/tldw/voice-conversation.ts
@@ -358,7 +358,12 @@ export const buildVoiceConversationPreflight = async (
: {}
return {
- websocketUrl: `${resolveBrowserWebSocketBase(serverUrl)}/api/v1/audio/chat/stream?token=${encodeURIComponent(token)}`,
+ // TASK-12106: the auth token is intentionally NOT placed in the URL (it would
+ // leak into access/proxy logs). The audio WS endpoint authenticates from an
+ // {type:"auth", token} first frame sent by the client after `onopen`
+ // (streaming_service.py:641-647 multi-user / 720-723 single-user). The token
+ // is still validated above so callers fail fast when it is missing.
+ websocketUrl: `${resolveBrowserWebSocketBase(serverUrl)}/api/v1/audio/chat/stream`,
llm,
tts: ttsConfig
}
diff --git a/apps/packages/ui/src/store/__tests__/connection.test.ts b/apps/packages/ui/src/store/__tests__/connection.test.ts
index 7d9fa02d87..4d4d5488d8 100644
--- a/apps/packages/ui/src/store/__tests__/connection.test.ts
+++ b/apps/packages/ui/src/store/__tests__/connection.test.ts
@@ -36,6 +36,9 @@ const mockedApiSend = vi.mocked(apiSend)
const mockedClient = vi.mocked(tldwClient, true)
const mockedRuntimeApiKey = vi.mocked(getRuntimeSingleUserApiKeyOverride)
const originalDeploymentMode = process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE
+// Fixed wall-clock so state-setup timestamps are deterministic across runs
+// (workflow code must use real Date.now(), but test fixtures should not).
+const FIXED_NOW_MS = 1_700_000_000_000
const setConnectionState = (overrides: Record) => {
const prev = useConnectionStore.getState().state
@@ -846,4 +849,128 @@ describe("connection store stability", () => {
expect(state.isConnected).toBe(false)
expect(state.configStep).toBe("url")
})
+
+ it("does not revert a concurrent config edit when a slow health check finishes (H7)", async () => {
+ setConnectionState({
+ phase: ConnectionPhase.SEARCHING,
+ isConnected: false,
+ isChecking: false,
+ configStep: "url",
+ hasCompletedFirstRun: false,
+ userPersona: null,
+ knowledgeStatus: "ready",
+ knowledgeLastCheckedAt: FIXED_NOW_MS,
+ lastCheckedAt: FIXED_NOW_MS - 60_000,
+ consecutiveFailures: 0,
+ errorKind: "none"
+ })
+
+ // Gate the health check so it stays in-flight while other actions run.
+ let releaseHealth: (value: {
+ ok: boolean
+ status: number
+ data?: unknown
+ }) => void = () => {}
+ const healthGate = new Promise<{
+ ok: boolean
+ status: number
+ data?: unknown
+ }>((resolve) => {
+ releaseHealth = resolve
+ })
+ mockedApiSend.mockReturnValue(healthGate as never)
+
+ // Start the (slow) health check but do not await it yet.
+ const checkPromise = useConnectionStore.getState().checkOnce({ force: true })
+
+ // While it is in-flight, concurrent onboarding actions mutate the store.
+ await useConnectionStore
+ .getState()
+ .setConfigPartial({ serverUrl: "http://concurrent.test:9999" })
+ await useConnectionStore.getState().markFirstRunComplete()
+
+ // Let the slow health check complete. Its terminal write must merge onto the
+ // LATEST state, not the snapshot captured before the concurrent edits.
+ releaseHealth({ ok: true, status: 200, data: { status: "alive" } })
+ await checkPromise
+
+ const final = useConnectionStore.getState().state
+ expect(final.configStep).toBe("auth")
+ expect(final.hasCompletedFirstRun).toBe(true)
+ expect(final.phase).toBe(ConnectionPhase.CONNECTED)
+ expect(final.isConnected).toBe(true)
+ })
+
+ it("ignores a concurrent checkOnce while one is already in flight (H7 guard)", async () => {
+ setConnectionState({
+ phase: ConnectionPhase.SEARCHING,
+ isConnected: false,
+ isChecking: false,
+ knowledgeStatus: "ready",
+ knowledgeLastCheckedAt: Date.now(),
+ lastCheckedAt: Date.now() - 60_000
+ })
+
+ let releaseHealth: (value: {
+ ok: boolean
+ status: number
+ data?: unknown
+ }) => void = () => {}
+ const healthGate = new Promise<{
+ ok: boolean
+ status: number
+ data?: unknown
+ }>((resolve) => {
+ releaseHealth = resolve
+ })
+ mockedApiSend.mockReturnValue(healthGate as never)
+
+ // The in-flight guard is claimed synchronously (before the first await), so
+ // the second call must bail before issuing its own health request.
+ const first = useConnectionStore.getState().checkOnce({ force: true })
+ const second = useConnectionStore.getState().checkOnce({ force: true })
+
+ releaseHealth({ ok: true, status: 200, data: { status: "alive" } })
+ await Promise.all([first, second])
+
+ expect(mockedApiSend).toHaveBeenCalledTimes(1)
+ })
+
+ it("releases the in-flight guard when a step before the health check throws (H7 deadlock)", async () => {
+ // A first-run flag in storage makes checkOnce run its first-run-sync set(...)
+ // BEFORE it flips isChecking, so a throw there leaves isChecking false and the
+ // synchronous in-flight guard as the only thing that could block a retry.
+ setConnectionState({
+ phase: ConnectionPhase.CONNECTED,
+ serverUrl: "http://127.0.0.1:8000",
+ isConnected: true,
+ isChecking: false,
+ hasCompletedFirstRun: false,
+ userPersona: null,
+ knowledgeStatus: "ready",
+ knowledgeLastCheckedAt: FIXED_NOW_MS,
+ lastCheckedAt: FIXED_NOW_MS - 60_000
+ })
+ localStorage.setItem("__tldw_first_run_complete", "true")
+ mockedApiSend.mockResolvedValue({
+ ok: true,
+ status: 200,
+ data: { status: "alive" }
+ } as never)
+
+ // Throw from a store subscriber to simulate a pre-`try` step failing.
+ const unsubscribe = useConnectionStore.subscribe(() => {
+ throw new Error("pre-check boom")
+ })
+ await expect(
+ useConnectionStore.getState().checkOnce({ force: true })
+ ).rejects.toThrow("pre-check boom")
+ unsubscribe()
+
+ // If the guard had leaked, this second checkOnce would bail before issuing a
+ // health request; it must run and reach apiSend instead.
+ mockedApiSend.mockClear()
+ await useConnectionStore.getState().checkOnce({ force: true })
+ expect(mockedApiSend).toHaveBeenCalled()
+ })
})
diff --git a/apps/packages/ui/src/store/__tests__/folder.test.ts b/apps/packages/ui/src/store/__tests__/folder.test.ts
new file mode 100644
index 0000000000..c8b6345356
--- /dev/null
+++ b/apps/packages/ui/src/store/__tests__/folder.test.ts
@@ -0,0 +1,142 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+// The folder store persists a small slice of state to localStorage. The tests
+// below exercise the H11 fix: a single transient 404 must not permanently
+// disable folder sync across sessions.
+
+vi.mock("@/services/folder-api", () => ({
+ fetchFolders: vi.fn(),
+ fetchKeywords: vi.fn(),
+ fetchFolderKeywordLinks: vi.fn(),
+ fetchConversationKeywordLinks: vi.fn(),
+ createFolder: vi.fn(),
+ updateFolder: vi.fn(),
+ deleteFolder: vi.fn(),
+ createKeyword: vi.fn(),
+ deleteKeyword: vi.fn(),
+ linkKeywordToFolder: vi.fn(),
+ unlinkKeywordFromFolder: vi.fn(),
+ linkKeywordToConversation: vi.fn(),
+ unlinkKeywordFromConversation: vi.fn()
+}))
+
+vi.mock("@/db/dexie/schema", () => {
+ const table = () => ({
+ clear: vi.fn(async () => undefined),
+ bulkPut: vi.fn(async () => undefined),
+ toArray: vi.fn(async () => []),
+ put: vi.fn(async () => undefined),
+ update: vi.fn(async () => undefined),
+ where: vi.fn(() => ({ equals: vi.fn(() => ({ delete: vi.fn(async () => undefined) })) }))
+ })
+ return {
+ db: {
+ transaction: vi.fn(async (..._args: unknown[]) => {
+ const cb = _args[_args.length - 1]
+ return typeof cb === "function" ? await (cb as () => unknown)() : undefined
+ }),
+ folders: table(),
+ keywords: table(),
+ folderKeywordLinks: table(),
+ conversationKeywordLinks: table()
+ }
+ }
+})
+
+import * as folderApi from "@/services/folder-api"
+import { useFolderStore } from "../folder"
+
+const FOLDER_STORAGE_KEY = "tldw-folder-store"
+
+const notFound = () => ({ ok: false, status: 404, error: "Not Found" })
+const ok = (data: T) => ({ ok: true, status: 200, data })
+
+const mockAllFetches = (result: unknown) => {
+ vi.mocked(folderApi.fetchFolders).mockResolvedValue(result as never)
+ vi.mocked(folderApi.fetchKeywords).mockResolvedValue(result as never)
+ vi.mocked(folderApi.fetchFolderKeywordLinks).mockResolvedValue(result as never)
+ vi.mocked(folderApi.fetchConversationKeywordLinks).mockResolvedValue(
+ result as never
+ )
+}
+
+describe("folder store sticky-failure recovery (H11)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ localStorage.clear()
+ useFolderStore.setState({
+ folders: [],
+ keywords: [],
+ folderKeywordLinks: [],
+ conversationKeywordLinks: [],
+ isLoading: false,
+ lastSynced: null,
+ error: null,
+ folderApiAvailable: null
+ })
+ })
+
+ it("disables sync only for the current session after a 404", async () => {
+ mockAllFetches(notFound())
+
+ await useFolderStore.getState().refreshFromServer()
+ expect(useFolderStore.getState().folderApiAvailable).toBe(false)
+
+ // Within the same session a subsequent refresh is skipped (avoids hammering
+ // a server that lacks the folder API) — no additional fetch is issued.
+ const callsAfterFirst = vi.mocked(folderApi.fetchFolders).mock.calls.length
+ await useFolderStore.getState().refreshFromServer()
+ expect(vi.mocked(folderApi.fetchFolders).mock.calls.length).toBe(
+ callsAfterFirst
+ )
+ })
+
+ it("does not rehydrate a persisted folderApiAvailable:false flag", async () => {
+ // Simulate a user whose localStorage still carries a stale `false` written
+ // by an earlier build (before the flag was removed from partialize).
+ localStorage.setItem(
+ FOLDER_STORAGE_KEY,
+ JSON.stringify({
+ state: {
+ uiPrefs: {},
+ viewMode: "folders",
+ lastSynced: 123,
+ folderApiAvailable: false
+ },
+ version: 0
+ })
+ )
+
+ await useFolderStore.persist.rehydrate()
+
+ // The unavailable flag resets to the retryable `null` default, while the
+ // other persisted values still rehydrate normally.
+ expect(useFolderStore.getState().folderApiAvailable).toBeNull()
+ expect(useFolderStore.getState().viewMode).toBe("folders")
+ expect(useFolderStore.getState().lastSynced).toBe(123)
+ })
+
+ it("re-probes and recovers after the flag resets for a new session", async () => {
+ // A 404 disables sync this session.
+ mockAllFetches(notFound())
+ await useFolderStore.getState().refreshFromServer()
+ expect(useFolderStore.getState().folderApiAvailable).toBe(false)
+
+ // A new session starts with the flag reset (guaranteed by not persisting it
+ // + the merge stripper). Folder sync must now succeed again.
+ useFolderStore.setState({ folderApiAvailable: null })
+ vi.mocked(folderApi.fetchFolders).mockResolvedValue(
+ ok([{ id: 1, name: "Recovered", parent_id: null, deleted: false }]) as never
+ )
+ vi.mocked(folderApi.fetchKeywords).mockResolvedValue(ok([]) as never)
+ vi.mocked(folderApi.fetchFolderKeywordLinks).mockResolvedValue(ok([]) as never)
+ vi.mocked(folderApi.fetchConversationKeywordLinks).mockResolvedValue(
+ ok([]) as never
+ )
+
+ await useFolderStore.getState().refreshFromServer()
+
+ expect(useFolderStore.getState().folderApiAvailable).toBe(true)
+ expect(useFolderStore.getState().folders).toHaveLength(1)
+ })
+})
diff --git a/apps/packages/ui/src/store/__tests__/workspace.test.ts b/apps/packages/ui/src/store/__tests__/workspace.test.ts
index d7f5f74a92..46cd819c89 100644
--- a/apps/packages/ui/src/store/__tests__/workspace.test.ts
+++ b/apps/packages/ui/src/store/__tests__/workspace.test.ts
@@ -596,6 +596,84 @@ describe("workspace store snapshot persistence", () => {
expect(state.storeHydrated).toBe(true)
})
+ it("publishes post-processed hydrated state through set so subscribers are notified", async () => {
+ resetWorkspaceStore()
+
+ const persistedState = {
+ state: {
+ workspaceId: "workspace-h8",
+ workspaceName: "H8 Name",
+ workspaceTag: "workspace:h8",
+ workspaceCreatedAt: "2026-02-01T00:00:00.000Z",
+ workspaceChatReferenceId: "h8-chat-ref",
+ // Top-level sources are intentionally empty; the canonical data lives in
+ // the active snapshot and is only applied inside onRehydrateStorage.
+ sources: [],
+ selectedSourceIds: [],
+ generatedArtifacts: [],
+ notes: "",
+ currentNote: { ...DEFAULT_WORKSPACE_NOTE },
+ leftPaneCollapsed: false,
+ rightPaneCollapsed: false,
+ audioSettings: { ...DEFAULT_AUDIO_SETTINGS },
+ savedWorkspaces: [],
+ archivedWorkspaces: [],
+ workspaceSnapshots: {
+ "workspace-h8": {
+ workspaceId: "workspace-h8",
+ workspaceName: "H8 Snapshot",
+ workspaceTag: "workspace:h8-snapshot",
+ workspaceCreatedAt: "2026-02-02T00:00:00.000Z",
+ workspaceChatReferenceId: "h8-snapshot-chat-ref",
+ sources: [
+ {
+ id: "source-h8-1",
+ mediaId: 8001,
+ title: "H8 Source",
+ type: "pdf",
+ addedAt: "2026-02-03T00:00:00.000Z"
+ }
+ ],
+ selectedSourceIds: [],
+ generatedArtifacts: [],
+ notes: "",
+ currentNote: { ...DEFAULT_WORKSPACE_NOTE },
+ leftPaneCollapsed: false,
+ rightPaneCollapsed: false,
+ audioSettings: { ...DEFAULT_AUDIO_SETTINGS }
+ }
+ },
+ workspaceChatSessions: {}
+ },
+ version: 0
+ }
+
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(persistedState))
+
+ const notified: Array<{ storeHydrated: boolean; sourcesLen: number }> = []
+ const unsubscribe = useWorkspaceStore.subscribe((current) => {
+ notified.push({
+ storeHydrated: current.storeHydrated,
+ sourcesLen: current.sources.length
+ })
+ })
+
+ await useWorkspaceStore.persist.rehydrate()
+ unsubscribe()
+
+ // A subscriber notification must carry the fully post-processed state:
+ // `storeHydrated` flipped to true AND the active snapshot's sources applied.
+ // With the previous in-place mutation this was never published (subscribers
+ // only ever saw the raw persisted merge with storeHydrated:false).
+ expect(
+ notified.some(
+ (entry) => entry.storeHydrated === true && entry.sourcesLen === 1
+ )
+ ).toBe(true)
+ expect(useWorkspaceStore.getState().storeHydrated).toBe(true)
+ expect(useWorkspaceStore.getState().sources).toHaveLength(1)
+ })
+
it("marks interrupted generating artifacts as failed during rehydration", async () => {
resetWorkspaceStore()
diff --git a/apps/packages/ui/src/store/acp-sessions.ts b/apps/packages/ui/src/store/acp-sessions.ts
index 1fb1add82b..4d811aaeb1 100644
--- a/apps/packages/ui/src/store/acp-sessions.ts
+++ b/apps/packages/ui/src/store/acp-sessions.ts
@@ -854,6 +854,10 @@ export const useACPSessionsStore = createWithEqualityFn()(
}),
{
name: STORAGE_KEY,
+ // Baseline version so future shape changes can migrate instead of discarding
+ // persisted state (see apps/FRONTEND_AUDIT.md §6 / TASK-12102).
+ version: 1,
+ migrate: (persisted) => persisted as any,
storage: createJSONStorage(() => createACPStorage()),
partialize: (state): PersistedState => ({
// Only persist session metadata, not transient state like updates
diff --git a/apps/packages/ui/src/store/actor.tsx b/apps/packages/ui/src/store/actor.tsx
index 18cff18507..a64ec6bb6f 100644
--- a/apps/packages/ui/src/store/actor.tsx
+++ b/apps/packages/ui/src/store/actor.tsx
@@ -70,6 +70,10 @@ export const useActorEditorPrefs = createWithEqualityFn()
}),
{
name: "tldw-actor-editor-prefs",
+ // Baseline version so future shape changes can migrate instead of discarding
+ // persisted state (see apps/FRONTEND_AUDIT.md §6 / TASK-12102).
+ version: 1,
+ migrate: (persisted) => persisted as any,
storage: createJSONStorage(() => localStorage)
}
)
diff --git a/apps/packages/ui/src/store/connection.tsx b/apps/packages/ui/src/store/connection.tsx
index 10d109c502..3a8fe6ee25 100644
--- a/apps/packages/ui/src/store/connection.tsx
+++ b/apps/packages/ui/src/store/connection.tsx
@@ -599,187 +599,69 @@ const deriveOnboardingConfigStep = (
return currentState.configStep === "none" ? "health" : currentState.configStep
}
+// Synchronous in-flight guard for checkOnce. It is set BEFORE the first await
+// so concurrent callers can't slip past the overlap check while persisted flags
+// are being read (the store `isChecking` flag was previously only set after
+// several awaits, leaving a race window). Released at every exit path below.
+let checkInFlight = false
+
export const useConnectionStore = createWithEqualityFn((set, get) => ({
state: initialState,
async checkOnce(options = {}) {
const prev = get().state
- // Avoid overlapping checks
- if (prev.isChecking) {
+ // Avoid overlapping checks. Claim the in-flight guard synchronously (no
+ // await between reading and setting it) so a second caller can't proceed.
+ if (prev.isChecking || checkInFlight) {
return
}
+ checkInFlight = true
- // Load all persisted flags upfront
- const persistedFirstRun = await getFirstRunCompleteFlag()
- const persistedUserPersona = await getUserPersonaFlag()
- const persistedServerUrl = await getPersistedServerUrl()
- const forceUnconfigured = await getForceUnconfiguredFlag()
- const bypass = await getOfflineBypassFlag()
-
- const needsFirstRunSync = !prev.hasCompletedFirstRun && persistedFirstRun
- const needsPersonaSync = prev.userPersona !== persistedUserPersona
+ try {
+ // Load all persisted flags upfront
+ const persistedFirstRun = await getFirstRunCompleteFlag()
+ const persistedUserPersona = await getUserPersonaFlag()
+ const persistedServerUrl = await getPersistedServerUrl()
+ const forceUnconfigured = await getForceUnconfiguredFlag()
+ const bypass = await getOfflineBypassFlag()
+
+ const needsFirstRunSync = !prev.hasCompletedFirstRun && persistedFirstRun
+ const needsPersonaSync = prev.userPersona !== persistedUserPersona
+
+ const currentState =
+ needsFirstRunSync || needsPersonaSync
+ ? {
+ ...prev,
+ ...(needsFirstRunSync ? { hasCompletedFirstRun: true } : {}),
+ ...(needsPersonaSync ? { userPersona: persistedUserPersona } : {})
+ }
+ : prev
- const currentState =
- needsFirstRunSync || needsPersonaSync
- ? {
- ...prev,
+ // Apply persisted first-run flag if not already set. Merge onto the LATEST
+ // state (not the captured snapshot) so a concurrent update isn't reverted.
+ if (currentState !== prev) {
+ set((s) => ({
+ state: {
+ ...s.state,
...(needsFirstRunSync ? { hasCompletedFirstRun: true } : {}),
...(needsPersonaSync ? { userPersona: persistedUserPersona } : {})
}
- : prev
-
- // Apply persisted first-run flag if not already set
- if (currentState !== prev) {
- set({
- state: currentState
- })
- }
-
- // Test-only hook: force a missing/unconfigured state without network calls.
- if (forceUnconfigured) {
- set({
- state: {
- ...currentState,
- errorKind: "none",
- phase: ConnectionPhase.UNCONFIGURED,
- serverUrl: persistedServerUrl,
- isConnected: false,
- isChecking: false,
- consecutiveFailures: 0,
- offlineBypass: false,
- lastCheckedAt: Date.now(),
- lastError: null,
- lastStatusCode: null,
- knowledgeStatus: "unknown",
- knowledgeLastCheckedAt: null,
- knowledgeError: null
- }
- })
- return
- }
-
- // Optional test toggle: allow CI/Playwright to treat the app as "connected"
- // without hitting a live server. Controlled via env VITE_TLDW_E2E_ALLOW_OFFLINE
- // or chrome.storage.local[__tldw_allow_offline].
- if (bypass) {
- const serverUrl =
- persistedServerUrl ??
- (await ensurePlaceholderConfig()) ??
- currentState.serverUrl ??
- "offline://local"
-
- set({
- state: {
- ...currentState,
- phase: ConnectionPhase.CONNECTED,
- serverUrl,
- isConnected: true,
- isChecking: false,
- consecutiveFailures: 0,
- offlineBypass: true,
- errorKind: "none",
- lastCheckedAt: Date.now(),
- lastError: null,
- lastStatusCode: null,
- knowledgeStatus: "ready",
- knowledgeLastCheckedAt: Date.now(),
- knowledgeError: null
- }
- })
- return
- }
-
- // Throttle repeated checks when already connected recently.
- // This prevents the landing page/header from hammering the server.
- const now = Date.now()
- const nextChecksSinceConfigChange = currentState.checksSinceConfigChange + 1
- if (
- !options.force &&
- currentState.isConnected &&
- currentState.phase === ConnectionPhase.CONNECTED &&
- currentState.lastCheckedAt != null &&
- now - currentState.lastCheckedAt < CONNECTED_THROTTLE_MS
- ) {
- return
- }
-
- const isBackgroundRefresh =
- currentState.isConnected && currentState.phase === ConnectionPhase.CONNECTED
- set({
- state: {
- ...currentState,
- phase: isBackgroundRefresh
- ? ConnectionPhase.CONNECTED
- : ConnectionPhase.SEARCHING,
- serverUrl: persistedServerUrl ?? currentState.serverUrl,
- errorKind: isBackgroundRefresh ? currentState.errorKind : "none",
- isChecking: true,
- offlineBypass: false,
- lastError: isBackgroundRefresh ? currentState.lastError : null,
- checksSinceConfigChange: nextChecksSinceConfigChange
- }
- })
-
- try {
- let cfg = await tldwClient.getConfig()
- const quickstartWebUiServerUrl = getQuickstartWebUiServerUrl()
- const recoveryProbeSourceServerUrl = cfg?.serverUrl ?? currentState.serverUrl ?? null
- let serverUrl = quickstartWebUiServerUrl ?? cfg?.serverUrl ?? null
-
- if (
- quickstartWebUiServerUrl &&
- cfg?.serverUrl !== quickstartWebUiServerUrl
- ) {
- await tldwClient.updateConfig({ serverUrl: quickstartWebUiServerUrl })
- cfg = {
- ...(cfg || {}),
- serverUrl: quickstartWebUiServerUrl,
- authMode: cfg?.authMode || "single-user"
- } as TldwConfig
- serverUrl = quickstartWebUiServerUrl
- }
-
- if (!serverUrl) {
- try {
- // Only reuse a previously stored URL; do not implicitly
- // fall back to the hard-coded localhost default here.
- const storedUrl = await getStoredTldwServerURL()
- if (storedUrl) {
- await tldwClient.updateConfig({
- serverUrl: storedUrl
- })
- cfg = await tldwClient.getConfig()
- serverUrl = cfg?.serverUrl ?? storedUrl
- }
- } catch {
- // ignore fallback errors; we will treat as unconfigured below
- }
+ }))
}
- const hasSingleUserApiKeyValue = hasSingleUserApiKey(cfg)
- const missingSingleUserApiKey =
- Boolean(serverUrl) &&
- (cfg?.authMode ?? "single-user") === "single-user" &&
- !hasSingleUserApiKeyValue
-
- // If we have a server URL but no single-user API key, treat as
- // unconfigured/unauthenticated instead of marking the app connected
- // off an unauthenticated liveness check.
- // Users must explicitly configure their own credentials in
- // Settings/Onboarding before authenticated pages can function.
- if (missingSingleUserApiKey) {
- set({
+ // Test-only hook: force a missing/unconfigured state without network calls.
+ if (forceUnconfigured) {
+ set((s) => ({
state: {
- ...currentState,
+ ...s.state,
+ errorKind: "none",
phase: ConnectionPhase.UNCONFIGURED,
- serverUrl,
+ serverUrl: persistedServerUrl,
isConnected: false,
isChecking: false,
consecutiveFailures: 0,
offlineBypass: false,
- errorKind: "none",
- configStep: "auth",
lastCheckedAt: Date.now(),
lastError: null,
lastStatusCode: null,
@@ -787,244 +669,392 @@ export const useConnectionStore = createWithEqualityFn((set, ge
knowledgeLastCheckedAt: null,
knowledgeError: null
}
- })
+ }))
+ checkInFlight = false
return
}
- if (!serverUrl) {
- set({
+ // Optional test toggle: allow CI/Playwright to treat the app as "connected"
+ // without hitting a live server. Controlled via env VITE_TLDW_E2E_ALLOW_OFFLINE
+ // or chrome.storage.local[__tldw_allow_offline].
+ if (bypass) {
+ const serverUrl =
+ persistedServerUrl ??
+ (await ensurePlaceholderConfig()) ??
+ currentState.serverUrl ??
+ "offline://local"
+
+ set((s) => ({
state: {
- ...currentState,
- phase: ConnectionPhase.UNCONFIGURED,
- serverUrl: null,
- isConnected: false,
+ ...s.state,
+ phase: ConnectionPhase.CONNECTED,
+ serverUrl,
+ isConnected: true,
isChecking: false,
consecutiveFailures: 0,
- offlineBypass: false,
+ offlineBypass: true,
errorKind: "none",
lastCheckedAt: Date.now(),
lastError: null,
lastStatusCode: null,
- knowledgeStatus: "unknown",
- knowledgeLastCheckedAt: null,
+ knowledgeStatus: "ready",
+ knowledgeLastCheckedAt: Date.now(),
knowledgeError: null
}
- })
+ }))
+ checkInFlight = false
return
}
- await tldwClient.initialize()
-
- // Request health via background for detailed status codes.
- // Health endpoints may require auth; apiSend injects headers based
- // on tldwConfig (API key / access token).
- const noAuthForHealth = !cfg ||
- (!hasSingleUserApiKeyValue &&
- !cfg.accessToken &&
- cfg.authMode !== "multi-user")
-
- const healthPromise = (async () => {
- try {
- const resp = await apiSend({
- path: HEALTH_LIVENESS_PATH,
- method: 'GET',
- timeoutMs: CONNECTION_TIMEOUT_MS,
- // Allow unauthenticated health checks when no credentials have
- // been configured yet so first‑run onboarding can still detect a
- // reachable server URL. Once an API key or access token exists,
- // health should run with auth.
- noAuth: noAuthForHealth
- })
- return { ok: Boolean(resp?.ok), status: Number(resp?.status) || 0, error: resp?.ok ? null : (resp?.error || null) }
- } catch (e) {
- return { ok: false, status: 0, error: (e as Error)?.message || 'Network error' }
- }
- })()
- let healthResult = await Promise.race([
- healthPromise,
- new Promise<{ ok: boolean; status: number; error: string | null }>((resolve) =>
- setTimeout(() => resolve({ ok: false, status: 0, error: 'timeout' }), CONNECTION_TIMEOUT_MS)
- )
- ])
-
- const fallbackServerUrl = deriveCurrentHostRecoveryServerUrl(
- quickstartWebUiServerUrl ? recoveryProbeSourceServerUrl : serverUrl
- )
+ // Throttle repeated checks when already connected recently.
+ // This prevents the landing page/header from hammering the server.
+ const now = Date.now()
+ const nextChecksSinceConfigChange = currentState.checksSinceConfigChange + 1
if (
- !healthResult.ok &&
- healthResult.status === 0 &&
- isNetworkTransportFailure(healthResult.error) &&
- fallbackServerUrl
+ !options.force &&
+ currentState.isConnected &&
+ currentState.phase === ConnectionPhase.CONNECTED &&
+ currentState.lastCheckedAt != null &&
+ now - currentState.lastCheckedAt < CONNECTED_THROTTLE_MS
) {
- const probeOk = await probeServerLiveness(
- fallbackServerUrl,
- Math.min(5_000, CONNECTION_TIMEOUT_MS)
- )
- if (probeOk) {
- if (!quickstartWebUiServerUrl) {
- await tldwClient.updateConfig({ serverUrl: fallbackServerUrl })
- serverUrl = fallbackServerUrl
- cfg = {
- ...(cfg || {}),
- serverUrl: fallbackServerUrl
- } as TldwConfig
- }
- const fallbackHasSingleUserApiKey = hasSingleUserApiKey(cfg)
- const fallbackNoAuth = !cfg ||
- (!fallbackHasSingleUserApiKey &&
- !cfg.accessToken &&
- cfg.authMode !== "multi-user")
- const fallbackResp = await apiSend({
- path: HEALTH_LIVENESS_PATH,
- method: "GET",
- timeoutMs: CONNECTION_TIMEOUT_MS,
- noAuth: fallbackNoAuth
- })
- healthResult = {
- ok: Boolean(fallbackResp?.ok),
- status: Number(fallbackResp?.status) || 0,
- error: fallbackResp?.ok ? null : (fallbackResp?.error || null)
+ checkInFlight = false
+ return
+ }
+
+ const isBackgroundRefresh =
+ currentState.isConnected && currentState.phase === ConnectionPhase.CONNECTED
+ set((s) => ({
+ state: {
+ ...s.state,
+ phase: isBackgroundRefresh
+ ? ConnectionPhase.CONNECTED
+ : ConnectionPhase.SEARCHING,
+ serverUrl: persistedServerUrl ?? currentState.serverUrl,
+ errorKind: isBackgroundRefresh ? currentState.errorKind : "none",
+ isChecking: true,
+ offlineBypass: false,
+ lastError: isBackgroundRefresh ? currentState.lastError : null,
+ checksSinceConfigChange: nextChecksSinceConfigChange
+ }
+ }))
+
+ try {
+ let cfg = await tldwClient.getConfig()
+ const quickstartWebUiServerUrl = getQuickstartWebUiServerUrl()
+ const recoveryProbeSourceServerUrl = cfg?.serverUrl ?? currentState.serverUrl ?? null
+ let serverUrl = quickstartWebUiServerUrl ?? cfg?.serverUrl ?? null
+
+ if (
+ quickstartWebUiServerUrl &&
+ cfg?.serverUrl !== quickstartWebUiServerUrl
+ ) {
+ await tldwClient.updateConfig({ serverUrl: quickstartWebUiServerUrl })
+ cfg = {
+ ...(cfg || {}),
+ serverUrl: quickstartWebUiServerUrl,
+ authMode: cfg?.authMode || "single-user"
+ } as TldwConfig
+ serverUrl = quickstartWebUiServerUrl
+ }
+
+ if (!serverUrl) {
+ try {
+ // Only reuse a previously stored URL; do not implicitly
+ // fall back to the hard-coded localhost default here.
+ const storedUrl = await getStoredTldwServerURL()
+ if (storedUrl) {
+ await tldwClient.updateConfig({
+ serverUrl: storedUrl
+ })
+ cfg = await tldwClient.getConfig()
+ serverUrl = cfg?.serverUrl ?? storedUrl
+ }
+ } catch {
+ // ignore fallback errors; we will treat as unconfigured below
}
}
- }
- const ok = healthResult.ok
- const resolvedHealthError = maybeAnnotateCorsMismatchError({
- error: healthResult.error,
- status: healthResult.status,
- serverUrl
- })
+ const hasSingleUserApiKeyValue = hasSingleUserApiKey(cfg)
+ const missingSingleUserApiKey =
+ Boolean(serverUrl) &&
+ (cfg?.authMode ?? "single-user") === "single-user" &&
+ !hasSingleUserApiKeyValue
+
+ // If we have a server URL but no single-user API key, treat as
+ // unconfigured/unauthenticated instead of marking the app connected
+ // off an unauthenticated liveness check.
+ // Users must explicitly configure their own credentials in
+ // Settings/Onboarding before authenticated pages can function.
+ if (missingSingleUserApiKey) {
+ set((s) => ({
+ state: {
+ ...s.state,
+ phase: ConnectionPhase.UNCONFIGURED,
+ serverUrl,
+ isConnected: false,
+ isChecking: false,
+ consecutiveFailures: 0,
+ offlineBypass: false,
+ errorKind: "none",
+ configStep: "auth",
+ lastCheckedAt: Date.now(),
+ lastError: null,
+ lastStatusCode: null,
+ knowledgeStatus: "unknown",
+ knowledgeLastCheckedAt: null,
+ knowledgeError: null
+ }
+ }))
+ checkInFlight = false
+ return
+ }
- let knowledgeStatus: KnowledgeStatus = currentState.knowledgeStatus
- let knowledgeLastCheckedAt = currentState.knowledgeLastCheckedAt
- let knowledgeError = currentState.knowledgeError
- const shouldRefreshKnowledge =
- !currentState.knowledgeLastCheckedAt ||
- now - currentState.knowledgeLastCheckedAt >= KNOWLEDGE_RECHECK_INTERVAL_MS ||
- currentState.knowledgeStatus !== "ready"
-
- if (ok && shouldRefreshKnowledge) {
- try {
- // Add timeout to RAG health check to prevent hanging
- // Increased from 5s to 15s to avoid false "offline" status when RAG is slow but working
- const ragPromise = tldwClient.ragHealth()
- const ragTimeout = new Promise((resolve) =>
- setTimeout(() => resolve(null), 15000)
+ if (!serverUrl) {
+ set((s) => ({
+ state: {
+ ...s.state,
+ phase: ConnectionPhase.UNCONFIGURED,
+ serverUrl: null,
+ isConnected: false,
+ isChecking: false,
+ consecutiveFailures: 0,
+ offlineBypass: false,
+ errorKind: "none",
+ lastCheckedAt: Date.now(),
+ lastError: null,
+ lastStatusCode: null,
+ knowledgeStatus: "unknown",
+ knowledgeLastCheckedAt: null,
+ knowledgeError: null
+ }
+ }))
+ checkInFlight = false
+ return
+ }
+
+ await tldwClient.initialize()
+
+ // Request health via background for detailed status codes.
+ // Health endpoints may require auth; apiSend injects headers based
+ // on tldwConfig (API key / access token).
+ const noAuthForHealth = !cfg ||
+ (!hasSingleUserApiKeyValue &&
+ !cfg.accessToken &&
+ cfg.authMode !== "multi-user")
+
+ const healthPromise = (async () => {
+ try {
+ const resp = await apiSend({
+ path: HEALTH_LIVENESS_PATH,
+ method: 'GET',
+ timeoutMs: CONNECTION_TIMEOUT_MS,
+ // Allow unauthenticated health checks when no credentials have
+ // been configured yet so first‑run onboarding can still detect a
+ // reachable server URL. Once an API key or access token exists,
+ // health should run with auth.
+ noAuth: noAuthForHealth
+ })
+ return { ok: Boolean(resp?.ok), status: Number(resp?.status) || 0, error: resp?.ok ? null : (resp?.error || null) }
+ } catch (e) {
+ return { ok: false, status: 0, error: (e as Error)?.message || 'Network error' }
+ }
+ })()
+ let healthResult = await Promise.race([
+ healthPromise,
+ new Promise<{ ok: boolean; status: number; error: string | null }>((resolve) =>
+ setTimeout(() => resolve({ ok: false, status: 0, error: 'timeout' }), CONNECTION_TIMEOUT_MS)
)
- const rag = await Promise.race([ragPromise, ragTimeout])
- if (rag !== null) {
- knowledgeStatus = deriveKnowledgeStatusFromHealth(rag)
- } else {
- knowledgeStatus = "offline"
- knowledgeError = "rag-timeout"
+ ])
+
+ const fallbackServerUrl = deriveCurrentHostRecoveryServerUrl(
+ quickstartWebUiServerUrl ? recoveryProbeSourceServerUrl : serverUrl
+ )
+ if (
+ !healthResult.ok &&
+ healthResult.status === 0 &&
+ isNetworkTransportFailure(healthResult.error) &&
+ fallbackServerUrl
+ ) {
+ const probeOk = await probeServerLiveness(
+ fallbackServerUrl,
+ Math.min(5_000, CONNECTION_TIMEOUT_MS)
+ )
+ if (probeOk) {
+ if (!quickstartWebUiServerUrl) {
+ await tldwClient.updateConfig({ serverUrl: fallbackServerUrl })
+ serverUrl = fallbackServerUrl
+ cfg = {
+ ...(cfg || {}),
+ serverUrl: fallbackServerUrl
+ } as TldwConfig
+ }
+ const fallbackHasSingleUserApiKey = hasSingleUserApiKey(cfg)
+ const fallbackNoAuth = !cfg ||
+ (!fallbackHasSingleUserApiKey &&
+ !cfg.accessToken &&
+ cfg.authMode !== "multi-user")
+ const fallbackResp = await apiSend({
+ path: HEALTH_LIVENESS_PATH,
+ method: "GET",
+ timeoutMs: CONNECTION_TIMEOUT_MS,
+ noAuth: fallbackNoAuth
+ })
+ healthResult = {
+ ok: Boolean(fallbackResp?.ok),
+ status: Number(fallbackResp?.status) || 0,
+ error: fallbackResp?.ok ? null : (fallbackResp?.error || null)
+ }
}
- knowledgeLastCheckedAt = Date.now()
- if (knowledgeStatus === "empty") {
- knowledgeError = "no-index"
+ }
+
+ const ok = healthResult.ok
+ const resolvedHealthError = maybeAnnotateCorsMismatchError({
+ error: healthResult.error,
+ status: healthResult.status,
+ serverUrl
+ })
+
+ let knowledgeStatus: KnowledgeStatus = currentState.knowledgeStatus
+ let knowledgeLastCheckedAt = currentState.knowledgeLastCheckedAt
+ let knowledgeError = currentState.knowledgeError
+ const shouldRefreshKnowledge =
+ !currentState.knowledgeLastCheckedAt ||
+ now - currentState.knowledgeLastCheckedAt >= KNOWLEDGE_RECHECK_INTERVAL_MS ||
+ currentState.knowledgeStatus !== "ready"
+
+ if (ok && shouldRefreshKnowledge) {
+ try {
+ // Add timeout to RAG health check to prevent hanging
+ // Increased from 5s to 15s to avoid false "offline" status when RAG is slow but working
+ const ragPromise = tldwClient.ragHealth()
+ const ragTimeout = new Promise((resolve) =>
+ setTimeout(() => resolve(null), 15000)
+ )
+ const rag = await Promise.race([ragPromise, ragTimeout])
+ if (rag !== null) {
+ knowledgeStatus = deriveKnowledgeStatusFromHealth(rag)
+ } else {
+ knowledgeStatus = "offline"
+ knowledgeError = "rag-timeout"
+ }
+ knowledgeLastCheckedAt = Date.now()
+ if (knowledgeStatus === "empty") {
+ knowledgeError = "no-index"
+ }
+ } catch (e) {
+ knowledgeStatus = "offline"
+ knowledgeLastCheckedAt = Date.now()
+ knowledgeError = (e as Error)?.message ?? "unknown-error"
}
- } catch (e) {
+ } else if (!ok) {
knowledgeStatus = "offline"
knowledgeLastCheckedAt = Date.now()
- knowledgeError = (e as Error)?.message ?? "unknown-error"
+ knowledgeError = "core-offline"
}
- } else if (!ok) {
- knowledgeStatus = "offline"
- knowledgeLastCheckedAt = Date.now()
- knowledgeError = "core-offline"
- }
- let errorKind: ConnectionState["errorKind"] = "none"
- const nextConsecutiveFailures = ok ? 0 : currentState.consecutiveFailures + 1
+ let errorKind: ConnectionState["errorKind"] = "none"
+ const nextConsecutiveFailures = ok ? 0 : currentState.consecutiveFailures + 1
- if (ok) {
- if (knowledgeStatus === "offline") {
- errorKind = "partial"
- } else {
- errorKind = "none"
- }
- } else {
- const status = healthResult.status
- if (status === 401 || status === 403) {
- errorKind = "auth"
+ if (ok) {
+ if (knowledgeStatus === "offline") {
+ errorKind = "partial"
+ } else {
+ errorKind = "none"
+ }
} else {
- errorKind = "unreachable"
+ const status = healthResult.status
+ if (status === 401 || status === 403) {
+ errorKind = "auth"
+ } else {
+ errorKind = "unreachable"
+ }
}
- }
- const holdConnectedOnTransientFailure =
- !ok &&
- errorKind === "unreachable" &&
- currentState.isConnected &&
- currentState.phase === ConnectionPhase.CONNECTED &&
- nextConsecutiveFailures < CONNECTED_FAILURE_THRESHOLD
+ const holdConnectedOnTransientFailure =
+ !ok &&
+ errorKind === "unreachable" &&
+ currentState.isConnected &&
+ currentState.phase === ConnectionPhase.CONNECTED &&
+ nextConsecutiveFailures < CONNECTED_FAILURE_THRESHOLD
+
+ if (holdConnectedOnTransientFailure) {
+ set((s) => ({
+ state: {
+ ...s.state,
+ phase: ConnectionPhase.CONNECTED,
+ isConnected: true,
+ isChecking: false,
+ offlineBypass: false,
+ lastCheckedAt: Date.now(),
+ lastError: resolvedHealthError || "transient-health-check-failure",
+ lastStatusCode: healthResult.status || 0,
+ errorKind: "partial",
+ consecutiveFailures: nextConsecutiveFailures
+ }
+ }))
+ checkInFlight = false
+ return
+ }
- if (holdConnectedOnTransientFailure) {
- set({
+ set((s) => ({
state: {
- ...currentState,
- phase: ConnectionPhase.CONNECTED,
- isConnected: true,
+ ...s.state,
+ phase: ok ? ConnectionPhase.CONNECTED : ConnectionPhase.ERROR,
+ serverUrl,
+ isConnected: ok,
isChecking: false,
+ consecutiveFailures:
+ ok
+ ? 0
+ : errorKind === "unreachable"
+ ? nextConsecutiveFailures
+ : 0,
offlineBypass: false,
lastCheckedAt: Date.now(),
- lastError: resolvedHealthError || "transient-health-check-failure",
- lastStatusCode: healthResult.status || 0,
- errorKind: "partial",
- consecutiveFailures: nextConsecutiveFailures
+ lastError: ok ? null : (resolvedHealthError || 'timeout-or-offline'),
+ lastStatusCode: ok ? null : healthResult.status,
+ knowledgeStatus,
+ knowledgeLastCheckedAt,
+ knowledgeError,
+ errorKind,
+ checksSinceConfigChange: nextChecksSinceConfigChange
}
- })
- return
+ }))
+ } catch (error) {
+ const fallbackError =
+ maybeAnnotateCorsMismatchError({
+ error: (error as Error)?.message ?? "unknown-error",
+ status: 0,
+ serverUrl: currentState.serverUrl
+ }) ?? "unknown-error"
+ set((s) => ({
+ state: {
+ ...s.state,
+ phase: ConnectionPhase.ERROR,
+ isConnected: false,
+ isChecking: false,
+ consecutiveFailures: currentState.consecutiveFailures + 1,
+ offlineBypass: false,
+ lastCheckedAt: Date.now(),
+ lastError: fallbackError,
+ lastStatusCode: 0,
+ knowledgeStatus: "offline",
+ knowledgeLastCheckedAt: Date.now(),
+ knowledgeError: fallbackError,
+ errorKind: "unreachable",
+ checksSinceConfigChange: nextChecksSinceConfigChange
+ }
+ }))
}
-
- set({
- state: {
- ...currentState,
- phase: ok ? ConnectionPhase.CONNECTED : ConnectionPhase.ERROR,
- serverUrl,
- isConnected: ok,
- isChecking: false,
- consecutiveFailures:
- ok
- ? 0
- : errorKind === "unreachable"
- ? nextConsecutiveFailures
- : 0,
- offlineBypass: false,
- lastCheckedAt: Date.now(),
- lastError: ok ? null : (resolvedHealthError || 'timeout-or-offline'),
- lastStatusCode: ok ? null : healthResult.status,
- knowledgeStatus,
- knowledgeLastCheckedAt,
- knowledgeError,
- errorKind,
- checksSinceConfigChange: nextChecksSinceConfigChange
- }
- })
- } catch (error) {
- const fallbackError =
- maybeAnnotateCorsMismatchError({
- error: (error as Error)?.message ?? "unknown-error",
- status: 0,
- serverUrl: currentState.serverUrl
- }) ?? "unknown-error"
- set({
- state: {
- ...currentState,
- phase: ConnectionPhase.ERROR,
- isConnected: false,
- isChecking: false,
- consecutiveFailures: currentState.consecutiveFailures + 1,
- offlineBypass: false,
- lastCheckedAt: Date.now(),
- lastError: fallbackError,
- lastStatusCode: 0,
- knowledgeStatus: "offline",
- knowledgeLastCheckedAt: Date.now(),
- knowledgeError: fallbackError,
- errorKind: "unreachable",
- checksSinceConfigChange: nextChecksSinceConfigChange
- }
- })
+ // Release the in-flight guard for both the normal-completion and caught-error
+ // paths (they converge here after the try/catch above).
+ checkInFlight = false
+ } catch (guardError) {
+ // A throw anywhere above (persisted-flag reads, pre-check state syncs, or
+ // the health check) must still release the synchronous in-flight guard;
+ // otherwise every future health check would be permanently deadlocked.
+ checkInFlight = false
+ throw guardError
}
},
diff --git a/apps/packages/ui/src/store/feedback.tsx b/apps/packages/ui/src/store/feedback.tsx
index 6462a730ab..85a060780d 100644
--- a/apps/packages/ui/src/store/feedback.tsx
+++ b/apps/packages/ui/src/store/feedback.tsx
@@ -159,6 +159,10 @@ export const useFeedbackStore = createWithEqualityFn()(
}),
{
name: "tldw-feedback-store",
+ // Baseline version so future shape changes can migrate instead of discarding
+ // persisted state (see apps/FRONTEND_AUDIT.md §6 / TASK-12102).
+ version: 1,
+ migrate: (persisted) => persisted as any,
storage: createJSONStorage(() =>
typeof window !== "undefined" ? localStorage : createMemoryStorage()
),
diff --git a/apps/packages/ui/src/store/folder.tsx b/apps/packages/ui/src/store/folder.tsx
index 53dee7133d..42efa31746 100644
--- a/apps/packages/ui/src/store/folder.tsx
+++ b/apps/packages/ui/src/store/folder.tsx
@@ -833,15 +833,37 @@ export const useFolderStore = createWithEqualityFn()(
}),
{
name: 'tldw-folder-store',
+ // Baseline version so future shape changes can migrate instead of discarding
+ // persisted state (see apps/FRONTEND_AUDIT.md §6 / TASK-12102).
+ version: 1,
+ migrate: (persisted) => persisted as any,
// Use throttled storage to avoid exceeding browser write quota limits
storage: createJSONStorage(() => createThrottledLocalStorage(1000)),
// Persist UI prefs + cache metadata (not the server data itself).
+ // NOTE: `folderApiAvailable` is intentionally NOT persisted. It is a
+ // transient runtime signal: a single 404 sets it false to avoid hammering
+ // a server that lacks the folder API for the rest of the session, but it
+ // must reset to `null` (unknown, retryable) on the next session so one
+ // transient 404 cannot disable folder sync forever.
partialize: (state) => ({
uiPrefs: state.uiPrefs,
viewMode: state.viewMode,
- lastSynced: state.lastSynced,
- folderApiAvailable: state.folderApiAvailable
- })
+ lastSynced: state.lastSynced
+ }),
+ // Never rehydrate `folderApiAvailable` from storage. Besides not persisting
+ // it going forward (see partialize), this strips any stale `false` written
+ // by an earlier build so users already "stuck" with folder sync disabled
+ // recover to the retryable `null` default on their next load. Behaves like
+ // the default shallow merge otherwise.
+ merge: (persistedState, currentState) => {
+ const persisted = (persistedState ?? {}) as Partial
+ const { folderApiAvailable: _legacyFolderApiAvailable, ...rest } =
+ persisted
+ return {
+ ...currentState,
+ ...rest
+ }
+ }
}
)
)
diff --git a/apps/packages/ui/src/store/notes-dock.tsx b/apps/packages/ui/src/store/notes-dock.tsx
index 2013725761..48f92d14dd 100644
--- a/apps/packages/ui/src/store/notes-dock.tsx
+++ b/apps/packages/ui/src/store/notes-dock.tsx
@@ -230,6 +230,10 @@ export const useNotesDockStore = createWithEqualityFn()(
}),
{
name: "tldw-notes-dock",
+ // Baseline version so future shape changes can migrate instead of discarding
+ // persisted state (see apps/FRONTEND_AUDIT.md §6 / TASK-12102).
+ version: 1,
+ migrate: (persisted) => persisted as any,
storage: createJSONStorage(() =>
typeof window !== "undefined" ? localStorage : createMemoryStorage()
),
diff --git a/apps/packages/ui/src/store/persona-buddy-shell.ts b/apps/packages/ui/src/store/persona-buddy-shell.ts
index 985554039b..a6875ee97e 100644
--- a/apps/packages/ui/src/store/persona-buddy-shell.ts
+++ b/apps/packages/ui/src/store/persona-buddy-shell.ts
@@ -171,6 +171,10 @@ export const usePersonaBuddyShellStore =
}),
{
name: PERSONA_BUDDY_SHELL_STORAGE_KEY,
+ // Baseline version so future shape changes can migrate instead of discarding
+ // persisted state (see apps/FRONTEND_AUDIT.md §6 / TASK-12102).
+ version: 1,
+ migrate: (persisted) => persisted as any,
storage: createJSONStorage(() =>
typeof window !== "undefined" ? localStorage : createMemoryStorage()
),
diff --git a/apps/packages/ui/src/store/playground-session.tsx b/apps/packages/ui/src/store/playground-session.tsx
index 42cbbd12f6..7268723b5e 100644
--- a/apps/packages/ui/src/store/playground-session.tsx
+++ b/apps/packages/ui/src/store/playground-session.tsx
@@ -118,6 +118,10 @@ export const usePlaygroundSessionStore = createWithEqualityFn persisted as any,
storage: createJSONStorage(() =>
typeof window !== "undefined" ? localStorage : createMemoryStorage()
),
diff --git a/apps/packages/ui/src/store/quick-ingest-session.ts b/apps/packages/ui/src/store/quick-ingest-session.ts
index 30d6638950..f81ad7f64c 100644
--- a/apps/packages/ui/src/store/quick-ingest-session.ts
+++ b/apps/packages/ui/src/store/quick-ingest-session.ts
@@ -709,6 +709,10 @@ export const createQuickIngestSessionStore = () =>
}),
{
name: STORAGE_KEY,
+ // Baseline version so future shape changes can migrate instead of discarding
+ // persisted state (see apps/FRONTEND_AUDIT.md §6 / TASK-12102).
+ version: 1,
+ migrate: (persisted) => persisted as any,
storage: createJSONStorage(() => createSessionStorage()),
partialize: (state) => buildPersistedState(state.session),
merge: (persistedState, currentState) => {
diff --git a/apps/packages/ui/src/store/ui-mode.tsx b/apps/packages/ui/src/store/ui-mode.tsx
index f53c205f44..4114966bd1 100644
--- a/apps/packages/ui/src/store/ui-mode.tsx
+++ b/apps/packages/ui/src/store/ui-mode.tsx
@@ -25,6 +25,10 @@ export const useUiModeStore = createWithEqualityFn()(
}),
{
name: "tldw-ui-mode",
+ // Baseline version so a future shape change can migrate instead of silently
+ // discarding persisted state (see apps/FRONTEND_AUDIT.md §6 / TASK-12102).
+ version: 1,
+ migrate: (persisted) => persisted as any,
storage: createJSONStorage(() =>
typeof window !== "undefined" ? localStorage : createMemoryStorage()
)
diff --git a/apps/packages/ui/src/store/workspace.ts b/apps/packages/ui/src/store/workspace.ts
index ce60323369..1b09c8a28a 100644
--- a/apps/packages/ui/src/store/workspace.ts
+++ b/apps/packages/ui/src/store/workspace.ts
@@ -3813,15 +3813,26 @@ export const duplicateWorkspaceSnapshot = (
// Store
// ─────────────────────────────────────────────────────────────────────────────
+// Captured store setter so `onRehydrateStorage` can publish the post-processed
+// hydrated state THROUGH the store (notifying subscribers) instead of mutating
+// the passed-in state object in place. The creator runs before hydration, so
+// this is always assigned by the time the rehydrate callback fires (this also
+// avoids a temporal-dead-zone reference to `useWorkspaceStore` when the backing
+// storage is synchronous and hydration happens during store construction).
+let publishWorkspaceHydration: ((next: WorkspaceState) => void) | null = null
+
export const useWorkspaceStore = createWithEqualityFn()(
persist(
- (set, get) => ({
- ...initialState,
- ...createSourcesSlice(set, get),
- ...createStudioSlice(set, get),
- ...createUISlice(set, get),
- ...createWorkspaceListSlice(set, get),
- }),
+ (set, get) => {
+ publishWorkspaceHydration = (next) => set(next, true)
+ return {
+ ...initialState,
+ ...createSourcesSlice(set, get),
+ ...createStudioSlice(set, get),
+ ...createUISlice(set, get),
+ ...createWorkspaceListSlice(set, get),
+ }
+ },
{
name: WORKSPACE_STORAGE_KEY,
storage: createJSONStorage(() => createWorkspaceStorage()),
@@ -3967,6 +3978,16 @@ export const useWorkspaceStore = createWithEqualityFn()(
}
state.storeHydrated = true
+
+ // Publish the post-processed hydrated state THROUGH the store so
+ // subscribers (already-mounted components, loading gates keyed on
+ // `storeHydrated`) are notified. Persist applies the raw persisted
+ // values via its own `set()` BEFORE this callback, but the mutations
+ // above (date revival, snapshot application, `storeHydrated`) happen
+ // afterwards and would otherwise never be broadcast. Spreading into a
+ // fresh object gives `set` a new reference so the update is not
+ // dropped as a no-op; `replace: true` matches the shape we mutated.
+ publishWorkspaceHydration?.({ ...state })
}
}
}
diff --git a/apps/packages/ui/src/utils/__tests__/absolute-url-guard.test.ts b/apps/packages/ui/src/utils/__tests__/absolute-url-guard.test.ts
new file mode 100644
index 0000000000..466e7170fe
--- /dev/null
+++ b/apps/packages/ui/src/utils/__tests__/absolute-url-guard.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from "vitest"
+import {
+ ABSOLUTE_URL_BLOCK_ERROR,
+ absoluteOriginAllowlistFromConfig,
+ evaluateAbsoluteUrlAccess,
+ isAbsoluteHttpUrl,
+ isAbsoluteUrlAllowlisted,
+ isSameOriginAbsoluteUrlForConfiguredServer
+} from "@/utils/absolute-url-guard"
+
+const serverCfg = {
+ serverUrl: "https://server.example.test",
+ authMode: "single-user",
+ apiKey: "secret-key"
+}
+
+describe("absolute-url-guard", () => {
+ it("treats only http(s) paths as absolute", () => {
+ expect(isAbsoluteHttpUrl("https://a.example/x")).toBe(true)
+ expect(isAbsoluteHttpUrl("http://a.example/x")).toBe(true)
+ expect(isAbsoluteHttpUrl("/api/v1/media")).toBe(false)
+ expect(isAbsoluteHttpUrl("ftp://a.example")).toBe(false)
+ expect(isAbsoluteHttpUrl(undefined)).toBe(false)
+ })
+
+ it("allowlist always contains the configured server origin", () => {
+ const allow = absoluteOriginAllowlistFromConfig(serverCfg)
+ expect(allow.has("https://server.example.test")).toBe(true)
+ })
+
+ it("merges explicit absoluteUrlAllowlist entries (string or array)", () => {
+ const arrayCfg = {
+ ...serverCfg,
+ absoluteUrlAllowlist: ["https://cdn.example.test", "not-a-url"]
+ }
+ expect(isAbsoluteUrlAllowlisted("https://cdn.example.test/f", arrayCfg)).toBe(
+ true
+ )
+
+ const stringCfg = {
+ ...serverCfg,
+ absoluteUrlAllowlist: "https://cdn.example.test, https://two.example.test"
+ }
+ expect(isAbsoluteUrlAllowlisted("https://two.example.test/x", stringCfg)).toBe(
+ true
+ )
+ })
+
+ it("same-origin check matches the configured server origin only", () => {
+ expect(
+ isSameOriginAbsoluteUrlForConfiguredServer(
+ "https://server.example.test/api/v1/media/ingest/jobs",
+ serverCfg
+ )
+ ).toBe(true)
+ expect(
+ isSameOriginAbsoluteUrlForConfiguredServer(
+ "https://attacker.example/x",
+ serverCfg
+ )
+ ).toBe(false)
+ })
+
+ describe("evaluateAbsoluteUrlAccess", () => {
+ it("blocks a non-allowlisted cross-origin absolute URL (attacker)", () => {
+ const decision = evaluateAbsoluteUrlAccess(
+ "https://attacker.example/steal",
+ serverCfg
+ )
+ expect(decision).toEqual({
+ isAbsolute: true,
+ blocked: true,
+ skipAuth: true
+ })
+ })
+
+ it("attaches auth for a same-origin absolute URL to the configured server", () => {
+ const decision = evaluateAbsoluteUrlAccess(
+ "https://server.example.test/api/v1/media/ingest/jobs",
+ serverCfg
+ )
+ expect(decision).toEqual({
+ isAbsolute: true,
+ blocked: false,
+ skipAuth: false
+ })
+ })
+
+ it("permits but strips auth for an allowlisted cross-origin absolute URL", () => {
+ const cfg = {
+ ...serverCfg,
+ absoluteUrlAllowlist: ["https://cdn.example.test"]
+ }
+ const decision = evaluateAbsoluteUrlAccess(
+ "https://cdn.example.test/file",
+ cfg
+ )
+ expect(decision).toEqual({
+ isAbsolute: true,
+ blocked: false,
+ skipAuth: true
+ })
+ })
+
+ it("leaves relative paths untouched (auth attached, not blocked)", () => {
+ const decision = evaluateAbsoluteUrlAccess(
+ "/api/v1/media/ingest/jobs",
+ serverCfg
+ )
+ expect(decision).toEqual({
+ isAbsolute: false,
+ blocked: false,
+ skipAuth: false
+ })
+ })
+ })
+
+ it("exposes a stable block error message", () => {
+ expect(ABSOLUTE_URL_BLOCK_ERROR).toContain("allowlisted")
+ })
+})
diff --git a/apps/packages/ui/src/utils/__tests__/character-export.ssrf.test.ts b/apps/packages/ui/src/utils/__tests__/character-export.ssrf.test.ts
new file mode 100644
index 0000000000..902f4448cc
--- /dev/null
+++ b/apps/packages/ui/src/utils/__tests__/character-export.ssrf.test.ts
@@ -0,0 +1,183 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import {
+ exportCharacterToPNG,
+ isSafeAvatarFetchUrl
+} from "../character-export"
+
+// A minimal-but-valid PNG: 8-byte signature + IHDR chunk (length 13). Enough for
+// embedMetadataInPNG's signature/IHDR checks so the export can complete.
+const makeMinimalPng = (): Uint8Array => {
+ const bytes = new Uint8Array(33)
+ bytes.set([137, 80, 78, 71, 13, 10, 26, 10], 0) // PNG signature
+ bytes[8] = 0
+ bytes[9] = 0
+ bytes[10] = 0
+ bytes[11] = 13 // IHDR length = 13
+ bytes.set([73, 72, 68, 82], 12) // "IHDR"
+ // remaining 13 IHDR data bytes + 4 CRC bytes stay zeroed
+ return bytes
+}
+
+const makeResponse = (
+ body: Uint8Array,
+ headers: Record = {}
+): any => ({
+ ok: true,
+ status: 200,
+ statusText: "OK",
+ headers: {
+ get: (name: string) => headers[name.toLowerCase()] ?? null
+ },
+ arrayBuffer: async () => body.buffer
+})
+
+const originalCreateObjectURL = (URL as any).createObjectURL
+const originalRevokeObjectURL = (URL as any).revokeObjectURL
+let createObjectURLSpy: ReturnType
+
+beforeEach(() => {
+ createObjectURLSpy = vi.fn(() => "blob:mock")
+ ;(URL as any).createObjectURL = createObjectURLSpy
+ ;(URL as any).revokeObjectURL = vi.fn()
+})
+
+afterEach(() => {
+ ;(URL as any).createObjectURL = originalCreateObjectURL
+ ;(URL as any).revokeObjectURL = originalRevokeObjectURL
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+})
+
+describe("isSafeAvatarFetchUrl", () => {
+ it("allows same-origin http(s) URLs", () => {
+ expect(isSafeAvatarFetchUrl(`${window.location.origin}/media/a.png`)).toBe(
+ true
+ )
+ })
+
+ it("rejects cross-origin URLs (SSRF / beacon guard)", () => {
+ expect(isSafeAvatarFetchUrl("https://evil.example.com/beacon.png")).toBe(
+ false
+ )
+ expect(isSafeAvatarFetchUrl("http://169.254.169.254/latest/meta-data")).toBe(
+ false
+ )
+ })
+
+ it("allows an explicitly allowlisted origin (e.g. the configured server)", () => {
+ expect(
+ isSafeAvatarFetchUrl("http://127.0.0.1:8000/media/a.png", [
+ "http://127.0.0.1:8000"
+ ])
+ ).toBe(true)
+ })
+
+ it("rejects non-http(s) protocols", () => {
+ expect(isSafeAvatarFetchUrl("file:///etc/passwd")).toBe(false)
+ expect(isSafeAvatarFetchUrl("javascript:alert(1)")).toBe(false)
+ })
+
+ it("rejects empty input", () => {
+ expect(isSafeAvatarFetchUrl("")).toBe(false)
+ expect(isSafeAvatarFetchUrl(" ")).toBe(false)
+ })
+})
+
+describe("exportCharacterToPNG avatar fetch hardening", () => {
+ it("does NOT fetch a cross-origin avatar_url", async () => {
+ const fetchFn = vi.fn(async () => makeResponse(makeMinimalPng()))
+ vi.stubGlobal("fetch", fetchFn)
+
+ // Cross-origin avatar is skipped; export falls back to a local placeholder
+ // (canvas is unavailable in jsdom, so this may reject — that's incidental).
+ await exportCharacterToPNG(
+ { name: "Ada" },
+ { avatarUrl: "https://evil.example.com/beacon.png" }
+ ).catch(() => undefined)
+
+ expect(fetchFn).not.toHaveBeenCalled()
+ })
+
+ it("fetches a same-origin avatar with an AbortSignal and no credentials", async () => {
+ const png = makeMinimalPng()
+ const fetchFn = vi.fn(async () =>
+ makeResponse(png, { "content-length": String(png.byteLength) })
+ )
+ vi.stubGlobal("fetch", fetchFn)
+
+ const sameOriginUrl = `${window.location.origin}/media/avatar.png`
+ await exportCharacterToPNG({ name: "Ada" }, { avatarUrl: sameOriginUrl })
+
+ expect(fetchFn).toHaveBeenCalledTimes(1)
+ const [calledUrl, init] = fetchFn.mock.calls[0] as [string, RequestInit]
+ expect(calledUrl).toBe(sameOriginUrl)
+ expect(init?.credentials).toBe("omit")
+ expect(init?.signal).toBeInstanceOf(AbortSignal)
+ // Download path ran, so the fetched avatar was embedded.
+ expect(createObjectURLSpy).toHaveBeenCalled()
+ })
+
+ it("bails before reading the body when content-length exceeds the size cap", async () => {
+ const arrayBufferSpy = vi.fn(async () => makeMinimalPng().buffer)
+ const oversized = 5 * 1024 * 1024 + 1
+ const fetchFn = vi.fn(async () => ({
+ ok: true,
+ status: 200,
+ statusText: "OK",
+ headers: {
+ get: (name: string) =>
+ name.toLowerCase() === "content-length" ? String(oversized) : null
+ },
+ arrayBuffer: arrayBufferSpy
+ }))
+ vi.stubGlobal("fetch", fetchFn)
+
+ const sameOriginUrl = `${window.location.origin}/media/huge.png`
+ await exportCharacterToPNG({ name: "Ada" }, { avatarUrl: sameOriginUrl }).catch(
+ () => undefined
+ )
+
+ expect(fetchFn).toHaveBeenCalledTimes(1)
+ expect(arrayBufferSpy).not.toHaveBeenCalled()
+ })
+
+ it("decodes an inline data: avatar without any network request", async () => {
+ const fetchFn = vi.fn(async () => makeResponse(makeMinimalPng()))
+ vi.stubGlobal("fetch", fetchFn)
+
+ const png = makeMinimalPng()
+ let binary = ""
+ png.forEach((byte) => {
+ binary += String.fromCharCode(byte)
+ })
+ const dataUrl = `data:image/png;base64,${btoa(binary)}`
+
+ await exportCharacterToPNG({ name: "Ada" }, { avatarUrl: dataUrl })
+
+ expect(fetchFn).not.toHaveBeenCalled()
+ expect(createObjectURLSpy).toHaveBeenCalled()
+ })
+
+ it("embeds an already-local base64 avatar without a network request", async () => {
+ const fetchFn = vi.fn(async () => makeResponse(makeMinimalPng()))
+ vi.stubGlobal("fetch", fetchFn)
+
+ const png = makeMinimalPng()
+ let binary = ""
+ png.forEach((byte) => {
+ binary += String.fromCharCode(byte)
+ })
+
+ await exportCharacterToPNG(
+ { name: "Ada" },
+ {
+ avatarBase64: btoa(binary),
+ // A hostile avatarUrl is ignored entirely when base64 is present.
+ avatarUrl: "https://evil.example.com/beacon.png"
+ }
+ )
+
+ expect(fetchFn).not.toHaveBeenCalled()
+ expect(createObjectURLSpy).toHaveBeenCalled()
+ })
+})
diff --git a/apps/packages/ui/src/utils/__tests__/message-variants.test.ts b/apps/packages/ui/src/utils/__tests__/message-variants.test.ts
index 355b492518..493946b5bd 100644
--- a/apps/packages/ui/src/utils/__tests__/message-variants.test.ts
+++ b/apps/packages/ui/src/utils/__tests__/message-variants.test.ts
@@ -58,6 +58,51 @@ describe("message variant metadata", () => {
expect(next.metadataExtra).toBeUndefined()
})
+ it("does not inherit the prior variant's serverMessageId for an unpersisted variant", () => {
+ const message = createMessage({
+ serverMessageId: "server-1",
+ serverMessageVersion: 3
+ })
+
+ const next = applyVariantToMessage(
+ message,
+ {
+ // A freshly-regenerated variant that has not been persisted yet.
+ id: "variant-2",
+ message: "regenerated answer",
+ sources: [],
+ images: []
+ },
+ 1
+ )
+
+ expect(next.serverMessageId).toBeUndefined()
+ expect(next.serverMessageVersion).toBeUndefined()
+ })
+
+ it("adopts a persisted variant's own server identity when swiping", () => {
+ const message = createMessage({
+ serverMessageId: "server-1",
+ serverMessageVersion: 3
+ })
+
+ const next = applyVariantToMessage(
+ message,
+ {
+ id: "variant-2",
+ message: "persisted answer",
+ serverMessageId: "server-2",
+ serverMessageVersion: 5,
+ sources: [],
+ images: []
+ },
+ 1
+ )
+
+ expect(next.serverMessageId).toBe("server-2")
+ expect(next.serverMessageVersion).toBe(5)
+ })
+
it("stores metadata updates on the active variant", () => {
const message = createMessage({
activeVariantIndex: 0,
diff --git a/apps/packages/ui/src/utils/__tests__/safe-external-url.test.ts b/apps/packages/ui/src/utils/__tests__/safe-external-url.test.ts
new file mode 100644
index 0000000000..82de7b9656
--- /dev/null
+++ b/apps/packages/ui/src/utils/__tests__/safe-external-url.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest"
+import { safeExternalUrl } from "../safe-external-url"
+
+describe("safeExternalUrl", () => {
+ it("allows http and https URLs", () => {
+ expect(safeExternalUrl("https://x")).toBe("https://x")
+ expect(safeExternalUrl("http://example.com/a?b=c#d")).toBe(
+ "http://example.com/a?b=c#d"
+ )
+ })
+
+ it("allows mailto URLs", () => {
+ expect(safeExternalUrl("mailto:a@b")).toBe("mailto:a@b")
+ })
+
+ it("allows relative paths and anchors", () => {
+ expect(safeExternalUrl("/foo/bar")).toBe("/foo/bar")
+ expect(safeExternalUrl("./rel")).toBe("./rel")
+ expect(safeExternalUrl("../up")).toBe("../up")
+ expect(safeExternalUrl("#section")).toBe("#section")
+ })
+
+ it("rejects javascript: URLs", () => {
+ expect(safeExternalUrl("javascript:alert(1)")).toBeNull()
+ expect(safeExternalUrl("JavaScript:alert(1)")).toBeNull()
+ expect(safeExternalUrl(" javascript:alert(1)")).toBeNull()
+ })
+
+ it("rejects control-char obfuscated schemes", () => {
+ // A tab inside the scheme (`java\tscript:`) is stripped by the browser at
+ // click time, so it must be neutralized before scheme matching.
+ expect(safeExternalUrl("java\tscript:alert(1)")).toBeNull()
+ expect(safeExternalUrl("java\nscript:alert(1)")).toBeNull()
+ expect(safeExternalUrl("javascript:alert(1)")).toBeNull()
+ })
+
+ it("rejects other dangerous schemes", () => {
+ expect(safeExternalUrl("data:text/html,")).toBeNull()
+ expect(safeExternalUrl("vbscript:msgbox(1)")).toBeNull()
+ expect(safeExternalUrl("file:///etc/passwd")).toBeNull()
+ })
+
+ it("rejects empty and non-string input", () => {
+ expect(safeExternalUrl("")).toBeNull()
+ expect(safeExternalUrl(" ")).toBeNull()
+ expect(safeExternalUrl(null)).toBeNull()
+ expect(safeExternalUrl(undefined)).toBeNull()
+ expect(safeExternalUrl(42)).toBeNull()
+ })
+})
diff --git a/apps/packages/ui/src/utils/absolute-url-guard.ts b/apps/packages/ui/src/utils/absolute-url-guard.ts
new file mode 100644
index 0000000000..1adf0f0aa5
--- /dev/null
+++ b/apps/packages/ui/src/utils/absolute-url-guard.ts
@@ -0,0 +1,157 @@
+// Absolute-URL credential guard — the single canonical source for the MV3
+// extension's origin-allowlist + cross-origin auth-suppression rules.
+//
+// Both the normal request path (`services/tldw/request-core.ts`) and the
+// background upload/stream proxy (`services/background-proxy.ts`) previously kept
+// their own byte-for-byte copies of this logic; they now import these primitives
+// so there is exactly one implementation. This module is intentionally
+// dependency-light (no imports) so it is safe to import from the background
+// entry, request-core, and background-proxy without circular-import or
+// heavy-dependency risk.
+//
+// `request-core.ts` additionally warns (once) about a malformed configured
+// serverUrl / allowlist entry; those diagnostics are supplied via the optional
+// `AllowlistWarnHooks` so the shared logic stays free of console side effects for
+// its other callers (which silently ignore malformed URLs, as before).
+
+export type AllowlistConfig = Record | null | undefined
+
+// Optional diagnostics hooks. When omitted, malformed URLs are silently ignored
+// (the behaviour the background handlers rely on). request-core supplies these
+// to preserve its once-per-value console warnings.
+export type AllowlistWarnHooks = {
+ onMalformedServerUrl?: (raw: string, error: unknown) => void
+ onMalformedAllowlistEntry?: (raw: string, error: unknown) => void
+}
+
+export const ABSOLUTE_URL_BLOCK_ERROR =
+ "Absolute URL requests are blocked unless the request origin is explicitly allowlisted."
+
+export const isAbsoluteHttpUrl = (path: unknown): boolean =>
+ typeof path === "string" && /^https?:/i.test(path)
+
+export const parseHttpOrigin = (
+ value: unknown,
+ onError?: (raw: string, error: unknown) => void
+): string | null => {
+ const raw = String(value || "").trim()
+ if (!raw) return null
+ try {
+ const parsed = new URL(raw)
+ if (!/^https?:$/i.test(parsed.protocol)) return null
+ return parsed.origin.toLowerCase()
+ } catch (error) {
+ onError?.(raw, error)
+ return null
+ }
+}
+
+const toAllowlistEntries = (value: unknown): string[] => {
+ if (Array.isArray(value)) {
+ return value
+ .map((entry) => String(entry || "").trim())
+ .filter((entry) => entry.length > 0)
+ }
+ if (typeof value === "string") {
+ const trimmed = value.trim()
+ if (!trimmed) return []
+ if (!trimmed.includes(",")) return [trimmed]
+ return trimmed
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter((entry) => entry.length > 0)
+ }
+ return []
+}
+
+const configuredServerOrigin = (
+ cfg: AllowlistConfig,
+ onError?: (raw: string, error: unknown) => void
+): string | null =>
+ parseHttpOrigin((cfg as Record | null)?.serverUrl, onError)
+
+export const absoluteOriginAllowlistFromConfig = (
+ cfg: AllowlistConfig,
+ hooks?: AllowlistWarnHooks
+): Set => {
+ const out = new Set()
+ // The configured serverUrl is parsed silently here (matching request-core's
+ // allowlist path, which only warns about explicit allowlist entries).
+ const serverOrigin = configuredServerOrigin(cfg)
+ if (serverOrigin) out.add(serverOrigin)
+ for (const entry of toAllowlistEntries(
+ (cfg as Record | null)?.absoluteUrlAllowlist
+ )) {
+ const parsedOrigin = parseHttpOrigin(entry, hooks?.onMalformedAllowlistEntry)
+ if (parsedOrigin) out.add(parsedOrigin)
+ }
+ return out
+}
+
+export const isAbsoluteUrlAllowlisted = (
+ absoluteUrl: string,
+ cfg: AllowlistConfig,
+ hooks?: AllowlistWarnHooks
+): boolean => {
+ try {
+ const target = new URL(absoluteUrl)
+ if (!/^https?:$/i.test(target.protocol)) return false
+ return absoluteOriginAllowlistFromConfig(cfg, hooks).has(
+ target.origin.toLowerCase()
+ )
+ } catch {
+ return false
+ }
+}
+
+export const isSameOriginAbsoluteUrlForConfiguredServer = (
+ absoluteUrl: string,
+ cfg: AllowlistConfig,
+ hooks?: AllowlistWarnHooks
+): boolean => {
+ const serverOrigin = configuredServerOrigin(cfg, hooks?.onMalformedServerUrl)
+ if (!serverOrigin) return false
+ try {
+ const target = new URL(absoluteUrl)
+ if (!/^https?:$/i.test(target.protocol)) return false
+ return target.origin.toLowerCase() === serverOrigin
+ } catch {
+ return false
+ }
+}
+
+export type AbsoluteUrlAccess = {
+ // Whether the resolved request path is an absolute http(s) URL.
+ isAbsolute: boolean
+ // Whether the request must be refused before any fetch (cross-origin and not
+ // allowlisted). Mirrors the request-path ABSOLUTE_URL_BLOCK_ERROR guard.
+ blocked: boolean
+ // Whether auth headers (X-API-KEY / Authorization / X-TLDW-Org-Id) must be
+ // withheld. Mirrors request-core's `shouldSkipAuth` for absolute URLs.
+ skipAuth: boolean
+}
+
+// Decide, for a background upload/stream request path, whether the request is
+// absolute, must be blocked, and whether credentials may be attached. This is
+// the single decision function the background handlers should call.
+export const evaluateAbsoluteUrlAccess = (
+ path: unknown,
+ cfg: AllowlistConfig,
+ hooks?: AllowlistWarnHooks
+): AbsoluteUrlAccess => {
+ if (!isAbsoluteHttpUrl(path)) {
+ return { isAbsolute: false, blocked: false, skipAuth: false }
+ }
+ const absoluteUrl = String(path)
+ const sameOrigin = isSameOriginAbsoluteUrlForConfiguredServer(
+ absoluteUrl,
+ cfg,
+ hooks
+ )
+ const allowlisted = isAbsoluteUrlAllowlisted(absoluteUrl, cfg, hooks)
+ return {
+ isAbsolute: true,
+ blocked: !sameOrigin && !allowlisted,
+ skipAuth: !sameOrigin
+ }
+}
diff --git a/apps/packages/ui/src/utils/character-export.ts b/apps/packages/ui/src/utils/character-export.ts
index 9130ce1ba6..168f752f7d 100644
--- a/apps/packages/ui/src/utils/character-export.ts
+++ b/apps/packages/ui/src/utils/character-export.ts
@@ -237,11 +237,142 @@ function calculateCRC32(data: Uint8Array): number {
let crc32Table: Uint32Array | null = null
+/** Maximum bytes to accept when fetching a remote avatar for PNG embedding. */
+const MAX_AVATAR_FETCH_BYTES = 5 * 1024 * 1024
+/** Timeout for the (allowlisted) remote avatar fetch. */
+const AVATAR_FETCH_TIMEOUT_MS = 10_000
+
+/**
+ * Decode a base64 (optionally `data:`-prefixed) string into an ArrayBuffer.
+ */
+function base64ToArrayBuffer(value: string): ArrayBuffer {
+ const base64 = value.replace(/^data:image\/\w+;base64,/, "")
+ const binaryString = atob(base64)
+ const bytes = new Uint8Array(binaryString.length)
+ for (let i = 0; i < binaryString.length; i++) {
+ bytes[i] = binaryString.charCodeAt(i)
+ }
+ return bytes.buffer
+}
+
+/**
+ * Decide whether an avatar URL is safe to fetch for PNG export.
+ *
+ * A character card's `avatar_url` is attacker-controllable (via shared/imported
+ * cards), so blindly fetching it would let a card fire an outbound request from
+ * the victim's browser (tracking beacon / internal-network probe). We therefore
+ * only permit same-origin URLs plus any explicitly-allowed origin (e.g. the
+ * configured tldw server). `data:` URLs are handled separately by the caller
+ * (decoded locally, no network request).
+ */
+export function isSafeAvatarFetchUrl(
+ rawUrl: string,
+ allowedOrigins?: string[]
+): boolean {
+ if (typeof rawUrl !== "string" || rawUrl.trim().length === 0) return false
+
+ let parsed: URL
+ try {
+ const base =
+ typeof window !== "undefined" && window.location
+ ? window.location.href
+ : undefined
+ parsed = new URL(rawUrl, base)
+ } catch {
+ return false
+ }
+
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false
+
+ const allowed = new Set()
+ if (typeof window !== "undefined" && window.location?.origin) {
+ allowed.add(window.location.origin)
+ }
+ for (const origin of allowedOrigins ?? []) {
+ if (typeof origin !== "string" || !origin.trim()) continue
+ try {
+ allowed.add(new URL(origin).origin)
+ } catch {
+ // ignore malformed allowlist entries
+ }
+ }
+
+ return allowed.has(parsed.origin)
+}
+
+/**
+ * Load avatar image bytes for PNG embedding, defending against SSRF / beacon
+ * abuse. Returns null (never throws) when the avatar cannot be loaded safely;
+ * callers should fall back to a locally-generated placeholder.
+ */
+async function loadAvatarImageData(
+ avatarUrl: string,
+ allowedOrigins?: string[]
+): Promise {
+ // Inline data: URLs never hit the network.
+ if (avatarUrl.startsWith("data:")) {
+ try {
+ return base64ToArrayBuffer(avatarUrl)
+ } catch {
+ console.warn("Character export: failed to decode inline avatar data URL")
+ return null
+ }
+ }
+
+ if (!isSafeAvatarFetchUrl(avatarUrl, allowedOrigins)) {
+ console.warn(
+ "Character export: skipping avatar fetch for a non-allowlisted URL; exporting without the embedded avatar"
+ )
+ return null
+ }
+
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), AVATAR_FETCH_TIMEOUT_MS)
+ try {
+ const response = await fetch(avatarUrl, {
+ signal: controller.signal,
+ // Do not attach the user's cookies/credentials to the avatar request.
+ credentials: "omit"
+ })
+ if (!response.ok) {
+ console.warn(
+ `Character export: avatar fetch failed (${response.status}); exporting without the embedded avatar`
+ )
+ return null
+ }
+ const declaredLength = Number(response.headers.get("content-length"))
+ if (
+ Number.isFinite(declaredLength) &&
+ declaredLength > MAX_AVATAR_FETCH_BYTES
+ ) {
+ console.warn(
+ "Character export: avatar exceeds the size cap; exporting without the embedded avatar"
+ )
+ return null
+ }
+ const buffer = await response.arrayBuffer()
+ if (buffer.byteLength > MAX_AVATAR_FETCH_BYTES) {
+ console.warn(
+ "Character export: avatar exceeds the size cap; exporting without the embedded avatar"
+ )
+ return null
+ }
+ return buffer
+ } catch (error) {
+ console.warn("Character export: avatar fetch aborted or failed", error)
+ return null
+ } finally {
+ clearTimeout(timeout)
+ }
+}
+
/**
* Export character to PNG with embedded metadata
*
- * If the character has an avatar image, embeds the metadata into it.
- * Otherwise, creates a simple placeholder image with the metadata.
+ * If the character has an already-local avatar (base64), it is embedded directly.
+ * A remote `avatarUrl` is only fetched when it is same-origin or from an allowed
+ * origin (see `isSafeAvatarFetchUrl`), with a timeout and response-size cap.
+ * Otherwise a locally-generated placeholder image is used.
*/
export async function exportCharacterToPNG(
character: CharacterV3,
@@ -249,32 +380,35 @@ export async function exportCharacterToPNG(
avatarUrl?: string
avatarBase64?: string
filename?: string
+ /**
+ * Extra origins (besides the WebUI origin) whose avatars may be fetched,
+ * e.g. the configured tldw server origin.
+ */
+ allowedAvatarOrigins?: string[]
}
): Promise {
const name = character.name || "character"
const filename = options?.filename || `${sanitizeFilename(name)}_character.png`
- let imageData: ArrayBuffer
+ let imageData: ArrayBuffer | null = null
- // Try to get image data from avatar URL or base64
+ // Prefer already-local base64 (no network request).
if (options?.avatarBase64) {
- // Convert base64 to ArrayBuffer
- const base64 = options.avatarBase64.replace(/^data:image\/\w+;base64,/, "")
- const binaryString = atob(base64)
- const bytes = new Uint8Array(binaryString.length)
- for (let i = 0; i < binaryString.length; i++) {
- bytes[i] = binaryString.charCodeAt(i)
+ try {
+ imageData = base64ToArrayBuffer(options.avatarBase64)
+ } catch {
+ console.warn("Character export: failed to decode provided avatar base64")
+ imageData = null
}
- imageData = bytes.buffer
} else if (options?.avatarUrl) {
- // Fetch image from URL
- const response = await fetch(options.avatarUrl)
- if (!response.ok) {
- throw new Error(`Failed to fetch avatar image: ${response.statusText}`)
- }
- imageData = await response.arrayBuffer()
- } else {
- // Create a placeholder PNG image
+ imageData = await loadAvatarImageData(
+ options.avatarUrl,
+ options.allowedAvatarOrigins
+ )
+ }
+
+ if (!imageData) {
+ // Fall back to a locally-generated placeholder image.
imageData = await createPlaceholderPNG(name)
}
diff --git a/apps/packages/ui/src/utils/extract-token-from-chunk.ts b/apps/packages/ui/src/utils/extract-token-from-chunk.ts
index 42cded886f..c085687b60 100644
--- a/apps/packages/ui/src/utils/extract-token-from-chunk.ts
+++ b/apps/packages/ui/src/utils/extract-token-from-chunk.ts
@@ -16,6 +16,28 @@ const extractText = (value: unknown, depth: number = 0): string => {
return ""
}
+/**
+ * Detect the synthesized `stream_transport_interrupted` sentinel that the
+ * background stream proxy emits when the extension port drops AFTER the first
+ * byte. It carries no assistant text (so `extractTokenFromChunk` returns ""),
+ * which is why it must be recognized separately and propagated to the chat
+ * pipeline so a truncated answer is finalized as interrupted, not complete.
+ */
+export function extractStreamTransportInterruption(
+ chunk: unknown
+): { detail: string | null } | null {
+ if (!chunk || typeof chunk !== "object" || Array.isArray(chunk)) return null
+ const record = chunk as Record
+ const event =
+ typeof record.event === "string" ? record.event.toLowerCase() : ""
+ if (event !== "stream_transport_interrupted") return null
+ const detail =
+ typeof record.detail === "string" && record.detail.trim().length > 0
+ ? record.detail.trim()
+ : null
+ return { detail }
+}
+
export function extractTokenFromChunk(chunk: unknown): string {
if (typeof chunk === "string") return chunk
if (!chunk || typeof chunk !== "object") return ""
diff --git a/apps/packages/ui/src/utils/message-variants.ts b/apps/packages/ui/src/utils/message-variants.ts
index d91fc76b25..bfdb7c17bd 100644
--- a/apps/packages/ui/src/utils/message-variants.ts
+++ b/apps/packages/ui/src/utils/message-variants.ts
@@ -72,9 +72,13 @@ export const applyVariantToMessage = (
reasoning_time_taken:
variant.reasoning_time_taken ?? message.reasoning_time_taken,
createdAt: variant.createdAt ?? message.createdAt,
- serverMessageId: variant.serverMessageId ?? message.serverMessageId,
- serverMessageVersion:
- variant.serverMessageVersion ?? message.serverMessageVersion,
+ // A swiped variant carries its own server identity. Do NOT fall back to the
+ // previously-displayed variant's serverMessageId when the target variant is
+ // not yet persisted, otherwise a later edit/delete would target the wrong
+ // server row. Persisted variants supply their own id; unpersisted ones stay
+ // undefined so downstream edit/delete can gate on it.
+ serverMessageId: variant.serverMessageId,
+ serverMessageVersion: variant.serverMessageVersion,
metadataExtra: variant.metadataExtra,
id: variant.id ?? message.id
}
diff --git a/apps/packages/ui/src/utils/safe-external-url.ts b/apps/packages/ui/src/utils/safe-external-url.ts
new file mode 100644
index 0000000000..ec5aa08e5f
--- /dev/null
+++ b/apps/packages/ui/src/utils/safe-external-url.ts
@@ -0,0 +1,65 @@
+// Shared guard for rendering/opening untrusted URLs (source/citation metadata,
+// web-search results, API JSON). Hand-rolled `` and `window.open` call
+// sites bypass the markdown renderer's `urlTransform`, so a `javascript:` URL
+// would otherwise execute on click on the app origin. Allowlist http(s)/mailto.
+
+const SAFE_SCHEMES = new Set(["http:", "https:", "mailto:"])
+
+// Browsers strip C0 control characters (incl. tab/newline/CR) and DEL when they
+// resolve a URL, so `java\tscript:` becomes `javascript:` at click time. Remove
+// them before scheme detection so a control-char scheme can't slip through.
+const stripControlChars = (value: string): string => {
+ let out = ""
+ for (const ch of value) {
+ const code = ch.charCodeAt(0)
+ // Drop C0 controls (0x00-0x1F) and DEL (0x7F).
+ if (code <= 0x1f || code === 0x7f) continue
+ out += ch
+ }
+ return out
+}
+
+const isRelativeUrl = (value: string): boolean =>
+ value.startsWith("/") ||
+ value.startsWith("#") ||
+ value.startsWith("./") ||
+ value.startsWith("../")
+
+/**
+ * Returns a cleaned copy of `url` when it is safe to navigate to, otherwise
+ * `null`. "Safe" means an http/https/mailto absolute URL, or a relative URL
+ * (path/anchor). Whitespace and control characters are normalized so that
+ * obfuscated schemes such as `java\tscript:` cannot bypass the allowlist.
+ */
+export const safeExternalUrl = (url: unknown): string | null => {
+ if (typeof url !== "string") return null
+ const cleaned = stripControlChars(url).trim()
+ if (!cleaned) return null
+ // Relative URLs never carry a dangerous scheme; keep them as-is.
+ if (isRelativeUrl(cleaned)) return cleaned
+ let parsed: URL
+ try {
+ // Resolve against a base so both absolute and bare-relative inputs parse;
+ // the resulting protocol is authoritative regardless of casing.
+ parsed = new URL(cleaned, "http://localhost/")
+ } catch {
+ return null
+ }
+ if (!SAFE_SCHEMES.has(parsed.protocol)) return null
+ return cleaned
+}
+
+/**
+ * `window.open` guarded by {@link safeExternalUrl}. No-ops (returns null) when
+ * the URL is unsafe or `window` is unavailable (SSR).
+ */
+export const openExternalUrl = (
+ url: unknown,
+ target: string = "_blank",
+ features: string = "noopener,noreferrer"
+): Window | null => {
+ const safe = safeExternalUrl(url)
+ if (!safe) return null
+ if (typeof window === "undefined") return null
+ return window.open(safe, target, features)
+}
diff --git a/apps/tldw-frontend/AGENTS.md b/apps/tldw-frontend/AGENTS.md
index 1c6b9bfee7..6e2c46e41f 100644
--- a/apps/tldw-frontend/AGENTS.md
+++ b/apps/tldw-frontend/AGENTS.md
@@ -30,9 +30,7 @@ tldw-frontend/
│ └── shims/ # Browser API compatibility shims
│ ├── wxt-browser.ts # localStorage-based browser.* shim
│ └── react-router-dom.tsx # Next.js router shim for react-router-dom
-├── hooks/ # Web-only hooks
-│ ├── useAuth.ts # JWT authentication state
-│ └── useConfig.ts # Server configuration
+├── hooks/ # Web-only hooks (auth/config are shared — see @/services/tldw/TldwAuth)
├── lib/ # Web-only utilities
│ ├── api.ts # Fetch wrapper with auth
│ └── auth.ts # Token management
@@ -62,9 +60,9 @@ tldw-frontend/
import { MyComponent } from "@/components/MyComponent"
import { useMyHook } from "@/hooks/use-my-hook"
import { myService } from "@/services/my-service"
+import { tldwAuth } from "@/services/tldw/TldwAuth" // shared auth (live path)
// Web-only (from tldw-frontend/)
-import { useAuth } from "@web/hooks/useAuth"
import { api } from "@web/lib/api"
// Browser APIs (automatically shimmed)
@@ -112,23 +110,16 @@ import { Link, useNavigate } from "react-router-dom"
### 3. Authentication
-Web UI uses JWT authentication (vs extension's API key storage):
+Auth state lives in the shared stack (`@/services/tldw/TldwAuth` + the `@/store/connection`
+store), not a web-only hook. Pages stay thin wrappers; auth is resolved inside the shared route
+component:
```typescript
// pages/protected-page.tsx
import dynamic from "next/dynamic"
-import { useAuth } from "@web/hooks/useAuth"
-const ProtectedContent = dynamic(() => import("@/routes/protected-route"), { ssr: false })
-
-export default function ProtectedPage() {
- const { user, isLoading } = useAuth()
-
- if (isLoading) return
- if (!user) return
-
- return
-}
+// Auth is resolved inside the shared route via tldwAuth / the connection store.
+export default dynamic(() => import("@/routes/protected-route"), { ssr: false })
```
### 4. Platform Detection in Shared Code
@@ -218,7 +209,7 @@ export default dynamic(() => import("@/routes/my-route"), { ssr: false })
**Wrong:**
```typescript
// packages/ui/src/components/MyComponent.tsx
-import { useAuth } from "@web/hooks/useAuth" // Breaks extension!
+import { api } from "@web/lib/api" // Breaks extension!
```
**Right:** Keep web-only imports in `tldw-frontend/pages/` wrappers only.
diff --git a/apps/tldw-frontend/CLAUDE.md b/apps/tldw-frontend/CLAUDE.md
index 9ffb7a41f2..d8eaaf4210 100644
--- a/apps/tldw-frontend/CLAUDE.md
+++ b/apps/tldw-frontend/CLAUDE.md
@@ -30,9 +30,7 @@ tldw-frontend/
│ └── shims/ # Browser API compatibility shims
│ ├── wxt-browser.ts # localStorage-based browser.* shim
│ └── react-router-dom.tsx # Next.js router shim for react-router-dom
-├── hooks/ # Web-only hooks
-│ ├── useAuth.ts # JWT authentication state
-│ └── useConfig.ts # Server configuration
+├── hooks/ # Web-only hooks (auth/config are shared — see @/services/tldw/TldwAuth)
├── lib/ # Web-only utilities
│ ├── api.ts # Fetch wrapper with auth
│ └── auth.ts # Token management
@@ -62,9 +60,9 @@ tldw-frontend/
import { MyComponent } from "@/components/MyComponent"
import { useMyHook } from "@/hooks/use-my-hook"
import { myService } from "@/services/my-service"
+import { tldwAuth } from "@/services/tldw/TldwAuth" // shared auth (live path)
// Web-only (from tldw-frontend/)
-import { useAuth } from "@web/hooks/useAuth"
import { api } from "@web/lib/api"
// Browser APIs (automatically shimmed)
@@ -112,23 +110,16 @@ import { Link, useNavigate } from "react-router-dom"
### 3. Authentication
-Web UI uses JWT authentication (vs extension's API key storage):
+Auth state lives in the shared stack (`@/services/tldw/TldwAuth` + the `@/store/connection`
+store), not a web-only hook. Pages stay thin wrappers; auth is resolved inside the shared route
+component:
```typescript
// pages/protected-page.tsx
import dynamic from "next/dynamic"
-import { useAuth } from "@web/hooks/useAuth"
-const ProtectedContent = dynamic(() => import("@/routes/protected-route"), { ssr: false })
-
-export default function ProtectedPage() {
- const { user, isLoading } = useAuth()
-
- if (isLoading) return
- if (!user) return
-
- return
-}
+// Auth is resolved inside the shared route via tldwAuth / the connection store.
+export default dynamic(() => import("@/routes/protected-route"), { ssr: false })
```
### 4. Platform Detection in Shared Code
@@ -218,7 +209,7 @@ export default dynamic(() => import("@/routes/my-route"), { ssr: false })
**Wrong:**
```typescript
// packages/ui/src/components/MyComponent.tsx
-import { useAuth } from "@web/hooks/useAuth" // Breaks extension!
+import { api } from "@web/lib/api" // Breaks extension!
```
**Right:** Keep web-only imports in `tldw-frontend/pages/` wrappers only.
diff --git a/apps/tldw-frontend/__tests__/extension/plasmo-storage-watch.test.tsx b/apps/tldw-frontend/__tests__/extension/plasmo-storage-watch.test.tsx
new file mode 100644
index 0000000000..1da5053b7b
--- /dev/null
+++ b/apps/tldw-frontend/__tests__/extension/plasmo-storage-watch.test.tsx
@@ -0,0 +1,111 @@
+import { act, renderHook, waitFor } from "@testing-library/react"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+import { Storage } from "@web/extension/shims/plasmo-storage"
+import { useStorage } from "@web/extension/shims/plasmo-storage-hook"
+
+describe("plasmo storage cross-instance change propagation (H10)", () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it("notifies a watcher on a different instance when another instance writes", async () => {
+ const writer = new Storage({ area: "local" })
+ const watcher = new Storage({ area: "local" })
+
+ const changes: unknown[] = []
+ const unwatch = watcher.watch({
+ stickyChatInput: (change) => changes.push(change.newValue)
+ })
+
+ await writer.set("stickyChatInput", true)
+
+ expect(changes).toEqual([true])
+ unwatch()
+ })
+
+ it("keeps areas isolated: a sync write does not notify a local watcher", async () => {
+ const localWatcher = new Storage({ area: "local" })
+ const syncWriter = new Storage({ area: "sync" })
+
+ const localChanges: unknown[] = []
+ const unwatch = localWatcher.watch({
+ shared: (change) => localChanges.push(change.newValue)
+ })
+
+ await syncWriter.set("shared", "sync-only")
+
+ expect(localChanges).toEqual([])
+ unwatch()
+ })
+
+ it("useStorage reflects a value written by another instance without a reload", async () => {
+ const external = new Storage({ area: "local" })
+
+ const { result } = renderHook(() =>
+ useStorage("stickyChatInput", false)
+ )
+
+ // initial default value once loading settles
+ await waitFor(() => expect(result.current[2].isLoading).toBe(false))
+ expect(result.current[0]).toBe(false)
+
+ // a write from a *different* Storage instance should propagate
+ await act(async () => {
+ await external.set("stickyChatInput", true)
+ })
+
+ await waitFor(() => expect(result.current[0]).toBe(true))
+ })
+
+ it("does not clobber a fresher watch() update with a stale initial get()", async () => {
+ const instance = new Storage({ area: "local" })
+
+ // Control exactly when the initial get() resolves so we can interleave a
+ // watch update in between the synchronous get() call and its microtask.
+ let resolveGet: (value: string | undefined) => void = () => {}
+ vi.spyOn(instance, "get").mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolveGet = resolve
+ }) as Promise
+ )
+
+ const external = new Storage({ area: "local" })
+
+ const { result } = renderHook(() =>
+ useStorage({ key: "raced", instance, defaultValue: "default" })
+ )
+
+ // A fresher value arrives via watch() before the initial get() resolves.
+ await act(async () => {
+ await external.set("raced", "fresh")
+ })
+ expect(result.current[0]).toBe("fresh")
+
+ // The stale initial get() now resolves — it must NOT overwrite "fresh".
+ await act(async () => {
+ resolveGet("stale")
+ await Promise.resolve()
+ })
+
+ expect(result.current[0]).toBe("fresh")
+ vi.restoreAllMocks()
+ })
+
+ it("functional setValue uses the freshest value, not a stale closure", async () => {
+ const { result } = renderHook(() => useStorage("counter", 0))
+
+ await waitFor(() => expect(result.current[2].isLoading).toBe(false))
+
+ // Two functional updates in a row must compound (0 -> 1 -> 2), not drop.
+ await act(async () => {
+ await result.current[1]((prev) => (prev ?? 0) + 1)
+ })
+ await act(async () => {
+ await result.current[1]((prev) => (prev ?? 0) + 1)
+ })
+
+ expect(result.current[0]).toBe(2)
+ })
+})
diff --git a/apps/tldw-frontend/__tests__/extension/plasmo-storage.test.ts b/apps/tldw-frontend/__tests__/extension/plasmo-storage.test.ts
index c7bb3e772e..6cf1df91fa 100644
--- a/apps/tldw-frontend/__tests__/extension/plasmo-storage.test.ts
+++ b/apps/tldw-frontend/__tests__/extension/plasmo-storage.test.ts
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it } from "vitest"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { Storage } from "@web/extension/shims/plasmo-storage"
@@ -7,6 +7,10 @@ describe("plasmo storage web shim", () => {
localStorage.clear()
})
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
it("keeps local and sync areas from clobbering each other", async () => {
const local = new Storage({ area: "local" })
const sync = new Storage({ area: "sync" })
@@ -22,4 +26,54 @@ describe("plasmo storage web shim", () => {
expect(await local.get("selectedAssistant")).toEqual(assistant)
expect(await sync.get("selectedAssistant")).toBeUndefined()
})
+
+ it("does not throw when a foreign tab writes a non-JSON value to a watched key", () => {
+ const watcher = new Storage({ area: "local" })
+ const changes: Array<{ newValue: unknown }> = []
+ const unwatch = watcher.watch({
+ fromAnotherTab: (change) => changes.push({ newValue: change.newValue })
+ })
+
+ // Simulate the browser `storage` event that fires in *other* tabs, carrying
+ // a value that is NOT valid JSON. The window handler must not explode.
+ expect(() =>
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: "fromAnotherTab",
+ oldValue: null,
+ newValue: "definitely-not-json{{{"
+ })
+ )
+ ).not.toThrow()
+
+ // The watcher still fires and receives the raw (undeserializable) string.
+ expect(changes).toEqual([{ newValue: "definitely-not-json{{{" }])
+ unwatch()
+ })
+
+ it("logs (does not silently swallow) a throwing watch callback and keeps siblings working", async () => {
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
+ const writer = new Storage({ area: "local" })
+ const watcher = new Storage({ area: "local" })
+
+ const siblingCalls: unknown[] = []
+ const unwatchBoom = watcher.watch({
+ boom: () => {
+ throw new Error("watcher kaboom")
+ }
+ })
+ const unwatchSibling = watcher.watch({
+ boom: (change) => siblingCalls.push(change.newValue)
+ })
+
+ await writer.set("boom", 42)
+
+ // A sibling watcher still runs despite the first callback throwing ...
+ expect(siblingCalls).toEqual([42])
+ // ... and the failure is surfaced via console.error, not swallowed.
+ expect(errorSpy).toHaveBeenCalled()
+
+ unwatchBoom()
+ unwatchSibling()
+ })
})
diff --git a/apps/tldw-frontend/__tests__/extension/wxt-browser-storage.test.ts b/apps/tldw-frontend/__tests__/extension/wxt-browser-storage.test.ts
new file mode 100644
index 0000000000..7e316ea8b9
--- /dev/null
+++ b/apps/tldw-frontend/__tests__/extension/wxt-browser-storage.test.ts
@@ -0,0 +1,145 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import { browser } from "@web/extension/shims/wxt-browser"
+
+const { storage } = browser
+
+describe("wxt-browser storage shim", () => {
+ beforeEach(async () => {
+ localStorage.clear()
+ // session is memory-only; clear it explicitly between tests.
+ await storage.session.clear()
+ })
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it("clears only the target area, not the whole origin (H9)", async () => {
+ await storage.local.set({ "tldw-api-host": "http://localhost:8000" })
+ await storage.sync.set({ theme: "dark" })
+
+ await storage.sync.clear()
+
+ // sync area is empty, local area untouched
+ const syncAll = await storage.sync.get(null)
+ const localAfter = await storage.local.get("tldw-api-host")
+ expect(syncAll).toEqual({})
+ expect(localAfter["tldw-api-host"]).toBe("http://localhost:8000")
+ // the raw local key must still physically exist in the origin
+ expect(localStorage.getItem("tldw-api-host")).toBe(
+ JSON.stringify("http://localhost:8000")
+ )
+ })
+
+ it("isolates areas so sync.set does not clobber local (H9)", async () => {
+ await storage.local.set({ shared: "local-value" })
+ await storage.sync.set({ shared: "sync-value" })
+
+ const local = await storage.local.get("shared")
+ const sync = await storage.sync.get("shared")
+ expect(local.shared).toBe("local-value")
+ expect(sync.shared).toBe("sync-value")
+ // local stays UNPREFIXED for cross-shim / existing-data compatibility
+ expect(localStorage.getItem("shared")).toBe(JSON.stringify("local-value"))
+ expect(localStorage.getItem("plasmo-sync:shared")).toBe(
+ JSON.stringify("sync-value")
+ )
+ })
+
+ it("get(null) enumerates only the area's own keys", async () => {
+ await storage.local.set({ a: 1 })
+ await storage.sync.set({ b: 2 })
+
+ const localAll = await storage.local.get(null)
+ const syncAll = await storage.sync.get(null)
+
+ expect(localAll).toEqual({ a: 1 })
+ expect(syncAll).toEqual({ b: 2 })
+ })
+
+ it("does not persist session to disk (memory-only)", async () => {
+ await storage.session.set({ token: "ephemeral" })
+
+ // readable via the area API ...
+ const read = await storage.session.get("token")
+ expect(read.token).toBe("ephemeral")
+ // ... but never written to localStorage
+ expect(localStorage.getItem("token")).toBeNull()
+ expect(localStorage.getItem("plasmo-session:token")).toBeNull()
+ expect(localStorage.length).toBe(0)
+ })
+
+ it("does not emit onChanged nor resolve as success when set() fails (H9 #3)", async () => {
+ const listener = vi.fn()
+ storage.onChanged.addListener(listener)
+
+ // Force the write to throw (simulate quota exceeded / serialization error).
+ const setItemSpy = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(() => {
+ throw new Error("QuotaExceededError")
+ })
+
+ await expect(storage.local.set({ big: "x" })).rejects.toThrow(
+ "QuotaExceededError"
+ )
+ expect(listener).not.toHaveBeenCalled()
+
+ setItemSpy.mockRestore()
+ storage.onChanged.removeListener(listener)
+ })
+
+ it("emits onChanged only for committed keys when a later key in a multi-key set fails", async () => {
+ const listener = vi.fn()
+ storage.onChanged.addListener(listener)
+
+ // First key serializes/writes fine; the second throws mid-set. The earlier
+ // (committed) key must still emit onChanged, the failed key must not, and
+ // the overall promise must reject.
+ const realSetItem = Storage.prototype.setItem
+ const setItemSpy = vi
+ .spyOn(Storage.prototype, "setItem")
+ .mockImplementation(function (
+ this: globalThis.Storage,
+ key: string,
+ value: string
+ ) {
+ if (key === "bad") {
+ throw new Error("QuotaExceededError")
+ }
+ realSetItem.call(this, key, value)
+ })
+
+ await expect(
+ storage.local.set({ good: "committed", bad: "explodes" })
+ ).rejects.toThrow("QuotaExceededError")
+
+ // onChanged fired exactly once, for the committed key only.
+ expect(listener).toHaveBeenCalledTimes(1)
+ const [changes, areaName] = listener.mock.calls[0]
+ expect(areaName).toBe("local")
+ expect(Object.keys(changes)).toEqual(["good"])
+ expect(changes.good.newValue).toBe("committed")
+ expect(changes.bad).toBeUndefined()
+ // The committed key really landed in the backend.
+ expect(localStorage.getItem("good")).toBe(JSON.stringify("committed"))
+
+ setItemSpy.mockRestore()
+ storage.onChanged.removeListener(listener)
+ })
+
+ it("emits onChanged with the area name after a successful set", async () => {
+ const listener = vi.fn()
+ storage.onChanged.addListener(listener)
+
+ await storage.sync.set({ flag: true })
+
+ expect(listener).toHaveBeenCalledTimes(1)
+ const [changes, areaName] = listener.mock.calls[0]
+ expect(areaName).toBe("sync")
+ expect(changes.flag.newValue).toBe(true)
+
+ storage.onChanged.removeListener(listener)
+ })
+})
diff --git a/apps/tldw-frontend/__tests__/header-runs-gating.test.tsx b/apps/tldw-frontend/__tests__/header-runs-gating.test.tsx
deleted file mode 100644
index 2719a638be..0000000000
--- a/apps/tldw-frontend/__tests__/header-runs-gating.test.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import React from "react"
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-import { render, screen } from "@testing-library/react"
-
-const mockRouter = {
- pathname: "/",
- asPath: "/",
- push: vi.fn(),
- replace: vi.fn()
-}
-
-const authState = vi.hoisted(() => ({
- isAuthenticated: true,
- user: null as any,
- logout: vi.fn()
-}))
-
-vi.mock("next/router", () => ({
- useRouter: () => mockRouter
-}))
-
-vi.mock("next/link", () => ({
- default: ({ href, children, ...rest }: any) => (
-
- {children}
-
- )
-}))
-
-vi.mock("@web/hooks/useAuth", () => ({
- useAuth: () => authState
-}))
-
-import { Header } from "@web/components/layout/Header"
-
-const originalEnableRunsLink = process.env.NEXT_PUBLIC_ENABLE_RUNS_LINK
-const originalRequireAdmin = process.env.NEXT_PUBLIC_RUNS_REQUIRE_ADMIN
-const originalDeploymentMode = process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE
-
-const resetEnv = () => {
- if (originalEnableRunsLink === undefined) {
- delete process.env.NEXT_PUBLIC_ENABLE_RUNS_LINK
- } else {
- process.env.NEXT_PUBLIC_ENABLE_RUNS_LINK = originalEnableRunsLink
- }
- if (originalRequireAdmin === undefined) {
- delete process.env.NEXT_PUBLIC_RUNS_REQUIRE_ADMIN
- } else {
- process.env.NEXT_PUBLIC_RUNS_REQUIRE_ADMIN = originalRequireAdmin
- }
- if (originalDeploymentMode === undefined) {
- delete process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE
- } else {
- process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE = originalDeploymentMode
- }
-}
-
-describe("Header research link", () => {
- beforeEach(() => {
- authState.logout.mockClear()
- process.env.NEXT_PUBLIC_ENABLE_RUNS_LINK = "1"
- process.env.NEXT_PUBLIC_RUNS_REQUIRE_ADMIN = "1"
- })
-
- it("shows Research for non-admin users even when the legacy admin flag is enabled", () => {
- authState.user = {
- username: "normal-user",
- role: "user",
- roles: ["user"],
- is_admin: false
- }
-
- render()
-
- const link = screen.getByRole("link", { name: "Research" })
- expect(link).toBeInTheDocument()
- expect(link.getAttribute("href")).toBe("/research")
- })
-
- it("shows Research for admin users", () => {
- authState.user = {
- username: "admin-user",
- role: "admin",
- roles: ["user"],
- is_admin: false
- }
-
- render()
-
- const link = screen.getByRole("link", { name: "Research" })
- expect(link).toBeInTheDocument()
- expect(link.getAttribute("href")).toBe("/research")
- })
-
- it("shows Research when the user has an admin claims shape", () => {
- authState.user = {
- username: "claims-admin-user",
- role: "user",
- roles: ["admin"],
- is_admin: false
- }
-
- render()
-
- expect(screen.getByRole("link", { name: "Research" })).toBeInTheDocument()
- })
-
- it("shows Research for non-admin when the legacy admin requirement is disabled", () => {
- process.env.NEXT_PUBLIC_RUNS_REQUIRE_ADMIN = "0"
- authState.user = {
- username: "normal-user-2",
- role: "user",
- roles: ["user"],
- is_admin: false
- }
-
- render()
-
- expect(screen.getByRole("link", { name: "Research" })).toBeInTheDocument()
- })
-})
-
-afterEach(() => {
- resetEnv()
-})
diff --git a/apps/tldw-frontend/__tests__/navigation/react-router-search-params-dynamic.test.tsx b/apps/tldw-frontend/__tests__/navigation/react-router-search-params-dynamic.test.tsx
new file mode 100644
index 0000000000..61ae68dbf4
--- /dev/null
+++ b/apps/tldw-frontend/__tests__/navigation/react-router-search-params-dynamic.test.tsx
@@ -0,0 +1,53 @@
+import React from "react"
+import { render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+import { useSearchParams } from "@web/extension/shims/react-router-dom"
+
+// push/replace return a resolved Promise like the real Next.js router so the
+// shim's `navigation.catch(...)` has something to chain onto.
+const mockPush = vi.fn(() => Promise.resolve(true))
+const mockReplace = vi.fn(() => Promise.resolve(true))
+
+// Dynamic route: pathname is the `[bracket]` pattern, asPath is the resolved URL.
+const mockRouter = {
+ asPath: "/sources/source-123?tab=notes",
+ pathname: "/sources/[id]",
+ query: { id: "source-123" } as Record,
+ push: mockPush,
+ replace: mockReplace,
+ back: vi.fn()
+}
+
+vi.mock("next/router", () => ({
+ useRouter: () => mockRouter
+}))
+
+const SearchParamsButton = () => {
+ const [, setSearchParams] = useSearchParams()
+ return (
+
+ )
+}
+
+describe("useSearchParams on dynamic routes", () => {
+ beforeEach(() => {
+ mockPush.mockClear()
+ mockReplace.mockClear()
+ mockRouter.asPath = "/sources/source-123?tab=notes"
+ mockRouter.pathname = "/sources/[id]"
+ })
+
+ it("builds the URL from the resolved path, not the [bracket] pattern", async () => {
+ const user = userEvent.setup()
+ render()
+
+ await user.click(screen.getByRole("button", { name: "search" }))
+
+ // Must push the concrete path (/sources/source-123), never /sources/[id].
+ expect(mockPush).toHaveBeenCalledWith("/sources/source-123?tab=summary")
+ })
+})
diff --git a/apps/tldw-frontend/__tests__/pages/admin-maintenance.test.tsx b/apps/tldw-frontend/__tests__/pages/admin-maintenance.test.tsx
index d3e724548b..7ce93d59e4 100644
--- a/apps/tldw-frontend/__tests__/pages/admin-maintenance.test.tsx
+++ b/apps/tldw-frontend/__tests__/pages/admin-maintenance.test.tsx
@@ -1,4 +1,3 @@
-import type { ReactNode } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -15,21 +14,12 @@ const mocks = vi.hoisted(() => ({
showToast: vi.fn(),
buildAuthHeaders: vi.fn(() => ({ Authorization: 'Bearer test-token' })),
getApiBaseUrl: vi.fn(() => 'http://example.com/api/v1'),
- isAdmin: true,
-}));
-
-vi.mock('@web/components/layout/Layout', () => ({
- Layout: ({ children }: { children: ReactNode }) => {children}
,
}));
vi.mock('@web/components/ui/ToastProvider', () => ({
useToast: () => ({ show: mocks.showToast }),
}));
-vi.mock('@web/hooks/useIsAdmin', () => ({
- useIsAdmin: () => mocks.isAdmin,
-}));
-
vi.mock('@web/lib/api', () => ({
buildAuthHeaders: (...args: string[]) => mocks.buildAuthHeaders(...args),
getApiBaseUrl: () => mocks.getApiBaseUrl(),
@@ -42,7 +32,6 @@ describe.skipIf(SKIP_INTEGRATION_TESTS)('AdminMaintenancePage effective config f
beforeEach(() => {
vi.clearAllMocks();
- mocks.isAdmin = true;
originalFetch = globalThis.fetch;
});
diff --git a/apps/tldw-frontend/__tests__/pages/content-review.test.tsx b/apps/tldw-frontend/__tests__/pages/content-review.test.tsx
index 9876668fa8..52a8efe759 100644
--- a/apps/tldw-frontend/__tests__/pages/content-review.test.tsx
+++ b/apps/tldw-frontend/__tests__/pages/content-review.test.tsx
@@ -1,4 +1,3 @@
-import type { ReactNode } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -35,10 +34,6 @@ vi.mock('@web/components/ui/ToastProvider', () => ({
useToast: () => ({ show: mocks.showToast }),
}));
-vi.mock('@web/components/layout/Layout', () => ({
- Layout: ({ children }: { children: ReactNode }) => {children}
,
-}));
-
vi.mock('@web/lib/api', () => ({
apiClient: mocks.apiClient,
}));
diff --git a/apps/tldw-frontend/components/layout/Header.tsx b/apps/tldw-frontend/components/layout/Header.tsx
deleted file mode 100644
index 7d91d26ea1..0000000000
--- a/apps/tldw-frontend/components/layout/Header.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import Link from 'next/link';
-import { useRouter } from 'next/router';
-import { cn } from '@web/lib/utils';
-import { useAuth } from '@web/hooks/useAuth';
-import { useIsAdmin } from '@web/hooks/useIsAdmin';
-
-/**
- * Render the application's top navigation header with logo, primary links, and user controls.
- *
- * The rendered header includes a logo linking to home, a set of navigation links, and a user area that
- * shows the signed-in username or a Login link. The "Research" navigation link is included only when the
- * legacy NEXT_PUBLIC_ENABLE_RUNS_LINK environment flag is enabled.
- *
- * @returns The header element containing the logo, navigation links, and user session controls.
- */
-export function Header() {
- const router = useRouter();
- const { isAuthenticated, user, logout } = useAuth();
-
- const handleLogout = () => {
- logout();
- };
-
- const showResearchLink = (process.env.NEXT_PUBLIC_ENABLE_RUNS_LINK ?? '1').toString().toLowerCase() !== '0' && (process.env.NEXT_PUBLIC_ENABLE_RUNS_LINK ?? '1').toString().toLowerCase() !== 'false';
- const userIsAdmin = useIsAdmin();
- const canReviewClaims = (() => {
- if (userIsAdmin) return true;
- if (!user) return false;
- const roles = Array.isArray(user.roles) ? user.roles : (user.roles ? [user.roles] : []);
- const perms = Array.isArray(user.permissions) ? user.permissions : (user.permissions ? [user.permissions] : []);
- const normalizedRoles = roles.map((r) => String(r).toLowerCase());
- const normalizedPerms = perms.map((p) => String(p).toLowerCase());
- return normalizedRoles.includes('reviewer') || normalizedPerms.includes('claims.review') || normalizedPerms.includes('claims.admin');
- })();
- const navLinks = [
- { href: '/', label: 'Home' },
- { href: '/media', label: 'Media' },
- { href: '/items', label: 'Items' },
- { href: '/reading', label: 'Reading' },
- { href: '/watchlists', label: 'Watchlists' },
- ...(showResearchLink ? [{ href: '/research', label: 'Research' } as const] : []),
- { href: '/chat', label: 'Chat' },
- { href: '/search', label: 'Search' },
- { href: '/audio', label: 'Audio' },
- { href: '/evaluations', label: 'Evals' },
- ...(canReviewClaims ? [{ href: '/claims-review', label: 'Claims Review' } as const] : []),
- ...(userIsAdmin
- ? [
- { href: '/admin/data-ops', label: 'Data Ops' } as const,
- { href: '/admin', label: 'Admin' } as const,
- ]
- : []),
- { href: '/profile', label: 'Profile' },
- { href: '/config', label: 'Config' },
- ];
-
- return (
-
-
-
- {/* Logo */}
-
-
- TLDW
-
-
-
- {/* Navigation */}
-
-
- {/* User menu */}
-
- {isAuthenticated ? (
- <>
-
- {user?.username || 'User'}
-
- {/* Only show logout when using session-based auth */}
- {!process.env.NEXT_PUBLIC_X_API_KEY && !process.env.NEXT_PUBLIC_API_BEARER && (
-
- )}
- >
- ) : (
-
- Login
-
- )}
-
-
-
-
- );
-}
diff --git a/apps/tldw-frontend/components/layout/Layout.tsx b/apps/tldw-frontend/components/layout/Layout.tsx
deleted file mode 100644
index 3fc5a8b48e..0000000000
--- a/apps/tldw-frontend/components/layout/Layout.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { ReactNode } from 'react';
-import { Header } from './Header';
-
-interface LayoutProps {
- children: ReactNode;
-}
-
-export function Layout({ children }: LayoutProps) {
- return (
-
-
-
- {children}
-
-
- );
-}
diff --git a/apps/tldw-frontend/extension/routes/_RUNTIME_UNUSED.md b/apps/tldw-frontend/extension/routes/_RUNTIME_UNUSED.md
new file mode 100644
index 0000000000..bcf98ce841
--- /dev/null
+++ b/apps/tldw-frontend/extension/routes/_RUNTIME_UNUSED.md
@@ -0,0 +1,16 @@
+# Runtime-unused, but parity-maintained — edit `packages/ui/src/routes/` instead
+
+See `apps/FRONTEND_AUDIT.md` (§6b) and backlog **TASK-12103**.
+
+At **runtime** the Next.js web build does not render these files — the `@/` alias resolves to
+`../../packages/ui/src`, so pages mount `packages/ui/src/routes/*`. **Editing a component here has
+no effect on the running app;** change the `packages/ui/src/routes/*` version instead.
+
+**Do NOT delete this directory.** Unlike truly-dead code, it is **actively referenced by ~22 tests**:
+a few import these modules directly, and ~19 `readFileSync` *parity-guard* tests assert this copy
+stays byte-in-sync with `packages/ui/src/routes/*`. Deleting it would cascade-break those suites.
+
+If this copy is ever to be removed, it needs a deliberate follow-up: migrate/retire those parity
+tests to target `packages/ui/src/routes/*` first. Tracked in TASK-12103.
+
+(The sibling `../shims/` directory **is** live — do not confuse the two.)
diff --git a/apps/tldw-frontend/extension/shims/plasmo-storage-hook.tsx b/apps/tldw-frontend/extension/shims/plasmo-storage-hook.tsx
index d5b78426a9..50cb83ee90 100644
--- a/apps/tldw-frontend/extension/shims/plasmo-storage-hook.tsx
+++ b/apps/tldw-frontend/extension/shims/plasmo-storage-hook.tsx
@@ -34,18 +34,28 @@ export function useStorage(
const [value, setValue] = useState(defaultValueRef.current)
const [isLoading, setIsLoading] = useState(true)
+ // Track the freshest value so functional updates (`setValue(v => ...)`) don't
+ // read a stale render closure and drop updates.
+ const valueRef = useRef(value)
+ const applyValue = useCallback((next: T | undefined) => {
+ valueRef.current = next
+ setValue(next)
+ }, [])
+
useEffect(() => {
let cancelled = false
+ // `storage.get()` snapshots the backend synchronously but resolves in a
+ // later microtask. If a `watch` callback delivers a fresher value (from
+ // another write) in that window, the stale get() result must NOT clobber
+ // it. This flag records that a watch update already won the race.
+ let watchUpdated = false
setIsLoading(true)
storage
.get(options.key)
.then((stored) => {
if (cancelled) return
- if (stored === undefined) {
- setValue(defaultValueRef.current)
- } else {
- setValue(stored)
- }
+ if (watchUpdated) return
+ applyValue(stored === undefined ? defaultValueRef.current : stored)
})
.finally(() => {
if (!cancelled) {
@@ -53,22 +63,36 @@ export function useStorage(
}
})
+ // Subscribe so cross-instance / cross-tab writes apply without a reload.
+ const unwatch = storage.watch({
+ [options.key]: (change) => {
+ if (cancelled) return
+ watchUpdated = true
+ applyValue(
+ change.newValue === undefined
+ ? defaultValueRef.current
+ : (change.newValue as T)
+ )
+ }
+ })
+
return () => {
cancelled = true
+ unwatch()
}
- }, [options.key, storage])
+ }, [options.key, storage, applyValue])
const setStoredValue = useCallback>(
async (next) => {
const resolved =
typeof next === "function"
- ? (next as (prev: T | undefined) => T)(value)
+ ? (next as (prev: T | undefined) => T)(valueRef.current)
: next
- setValue(resolved)
+ applyValue(resolved)
await storage.set(options.key, resolved)
},
- [options.key, storage, value]
+ [options.key, storage, applyValue]
)
- return [value, setStoredValue, { isLoading, setRenderValue: setValue }]
+ return [value, setStoredValue, { isLoading, setRenderValue: applyValue }]
}
diff --git a/apps/tldw-frontend/extension/shims/plasmo-storage.ts b/apps/tldw-frontend/extension/shims/plasmo-storage.ts
index 58aec98010..0069899d58 100644
--- a/apps/tldw-frontend/extension/shims/plasmo-storage.ts
+++ b/apps/tldw-frontend/extension/shims/plasmo-storage.ts
@@ -64,6 +64,72 @@ const defaultSerde: Required = {
}
}
+// Module-level (shared) watch registry keyed by the *scoped* storage key so
+// that every Storage instance — and every React `useStorage` hook — that
+// watches the same key is notified when any instance writes it. Previously
+// watchers lived per-instance, so two components on the same key desynced and
+// changes only applied after a full page reload (H10).
+const globalWatchers = new Map>()
+
+const subscribeGlobal = (storageKey: string, cb: WatchCallback): (() => void) => {
+ let set = globalWatchers.get(storageKey)
+ if (!set) {
+ set = new Set()
+ globalWatchers.set(storageKey, set)
+ }
+ set.add(cb)
+ return () => {
+ const current = globalWatchers.get(storageKey)
+ if (!current) return
+ current.delete(cb)
+ if (current.size === 0) globalWatchers.delete(storageKey)
+ }
+}
+
+const notifyGlobal = (storageKey: string, change: StorageChange) => {
+ const set = globalWatchers.get(storageKey)
+ if (!set) return
+ set.forEach((cb) => {
+ try {
+ cb(change)
+ } catch (err) {
+ // A misbehaving watch callback must not break sibling watchers or the
+ // write that triggered the notification — but the failure must not be
+ // silently swallowed either, or watcher-callback bugs become invisible.
+ console.error("[plasmo-storage] watch callback threw", err)
+ }
+ })
+}
+
+// Cross-tab propagation: the browser `storage` event fires in *other* tabs
+// (never the tab that made the change), so combined with notifyGlobal above we
+// cover both same-tab-cross-instance and cross-tab updates.
+if (typeof window !== "undefined") {
+ window.addEventListener("storage", (event) => {
+ if (event.storageArea && event.storageArea !== window.localStorage) return
+ if (event.key == null) return
+ if (!globalWatchers.has(event.key)) return
+ try {
+ // A foreign tab or external script can write an arbitrary, non-JSON
+ // value to a watched key. Deserialization + watcher dispatch must never
+ // throw out of the window `storage` handler (an uncaught error there is
+ // globally unhandled), so guard the whole body defensively.
+ notifyGlobal(event.key, {
+ oldValue:
+ event.oldValue == null
+ ? undefined
+ : defaultSerde.deserializer(event.oldValue),
+ newValue:
+ event.newValue == null
+ ? undefined
+ : defaultSerde.deserializer(event.newValue)
+ })
+ } catch (err) {
+ console.error("[plasmo-storage] failed to process storage event", err)
+ }
+ })
+}
+
export class Storage {
private backend: StorageBackend
private serde: Required
@@ -142,21 +208,26 @@ export class Storage {
watch(map: Record): () => void {
const entries = Object.entries(map)
- entries.forEach(([key, cb]) => {
+ const unsubscribers = entries.map(([key, cb]) => {
+ // Track per-instance for `unwatch()` compatibility ...
if (!this.watchers.has(key)) {
this.watchers.set(key, new Set())
}
this.watchers.get(key)!.add(cb)
+ // ... and register on the shared, cross-instance registry.
+ return subscribeGlobal(this.storageKey(key), cb)
})
return () => {
- entries.forEach(([key, cb]) => {
+ entries.forEach(([key, cb], index) => {
const set = this.watchers.get(key)
- if (!set) return
- set.delete(cb)
- if (set.size === 0) {
- this.watchers.delete(key)
+ if (set) {
+ set.delete(cb)
+ if (set.size === 0) {
+ this.watchers.delete(key)
+ }
}
+ unsubscribers[index]()
})
}
}
@@ -164,23 +235,23 @@ export class Storage {
unwatch(map: Record): void {
Object.entries(map).forEach(([key, cb]) => {
const set = this.watchers.get(key)
- if (!set) return
- set.delete(cb)
- if (set.size === 0) {
- this.watchers.delete(key)
+ if (set) {
+ set.delete(cb)
+ if (set.size === 0) {
+ this.watchers.delete(key)
+ }
+ }
+ const globalSet = globalWatchers.get(this.storageKey(key))
+ if (globalSet) {
+ globalSet.delete(cb)
+ if (globalSet.size === 0) {
+ globalWatchers.delete(this.storageKey(key))
+ }
}
})
}
private emitWatch(key: string, change: StorageChange) {
- const callbacks = this.watchers.get(key)
- if (!callbacks) return
- callbacks.forEach((cb) => {
- try {
- cb(change)
- } catch {
- // ignore watcher errors
- }
- })
+ notifyGlobal(this.storageKey(key), change)
}
}
diff --git a/apps/tldw-frontend/extension/shims/react-router-dom.tsx b/apps/tldw-frontend/extension/shims/react-router-dom.tsx
index 8653077f2f..6483c50269 100644
--- a/apps/tldw-frontend/extension/shims/react-router-dom.tsx
+++ b/apps/tldw-frontend/extension/shims/react-router-dom.tsx
@@ -197,9 +197,13 @@ export const useSearchParams = (): [
const nextParams =
next instanceof URLSearchParams ? next : new URLSearchParams(next)
const queryString = nextParams.toString()
+ // Use the *actual* current path, not router.pathname (which is the
+ // `[bracket]` dynamic-route pattern) so setSearchParams works on routes
+ // like /sources/[id].
+ const currentPath = router.asPath.split("?")[0].split("#")[0]
const nextPath = queryString
- ? `${router.pathname}?${queryString}`
- : router.pathname
+ ? `${currentPath}?${queryString}`
+ : currentPath
runNavigationTransition(() => {
const navigation = options?.replace
? router.replace(nextPath)
diff --git a/apps/tldw-frontend/extension/shims/wxt-browser.ts b/apps/tldw-frontend/extension/shims/wxt-browser.ts
index 9f5ec162bc..914afa6da8 100644
--- a/apps/tldw-frontend/extension/shims/wxt-browser.ts
+++ b/apps/tldw-frontend/extension/shims/wxt-browser.ts
@@ -87,13 +87,80 @@ const notifications = {
create: async (_options?: Record) => undefined
}
-const getStorageBackend = () => {
+type StorageAreaName = "local" | "sync" | "session"
+
+type StorageBackendLike = {
+ getItem: (key: string) => string | null
+ setItem: (key: string, value: string) => void
+ removeItem: (key: string) => void
+ key: (index: number) => string | null
+ readonly length: number
+}
+
+// Per-area key prefixes. `local` stays UNPREFIXED so existing data
+// (tldwConfig, tldw-api-host, ...) and the plasmo shim (which also writes
+// `local` unprefixed and uses `plasmo-sync:` / `plasmo-session:` for the
+// other areas) remain cross-compatible.
+const SYNC_PREFIX = "plasmo-sync:"
+const SESSION_PREFIX = "plasmo-session:"
+
+const scopedKey = (areaName: StorageAreaName, key: string): string => {
+ if (areaName === "sync") return `${SYNC_PREFIX}${key}`
+ if (areaName === "session") return `${SESSION_PREFIX}${key}`
+ return key
+}
+
+// Returns the logical (unprefixed) key if `storedKey` belongs to `areaName`,
+// otherwise null. `local` explicitly excludes keys owned by the other areas.
+const unscopedKey = (
+ areaName: StorageAreaName,
+ storedKey: string
+): string | null => {
+ if (areaName === "sync") {
+ return storedKey.startsWith(SYNC_PREFIX)
+ ? storedKey.slice(SYNC_PREFIX.length)
+ : null
+ }
+ if (areaName === "session") {
+ return storedKey.startsWith(SESSION_PREFIX)
+ ? storedKey.slice(SESSION_PREFIX.length)
+ : null
+ }
+ return storedKey.startsWith(SYNC_PREFIX) ||
+ storedKey.startsWith(SESSION_PREFIX)
+ ? null
+ : storedKey
+}
+
+// `session` is memory-only (matches chrome.storage.session semantics): a
+// module-level Map that is never persisted to localStorage/disk.
+const sessionMemory = new Map()
+const sessionBackend: StorageBackendLike = {
+ getItem: (key) => (sessionMemory.has(key) ? sessionMemory.get(key)! : null),
+ setItem: (key, value) => {
+ sessionMemory.set(key, value)
+ },
+ removeItem: (key) => {
+ sessionMemory.delete(key)
+ },
+ key: (index) => Array.from(sessionMemory.keys())[index] ?? null,
+ get length() {
+ return sessionMemory.size
+ }
+}
+
+const getLocalStorageBackend = (): StorageBackendLike | null => {
if (typeof window !== "undefined" && window.localStorage) {
return window.localStorage
}
return null
}
+const getAreaBackend = (areaName: StorageAreaName): StorageBackendLike | null => {
+ if (areaName === "session") return sessionBackend
+ return getLocalStorageBackend()
+}
+
const storageOnChanged = createEventTarget()
const parseStoredValue = (raw: string | null): unknown => {
@@ -105,76 +172,92 @@ const parseStoredValue = (raw: string | null): unknown => {
}
}
-const createStorageArea = (areaName: "local" | "sync" | "session") => ({
+const createStorageArea = (areaName: StorageAreaName) => ({
get: (
keys?: string | string[] | null,
callback?: (items: Record) => void
) => {
- const backend = getStorageBackend()
+ const backend = getAreaBackend(areaName)
const result: Record = {}
if (!backend) {
callback?.(result)
return Promise.resolve(result)
}
if (!keys) {
+ // Enumerate only the keys belonging to this area.
for (let i = 0; i < backend.length; i += 1) {
- const key = backend.key(i)
- if (key) {
- result[key] = parseStoredValue(backend.getItem(key))
- }
+ const storedKey = backend.key(i)
+ if (!storedKey) continue
+ const logicalKey = unscopedKey(areaName, storedKey)
+ if (logicalKey == null) continue
+ result[logicalKey] = parseStoredValue(backend.getItem(storedKey))
}
} else {
const keyList = Array.isArray(keys) ? keys : [keys]
keyList.forEach((key) => {
- result[key] = parseStoredValue(backend.getItem(key))
+ result[key] = parseStoredValue(
+ backend.getItem(scopedKey(areaName, key))
+ )
})
}
callback?.(result)
return Promise.resolve(result)
},
set: (items: Record, callback?: () => void) => {
- const backend = getStorageBackend()
+ const backend = getAreaBackend(areaName)
const changes: Record =
{}
+ let writeError: Error | null = null
if (backend) {
- Object.entries(items).forEach(([key, value]) => {
+ for (const [key, value] of Object.entries(items)) {
+ const storedKey = scopedKey(areaName, key)
+ const oldRaw = backend.getItem(storedKey)
+ let newRaw: string
try {
- const oldRaw = backend.getItem(key)
- const newRaw = JSON.stringify(value)
- if (oldRaw !== newRaw) {
- changes[key] = {
- oldValue: parseStoredValue(oldRaw),
- newValue: parseStoredValue(newRaw)
- }
+ newRaw = JSON.stringify(value)
+ backend.setItem(storedKey, newRaw)
+ } catch (err) {
+ // Quota exceeded, circular refs, etc. Do NOT record a change for
+ // this key so no phantom onChanged fires, and surface the error.
+ writeError = err instanceof Error ? err : new Error(String(err))
+ break
+ }
+ // Only record the change after the write actually succeeded.
+ if (oldRaw !== newRaw) {
+ changes[key] = {
+ oldValue: parseStoredValue(oldRaw),
+ newValue: parseStoredValue(newRaw)
}
- backend.setItem(key, newRaw)
- } catch {
- // Silently ignore storage failures (quota exceeded, circular refs, etc.)
}
- })
+ }
}
if (Object.keys(changes).length > 0) {
storageOnChanged.trigger(changes, areaName)
}
+ if (writeError) {
+ // Propagate the failure instead of resolving as a success.
+ return Promise.reject(writeError)
+ }
callback?.()
return Promise.resolve()
},
remove: (keys: string | string[], callback?: () => void) => {
- const backend = getStorageBackend()
+ const backend = getAreaBackend(areaName)
const changes: Record =
{}
if (backend) {
const keyList = Array.isArray(keys) ? keys : [keys]
keyList.forEach((key) => {
+ const storedKey = scopedKey(areaName, key)
try {
- const oldRaw = backend.getItem(key)
+ const oldRaw = backend.getItem(storedKey)
if (oldRaw !== null) {
changes[key] = {
oldValue: parseStoredValue(oldRaw),
newValue: undefined
}
}
- backend.removeItem(key)
+ backend.removeItem(storedKey)
} catch {
// Silently ignore storage failures
}
@@ -187,21 +270,26 @@ const createStorageArea = (areaName: "local" | "sync" | "session") => ({
return Promise.resolve()
},
clear: (callback?: () => void) => {
- const backend = getStorageBackend()
+ const backend = getAreaBackend(areaName)
const changes: Record =
{}
if (backend) {
try {
+ // Collect only THIS area's keys first, then remove them so we never
+ // wipe the whole origin (H9) and don't mutate while indexing.
+ const keysToRemove: string[] = []
for (let i = 0; i < backend.length; i += 1) {
- const key = backend.key(i)
- if (!key) continue
- const oldRaw = backend.getItem(key)
- changes[key] = {
- oldValue: parseStoredValue(oldRaw),
+ const storedKey = backend.key(i)
+ if (!storedKey) continue
+ const logicalKey = unscopedKey(areaName, storedKey)
+ if (logicalKey == null) continue
+ changes[logicalKey] = {
+ oldValue: parseStoredValue(backend.getItem(storedKey)),
newValue: undefined
}
+ keysToRemove.push(storedKey)
}
- backend.clear()
+ keysToRemove.forEach((key) => backend.removeItem(key))
} catch {
// Silently ignore storage failures
}
diff --git a/apps/tldw-frontend/hooks/__tests__/useConfig.networking.test.tsx b/apps/tldw-frontend/hooks/__tests__/useConfig.networking.test.tsx
deleted file mode 100644
index 272510ef72..0000000000
--- a/apps/tldw-frontend/hooks/__tests__/useConfig.networking.test.tsx
+++ /dev/null
@@ -1,294 +0,0 @@
-import { act, renderHook, waitFor } from '@testing-library/react';
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-
-const authStorageMocks = vi.hoisted(() => ({
- setRuntimeApiBearer: vi.fn(),
- setRuntimeApiKey: vi.fn(),
-}));
-
-vi.mock('@web/lib/authStorage', () => authStorageMocks);
-
-function readStoredTldwConfig(): Record {
- return JSON.parse(localStorage.getItem('tldwConfig') ?? '{}') as Record;
-}
-
-describe('useConfig networking', () => {
- beforeEach(() => {
- vi.resetModules();
- vi.clearAllMocks();
- localStorage.clear();
- vi.unstubAllGlobals();
- delete process.env.NEXT_PUBLIC_API_URL;
- delete process.env.NEXT_PUBLIC_API_BASE_URL;
- delete process.env.NEXT_PUBLIC_API_VERSION;
- delete process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE;
- delete process.env.NEXT_PUBLIC_X_API_KEY;
- delete process.env.NEXT_PUBLIC_API_BEARER;
- });
-
- it('keeps a relative /api/v1 base in quickstart mode', async () => {
- process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE = 'quickstart';
-
- const apiModule = await import('@web/lib/api');
- const { ConfigProvider, useConfig } = await import('@web/hooks/useConfig');
-
- const { result } = renderHook(() => useConfig(), {
- wrapper: ({ children }) => {children},
- });
-
- await waitFor(() => {
- expect(apiModule.getApiBaseUrl()).toBe('/api/v1');
- });
-
- expect(result.current.config.apiVersion).toBe('v1');
- });
-
- it('does not let a stored absolute host override quickstart mode', async () => {
- process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE = 'quickstart';
- localStorage.setItem('tldw-api-host', 'http://127.0.0.1:8000');
-
- const apiModule = await import('@web/lib/api');
- const { ConfigProvider, useConfig } = await import('@web/hooks/useConfig');
-
- renderHook(() => useConfig(), {
- wrapper: ({ children }) => {children},
- });
-
- await waitFor(() => {
- expect(apiModule.getApiBaseUrl()).toBe('/api/v1');
- });
-
- expect(localStorage.getItem('tldw-api-host')).not.toBe('http://127.0.0.1:8000');
- });
-
- it('fetches docs-info from the quickstart same-origin api root', async () => {
- process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE = 'quickstart';
-
- const fetchMock = vi.fn().mockResolvedValue({
- ok: true,
- json: async () => ({}),
- });
- vi.stubGlobal('fetch', fetchMock);
-
- const { ConfigProvider, useConfig } = await import('@web/hooks/useConfig');
-
- const { result } = renderHook(() => useConfig(), {
- wrapper: ({ children }) => {children},
- });
-
- await result.current.reloadBootstrapConfig();
-
- expect(fetchMock).toHaveBeenCalledWith('/api/v1/config/docs-info', {
- credentials: 'omit',
- });
- });
-
- it('pins quickstart docs-info fetches to api v1 even when a different version is stored', async () => {
- process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE = 'quickstart';
- localStorage.setItem('tldw-api-version', 'v9');
-
- const fetchMock = vi.fn().mockResolvedValue({
- ok: true,
- json: async () => ({}),
- });
- vi.stubGlobal('fetch', fetchMock);
-
- const { ConfigProvider, useConfig } = await import('@web/hooks/useConfig');
-
- const { result } = renderHook(() => useConfig(), {
- wrapper: ({ children }) => {children},
- });
-
- await result.current.reloadBootstrapConfig();
-
- expect(fetchMock).toHaveBeenCalledWith('/api/v1/config/docs-info', {
- credentials: 'omit',
- });
- });
-
- it('fetches docs-info from the advanced api origin', async () => {
- process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE = 'advanced';
- process.env.NEXT_PUBLIC_API_URL = 'https://api.example.test';
-
- const fetchMock = vi.fn().mockResolvedValue({
- ok: true,
- json: async () => ({}),
- });
- vi.stubGlobal('fetch', fetchMock);
-
- const { ConfigProvider, useConfig } = await import('@web/hooks/useConfig');
-
- const { result } = renderHook(() => useConfig(), {
- wrapper: ({ children }) => {children},
- });
-
- await result.current.reloadBootstrapConfig();
-
- expect(fetchMock).toHaveBeenCalledWith('https://api.example.test/api/v1/config/docs-info', {
- credentials: 'omit',
- });
- });
-
- it('hydrates single-user api keys from the canonical browser config', async () => {
- process.env.NEXT_PUBLIC_API_URL = 'http://127.0.0.1:8000';
- localStorage.setItem(
- 'tldwConfig',
- JSON.stringify({
- authMode: 'single-user',
- apiKey: 'stored-api-key',
- serverUrl: 'http://127.0.0.1:8123',
- })
- );
-
- const { ConfigProvider, useConfig } = await import('@web/hooks/useConfig');
-
- const { result } = renderHook(() => useConfig(), {
- wrapper: ({ children }) => {children},
- });
-
- expect(result.current.config.xApiKey).toBe('stored-api-key');
- expect(result.current.config.apiBaseHost).toBe('http://127.0.0.1:8123');
-
- await waitFor(() => {
- expect(authStorageMocks.setRuntimeApiKey).toHaveBeenLastCalledWith('stored-api-key');
- });
- });
-
- it('persists manually entered single-user api keys for reloads', async () => {
- process.env.NEXT_PUBLIC_API_URL = 'http://127.0.0.1:8000';
- localStorage.setItem('apiBearer', 'legacy-bearer');
- localStorage.setItem('refreshToken', 'legacy-refresh');
- const { ConfigProvider, useConfig } = await import('@web/hooks/useConfig');
-
- const { result } = renderHook(() => useConfig(), {
- wrapper: ({ children }) => {children},
- });
-
- act(() => {
- result.current.setXApiKey('saved-api-key');
- });
-
- await waitFor(() => {
- expect(authStorageMocks.setRuntimeApiKey).toHaveBeenLastCalledWith('saved-api-key');
- });
- expect(localStorage.getItem('apiKey')).toBe('saved-api-key');
- expect(localStorage.getItem('apiBearer')).toBeNull();
- expect(readStoredTldwConfig()).toMatchObject({
- authMode: 'single-user',
- apiKey: 'saved-api-key',
- });
- expect(readStoredTldwConfig()).not.toHaveProperty('accessToken');
- expect(localStorage.getItem('refreshToken')).toBeNull();
- });
-
- it('persists manually entered multi-user bearer tokens for reloads', async () => {
- process.env.NEXT_PUBLIC_API_URL = 'http://127.0.0.1:8000';
- localStorage.setItem('apiKey', 'legacy-api-key');
- const { ConfigProvider, useConfig } = await import('@web/hooks/useConfig');
-
- const { result } = renderHook(() => useConfig(), {
- wrapper: ({ children }) => {children},
- });
-
- act(() => {
- result.current.setApiBearer('Bearer saved-bearer-token');
- });
-
- await waitFor(() => {
- expect(authStorageMocks.setRuntimeApiBearer).toHaveBeenLastCalledWith('Bearer saved-bearer-token');
- });
- expect(localStorage.getItem('accessToken')).toBe('saved-bearer-token');
- expect(localStorage.getItem('apiKey')).toBeNull();
- expect(readStoredTldwConfig()).toMatchObject({
- authMode: 'multi-user',
- accessToken: 'saved-bearer-token',
- });
- expect(readStoredTldwConfig()).not.toHaveProperty('apiKey');
- });
-
- it('refreshes live config after settings writes canonical tldw config', async () => {
- process.env.NEXT_PUBLIC_API_URL = 'http://127.0.0.1:8000';
- const { ConfigProvider, useConfig } = await import('@web/hooks/useConfig');
-
- const { result } = renderHook(() => useConfig(), {
- wrapper: ({ children }) => {children},
- });
-
- act(() => {
- localStorage.setItem(
- 'tldwConfig',
- JSON.stringify({
- authMode: 'single-user',
- apiKey: 'event-api-key',
- serverUrl: 'http://127.0.0.1:8222',
- })
- );
- window.dispatchEvent(new CustomEvent('tldw:config-updated'));
- });
-
- await waitFor(() => {
- expect(result.current.config.xApiKey).toBe('event-api-key');
- expect(result.current.config.apiBaseHost).toBe('http://127.0.0.1:8222');
- expect(authStorageMocks.setRuntimeApiKey).toHaveBeenLastCalledWith('event-api-key');
- });
- expect(localStorage.getItem('apiKey')).toBe('event-api-key');
- expect(readStoredTldwConfig()).toMatchObject({
- authMode: 'single-user',
- apiKey: 'event-api-key',
- });
- expect(readStoredTldwConfig()).not.toHaveProperty('accessToken');
- });
-
- it('keeps environment api keys ahead of stale browser config', async () => {
- process.env.NEXT_PUBLIC_API_URL = 'http://127.0.0.1:8000';
- process.env.NEXT_PUBLIC_X_API_KEY = 'env-api-key';
- localStorage.setItem(
- 'tldwConfig',
- JSON.stringify({
- authMode: 'single-user',
- apiKey: 'stale-browser-key',
- })
- );
-
- const { ConfigProvider, useConfig } = await import('@web/hooks/useConfig');
-
- const { result } = renderHook(() => useConfig(), {
- wrapper: ({ children }) => {children},
- });
-
- expect(result.current.config.xApiKey).toBe('env-api-key');
-
- await waitFor(() => {
- expect(authStorageMocks.setRuntimeApiKey).toHaveBeenLastCalledWith('env-api-key');
- });
- expect(localStorage.getItem('apiKey')).toBeNull();
- expect(readStoredTldwConfig()).not.toHaveProperty('apiKey');
- expect(readStoredTldwConfig()).not.toHaveProperty('accessToken');
- });
-
- it('persists env auth opt-out when users clear seeded credentials', async () => {
- process.env.NEXT_PUBLIC_API_URL = 'http://127.0.0.1:8000';
- process.env.NEXT_PUBLIC_X_API_KEY = 'env-api-key';
-
- const { ConfigProvider, useConfig } = await import('@web/hooks/useConfig');
-
- const { result } = renderHook(() => useConfig(), {
- wrapper: ({ children }) => {children},
- });
-
- await waitFor(() => {
- expect(authStorageMocks.setRuntimeApiKey).toHaveBeenLastCalledWith('env-api-key');
- });
-
- act(() => {
- result.current.setXApiKey('');
- });
-
- await waitFor(() => {
- expect(authStorageMocks.setRuntimeApiKey).toHaveBeenLastCalledWith(undefined);
- });
- expect(localStorage.getItem('tldwRuntimeEnvAuthOptOut')).toBe('true');
- expect(localStorage.getItem('apiKey')).toBeNull();
- expect(readStoredTldwConfig()).not.toHaveProperty('apiKey');
- });
-});
diff --git a/apps/tldw-frontend/hooks/useAuth.tsx b/apps/tldw-frontend/hooks/useAuth.tsx
deleted file mode 100644
index 2cecc3b006..0000000000
--- a/apps/tldw-frontend/hooks/useAuth.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import { useState, useEffect, createContext, useContext, ReactNode, startTransition } from 'react';
-import { useRouter } from 'next/router';
-import { authService, getAuthMode, User } from '@web/lib/auth';
-import { emitSplashAfterLoginSuccess } from '@/services/splash-events';
-
-interface AuthContextType {
- user: User | null;
- loading: boolean;
- login: (username: string, password: string) => Promise;
- logout: () => void;
- isAuthenticated: boolean;
-}
-
-const AuthContext = createContext(undefined);
-
-export function AuthProvider({ children }: { children: ReactNode }) {
- const [user, setUser] = useState(null);
- const [loading, setLoading] = useState(true);
- const router = useRouter();
-
- useEffect(() => {
- // Check if user is logged in on mount
- const checkAuth = async () => {
- try {
- const mode = getAuthMode();
-
- // Env-based auth: synthesize a user and treat as authenticated
- if (mode === 'env_single_user' || mode === 'env_bearer') {
- const envUser = authService.getUser();
- if (envUser) {
- setUser(envUser);
- }
- return;
- }
-
- // JWT-based auth: validate token against backend and hydrate user profile
- if (mode === 'jwt') {
- const isValid = await authService.validateToken();
- if (isValid) {
- const currentUser = authService.getUser();
- if (currentUser) {
- setUser(currentUser);
- }
- } else {
- authService.logout();
- startTransition(() => {
- void router.push('/login');
- });
- }
- }
- } catch (error) {
- console.error('Auth check failed:', error);
- } finally {
- setLoading(false);
- }
- };
-
- checkAuth();
- }, [router]);
-
- const login = async (username: string, password: string) => {
- await authService.login({ username, password });
- const loggedInUser = authService.getUser();
- setUser(loggedInUser);
- emitSplashAfterLoginSuccess();
- startTransition(() => {
- void router.push('/');
- });
- };
-
- const logout = () => {
- authService.logout();
- setUser(null);
- startTransition(() => {
- void router.push('/login');
- });
- };
-
- return (
-
- {children}
-
- );
-}
-
-export function useAuth() {
- const context = useContext(AuthContext);
- if (context === undefined) {
- throw new Error('useAuth must be used within an AuthProvider');
- }
- return context;
-}
diff --git a/apps/tldw-frontend/hooks/useConfig.tsx b/apps/tldw-frontend/hooks/useConfig.tsx
deleted file mode 100644
index 317ebb417b..0000000000
--- a/apps/tldw-frontend/hooks/useConfig.tsx
+++ /dev/null
@@ -1,356 +0,0 @@
-import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
-import api, { getApiBaseUrl } from '@web/lib/api';
-import { buildApiBaseUrl, resolveDeploymentMode, resolvePublicApiOrigin } from '@web/lib/api-base';
-import { setRuntimeApiBearer, setRuntimeApiKey } from '@web/lib/authStorage';
-
-type Theme = 'light' | 'dark' | 'system';
-
-interface AppConfig {
- apiBaseHost: string; // e.g., http://127.0.0.1:8000
- apiVersion: string; // e.g., v1
- xApiKey?: string;
- apiBearer?: string;
- theme: Theme;
- csrfToken?: string | null;
-}
-
-interface ConfigContextType {
- config: AppConfig;
- setApiBaseHost: (host: string) => void;
- setApiVersion: (version: string) => void;
- setXApiKey: (key: string) => void;
- setApiBearer: (bearer: string) => void;
- setTheme: (theme: Theme) => void;
- reloadBootstrapConfig: () => Promise;
-}
-
-type StoredTldwConfig = {
- authMode?: unknown;
- apiKey?: unknown;
- apiBearer?: unknown;
- accessToken?: unknown;
- refreshToken?: unknown;
- serverUrl?: unknown;
- [key: string]: unknown;
-};
-
-const DEFAULT_HOST = (typeof window !== 'undefined' && window.location?.origin) || (process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:8000');
-const DEPLOYMENT_ENV = {
- NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE: process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE,
- NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
-};
-const DOCS_INFO_API_VERSION = 'v1';
-const RUNTIME_ENV_AUTH_OPT_OUT_KEY = 'tldwRuntimeEnvAuthOptOut';
-
-const ConfigContext = createContext(undefined);
-
-function getPageOrigin(): string | undefined {
- return typeof window !== 'undefined' ? window.location?.origin : undefined;
-}
-
-function getDefaultHost(): string {
- const pageOrigin = getPageOrigin();
- const resolvedOrigin = resolvePublicApiOrigin(DEPLOYMENT_ENV, pageOrigin);
- return resolvedOrigin || pageOrigin || DEFAULT_HOST;
-}
-
-function normalizeTextValue(value: unknown): string | null {
- if (typeof value !== 'string') return null;
- const trimmed = value.trim();
- return trimmed ? trimmed : null;
-}
-
-function normalizeApiKeyValue(value: unknown): string | null {
- const normalized = normalizeTextValue(value);
- if (!normalized || /\s/.test(normalized)) return null;
- return normalized;
-}
-
-function normalizeBearerValue(value: unknown): string | null {
- const normalized = normalizeTextValue(value);
- if (!normalized) return null;
- const stripped = normalized.replace(/^Bearer\s+/i, '').trim();
- if (!stripped || /\s/.test(stripped)) return null;
- return stripped;
-}
-
-function readStoredTldwConfig(): StoredTldwConfig | null {
- if (typeof window === 'undefined') return null;
- try {
- const raw = window.localStorage.getItem('tldwConfig');
- if (!raw) return null;
- const parsed = JSON.parse(raw);
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
- return null;
- }
- return parsed as StoredTldwConfig;
- } catch {
- return null;
- }
-}
-
-function readStoredValue(key: string): string | null {
- if (typeof window === 'undefined') return null;
- try {
- return normalizeTextValue(window.localStorage.getItem(key));
- } catch {
- return null;
- }
-}
-
-function isTheme(value: string | null): value is Theme {
- return value === 'light' || value === 'dark' || value === 'system';
-}
-
-function getStoredApiKey(storedConfig: StoredTldwConfig | null): string | null {
- if (normalizeTextValue(storedConfig?.authMode) === 'single-user') {
- const canonicalKey = normalizeApiKeyValue(storedConfig?.apiKey);
- if (canonicalKey) return canonicalKey;
- }
- return normalizeApiKeyValue(readStoredValue('apiKey'));
-}
-
-function getStoredApiBearer(storedConfig: StoredTldwConfig | null): string | null {
- if (normalizeTextValue(storedConfig?.authMode) === 'multi-user') {
- const canonicalBearer =
- normalizeBearerValue(storedConfig?.accessToken) ||
- normalizeBearerValue(storedConfig?.apiBearer);
- if (canonicalBearer) return canonicalBearer;
- }
- return normalizeBearerValue(readStoredValue('accessToken'));
-}
-
-function loadBrowserConfig(current?: AppConfig): AppConfig {
- const storedConfig = readStoredTldwConfig();
- const deploymentMode = resolveDeploymentMode(DEPLOYMENT_ENV);
- const canonicalHost = normalizeTextValue(storedConfig?.serverUrl);
- const legacyHost = readStoredValue('tldw-api-host');
- const apiBaseHost =
- deploymentMode === 'quickstart'
- ? getDefaultHost()
- : canonicalHost || legacyHost || current?.apiBaseHost || getDefaultHost();
- const storedVersion = readStoredValue('tldw-api-version');
- const apiVersion = storedVersion || current?.apiVersion || process.env.NEXT_PUBLIC_API_VERSION || 'v1';
- const storedTheme = readStoredValue('theme') || readStoredValue('tldw-theme');
- const theme = isTheme(storedTheme) ? storedTheme : current?.theme || 'dark';
- const envApiKey = normalizeApiKeyValue(process.env.NEXT_PUBLIC_X_API_KEY);
- const envApiBearer = normalizeBearerValue(process.env.NEXT_PUBLIC_API_BEARER);
- const xApiKey = envApiKey || getStoredApiKey(storedConfig) || undefined;
- const apiBearer = envApiBearer || getStoredApiBearer(storedConfig) || undefined;
-
- return {
- apiBaseHost,
- apiVersion,
- xApiKey,
- apiBearer,
- theme,
- csrfToken: current?.csrfToken ?? null,
- };
-}
-
-function writeBrowserConfig(config: AppConfig): void {
- if (typeof window === 'undefined') return;
-
- // codeql[js/clear-text-storage-of-sensitive-data]: tldw-api-host stores non-secret server metadata only.
- window.localStorage.setItem('tldw-api-host', config.apiBaseHost);
- window.localStorage.setItem('tldw-api-version', config.apiVersion);
- window.localStorage.setItem('theme', config.theme);
- window.localStorage.removeItem('tldw-theme');
-
- const existingConfig = readStoredTldwConfig();
- const envApiKey = normalizeApiKeyValue(process.env.NEXT_PUBLIC_X_API_KEY);
- const envApiBearer = normalizeBearerValue(process.env.NEXT_PUBLIC_API_BEARER);
- const apiKey = normalizeApiKeyValue(config.xApiKey);
- const apiBearer = normalizeBearerValue(config.apiBearer);
- const shouldPersistApiKey = !!apiKey && apiKey !== envApiKey;
- const shouldPersistApiBearer = !!apiBearer && apiBearer !== envApiBearer;
- const clearedEnvApiKey = !!envApiKey && !apiKey;
- const clearedEnvApiBearer = !!envApiBearer && !apiBearer;
-
- window.localStorage.removeItem('apiKey');
- window.localStorage.removeItem('apiBearer');
- window.localStorage.removeItem('accessToken');
- window.localStorage.removeItem('refreshToken');
-
- if (clearedEnvApiKey || clearedEnvApiBearer) {
- window.localStorage.setItem(RUNTIME_ENV_AUTH_OPT_OUT_KEY, 'true');
- }
-
- if (!existingConfig && !shouldPersistApiKey && !shouldPersistApiBearer) {
- return;
- }
-
- const nextConfig: StoredTldwConfig = { ...(existingConfig || {}) };
- nextConfig.serverUrl = config.apiBaseHost;
- delete nextConfig.apiKey;
- delete nextConfig.apiBearer;
- delete nextConfig.accessToken;
- delete nextConfig.refreshToken;
-
- if (shouldPersistApiKey) {
- nextConfig.authMode = 'single-user';
- nextConfig.apiKey = apiKey;
- // codeql[js/clear-text-storage-of-sensitive-data]: local self-hosted credential persistence is explicit user config.
- window.localStorage.setItem('apiKey', apiKey);
- window.localStorage.removeItem('accessToken');
- } else {
- delete nextConfig.apiKey;
- window.localStorage.removeItem('apiKey');
- }
-
- if (shouldPersistApiBearer) {
- nextConfig.authMode = 'multi-user';
- nextConfig.accessToken = apiBearer;
- delete nextConfig.apiKey;
- // codeql[js/clear-text-storage-of-sensitive-data]: local self-hosted credential persistence is explicit user config.
- window.localStorage.setItem('accessToken', apiBearer);
- window.localStorage.removeItem('apiKey');
- } else {
- delete nextConfig.accessToken;
- window.localStorage.removeItem('accessToken');
- }
-
- // codeql[js/clear-text-storage-of-sensitive-data]: this intentionally persists local/self-hosted auth mode and user-supplied credentials for reloads.
- window.localStorage.setItem('tldwConfig', JSON.stringify(nextConfig));
-}
-
-function computeBaseURL(host: string, version: string) {
- if (resolveDeploymentMode(DEPLOYMENT_ENV) === 'quickstart') {
- return buildApiBaseUrl('', version);
- }
- return buildApiBaseUrl(host || resolvePublicApiOrigin(DEPLOYMENT_ENV, getPageOrigin()), version);
-}
-
-function normalizeDocsInfoOrigin(value: string): string {
- return value.replace(/\/api\/[^/]+\/?$/, '').replace(/\/$/, '');
-}
-
-function computeDocsInfoUrl(host: string): string {
- if (resolveDeploymentMode(DEPLOYMENT_ENV) === 'quickstart') {
- return `${buildApiBaseUrl('', DOCS_INFO_API_VERSION)}/config/docs-info`;
- }
-
- const preferredOrigin = (process.env.NEXT_PUBLIC_API_BASE_URL || '').toString().trim();
- const resolvedOrigin = normalizeDocsInfoOrigin(
- preferredOrigin || host || resolvePublicApiOrigin(DEPLOYMENT_ENV, getPageOrigin())
- );
- return `${buildApiBaseUrl(resolvedOrigin, DOCS_INFO_API_VERSION)}/config/docs-info`;
-}
-
-function applyTheme(theme: Theme) {
- if (typeof document === 'undefined') return;
- const root = document.documentElement;
- const isDark =
- theme === 'dark' ||
- (theme === 'system' &&
- typeof window !== 'undefined' &&
- window.matchMedia('(prefers-color-scheme: dark)').matches);
- root.classList.toggle('dark', isDark);
- // Keep legacy aliases to avoid breaking existing selectors/readers.
- root.classList.toggle('theme-dark', isDark);
- root.classList.toggle('theme-light', !isDark);
- root.setAttribute('data-theme', isDark ? 'dark' : 'light');
-}
-
-export function ConfigProvider({ children }: { children: React.ReactNode }) {
- const [config, setConfig] = useState(() => {
- if (typeof window === 'undefined') {
- return {
- apiBaseHost: getDefaultHost(),
- apiVersion: process.env.NEXT_PUBLIC_API_VERSION || 'v1',
- xApiKey: process.env.NEXT_PUBLIC_X_API_KEY,
- apiBearer: process.env.NEXT_PUBLIC_API_BEARER,
- theme: 'dark',
- csrfToken: null,
- };
- }
- return loadBrowserConfig();
- });
-
- // Initialize API baseURL and theme on mount
- useEffect(() => {
- const current = computeBaseURL(config.apiBaseHost, config.apiVersion);
- if (getApiBaseUrl() !== current) {
- api.defaults.baseURL = current;
- }
- applyTheme(config.theme);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- // Persist config changes and update API baseURL
- useEffect(() => {
- if (typeof window === 'undefined') return;
- setRuntimeApiKey(config.xApiKey);
- setRuntimeApiBearer(config.apiBearer);
- // Persist
- try {
- writeBrowserConfig(config);
- } catch {
- // localStorage may be unavailable in some contexts
- }
- // Apply API base URL
- const nextBase = computeBaseURL(config.apiBaseHost, config.apiVersion);
- api.defaults.baseURL = nextBase;
- // Apply theme
- applyTheme(config.theme);
- }, [config]);
-
- useEffect(() => {
- if (typeof window === 'undefined') return;
- const handleConfigUpdated = () => {
- setConfig((current) => loadBrowserConfig(current));
- };
- window.addEventListener('tldw:config-updated', handleConfigUpdated);
- return () => {
- window.removeEventListener('tldw:config-updated', handleConfigUpdated);
- };
- }, []);
-
- const setApiBaseHost = (host: string) => setConfig((c) => ({ ...c, apiBaseHost: host }));
- const setApiVersion = (ver: string) => setConfig((c) => ({ ...c, apiVersion: ver || 'v1' }));
- const setXApiKey = (key: string) => setConfig((c) => ({ ...c, xApiKey: key || undefined }));
- const setApiBearer = (bearer: string) => setConfig((c) => ({ ...c, apiBearer: bearer || undefined }));
- const setTheme = (t: Theme) => setConfig((c) => ({ ...c, theme: t }));
-
- const reloadBootstrapConfig = useCallback(async () => {
- try {
- const docsInfoUrl = computeDocsInfoUrl(config.apiBaseHost);
- // docs-info is intentionally non-sensitive; avoid credentialed CORS requirements.
- const resp = await fetch(docsInfoUrl, { credentials: 'omit' });
- if (!resp.ok) return;
- const json = await resp.json();
- const host =
- resolveDeploymentMode(DEPLOYMENT_ENV) === 'quickstart'
- ? getDefaultHost()
- : json?.base_url || json?.api_base_url || config.apiBaseHost;
- const version = config.apiVersion || 'v1';
- const rawKey = json?.api_key || json?.x_api_key || '';
- const key = rawKey && rawKey !== 'YOUR_API_KEY' ? rawKey : config.xApiKey;
- const bearer = json?.api_bearer || config.apiBearer;
- setConfig((c) => ({ ...c, apiBaseHost: host, apiVersion: version, xApiKey: key, apiBearer: bearer }));
- } catch {
- // ignore bootstrap config fetch failures
- }
- }, [config.apiBaseHost, config.apiVersion, config.xApiKey, config.apiBearer]);
-
- const value = useMemo(
- () => ({
- config,
- setApiBaseHost,
- setApiVersion,
- setXApiKey,
- setApiBearer,
- setTheme,
- reloadBootstrapConfig,
- }),
- [config, reloadBootstrapConfig]
- );
-
- return {children};
-}
-
-export function useConfig() {
- const ctx = useContext(ConfigContext);
- if (!ctx) throw new Error('useConfig must be used within ConfigProvider');
- return ctx;
-}
diff --git a/apps/tldw-frontend/hooks/useIsAdmin.ts b/apps/tldw-frontend/hooks/useIsAdmin.ts
deleted file mode 100644
index 33911d9bb6..0000000000
--- a/apps/tldw-frontend/hooks/useIsAdmin.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { useAuth } from '@web/hooks/useAuth';
-import { isAdmin } from '@web/lib/authz';
-
-/**
- * useIsAdmin - returns true when the current user is considered an admin.
- * Centralizes the logic so components remain consistent.
- */
-export function useIsAdmin(): boolean {
- const { user } = useAuth();
- return isAdmin(user);
-}
diff --git a/apps/tldw-frontend/lib/__tests__/history-redaction.test.ts b/apps/tldw-frontend/lib/__tests__/history-redaction.test.ts
new file mode 100644
index 0000000000..b9ca38fb02
--- /dev/null
+++ b/apps/tldw-frontend/lib/__tests__/history-redaction.test.ts
@@ -0,0 +1,198 @@
+/** @vitest-environment jsdom */
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import {
+ addRequestHistory,
+ clearRequestHistory,
+ getRequestHistory,
+} from '../history';
+
+const KEY = 'tldw-request-history';
+
+describe('request history redaction', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it('redacts auth-bearing request headers before persisting', () => {
+ addRequestHistory({
+ id: '1',
+ method: 'GET',
+ url: '/media/search',
+ timestamp: new Date().toISOString(),
+ requestHeaders: {
+ authorization: 'Bearer XYZ',
+ 'x-api-key': 'APIKEY-abc',
+ 'x-csrf-token': 'csrf-123',
+ 'x-tldw-org-id': 'org-9',
+ 'content-type': 'application/json',
+ },
+ });
+
+ const raw = localStorage.getItem(KEY) || '';
+ // Distinctive secret values must not appear anywhere in the stored blob.
+ expect(raw).not.toContain('Bearer XYZ');
+ expect(raw).not.toContain('APIKEY-abc');
+ expect(raw).not.toContain('csrf-123');
+ expect(raw).not.toContain('org-9');
+
+ const [item] = getRequestHistory();
+ expect(item.requestHeaders?.authorization).toBe('[REDACTED]');
+ expect(item.requestHeaders?.['x-api-key']).toBe('[REDACTED]');
+ expect(item.requestHeaders?.['x-csrf-token']).toBe('[REDACTED]');
+ expect(item.requestHeaders?.['x-tldw-org-id']).toBe('[REDACTED]');
+ // Non-sensitive headers are preserved for debugging value.
+ expect(item.requestHeaders?.['content-type']).toBe('application/json');
+ });
+
+ it('matches sensitive header names case-insensitively', () => {
+ addRequestHistory({
+ id: '1b',
+ method: 'GET',
+ url: '/media/search',
+ timestamp: new Date().toISOString(),
+ requestHeaders: {
+ Authorization: 'Bearer MIXEDCASE',
+ 'X-API-KEY': 'MIXED-KEY',
+ },
+ });
+
+ const raw = localStorage.getItem(KEY) || '';
+ expect(raw).not.toContain('MIXEDCASE');
+ expect(raw).not.toContain('MIXED-KEY');
+ });
+
+ it('does not persist access_token from an /auth/login response body', () => {
+ addRequestHistory({
+ id: '2',
+ method: 'POST',
+ url: '/auth/login',
+ timestamp: new Date().toISOString(),
+ requestHeaders: { authorization: 'Bearer XYZ' },
+ responseBody: { access_token: 'SECRET-TOKEN-123', token_type: 'bearer' },
+ });
+
+ const raw = localStorage.getItem(KEY) || '';
+ expect(raw).not.toContain('SECRET-TOKEN-123');
+ expect(raw).not.toContain('Bearer XYZ');
+ });
+
+ it('redacts access_token/refresh_token on /auth/refresh responses', () => {
+ addRequestHistory({
+ id: '2b',
+ method: 'POST',
+ url: '/auth/refresh',
+ timestamp: new Date().toISOString(),
+ responseBody: { access_token: 'REFRESH-ACCESS', refresh_token: 'REFRESH-REFRESH' },
+ });
+
+ const raw = localStorage.getItem(KEY) || '';
+ expect(raw).not.toContain('REFRESH-ACCESS');
+ expect(raw).not.toContain('REFRESH-REFRESH');
+ });
+
+ it('redacts access_token/refresh_token keys on non-auth routes too', () => {
+ addRequestHistory({
+ id: '3',
+ method: 'GET',
+ url: '/some/route',
+ timestamp: new Date().toISOString(),
+ responseBody: {
+ data: { access_token: 'LEAK-1', nested: { refresh_token: 'LEAK-2' } },
+ list: [{ access_token: 'LEAK-3' }],
+ },
+ });
+
+ const raw = localStorage.getItem(KEY) || '';
+ expect(raw).not.toContain('LEAK-1');
+ expect(raw).not.toContain('LEAK-2');
+ expect(raw).not.toContain('LEAK-3');
+ });
+
+ it('redacts additional credential-shaped body keys on non-auth routes', () => {
+ addRequestHistory({
+ id: '3b',
+ method: 'POST',
+ url: '/some/route',
+ timestamp: new Date().toISOString(),
+ requestBody: {
+ id_token: 'IDT-LEAK',
+ session_token: 'SESS-LEAK',
+ api_key: 'APIK-LEAK',
+ apiKey: 'APIK2-LEAK',
+ 'x-api-key': 'XAPIK-LEAK',
+ jwt: 'JWT-LEAK',
+ secret: 'SEC-LEAK',
+ password: 'PW-LEAK',
+ client_secret: 'CS-LEAK',
+ keep: 'visible-value',
+ },
+ responseBody: { data: { api_key: 'RESP-APIK-LEAK' } },
+ });
+
+ const raw = localStorage.getItem(KEY) || '';
+ for (const leaked of [
+ 'IDT-LEAK',
+ 'SESS-LEAK',
+ 'APIK-LEAK',
+ 'APIK2-LEAK',
+ 'XAPIK-LEAK',
+ 'JWT-LEAK',
+ 'SEC-LEAK',
+ 'PW-LEAK',
+ 'CS-LEAK',
+ 'RESP-APIK-LEAK',
+ ]) {
+ expect(raw).not.toContain(leaked);
+ }
+ // Non-sensitive keys stay readable for debugging value.
+ expect(raw).toContain('visible-value');
+
+ const [item] = getRequestHistory();
+ const body = item.requestBody as Record;
+ expect(body.id_token).toBe('[REDACTED]');
+ expect(body.api_key).toBe('[REDACTED]');
+ expect(body.apiKey).toBe('[REDACTED]');
+ expect(body.client_secret).toBe('[REDACTED]');
+ expect(body.keep).toBe('visible-value');
+ });
+
+ it('fails closed on tokens nested deeper than the redaction depth limit', () => {
+ // Bury a token below the (>6) recursion depth. The old fail-open behavior
+ // returned the raw subtree past the limit, leaking the token.
+ addRequestHistory({
+ id: '3c',
+ method: 'POST',
+ url: '/some/route',
+ timestamp: new Date().toISOString(),
+ responseBody: {
+ a: { b: { c: { d: { e: { f: { g: { access_token: 'DEEP-LEAK' } } } } } } },
+ },
+ });
+
+ const raw = localStorage.getItem(KEY) || '';
+ expect(raw).not.toContain('DEEP-LEAK');
+ // The truncated subtree is replaced with the redaction placeholder.
+ expect(raw).toContain('[REDACTED]');
+ });
+
+ it('clearRequestHistory empties the store', () => {
+ addRequestHistory({
+ id: '4',
+ method: 'GET',
+ url: '/x',
+ timestamp: new Date().toISOString(),
+ });
+ expect(getRequestHistory().length).toBe(1);
+
+ clearRequestHistory();
+
+ expect(getRequestHistory()).toEqual([]);
+ expect(localStorage.getItem(KEY)).toBeNull();
+ });
+});
diff --git a/apps/tldw-frontend/lib/api.ts b/apps/tldw-frontend/lib/api.ts
index 2b5630e7e9..8816a46f03 100644
--- a/apps/tldw-frontend/lib/api.ts
+++ b/apps/tldw-frontend/lib/api.ts
@@ -1,4 +1,4 @@
-import { addRequestHistory } from '@web/lib/history';
+import { addRequestHistory, clearRequestHistory } from '@web/lib/history';
import { getApiBearer, getApiKey, hasEnvApiAuth } from '@web/lib/authStorage';
import { buildApiBaseUrl, resolvePublicApiOrigin } from '@web/lib/api-base';
import { captureSessionIdFromHeaders, getOrCreateSessionId, SESSION_HEADER_NAME } from '@web/lib/session';
@@ -440,6 +440,9 @@ function handleUnauthorized(): void {
localStorage.removeItem('access_token');
localStorage.removeItem('user');
+ // Forced logout on 401: also purge captured request-history so any tokens
+ // recorded during the session do not persist past the invalidated session.
+ clearRequestHistory();
const hasStoredAuth = !!(getApiKey() || getApiBearer());
if (
!hasEnvAuthConfigured() &&
diff --git a/apps/tldw-frontend/lib/auth.ts b/apps/tldw-frontend/lib/auth.ts
index 72aa2f548d..2fb662dc81 100644
--- a/apps/tldw-frontend/lib/auth.ts
+++ b/apps/tldw-frontend/lib/auth.ts
@@ -1,5 +1,6 @@
import { apiClient } from './api';
import { getRuntimeApiBearer, getRuntimeApiKey } from './authStorage';
+import { clearRequestHistory } from './history';
export interface LoginCredentials {
username: string;
@@ -209,6 +210,9 @@ class AuthService {
if (typeof window !== 'undefined') {
localStorage.removeItem('access_token');
localStorage.removeItem('user');
+ // Purge any credentials/tokens that may have been captured in the
+ // request-history ring so they cannot survive logout.
+ clearRequestHistory();
}
}
diff --git a/apps/tldw-frontend/lib/history.ts b/apps/tldw-frontend/lib/history.ts
index 41306f8f22..837ea8377a 100644
--- a/apps/tldw-frontend/lib/history.ts
+++ b/apps/tldw-frontend/lib/history.ts
@@ -15,42 +15,120 @@ export interface RequestHistoryItem {
const KEY = 'tldw-request-history';
const MAX = 200;
-const SENSITIVE_HEADER_NAMES = new Set([
+
+const REDACTED = '[REDACTED]';
+
+// Header names (compared case-insensitively) whose values must never be
+// persisted to localStorage. These carry credentials or anti-CSRF secrets.
+const SENSITIVE_HEADERS = new Set([
'authorization',
'cookie',
'proxy-authorization',
'set-cookie',
'x-api-key',
'x-auth-token',
+ 'x-csrf-token',
+ 'x-tldw-org-id',
+]);
+
+// Body keys (compared case-insensitively) whose values must never be persisted.
+// Stored lower-cased because lookups normalize the key via `.toLowerCase()`.
+// Covers OAuth/JWT tokens plus common credential-shaped keys (api keys,
+// passwords, client secrets) so they are stripped on non-auth routes too.
+const SENSITIVE_BODY_KEYS = new Set([
+ 'access_token',
+ 'refresh_token',
+ 'id_token',
+ 'session_token',
+ 'api_key',
+ 'apikey',
+ 'x-api-key',
+ 'jwt',
+ 'secret',
+ 'password',
+ 'client_secret',
]);
-function redactRequestHeaders(headers: RequestHistoryItem['requestHeaders']): RequestHistoryItem['requestHeaders'] {
+// Auth routes whose response bodies carry credentials; their response bodies
+// are dropped entirely rather than merely key-redacted.
+const AUTH_ROUTE_PATTERNS = ['/auth/login', '/auth/refresh', '/auth/magic-link'];
+
+function isAuthRoute(url?: string): boolean {
+ if (!url) return false;
+ const lower = url.toLowerCase();
+ return AUTH_ROUTE_PATTERNS.some((route) => lower.includes(route));
+}
+
+function redactHeaders(
+ headers?: Record
+): Record | undefined {
if (!headers) return headers;
- return Object.fromEntries(
- Object.entries(headers).map(([name, value]) => [
- name,
- SENSITIVE_HEADER_NAMES.has(name.toLowerCase()) ? '[REDACTED]' : value,
- ]),
- );
+ const out: Record = {};
+ for (const [key, value] of Object.entries(headers)) {
+ out[key] = SENSITIVE_HEADERS.has(key.toLowerCase()) ? REDACTED : value;
+ }
+ return out;
+}
+
+function isPlainObject(value: unknown): value is Record {
+ if (value === null || typeof value !== 'object') return false;
+ const proto = Object.getPrototypeOf(value);
+ return proto === Object.prototype || proto === null;
+}
+
+// Non-mutating deep copy that replaces known credential-bearing keys with a
+// placeholder. Returns the original reference for non-plain values so we never
+// corrupt Blobs/ArrayBuffers or other non-JSON payloads.
+function redactTokens(value: unknown, depth = 0): unknown {
+ // Fail CLOSED past the depth limit: a security redactor must never return a
+ // raw (unredacted) subtree, since credentials could be nested deeper than we
+ // walk. Replace the entire truncated subtree with the placeholder instead.
+ if (depth > 6) return REDACTED;
+ if (Array.isArray(value)) {
+ return value.map((entry) => redactTokens(entry, depth + 1));
+ }
+ if (!isPlainObject(value)) {
+ return value;
+ }
+ const out: Record = {};
+ for (const [key, entry] of Object.entries(value)) {
+ out[key] = SENSITIVE_BODY_KEYS.has(key.toLowerCase())
+ ? REDACTED
+ : redactTokens(entry, depth + 1);
+ }
+ return out;
}
-function sanitizeHistoryItem(item: RequestHistoryItem): RequestHistoryItem {
+function redactHistoryItem(item: RequestHistoryItem): RequestHistoryItem {
return {
...item,
- requestHeaders: redactRequestHeaders(item.requestHeaders),
+ requestHeaders: redactHeaders(item.requestHeaders),
+ requestBody: redactTokens(item.requestBody),
+ // Auth-route responses can carry tokens under many shapes, so drop the body
+ // entirely. Other responses only need known credential keys stripped.
+ responseBody: isAuthRoute(item.url)
+ ? REDACTED
+ : redactTokens(item.responseBody),
};
}
function parseHistory(raw: string | null): RequestHistoryItem[] {
if (!raw) return [];
- const parsed = JSON.parse(raw);
- return Array.isArray(parsed) ? parsed : [];
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
}
export function addRequestHistory(item: RequestHistoryItem) {
try {
+ // Redact first so a serialization failure can never persist raw secrets.
+ // Re-redact existing entries too, as defense-in-depth against any legacy
+ // unredacted entry written by an older build.
const arr = parseHistory(localStorage.getItem(KEY));
- const next = [sanitizeHistoryItem(item), ...arr.map(sanitizeHistoryItem)].slice(0, MAX);
+ const next = [redactHistoryItem(item), ...arr.map(redactHistoryItem)].slice(0, MAX);
localStorage.setItem(KEY, JSON.stringify(next));
} catch {
// ignore
@@ -61,7 +139,7 @@ export function getRequestHistory(): RequestHistoryItem[] {
try {
const raw = localStorage.getItem(KEY);
const arr = parseHistory(raw);
- const sanitized = arr.map(sanitizeHistoryItem).slice(0, MAX);
+ const sanitized = arr.map(redactHistoryItem).slice(0, MAX);
const sanitizedRaw = JSON.stringify(sanitized);
if (raw !== null && raw !== sanitizedRaw) {
localStorage.setItem(KEY, sanitizedRaw);
diff --git a/apps/tldw-frontend/next.config.mjs b/apps/tldw-frontend/next.config.mjs
index ea792523a0..98a532d489 100644
--- a/apps/tldw-frontend/next.config.mjs
+++ b/apps/tldw-frontend/next.config.mjs
@@ -12,6 +12,58 @@ const {
} = validateNetworkingConfig(process.env);
const internalApiOrigin = validatedInternalApiOrigin.replace(/\/$/, '');
+// Content-Security-Policy (defense-in-depth for DOM-XSS; TASK-12093 + H1 follow-up).
+// Locks script sources to the app origin, forbids plugins (object-src) and
+// hijacking (base-uri), and blocks framing of the app (frame-ancestors).
+//
+// H1 follow-up: script-src drops 'unsafe-inline'. Arbitrary inline