diff --git a/apps/FRONTEND_AUDIT.md b/apps/FRONTEND_AUDIT.md index 8fd0e68bd7..e03f553a0a 100644 --- a/apps/FRONTEND_AUDIT.md +++ b/apps/FRONTEND_AUDIT.md @@ -1,246 +1,283 @@ -## tldw-frontend Audit +# tldw-frontend + Extension Audit -This document is a working log for assessing and improving the `tldw-frontend` application. Use it to capture what you find, decisions made, and follow-up tasks. - -> Tip: Keep entries terse and dated. Treat this as an engineering notebook rather than polished docs. +This document is a working log for assessing and improving the `tldw-frontend` web app and the WXT browser `extension`. Both are thin shells over the shared `packages/ui` code, so most findings live there and affect **both** clients. --- ## 0. Scope & Goals -- **Date / Reviewer(s)**: -- **Backend version / branch**: -- **Frontend version / branch**: -- **Primary goals for this audit** (for example: “make it production-safe for single_user”, “align with v0.1.0 API”, “remove critical UX blockers”): -- **Out of scope for now**: +- **Date / Reviewer(s)**: 2026-07-02, deep code audit (8 parallel reviewers + manual verification of every Critical/High). +- **Frontend version / branch**: `dev`. +- **Primary goal**: Inherited-codebase risk audit — find real bugs and "ticking time bombs" in structure/design for a maintainer without deep frontend history. **This is an assessment; nothing here has been changed in code.** +- **Method**: Read the hotspot files (8k-LOC API client, 4k-LOC stores, MV3 background worker, auth layer, chat pipeline, render/XSS sinks). Each finding cites `file:line`. Criticals/Highs were re-read and refutation-checked; a prior explorer claim of "21 GB build artifacts in git history" was **disproven** (`.git` is 905 MB, zero tracked build artifacts) and dropped. +- **Out of scope**: backend (`tldw_Server_API`), UX polish (covered by `ux-audit-v3/` and `QA_PAGE_REVIEW_CHECKLIST.md`), performance profiling. + +### How to read severity +- **Critical** — silent data loss or credential exposure happening on normal, everyday use. +- **High** — a real bug that breaks a feature or strands the UI under common conditions, or a security gap with a plausible trigger. +- **Medium** — intermittent breakage, fragility, or a maintenance trap that will bite later. +- **Low** — edge cases and latent traps. --- -## 1. Environment & Tooling +## 0.5 Findings summary (ranked) + +| # | Sev | One-line | Where | +|---|-----|----------|-------| +| C1 | **Critical** | Successful non-streaming chat replies containing the word "error"/"exception"/a file path are silently replaced with `"Chat completion failed."` | `packages/ui/src/services/tldw/TldwApiClient.ts:156-217,2660` | +| C2 | **Critical** | Live auth headers + login-response tokens are written to `localStorage` request-history (200 entries) and never cleared, even on logout | `apps/tldw-frontend/lib/api.ts:468-470`, `lib/history.ts:16-28` | +| H1 | High | `javascript:` DOM-XSS: ~10 hand-rolled source/citation anchors render URLs with no protocol allowlist, and the web app ships **no CSP** | `packages/ui/.../MessageSource.tsx:80,183` (+9 sites) | +| H2 | High | MV3 service-worker suspension orphans in-memory ingest / quick-ingest / auth-replay sessions; "will retry automatically" never happens | `packages/ui/src/entries/background.ts:444-446,695-706,1716` | +| H3 | High | `tldw:upload` / `tldw:stream` attach the API key/bearer to **any** absolute URL — no origin allowlist (the request path has one; these don't) | `packages/ui/src/entries/background.ts:1055-1073,3234-3257` | +| H4 | High | No reachable token refresh in the browser — refresh code exists but is wired only in the extension; expiry mid-session misreports "backend unavailable" | `packages/ui/src/services/background-proxy.ts:835-847`, `TldwApiClient`/`request-core` | +| H5 | High | Stop doesn't actually abort the network stream in normal/RAG chat (signal never threaded); a module-singleton controller makes concurrent streams cancel each other (breaks Compare mode) | `models/ChatTldw.ts:181-223`, `services/tldw/TldwChat.ts:443-446` | +| H6 | High | 5-second connection timeout silently **replays a non-idempotent chat POST** → duplicate generation + duplicate saved messages (triple-confirmed) | `services/background-proxy.ts:1236-1243,1320-1324` | +| H7 | High | Connection store spreads a 20s-old state snapshot over concurrent updates and has a racy overlap guard → UI flips to "disconnected" after a good check; onboarding jumps back a step | `packages/ui/src/store/connection.tsx:594-711,949-1014` | +| H8 | High | Workspace store's rehydrate mutates state in place with no `set()` → subscribers never notified → empty workspace / eternal loading gate | `packages/ui/src/store/workspace.ts:3875-3970` | +| H9 | High | `wxt-browser` storage shim: `clear()` wipes the **entire origin** localStorage, and `local`/`sync`/`session` areas all collide on the same keys | `apps/tldw-frontend/extension/shims/wxt-browser.ts:108-215` | +| H10 | High | Plasmo `useStorage`/`watch()` shims never propagate changes across instances → settings toggles don't apply until a full page reload | `apps/tldw-frontend/extension/shims/plasmo-storage*.ts` | +| H11 | High | One transient `404` permanently disables folder sync (persisted `folderApiAvailable:false`, never reset) until the user clears localStorage | `packages/ui/src/store/folder.tsx:314,401,843` | +| H12 | High | Default 10s timeouts abort normal LLM generations and TTS mid-flight → "Network error" while the server keeps working | `services/tldw/request-core.ts:95-100`, `background-proxy.ts:31-32,271-281` | +| — | **Config** | `strict:false`, `ignoreBuildErrors:true`, and disabled newer `react-hooks` lint (`set-state-in-effect` et al.) let real bugs ship silently | see §9 | +| — | **Config** | 8 of 9 persisted stores have no `version`/`migrate`; frontend vs extension dependency skew on shared code | see §6, §9 | + +Medium/Low findings and the full "verified-OK" list are in §4–§10. -- **Node/npm version used**: -- **Install status** (`npm install` / `npm ci`): -- **Core scripts** (`npm run dev`, `build`, `start`, `smoke`) – working? Notes: - - `dev`: Next dev server (pages router). - - `build`: Next production build. - - `start`: Next production server. - - `smoke`: Connectivity check against backend providers + core APIs. -- **Lint / typecheck / test scripts present?** (`npm run lint`, `npm run test`, etc.) If yes, status and typical warnings: -- **Dev server URL / port used** (default: `http://localhost:8080`): +--- -Notes / issues: +## 0.6 Remediation status (2026-07-02) + +Every Critical and High **bug** finding above was **fixed** in this pass, each with focused unit tests. The two remaining items are not bug fixes: the **config-hardening** task (12102, TS-strict) is a partial/phased migration, and a few **residual refinements** (H1 CSP `unsafe-eval`, 12103 dead-tree removal) need out-of-band verification — both are called out explicitly in the rows below and in "Residuals", so "fixed" refers to the defects, not to those tracked follow-ups. Backlog tasks `task-12091`…`task-12103` track the work. + +**Verification:** the new + affected suites all pass — 127 tests across 14 packages/ui files, 6 (C2 redaction), 11 (shim/nav), and 23 existing store/audio/client regression tests. The only red test is a **pre-existing** `workspace.ts` quota-warning test that fails identically on baseline `dev` (confirmed via `git stash` by two reviewers) and is unrelated to these changes. + +| # | Status | Notes | +|---|--------|-------| +| C1 | ✅ Fixed | Sanitizer removed from the runtime path (`chat-rag.ts`) and base class; buggy helper deleted. | +| C2 | ✅ Fixed | Centralized redaction in `history.ts`; logout clears history. | +| H1 | ✅ Fixed | Shared `safeExternalUrl`/`openExternalUrl` at all sinks + CSP added and **tightened**: `'unsafe-inline'` dropped from `script-src` (the one trusted inline script is SHA-256-hash-allowlisted), plus `X-Content-Type-Options`/`Referrer-Policy`/`X-Frame-Options`/`Permissions-Policy`. `'unsafe-eval'` retained (WASM) as a documented follow-up. ⚠️ **Verify in a browser before merge** — confirm no CSP violations on the main routes and the dev/error overlays (I can't run the browser here). | +| H2 | ✅ Fixed | Session state persisted to `chrome.storage.session` + `chrome.alarms` backstop. Quick-ingest **remote-job polling now resumes** after a worker restart (alarm-driven, from persisted batch records); an in-flight multipart *upload* is non-resumable by design and instead reports interrupted so the UI isn't stuck, and post-restart cancel always finds the session. | +| H3 | ✅ Fixed | Origin allowlist + `sender.id` guard on `tldw:upload`/`tldw:stream`. Guard logic **consolidated** into a single canonical `absolute-url-guard.ts` (request-core + background-proxy now import it; request-core's diagnostic warnings preserved via optional hooks) — no more triplication. | +| H4 | ✅ Fixed | Web `refreshAuth` wired with single-flight; re-armable timer; FormData-retry bug fixed. | +| H5 | ✅ Fixed | Signal threaded, per-call controllers, ownership-guarded resets, early-throw + abort-path fixes. Regenerate-abort-before-first-token now discards the empty variant and restores the prior active variant/index. | +| H6 | ✅ Fixed | 5s→config-derived timeout; non-idempotent POSTs throw `StreamInterruptedError` instead of being replayed. **F10 closed:** the `stream_transport_interrupted` sentinel is now surfaced through the normal/RAG token pipeline (captured + re-emitted in `ChatTldw.stream`), so a post-first-byte truncation is finalized as *interrupted* (parity with character chat), never saved as complete. | +| H7, H8, H11 | ✅ Fixed | Functional `set` + synchronous guard (connection); hydration published via `set` (workspace); availability flag no longer persisted, with self-healing `merge` (folder). | +| H9, H10 | ✅ Fixed | Per-area isolation + memory-only `session` + scoped `clear()`; cross-instance watch bus + `useStorage` subscription; dynamic-route `useSearchParams`. | +| H12 | ✅ Fixed | Generation-endpoint timeout 10s→120s; messaging-ack decoupled (10s→130s); body read bounded; TTS timeout in `synthesizeSpeech`. | +| Config (12102) | ◑ Partial | Done: `version`/`migrate` baseline on the 8 unversioned stores; added a `typecheck` script (`tsc --noEmit`); corrected the audit's `rules-of-hooks` claim (it is already enabled). **Blocked/phased:** removing `ignoreBuildErrors` / enabling `strict` requires first clearing ~47 **pre-existing** `tsc` errors (in unrelated Watchlists components, at the current loose settings) — that cleanup is separate from audit remediation. Enabling the newer `react-hooks` rules (`set-state-in-effect` et al.) is deferred to avoid a large, noisy fix in this PR. | +| Dead code (12103) | ✅ Done | Web auth stack (`useAuth`/`useConfig`/`Header`/`Layout`/`useIsAdmin`) **deleted** (5 files + their exclusive tests; docs updated; live `lib/*`/`WebLayout` left intact). The `extension/routes/` tree was **kept** — investigation showed it's runtime-unused but parity-test-maintained (not deletable without migrating ~22 tests); documented via `_RUNTIME_UNUSED.md`. | + +**Residuals — nearly all closed in a follow-up pass.** Fixed since the first draft: F10 partial-stream marking (H6), quick-ingest resume (H2), guard-helper consolidation (H3), regenerate-abort discard (H5), CSP `script-src` tightening + extra security headers (H1). **What genuinely remains** (each a larger effort or needing out-of-band verification, none reintroducing a defect): +- **12102** — removing `ignoreBuildErrors` / enabling TS `strict` is blocked on ~50 **pre-existing** `tsc` errors in unrelated code (measured; must be cleared first); enabling the newer `react-hooks` rules (`set-state-in-effect` et al.) is deferred to avoid a large noisy fix. A `typecheck` script was added so the team can burn the baseline down. +- **H1** — dropping `'unsafe-eval'` from the CSP needs per-feature (WASM/OCR/tokenizer) browser verification; and the tightened CSP overall should get a quick browser smoke before merge (dev/error overlays especially). +- **12103** — the `extension/routes/` mirror is runtime-unused but kept intentionally in sync by ~22 parity tests; removing it needs those tests migrated first. -- … +--- -Follow-ups: +## 1. Environment & Tooling -- [ ] … +- Bun workspace at `apps/`. Web app: **Next.js 16, pages router**. Extension: **WXT 0.20, Manifest V3**. Both import shared code from `packages/ui/src` via `@`/`~` aliases. +- CI runs lint + changed-only Vitest + Playwright e2e (`frontend-required.yml`, `e2e-required.yml`). Gaps: no coverage gate, TypeScript errors do not fail the build (see §9), extension e2e not gated on the main frontend job. +- Local `.next` build output on disk is large (~21 GB) but **not** in git — a `git clean`/prune housekeeping note, not a repo problem. --- ## 2. High-Level Architecture -- **Routing** (pages router, notable routes: `/`, `/login`, `/media`, `/chat`, `/audio`, `/search`, `/evaluations`, `/admin/*`, etc.): -- **State management** (React Query, React context, local state, others): -- **Key shared modules** (for example `lib/api.ts`, `lib/auth.ts`, `hooks/useAuth.ts`, UI layout components): -- **Main layout/navigation components**: - -Observations: - -- … - -Risks / questions: - -- … +- **Thin-shell pattern**: `tldw-frontend/pages/*` and `extension/entrypoints/*` are wrappers; the real components, hooks, services, and 59 Zustand stores live in `packages/ui`. A bug in `packages/ui` ships to both clients. +- **State**: Zustand (59 stores; biggest are `workspace.ts` ~4k LOC and `connection.tsx` ~1.3k LOC) + React Query for server state + a few React contexts. +- **Two auth/request stacks** (this seam is the source of several bugs): + 1. **Web-only** — `tldw-frontend/lib/api.ts` + `lib/auth.ts` + `hooks/useAuth.tsx`/`useConfig.tsx`. Used by ~9 modules (VN workbench, connectors, VLM, characters, research runs). + 2. **Shared** — `packages/ui/src/services/tldw/*` routed through `background-proxy.ts` (`bgRequest`/`bgStream`/`bgUpload`). Used by everything else. In the extension these go through the MV3 background worker; in the web build they fall back to direct fetch. +- **Positive**: extension manifest permissions are tight (`host_permissions` is just `api.github.com`), `externally_connectable` is unset (arbitrary sites can't message the worker), and the main markdown/rich-text render pipeline is properly sanitized. See "Verified OK" lists. --- ## 3. API & Backend Alignment -- **API base configuration** (env vars: `NEXT_PUBLIC_API_URL`, `NEXT_PUBLIC_API_VERSION`, `NEXT_PUBLIC_X_API_KEY`, `NEXT_PUBLIC_API_BEARER`, etc.): -- **AuthNZ modes supported** (single_user X-API-KEY, multi_user JWT): -- **Key backend endpoints used** and notes (for each, confirm path, method, payload, and response shape match backend docs): - - Media (ingest/search): - - Chat / RAG: - - Audio (STT/TTS): - - Evaluations: - - MCP: - - Admin / Auth (e.g., `/auth/login`, `/users/me`, `/admin/*`): - - Other: - -Drift from backend (missing endpoints, outdated payloads, assumptions that no longer hold): - -- … - -Follow-ups: - -- [ ] … +Not re-audited for endpoint drift here (backend is out of scope). One correctness note surfaced: `TldwApiClient.waitForExportReady:6964` treats the server's `export_status="none"` as a failure and discards the real status/detail (`file_artifacts_service.py:387` emits it). --- ## 4. Auth, Roles & Security -- **Auth flow overview**: - - Where login happens (page/components). - - How tokens / API keys are stored and refreshed. - - How logout is handled. -- **Auth-related modules** reviewed (`lib/auth.ts`, `lib/authz.ts`, `hooks/useAuth.ts`, `hooks/useIsAdmin.ts`, login pages, admin pages): -- **Supported auth modes**: - - Single-user (X-API-KEY from env): - - Multi-user (JWT / sessions): -- **Role / privilege handling** (admin vs. regular user, per-page protection): +### C2 (Critical) — Credentials persisted to localStorage, survive logout +`lib/api.ts:468` runs `applyBrowserHeaders` (adds `Authorization`/`X-API-KEY`/`X-CSRF-Token`), then `:470` passes those headers into `buildRequestHistoryConfig`; `recordSuccess` (`:384-404`) stores `requestHeaders` **and** `responseBody` (which for `/auth/login` includes the `access_token`) into `localStorage['tldw-request-history']` — 200 entries, no redaction (`lib/history.ts:16-28`). `clearRequestHistory` exists but is **never called**, and logout (`lib/auth.ts:203-213`) doesn't touch the key. **Result**: bearer tokens and API keys sit in plaintext localStorage indefinitely and survive logout — readable by any XSS (see H1) or anyone on a shared machine. **Fix**: redact `authorization`/`x-api-key`/`x-csrf-token` and login-response bodies before storing; clear the history key on logout. -Findings: +### H1 (High) — `javascript:` DOM-XSS via source/citation anchors, no CSP backstop +The web app ships **no Content-Security-Policy** (verified: no `headers()` in `next.config.mjs`, no `middleware`). `MessageSource.tsx:80` reads `const url = source?.url` and `:183` renders `` — `rel`/`target` do not stop `javascript:`. The same unvalidated `` appears in ~9 more places (research sources, watchlist sources/runs/outputs/alerts, reading list, items, processed). `source.url` is attacker-influenceable: poisoned ingested-page metadata (yt-dlp title/URL, scraped feed), a malicious web-search/research citation, or a crafted API/JSON response. **Result**: clicking a "source" link runs script on the app origin → session/token theft (and C2 makes the loot valuable). The markdown renderer already blocks this via `urlTransform`; these hand-rolled anchors bypass it. **Fix**: a shared `safeExternalUrl()` (allowlist `http`/`https`/`mailto`, else no-op) at these anchors and the `window.open(url)` sites; add a CSP. -- … +Related same-family: **OutputPreviewDrawer.tsx:325** `safeHtml = sanitizedHtml || content` re-injects raw HTML into a same-origin `blob:` tab when DOMPurify returns empty (Medium); **notes `sanitizeUrl`** (`notes-manager-utils.ts:834`) lets a control-char scheme (`java\tscript:`) through (Low–Medium, inside `contentEditable`). -Security concerns / gaps: +### H3 (High) — Extension attaches credentials to arbitrary absolute URLs +The `tldw:upload` (`background.ts:1055-1073,1143-1168`) and `tldw:stream` port (`:3234-3257`) handlers treat any `path` starting with `http` as absolute and unconditionally add `X-API-KEY`/`Authorization`/`X-TLDW-Org-Id`, then `fetch(url)` — with **no origin allowlist**. The normal request path (`request-core.ts:248,363,387`) *does* gate this (`absoluteOriginAllowlistFromConfig` + `shouldSkipAuth` on cross-origin). So a caller posting `{path:"https://attacker/x"}` gets the user's API key sent to the attacker. **Reachability**: `externally_connectable` is unset, so this needs an extension-context caller — i.e. a buggy or compromised content script (which run on every `http(s)` page). That's why this is High, not remotely-triggerable Critical. Message handlers also don't validate `sender.id` (defense-in-depth gap). **Fix**: apply the same allowlist/`shouldSkipAuth` gate in the upload/stream handlers; add a `sender.id === browser.runtime.id` check. -- … +### H4 (High) — No reachable token refresh in the browser +Refresh-and-retry lives in `request-core.ts:470-514` but requires `runtime.refreshAuth`, which is wired **only** in the extension background (`background.ts:1248-1263`). The web direct fallback passes only `{getConfig}` (`background-proxy.ts:835-847`), and `TldwAuth`'s pre-expiry timer is armed only inside `login`/`verifyMagicLink` and is lost on page reload (`TldwAuth.ts:384-401`). **Result**: a multi-user user who logs in and reloads will, on token expiry, have every request 401 and the UI misreport "backend unavailable" while a valid refresh token sits unused — recovery needs manual re-login. **Fix**: pass a single-flighted `refreshAuth` into the web direct fallback; re-arm the refresh timer on load. -Refactor ideas (short bullets; detailed plan can live elsewhere): +### Medium/Low (auth) +- **Half-wired dead web auth stack** (Medium, maintenance trap): the web `AuthProvider` (`hooks/useAuth.tsx`) and web `ConfigProvider` (`hooks/useConfig.tsx`) are **never mounted** (the `ConfigProvider` in `AppProviders.tsx:80` is antd's, not this one). Their only consumers — `components/layout/Header.tsx` → `components/layout/Layout.tsx` — are imported by nothing. So `useAuth`/`useConfig` would throw if rendered, `api.defaults.baseURL` is never synced from user config, and several "latent" auth bugs never fire. It's ~500 lines of realistic-looking code a maintainer will mistake for live. **Recommendation**: delete or clearly quarantine it. +- **Refresh single-flight is per-context** (`TldwAuth.ts:231-261`): the mutex is only in the extension background; the UI-context auto-refresh timer can race a 401 refresh and persist a rotated/dead token. +- **Redaction is key-name-only** (`background-proxy.ts:209-236`): a JWT/stack/SQL string carried in a *value* (under `detail`, `message`, a string array, or a bare `text/plain` body) is not scrubbed and surfaces to logs/UI. +- **Fetches follow redirects** (no `redirect:"manual"`): browsers strip `Authorization` cross-origin but **not** the custom `X-API-KEY`, so an open redirect can leak it. +- **CSRF** read from `document.cookie` can't see a cross-origin API host's cookie → mutating requests 403 with an unfixable "refresh the page" message; also thrown as a plain `Error`, so `err.status === 403` checks miss (`lib/api.ts:229-236,506-519`). +- **STT token in WebSocket URL query string** (`background.ts:2817`) → lands in server access logs. -- … +### Verified OK (security) +Tight manifest permissions; `externally_connectable` unset; request path enforces the absolute-URL allowlist and strips auth cross-origin; origin comparison resists `user@host` tricks; failed refresh does **not** clear tokens (no logout storm); non-idempotent POSTs are not replayed on messaging timeout; the main markdown pipeline (react-markdown, no `rehype-raw`, `urlTransform` allowlist), st_compat rich text (marked + DOMPurify with `FORBID_TAGS`/`on*` stripping), Mermaid (`securityLevel:strict`), CodeBlock (`iframe sandbox` opaque origin + postMessage token), `JsonViewer` (escape-before-highlight), and the copilot popup (Shadow DOM `textContent` only) are all properly sanitized; the web-clipper Readability→cheerio→Turndown pipeline strips raw HTML before it becomes markdown. --- -## 5. UX & Flows - -Key user journeys (note status: ✅ works, ⚠ rough, ❌ broken): +## 6. State Management & Data Fetching -- [ ] Landing / navigation: -- [ ] Login / logout: -- [ ] Media upload, processing, and browsing: -- [ ] Search (FTS / RAG): -- [ ] Chat / character chat: -- [ ] Audio transcription / TTS: -- [ ] Evaluations: -- [ ] Admin (privileges, users, connectors, watchlists): -- [ ] Reading / notebook / notes: +### H7 (High) — Connection store: stale-snapshot clobber + racy overlap guard +`connection.tsx` `checkOnce` captures `currentState` at the top (`:595`), runs a health check for up to 20s, then every terminal `set()` does `{...currentState, ...}` (`:949-1014`) — silently reverting any `setConfigPartial`/`markFirstRunComplete`/`setUserPersona` that fired meanwhile (onboarding jumps back a step; `hasCompletedFirstRun` flips back to false). The overlap guard reads `isChecking` at `:598` but sets it at `:698` after five `await`s, so concurrent callers (poller, ChatPane, QuickIngestWizardModal, chat-history hook) both run and the last, stale finisher wins — UI flips to "disconnected" right after a good check. **Fix**: use functional `set((s) => …)`; set the in-flight guard synchronously before the first await, or use a request token. -UX notes (loading states, error messages, responsiveness, accessibility, clarity of labels): +### H8 (High) — Workspace store: silent-mutation rehydrate +`workspace.ts:3875-3970` `onRehydrateStorage` mutates the hydrated state object in place (`Object.assign(state, …)`, `state.storeHydrated = true`) with no `set()`, so subscribers are never notified. Hydration is async, so components are already mounted; the active workspace's sources/artifacts/notes and the `storeHydrated` gate only reflect once some *unrelated* `set()` happens to fire. **Result**: intermittent empty workspace / eternal loading gate. Compounded by `workspace-list-slice.ts:1208` `reset: () => set(initialState)` leaving `storeHydrated:false`. **Fix**: apply hydrated data via `set()`. -- … +### H11 (High) — Sticky persisted failure: one 404 kills folder sync forever +`folder.tsx`: any 404 (or a message merely containing "404", `:409`) sets `folderApiAvailable:false`; `partialize` persists it (`:843`); `refreshFromServer` hard-returns when false (`:314`); and the only reset to true (`:401`) is inside the now-skipped path. A transient 404 (server restart, proxy blip, older server) disables folder sync across every future session until localStorage is cleared. **Fix**: don't persist the availability flag, or add a reset path / retry. -Top UX issues to address first: +### Medium/Low (state) — mostly systemic +- **Persist without `version`/`migrate`** (Medium, systemic — 8 of 9 stores): only `workspace.ts` has it. The day anyone adds `version:1` to reshape a store without a `migrate`, all users' persisted state is silently discarded; any field rename before then ships `undefined` into consumers. Stores: `playground-session`, `persona-buddy-shell`, `notes-dock`, `ui-mode`, `actor`, `quick-ingest-session`, `folder`, `feedback`, `acp-sessions`. +- `workspace.ts` split-key persistence is a non-atomic async read-modify-write with no serialization (`:1777,1500-1756`) → two tabs / overlapping writes can leave the index pointing at deleted keys → workspaces vanish or rehydrate empty. +- `workflow-editor.ts:827-861` `loadRunInvestigation` can deadlock its own loading flag (early returns don't reset it) → switching runs mid-fetch blocks all future loads. +- `folder.tsx:213-237` throttled localStorage adapter drops the final write on fast tab-close and serves stale reads during the 1s window. +- `refreshFromServer` (`folder.tsx:309`) has no in-flight guard → older response overwrites newer in Dexie + store. +- Unbounded persisted growth: `feedback.tsx` `entries` (no cap/eviction/clear), `workspace.ts` `savedWorkspaces`/`workspaceSnapshots` (snapshots embed full data-URLs, re-serialized every state change). +- `quick-ingest.tsx:346` module-scope cross-store `subscribe()` never unsubscribed (HMR leak). `workspace.ts:73-76 ⇄ slices` circular value imports (works only by init order; reorder → TDZ crash). -- [ ] … +### Verified OK (state) +**No zustand v4-vs-v5 API divergence** despite the frontend(^5)/extension(^4) split — 36/59 stores use `createWithEqualityFn` (default `Object.is` in both majors), no removed-API usage, no lost equality functions. `timeline.ts`/`workflow-editor.ts`/`acp-sessions.ts` use correct request-token guards and bounded histories; workspace sources/studio slices are synchronous and stale-closure-free; quota handling (QuotaExceeded → LRU eviction) is coherent; storage adapters are SSR-guarded. --- -## 6. State Management & Data Fetching - -- **React Query usage** (where, patterns, cache keys): -- **Custom hooks for data** (for example `useAuth`, `useVlmBackends`, `useConnectorBackend`): -- **Patterns for loading / error / empty states**: +## 6b. Browser-API shims (web build only) -Good patterns: +The web build fakes `chrome.storage`/`browser.*` and `react-router-dom` over `localStorage`/Next router. These shims are **live** and have real bugs: -- … - -Inconsistent or problematic patterns: - -- … - -Follow-ups: - -- [ ] … +- **H9 (High)** `wxt-browser.ts:189-214` — `clear()` for any area calls `backend.clear()`, wiping the entire origin's localStorage (all areas, theme/flags, plasmo-prefixed keys). Live caller `system-settings.tsx:62`. And `:108-215` — no per-area isolation: `local`/`sync`/`session` share unprefixed keys, so `sync.set` clobbers `local`, and `session` (should be memory-only) persists to disk. +- **H10 (High)** `plasmo-storage.ts:143-185` + `plasmo-storage-hook.tsx:37-59` — `watch()` callbacks are per-instance and `useStorage` never subscribes at all, so two components on the same key desync and settings written elsewhere don't apply until a full reload (e.g. sticky-chat toggle, ReviewPage config watch). +- **Medium** `react-router-dom.tsx:192-213` — `useSearchParams` setter rebuilds the URL from `router.pathname` (the `[bracket]` pattern on dynamic routes) so it **silently fails on every dynamic route** (`/sources/:id`, `/media-collections/:id`, `/knowledge/thread/:id`) — only a `console.error`. Also `useNavigate` returns a new identity each render (dep-array churn), and `useLocation` mixes `router.asPath` with live `window.location` (hydration mismatch + stale `search`). +- **Medium (runtime-unused, maintenance trap)** `tldw-frontend/extension/routes/*` (route-registry, app-route, all `option-*`) is **not rendered at runtime** in the web build — pages mount `packages/ui/src/routes/*` instead, so editing a component here silently no-ops. **Correction to an earlier draft:** it is **not safely deletable** — ~22 tests reference it (3 direct imports + ~19 `readFileSync` parity-guard tests that keep it in sync with `packages/ui/src/routes/*`). So it's a deliberately-maintained mirror, not disposable dead code; the trap is that its runtime-irrelevance isn't obvious. A `_RUNTIME_UNUSED.md` marker documents this; genuine removal requires retiring the parity tests first. --- ## 7. Error Handling & Resilience -- **Global error surface** (toasts, banners, error boundary?): -- **How API errors are surfaced** (per-page vs. central): -- **Behavior on network failures / timeouts / 401 / 403 / 500**: +### H2 (High) — MV3 worker suspension orphans background sessions +Chrome kills an idle MV3 service worker (~30s). `background.ts` keeps critical state only in `main()`-closure Maps with no `chrome.storage.session` rehydration: `ingestSessions` (`:444`), `pendingAuthReplay` (`:445`), `quickIngestModalSessions` (`:446`). Long polls run via detached `setTimeout` loops for up to 10 min (`:1716`). When the worker is reclaimed mid-poll: an ingest that the server completed never emits `media-ingest-ready` → sidepanel stuck on "Queued for processing", and `cancel`/`retry` return "Ingest session not found" (permanently unrecoverable, `:1490,1792`); the 401 "ingest will retry automatically" promise (`:695-706`) never fires because the replay set is empty on wake; quick-ingest batches are orphaned with a frozen progress UI. **Fix**: persist session state to `chrome.storage.session`/`local`, rehydrate on `onStartup`, and drive long polls from `chrome.alarms` (the model-warmup code already does this correctly — a good template). -Findings: +### H5 (High) — Chat abort/stream lifecycle is broken in several ways +- **Stop doesn't abort the transport** in normal/RAG modes: `ChatTldw.stream()` gets the UI `signal` but calls `tldwChat.streamMessage` **without** it (`ChatTldw.ts:181-223`); the signal is only polled at loop top, so the fetch/port stays open (server keeps generating + persisting) until the next token or 30s idle. Character chat threads the real signal, so behavior diverges by mode. +- **Singleton controller collisions**: `tldwChat` is a module singleton with one `currentController`, and every `streamMessage()` starts with `cancelStream()` (`TldwChat.ts:443-446`) — so any two concurrent streams cancel each other. Compare mode (N parallel models) has N-1 die with "Request cancelled". +- **Shared-controller clobber** (`chatModePipeline.ts:808`): a finishing turn's `finally` unconditionally resets the shared streaming flag + controller, so an old turn re-enables the send button and nulls the controller of a newer in-flight turn (which then can't be stopped). +- **Stuck-streaming**: `onSubmit` sets `setStreaming(true)` then `await`s `buildChatModeParams` **outside** the try (`useChatActions.ts:2335-2394`); a throw leaves the spinner + disabled send button stuck until reload. +- **Fix bundle**: thread the UI `AbortSignal` through `ChatTldw.stream → streamMessage → bgStream`; replace the singleton with per-call controllers; make each `finally` reset flags only if it still owns the current controller; move `buildChatModeParams` inside the try. -- … +### H6 (High) — 5s connection timeout replays a non-idempotent chat POST +`background-proxy.ts:1236-1243,1320-1324`: if no stream byte arrives within a hard-coded 5s, `bgStream` disconnects and **re-sends the whole request** via `bgStreamDirect`. `/api/v1/chat/completions` (with `save_to_db`) and `/complete-v2` are not idempotent, and TTFT > 5s is normal for large prompts, RAG, or a cold local model. **Result**: duplicate generation and duplicate persisted messages. (Independently flagged by three reviewers.) Related dead-code hazard: the `stream_transport_interrupted` handling in the pipeline can never match in normal modes (`chatModePipeline.ts:582-599`) because the token extractor drops the event, so an extension port loss mid-answer silently truncates and saves as complete. **Fix**: derive the timeout from config (not a 5s constant), and don't auto-replay non-idempotent POSTs; surface the interruption event through the token pipeline. -Quick wins: +### H12 (High) — 10-second default timeouts abort normal LLM work +`request-core.ts:95-100` defaults `/chat/completions` to a 10s **total** timeout (vs 45s stream-idle); `background-proxy.ts:31-32,271-281` fails any extension-context write after 10s (`Number(undefined) → NaN → 10_000`) while the worker keeps running. Most `TldwApiClient` POST wrappers (including `synthesizeSpeech`) pass no `timeoutMs`. **Result**: unconfigured non-stream chat and TTS abort mid-generation as "Network error" / "Extension messaging timeout" while the server finishes and the result is lost. **Fix**: raise/annotate defaults for generation endpoints; decouple the messaging-ack timeout from the request timeout. -- [ ] … +### Medium/Low (resilience) +- `research.tsx` control handlers (`handlePauseRun`/`Resume`/`Cancel`/`LoadArtifact`) are async `onClick` with **no try/catch** (`:1249-1319`) → a failed POST is an unhandled rejection that can trip the global handler and replace the whole app with the recovery screen. Plus a last-writer-wins SSE-vs-refetch race that strands a completed run as "running" (`:1108`), and a transient reconnect that wipes an in-progress checkpoint-editor draft (`:1184`). +- App-level `ErrorBoundary` never resets on route change → after one page throws, healthy routes still show the error screen until "Try again"/reload. +- Message index-space mixups: `deleteMessage`/`createEditMessage` address Dexie rows by UI-array index while the UI list contains never-persisted entries (character greeting) → wrong row deleted/overwritten (`useChatActions.ts:3285`, `messageHandlers.ts:172`). +- `createChatCompletion` sanitizer aside, `bulkUpdateMediaKeywords` fabricates per-ID success when the response lacks a `results` array (`TldwApiClient.ts:3006`) → UI claims "all updated" when nothing was. --- -## 8. Performance & Bundling +## 9. Dependencies & Technical Debt -- **Next.js build output** (any large bundles, warnings): -- **Usage of heavy libraries** (Monaco, charts, etc.) and whether they are code-split/lazy-loaded: -- **Client-side caching / memoization**: +### Config "time bombs" (verified by direct read) +- `apps/tldw-frontend/tsconfig.json:11` and `apps/extension/tsconfig.json:9` — **`"strict": false`** in both. No null-safety on a ~1.2M-LOC shared surface; large refactors risk runtime crashes the compiler would otherwise catch. +- `apps/tldw-frontend/next.config.mjs:59` — **`typescript.ignoreBuildErrors: true`**. TS errors never fail the build or CI; combined with `strict:false` this is two safety nets removed at once. +- `apps/tldw-frontend/eslint.config.mjs:78-84` — the newer **react-compiler-era `react-hooks` rules are disabled** (`immutability`, `purity`, `preserve-manual-memoization`, `refs`, `set-state-in-effect`, `static-components`, `use-memo`). `set-state-in-effect` in particular would have flagged several of the effect-race bugs in this audit. **Correction to an earlier draft of this section:** the classic `react-hooks/rules-of-hooks` (which catches conditional/looped-hook crashes) is **not** globally disabled — the `off` at `:118` is scoped to `e2e/**` only, and the rule is active everywhere else via the `reactHooksRules` preset. `no-explicit-any` is only `warn`. +- **Dependency skew on shared code**: the frontend and extension pin *different majors* of libraries that both feed `packages/ui` — `zustand ^5` vs `^4`, `dexie-react-hooks ^4` vs `^1.1.7`, `marked 17` vs `15`, `d3-dsv 3` vs `2`, `react ^18.3` vs pinned `18.2`, TypeScript `5.6` vs `5.9`. A shared component that relies on one major's behavior can pass in one app and break in the other. (Zustand specifically was checked and is currently safe — see §6 — but it's a standing hazard.) -Notes: +### Recommendation +Turn the safety nets back on incrementally: `noImplicitAny` first, then `strictNullChecks`; remove `ignoreBuildErrors` once `packages/ui` typechecks; re-enable `react-hooks/rules-of-hooks` and fix violations. Add a `version`/`migrate` to every persisted store (§6). Align the shared-dependency majors or hoist them to one workspace-level version. -- … +--- -Potential optimizations: +## 10. Testing & Automation -- [ ] … +Solid e2e footprint (140+ Playwright specs in the extension, tiered gates in the web app) and the manifest/permission posture is good. Gaps worth closing: no coverage threshold; TS errors don't gate (see §9); extension e2e isn't part of the required frontend job; the shims (§6b) — which are load-bearing for the whole web build — have thin unit coverage relative to their bug density. --- -## 9. Dependencies & Technical Debt +## 12. Summary & Next Steps -- **Key dependencies and versions** (Next, React, React Query, Tailwind, etc.): -- **Known outdated / beta / misaligned dependencies**: -- **Unused or suspicious dependencies**: +- **Overall health**: **Yellow.** The architecture is reasonable (thin shells over a shared package, tight extension permissions, a properly sanitized main render path, good e2e coverage) and there was **no** repo-history disaster. But there is a cluster of real correctness/security bugs concentrated in four areas: the shared API client, the auth seam, the MV3 background worker, and the browser-API shims — plus disabled safety nets that let this class of bug ship. -Tech debt list (short, actionable bullets): +- **Top 3 risks** + 1. **Silent data loss / credential exposure on normal use** — C1 (chat replies corrupted) and C2 (tokens in localStorage). Both confirmed, both happen without anything unusual. + 2. **Auth/streaming lifecycle** — no web refresh (H4), Stop doesn't stop (H5), 5s replay duplicates messages (H6), 10s timeouts abort generations (H12). These make the core chat feature unreliable. + 3. **State/storage foundations** — connection & workspace store races (H7/H8), sticky failure states (H11), and shims that wipe storage / don't propagate changes (H9/H10). -- [ ] … +- **Top short-term fixes (1–2 weeks)** + 1. C1: stop routing successful completions through the error-string sanitizer (or narrow it to genuine error payloads). + 2. C2: redact auth headers/tokens from request-history and clear it on logout. + 3. H1: add `safeExternalUrl()` at the ~10 anchor/`window.open` sinks + ship a CSP. + 4. H3: add the URL allowlist to the extension upload/stream handlers. + 5. H6/H12: fix the streaming-replay and timeout defaults. -Priorities (P0/P1/P2): +- **Longer-term** + - Persist MV3 session state and move long polls to `chrome.alarms` (H2). + - Re-enable TypeScript strict + `react-hooks` lint incrementally (§9). + - Thread abort signals and use per-call controllers throughout the chat pipeline (H5). + - Delete or quarantine the half-wired web auth stack and the dead `extension/routes` tree. + - Add `version`/`migrate` to persisted stores. -- P0: -- P1: -- P2: +Per-finding backlog tasks were created for the Critical and High items (`backlog/tasks/task-12091`…`task-12103`). Full reviewer notes and the verification log are archived with this audit. --- -## 10. Testing & Automation - -- **Existing tests** (unit, integration, E2E – if any): -- **Smoke tests** (for example `npm run smoke`) – coverage and reliability: -- **CI integration** (if present): - -Gaps: +# Round 2 (2026-07-02): Character Chat + TTS/STT -- … +Focused follow-up audit of the two areas the maintainer was concerned about: **character chat** and **TTS/STT**. Five parallel reviewers over character-chat core, character card/data handling, TTS playback, mic capture, and real-time voice WebSockets. Findings verified against code identical between local `dev` and `origin/dev`. -First test targets: +## R2 findings summary (ranked) -- [ ] … - ---- +| # | Sev | One-line | Where | +|---|-----|----------|-------| +| R1 | **High** | Character chat delete/edit hits the **wrong Dexie row** — the greeting sits at UI index 0 but is never in Dexie, so array-index deletes/edits are off by one | `useMessage.tsx:2929,2781`; `db/dexie/helpers.ts:406` | +| R2 | **High (privacy)** | Microphone can **stay live after a `MediaRecorder` error** (no `onerror` handler) or on a double-start; indicator stuck on, capture coordinator locked | `useAudioRecorder.ts:110-130` (+ `useServerDictation.tsx`, `SpeechPlaygroundPage.tsx`) | +| R3 | **High (security)** | Voice/STT WS **auth token in the URL query string** → server/proxy logs. Backend already supports subprotocol (persona) and `{type:"auth"}` first-message (audio STT); client uses only the URL token | `persona-stream.ts:20,26`; `voice-conversation.ts:361`; `background.ts:3332` | +| R4 | Medium | Greeting seed is **never persisted to Dexie** (root cause of R1) → same conversation shows a different message count depending on Dexie vs server rehydrate | `chat-helper/index.ts:438-491` | +| R5 | Medium | A **stalled character stream hangs indefinitely** — the live path has no inactivity watchdog; the copy that *does* (`useCharacterChatMode.ts`) is tested-but-unused. Three diverged copies of `characterChatMode` | `useChatActions.ts`/`useMessage.tsx` vs `useCharacterChatMode.ts` | +| R6 | Medium | **Steady TTS memory leaks** — MediaSource/blob URL leaks on stream-fallback and on cancel-during-playback | `useStreamingAudioPlayer.tsx:273`, `useTTS.tsx:283-330` | +| R7 | Medium | **Overlapping TTS playback** in the chat drawer — playing a second clip doesn't stop the first (two voices) | `TtsClipsDrawer.tsx:128-160` | +| R8 | Medium | Real-time voice: **barge-in doesn't stop TTS** (assistant talks over you), no `bufferedAmount` backpressure, no handshake timeout (UI wedges "connecting"), WS leaks if unmounted mid-connect | `useVoiceChatStream.tsx`, `usePersonaLiveSession.tsx`, `usePersonaLiveControl.tsx` | +| R9 | Medium | Character cards: **PNG export fetches an attacker-controlled `avatar_url`** (tracking/SSRF beacon), avatar/import have no size caps, two unsynchronized favorite systems | `character-export.ts:269`, `AvatarField.tsx:118`, `Characters/utils.ts:516`, `CharacterSelect.tsx` vs `useCharacterCrud.tsx` | +| R10 | Low-Med | Swiping to a not-yet-persisted variant keeps the prior `serverMessageId` → later edit/delete hits the wrong server row | `message-variants.ts:75` | -## 11. Build, Deployment & Integration +Lower-severity items (empty-completion bubbles, greeting double-render, undo version guess, soft-delete visible window, headerless-WebM long recordings, play/pause races) are in the reviewer notes. -- **Build status** (`npm run build`): -- **Production runtime assumptions** (reverse proxy paths, CORS, `NEXT_PUBLIC_API_BASE_URL` usage): -- **How this frontend is deployed** (Vercel, Docker, static export, other): -- **Integration with backend release process**: +## Cross-cutting themes (Round 2) +1. **Duplicated/dead-tested code drifts** — `characterChatMode` exists in triplicate, and the *safe* copy (with an inactivity watchdog + recovery) is the unused one; tests pass against code that never runs (same trap as Round 1's dead auth stack / `extension/routes`). +2. **UI-index vs storage-index mismatch** — R1/R4: messages are addressed by array position; a greeting at index 0 desyncs UI from Dexie. Root fix is to address by stable message id. +3. **Lifecycle cleanup only covers the happy path** — mic tracks, blob URLs, and WebSockets leak on error/race/unmount paths. `useMicStream.ts` is the correct template the leaky sites should follow. +4. **WS token in URL** is systemic across every voice/STT path. -Notes: +## What's solid (verified OK) +No XSS in character/card rendering; character card image decode validates magic bytes + MIME allow-list; download filenames are traversal-safe; no ReDoS from lorebook regex; **no duplicate assistant persistence** (the server skips persistence while streaming and the client persists once, idempotently); cross-character session switching resets state correctly; TTS streaming chunk ordering is correct; `useMicStream.ts` teardown is complete on every path; no zero-delay reconnect loops. -- … +## R2 remediation status +Backlog tasks `task-12107`…`task-12113` (renumbered off `task-12104`–`12106`, which teammates used for PR-2573 work). -Follow-ups: +**Fixed (with tests):** +- **R1 (task-12111)** — character delete/edit now address the Dexie row by **stable message id**, not array position, so a greeting at UI index 0 no longer corrupts the store. *Deferred (AC#3):* the greeting is still not persisted to Dexie, so a Dexie-sourced rehydrate shows one fewer message than a server-sourced one — cosmetic, not corruption. +- **R2 (task-12112)** — the three mic-capture sites now match `useMicStream` (synchronous re-entry guard, stream held in a `catch`-reachable ref, `MediaRecorder` `onerror` that stops tracks + releases the capture lock). +- **R6/R7 (task-12107)** — TTS blob-URL/MediaSource leaks freed on every path; overlapping playback fixed; audiobook cancel aborts the in-flight chapter. +- **R8 (task-12109)** — real-time voice hardening: barge-in stops TTS + single interrupt, `bufferedAmount` backpressure, handshake timeout, unmount-mid-connect WS-leak guards. +- **R9/R10 (task-12110)** — card handling: export SSRF guard (same-origin/allowlisted + timeout + 5 MB cap), avatar/import size caps, favorite reconciled to the server flag with the correct cache key, and a swiped-but-unpersisted variant no longer inherits a stale `serverMessageId`. -- [ ] … - ---- - -## 12. Summary & Next Steps +**Partially done / gated:** +- **R3 (task-12113)** — WS token **moved out of the URL** (persona subprotocol `["bearer",cred]`; audio/STT `{type:"auth"}` first message), with a charset fallback so a non-token-safe custom key can't crash `new WebSocket`. ⚠️ **Needs a live-server smoke before merge** (subprotocol handshake, single-user key charset, auth-before-config ordering, extension STT). +- **R5 (task-12108)** — the high-value **stream-inactivity watchdog** (60s) is now on both live character paths + recovery classification; the full 3-copy consolidation is intentionally deferred (a large refactor while dev is actively churning chat). -- **Overall health** (subjective: green / yellow / red) and why: -- **Top 3 risks**: - 1. … - 2. … - 3. … -- **Top 3 short-term improvements** (1–2 weeks): - 1. … - 2. … - 3. … -- **Longer-term refactors / improvements**: - - … +**Not done (deliberately deferred, see the running commentary):** TS-strict enablement (blocked on ~66 pre-existing type errors — a real migration, not a flag flip); dropping CSP `'unsafe-eval'` (needs WASM/OCR browser verification); deleting the `extension/routes` mirror (kept in sync by ~22 parity tests; negative-value churn during dev's route refactor). diff --git a/apps/packages/ui/src/components/Common/CharacterSelect.tsx b/apps/packages/ui/src/components/Common/CharacterSelect.tsx index eecea5154e..99f81751ee 100644 --- a/apps/packages/ui/src/components/Common/CharacterSelect.tsx +++ b/apps/packages/ui/src/components/Common/CharacterSelect.tsx @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query" +import { useQuery, useQueryClient } from "@tanstack/react-query" import { Dropdown, Tooltip, @@ -43,6 +43,11 @@ import { buildCharactersRoute as buildCharactersRouteUrl, resolveCharactersDestinationMode } from "@/utils/characters-route" +import { + readFavoriteFromExtensions, + applyFavoriteToExtensions, + MAX_IMPORT_FILE_BYTES +} from "@/components/Option/Characters/utils" type Props = { className?: string @@ -104,12 +109,6 @@ type CharacterSelection = { type CharacterSortMode = "favorites" | "az" -type FavoriteCharacter = { - id?: string - slug?: string - name: string -} - const MAX_PERSONA_IMAGE_BYTES = 5 * 1024 * 1024 const MAX_MOOD_IMAGE_BYTES = 5 * 1024 * 1024 @@ -167,6 +166,7 @@ export const CharacterSelect: React.FC = ({ }) => { const { t } = useTranslation(["option", "common", "settings", "playground"]) const notification = useAntdNotification() + const queryClient = useQueryClient() const modal = useAntdModal() const confirmWithModal = useConfirmModal() const [selectedCharacter, setSelectedCharacter] = @@ -241,10 +241,6 @@ export const CharacterSelect: React.FC = ({ "menuDensity", "comfortable" ) - const [favoriteCharacters, setFavoriteCharacters] = useStorage( - "favoriteCharacters", - [] - ) const [sortMode, setSortMode] = useStorage( "characterSortMode", "favorites" @@ -1023,6 +1019,21 @@ export const CharacterSelect: React.FC = ({ const file = event.target.files?.[0] if (!file) return + if (file.size > MAX_IMPORT_FILE_BYTES) { + notification.error({ + message: t("settings:manageCharacters.notification.error", { + defaultValue: "Error" + }), + description: t("settings:manageCharacters.import.tooLarge", { + defaultValue: + "This file is too large to import. Please choose a smaller file (under {{maxMb}} MB).", + maxMb: Math.floor(MAX_IMPORT_FILE_BYTES / (1024 * 1024)) + }) + }) + event.target.value = "" + return + } + const getImageOnlyDetail = (error: unknown): ImageOnlyErrorDetail | null => { const details = (error as ImportError)?.details if (!details || typeof details !== "object") return null @@ -1137,18 +1148,6 @@ export const CharacterSelect: React.FC = ({ }) }, [data, searchQuery]) - const favoriteIndex = React.useMemo(() => { - const ids = new Set() - const slugs = new Set() - const names = new Set() - ;(favoriteCharacters || []).forEach((fav) => { - if (fav.id) ids.add(String(fav.id)) - if (fav.slug) slugs.add(String(fav.slug)) - if (fav.name) names.add(String(fav.name)) - }) - return { ids, slugs, names } - }, [favoriteCharacters]) - const getCharacterDisplayName = React.useCallback((character: CharacterSummary) => { return ( character.name || @@ -1158,41 +1157,117 @@ export const CharacterSelect: React.FC = ({ ).toString() }, []) + // Favorite is a server-side flag (extensions.tldw.favorite) so the header and + // the Characters Manager share a single source of truth instead of a stale + // localStorage mirror. const isFavoriteCharacter = React.useCallback( - (character: CharacterSummary) => { - const id = character.id != null ? String(character.id) : "" - const slug = character.slug ? String(character.slug) : "" - const name = getCharacterDisplayName(character) - return ( - (id && favoriteIndex.ids.has(id)) || - (slug && favoriteIndex.slugs.has(slug)) || - (name && favoriteIndex.names.has(name)) - ) - }, - [favoriteIndex, getCharacterDisplayName] + (character: CharacterSummary) => + readFavoriteFromExtensions(character.extensions), + [] ) const toggleFavoriteCharacter = React.useCallback( - (character: CharacterSummary) => { - const name = getCharacterDisplayName(character).trim() - const id = character.id != null ? String(character.id) : undefined - const slug = character.slug ? String(character.slug) : undefined - if (!name) return - void setFavoriteCharacters((prev) => { - const list = Array.isArray(prev) ? prev : [] - const next = list.filter((fav) => { - if (id && fav.id && fav.id === id) return false - if (slug && fav.slug && fav.slug === slug) return false - if (name && fav.name === name) return false - return true + async (character: CharacterSummary) => { + const id = + character.id != null + ? String(character.id) + : character.slug + ? String(character.slug) + : "" + if (!id) return + + const nextFavorite = !readFavoriteFromExtensions(character.extensions) + const nextExtensions = applyFavoriteToExtensions( + character.extensions, + nextFavorite + ) + if (nextExtensions === null) { + notification.warning({ + message: t( + "settings:manageCharacters.notification.favoriteInvalidExtensions", + { + defaultValue: "Couldn't update favorite" + } + ), + description: t( + "settings:manageCharacters.notification.favoriteInvalidExtensionsDesc", + { + defaultValue: + "This character has invalid extensions JSON. Fix the extensions field before toggling favorite." + } + ) }) - if (next.length === list.length) { - next.push({ id, slug, name }) + return + } + + const matchesCharacter = (candidate: any) => { + const candidateId = + candidate?.id != null + ? String(candidate.id) + : candidate?.slug + ? String(candidate.slug) + : "" + return candidateId === id + } + const applyToItem = (candidate: any) => + matchesCharacter(candidate) + ? { ...candidate, extensions: nextExtensions ?? {} } + : candidate + const updateCachedList = (old: any): any => { + if (Array.isArray(old)) return old.map(applyToItem) + if (old && Array.isArray(old.items)) { + return { ...old, items: old.items.map(applyToItem) } } - return next - }) + return old + } + + // Optimistically update every cached character list (bare header key and + // the Manager's 3-element paginated keys) by prefix match. + let previousEntries: [readonly unknown[], unknown][] = [] + try { + previousEntries = + (queryClient.getQueriesData?.({ + queryKey: ["tldw:listCharacters"] + }) as [readonly unknown[], unknown][] | undefined) ?? [] + queryClient.setQueriesData?.( + { queryKey: ["tldw:listCharacters"] }, + updateCachedList + ) + } catch { + // Optimistic update not available + } + + try { + await tldwClient.initialize().catch(() => null) + await tldwClient.updateCharacter( + id, + { extensions: nextExtensions ?? {} }, + character.version + ) + queryClient.invalidateQueries({ queryKey: ["tldw:listCharacters"] }) + } catch (error) { + for (const [key, prev] of previousEntries) { + try { + queryClient.setQueryData?.(key, prev) + } catch { + // noop + } + } + const messageText = + error instanceof Error ? error.message : String(error) + notification.error({ + message: t("settings:manageCharacters.notification.error", { + defaultValue: "Error" + }), + description: + messageText || + t("settings:manageCharacters.notification.someError", { + defaultValue: "Something went wrong. Please try again later" + }) + }) + } }, - [getCharacterDisplayName, setFavoriteCharacters] + [notification, queryClient, t] ) const sortedCharacters = React.useMemo(() => { @@ -1260,7 +1335,7 @@ export const CharacterSelect: React.FC = ({ onClick={(event) => { event.preventDefault() event.stopPropagation() - toggleFavoriteCharacter(character) + void toggleFavoriteCharacter(character) }} aria-label={favoriteTitle} title={favoriteTitle} diff --git a/apps/packages/ui/src/components/Common/Playground/MessageSource.tsx b/apps/packages/ui/src/components/Common/Playground/MessageSource.tsx index e6c4b138c8..db420146e2 100644 --- a/apps/packages/ui/src/components/Common/Playground/MessageSource.tsx +++ b/apps/packages/ui/src/components/Common/Playground/MessageSource.tsx @@ -1,4 +1,5 @@ import { KnowledgeIcon } from "@/components/Option/Knowledge/KnowledgeIcon" +import { safeExternalUrl } from "@/utils/safe-external-url" import { useTranslation } from "react-i18next" import React from "react" @@ -78,6 +79,7 @@ export const MessageSource: React.FC = ({ source?.snippet || "" const url = source?.url + const safeUrl = safeExternalUrl(url) const page = source?.metadata?.page const lineFrom = source?.metadata?.loc?.lines?.from const lineTo = source?.metadata?.loc?.lines?.to @@ -177,10 +179,10 @@ export const MessageSource: React.FC = ({ }, [emitDwell]) if (!isExpandable) { - if (url) { + if (safeUrl) { return ( { @@ -254,9 +256,9 @@ export const MessageSource: React.FC = ({ {`Line ${lineFrom} - ${lineTo}`} )} - {url && ( + {safeUrl && ( { diff --git a/apps/packages/ui/src/components/Knowledge/hooks/useFileSearch.ts b/apps/packages/ui/src/components/Knowledge/hooks/useFileSearch.ts index e7fd304fd4..a86ef3a1b6 100644 --- a/apps/packages/ui/src/components/Knowledge/hooks/useFileSearch.ts +++ b/apps/packages/ui/src/components/Knowledge/hooks/useFileSearch.ts @@ -2,6 +2,7 @@ import React from "react" import { useTranslation } from "react-i18next" import { tldwClient } from "@/services/tldw/TldwApiClient" import type { RagSettings } from "@/services/rag/unified-rag" +import { openExternalUrl } from "@/utils/safe-external-url" import { formatRagResult, type RagCopyFormat, @@ -219,7 +220,7 @@ export function useFileSearch({ const handleOpen = React.useCallback((item: RagResult) => { const url = getResultUrl(item) if (!url) return - window.open(String(url), "_blank") + openExternalUrl(String(url), "_blank") }, []) const handlePin = React.useCallback( diff --git a/apps/packages/ui/src/components/Knowledge/hooks/useKnowledgeSearch.ts b/apps/packages/ui/src/components/Knowledge/hooks/useKnowledgeSearch.ts index 2155982c8c..d0943077b5 100644 --- a/apps/packages/ui/src/components/Knowledge/hooks/useKnowledgeSearch.ts +++ b/apps/packages/ui/src/components/Knowledge/hooks/useKnowledgeSearch.ts @@ -4,6 +4,7 @@ import { useStorage } from "@plasmohq/storage/hook" import { shallow } from "zustand/shallow" import { tldwClient } from "@/services/tldw/TldwApiClient" import { type RagSettings } from "@/services/rag/unified-rag" +import { openExternalUrl } from "@/utils/safe-external-url" import { formatRagResult, type RagCopyFormat, @@ -617,7 +618,7 @@ export function useKnowledgeSearch({ const handleOpen = React.useCallback((item: RagResult) => { const url = getResultUrl(item) if (!url) return - window.open(String(url), "_blank") + openExternalUrl(String(url), "_blank") }, []) const handlePin = React.useCallback( diff --git a/apps/packages/ui/src/components/Layouts/__tests__/chat-rail-positioning-contract.guard.test.ts b/apps/packages/ui/src/components/Layouts/__tests__/chat-rail-positioning-contract.guard.test.ts index cadbac9520..5530193c53 100644 --- a/apps/packages/ui/src/components/Layouts/__tests__/chat-rail-positioning-contract.guard.test.ts +++ b/apps/packages/ui/src/components/Layouts/__tests__/chat-rail-positioning-contract.guard.test.ts @@ -3,15 +3,16 @@ import { describe, expect, it } from "vitest" import { COCKPIT_LEFT_RESTORE_WRAPPER_CLASS } from "../chat-rail-positioning" describe("chat rail positioning contract", () => { - it("keeps the cockpit context restore trigger attached to the left edge", () => { + it("keeps the cockpit context restore trigger clear of the app navigation rail", () => { expect(COCKPIT_LEFT_RESTORE_WRAPPER_CLASS.split(" ")).toEqual( expect.arrayContaining([ - "fixed", + "absolute", "left-0", "top-[clamp(18rem,36vh,24rem)]", "z-50" ]) ) + expect(COCKPIT_LEFT_RESTORE_WRAPPER_CLASS).not.toContain("fixed") expect(COCKPIT_LEFT_RESTORE_WRAPPER_CLASS).not.toContain("left-12") expect(COCKPIT_LEFT_RESTORE_WRAPPER_CLASS).not.toContain("top-1/2") }) diff --git a/apps/packages/ui/src/components/Layouts/chat-rail-positioning.ts b/apps/packages/ui/src/components/Layouts/chat-rail-positioning.ts index 6ce7c7325a..5dfb018540 100644 --- a/apps/packages/ui/src/components/Layouts/chat-rail-positioning.ts +++ b/apps/packages/ui/src/components/Layouts/chat-rail-positioning.ts @@ -1,2 +1,2 @@ export const COCKPIT_LEFT_RESTORE_WRAPPER_CLASS = - "fixed left-0 top-[clamp(18rem,36vh,24rem)] z-50 hidden lg:inline-flex" + "absolute left-0 top-[clamp(18rem,36vh,24rem)] z-50 hidden lg:inline-flex" diff --git a/apps/packages/ui/src/components/Notes/__tests__/notes-manager-utils.sanitize-url.test.ts b/apps/packages/ui/src/components/Notes/__tests__/notes-manager-utils.sanitize-url.test.ts new file mode 100644 index 0000000000..37ad38837f --- /dev/null +++ b/apps/packages/ui/src/components/Notes/__tests__/notes-manager-utils.sanitize-url.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest" +import { markdownInlineToHtml } from "../notes-manager-utils" + +// `sanitizeUrl` is private; exercise it through `markdownInlineToHtml`, which +// renders a markdown link only when the URL survives sanitization. +describe("markdownInlineToHtml URL sanitization", () => { + it("keeps safe http(s) links", () => { + const html = markdownInlineToHtml("[site](https://example.com)") + expect(html).toContain('') + }) + + it("neutralizes javascript: links", () => { + const html = markdownInlineToHtml("[x](javascript:alert(1))") + expect(html).not.toContain(" { + const html = markdownInlineToHtml("[x](java\tscript:alert(1))") + expect(html).not.toContain(" { + const html = markdownInlineToHtml("[x](java\nscript:alert(1))") + expect(html).not.toContain(" const SAFE_URL_PROTOCOLS = /^(https?:|mailto:|tel:|note:|#|\/)/i const sanitizeUrl = (url: string): string => { - const trimmed = url.trim() + // Strip C0 control characters (incl. tab/newline/CR) and DEL first: browsers + // remove them when resolving a URL, so `java\tscript:` would otherwise match + // neither branch below and be emitted verbatim as an executable scheme. + let stripped = '' + for (const ch of url) { + const code = ch.charCodeAt(0) + if (code <= 0x1f || code === 0x7f) continue + stripped += ch + } + const trimmed = stripped.trim() if (!trimmed) return '' if (SAFE_URL_PROTOCOLS.test(trimmed)) return trimmed // Block javascript:, data:, vbscript:, etc. diff --git a/apps/packages/ui/src/components/Option/Characters/AvatarField.tsx b/apps/packages/ui/src/components/Option/Characters/AvatarField.tsx index 5b19bdeefb..cb0ff22c85 100644 --- a/apps/packages/ui/src/components/Option/Characters/AvatarField.tsx +++ b/apps/packages/ui/src/components/Option/Characters/AvatarField.tsx @@ -11,6 +11,13 @@ import { } from "@/utils/image-utils" import { tldwClient, type ImageBackend } from "@/services/tldw/TldwApiClient" +/** + * Maximum size for an uploaded avatar image. Mirrors `MAX_PERSONA_IMAGE_BYTES` + * in CharacterSelect. Oversized images are rejected up front so their base64 is + * never autosaved to localStorage (which would trigger `QuotaExceededError`). + */ +export const MAX_AVATAR_IMAGE_BYTES = 5 * 1024 * 1024 + export type AvatarMode = "url" | "upload" | "generate" export interface AvatarFieldValue { @@ -125,6 +132,16 @@ export function AvatarField({ return false } + if (file.size > MAX_AVATAR_IMAGE_BYTES) { + message.error( + t("settings:manageCharacters.avatar.tooLargeError", { + defaultValue: + "Image is too large. Please choose an image around 5 MB or less." + }) + ) + return false + } + setLoading(true) try { const result = await new Promise((resolve, reject) => { diff --git a/apps/packages/ui/src/components/Option/Characters/__tests__/AvatarField.size-cap.test.tsx b/apps/packages/ui/src/components/Option/Characters/__tests__/AvatarField.size-cap.test.tsx new file mode 100644 index 0000000000..0d50fa62e9 --- /dev/null +++ b/apps/packages/ui/src/components/Option/Characters/__tests__/AvatarField.size-cap.test.tsx @@ -0,0 +1,72 @@ +import React from "react" +import { render, fireEvent, waitFor, act } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { message } from "antd" +import { AvatarField, MAX_AVATAR_IMAGE_BYTES } from "../AvatarField" + +vi.mock("@/services/tldw/TldwApiClient", () => ({ + tldwClient: { + getImageBackends: vi.fn().mockResolvedValue([]), + createImageArtifact: vi.fn() + } +})) + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: ( + key: string, + fallbackOrOptions?: string | { defaultValue?: string; [k: string]: unknown } + ) => { + if (typeof fallbackOrOptions === "string") return fallbackOrOptions + if (fallbackOrOptions && typeof fallbackOrOptions === "object") { + return fallbackOrOptions.defaultValue || key + } + return key + } + }) +})) + +const makeFile = (name: string, type: string, size: number): File => { + const file = new File(["x"], name, { type }) + Object.defineProperty(file, "size", { value: size }) + return file +} + +describe("AvatarField upload size cap", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("rejects an avatar larger than the cap and does not emit its base64", async () => { + const onChange = vi.fn() + const errorSpy = vi + .spyOn(message, "error") + .mockImplementation(() => ({}) as any) + + const { container } = render( + + ) + + const input = container.querySelector( + 'input[type="file"]' + ) as HTMLInputElement + expect(input).toBeTruthy() + + const bigFile = makeFile("big.png", "image/png", MAX_AVATAR_IMAGE_BYTES + 1) + await act(async () => { + fireEvent.change(input, { target: { files: [bigFile] } }) + }) + + await waitFor(() => expect(errorSpy).toHaveBeenCalled()) + expect( + errorSpy.mock.calls.some( + ([msg]) => typeof msg === "string" && msg.includes("too large") + ) + ).toBe(true) + // The oversized image's base64 must never reach onChange (would autosave to + // localStorage and risk QuotaExceededError). + expect(onChange).not.toHaveBeenCalledWith( + expect.objectContaining({ base64: expect.any(String) }) + ) + }) +}) diff --git a/apps/packages/ui/src/components/Option/Characters/__tests__/utils.import-size-cap.test.ts b/apps/packages/ui/src/components/Option/Characters/__tests__/utils.import-size-cap.test.ts new file mode 100644 index 0000000000..59f685f4aa --- /dev/null +++ b/apps/packages/ui/src/components/Option/Characters/__tests__/utils.import-size-cap.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest" +import { + MAX_IMPORT_FILE_BYTES, + parseCharacterImportPreview +} from "../utils" + +const makeFile = ( + name: string, + type: string, + size: number, + content = "{}" +): File => { + const file = new File([content], name, { type }) + Object.defineProperty(file, "size", { value: size }) + return file +} + +describe("parseCharacterImportPreview size cap", () => { + it("rejects a file larger than MAX_IMPORT_FILE_BYTES before reading it", async () => { + const file = makeFile( + "huge.json", + "application/json", + MAX_IMPORT_FILE_BYTES + 1 + ) + + const preview = await parseCharacterImportPreview(file, 0) + + expect(preview.parseError?.key).toBe( + "settings:manageCharacters.import.previewTooLarge" + ) + expect(preview.fieldCount).toBe(0) + }) + + it("does not flag a within-limit file as too large", async () => { + const file = makeFile( + "small.json", + "application/json", + 256, + JSON.stringify({ name: "Ada", description: "A researcher" }) + ) + + const preview = await parseCharacterImportPreview(file, 0) + + expect(preview.parseError?.key).not.toBe( + "settings:manageCharacters.import.previewTooLarge" + ) + }) +}) diff --git a/apps/packages/ui/src/components/Option/Characters/hooks/useCharacterCrud.tsx b/apps/packages/ui/src/components/Option/Characters/hooks/useCharacterCrud.tsx index 064b206315..9762cdbf39 100644 --- a/apps/packages/ui/src/components/Option/Characters/hooks/useCharacterCrud.tsx +++ b/apps/packages/ui/src/components/Option/Characters/hooks/useCharacterCrud.tsx @@ -473,10 +473,23 @@ export function useCharacterCrud(deps: UseCharacterCrudDeps) { const data = await tldwClient.exportCharacter(id, { format: 'v3' }) if (format === 'png') { + // Only allow the PNG exporter to fetch a remote avatar from the + // configured tldw server origin (same-origin is always allowed). An + // arbitrary card `avatar_url` is otherwise skipped, not fetched. + let allowedAvatarOrigins: string[] | undefined + try { + const cfg = await tldwClient.getConfig() + if (cfg?.serverUrl) { + allowedAvatarOrigins = [cfg.serverUrl] + } + } catch { + // Fall back to same-origin-only when the server URL can't be resolved. + } await exportCharacterToPNG(data, { avatarUrl: record.avatar_url, avatarBase64: record.image_base64, - filename: `${name.replace(/[^a-z0-9]/gi, '_')}_character.png` + filename: `${name.replace(/[^a-z0-9]/gi, '_')}_character.png`, + allowedAvatarOrigins }) } else { exportCharacterToJSON(data, `${name.replace(/[^a-z0-9]/gi, '_')}_character.json`) @@ -922,18 +935,34 @@ export function useCharacterCrud(deps: UseCharacterCrudDeps) { return } - let previousData: unknown = undefined + // Optimistically update EVERY cached character list. The Manager table + // caches under a 3-element key (["tldw:listCharacters", params, scope]) + // with a paginated `{ items }` shape, while the header uses the bare + // ["tldw:listCharacters"] array key — so match by prefix and handle both + // shapes instead of writing a single (wrong) key. + let previousEntries: [readonly unknown[], unknown][] = [] let previousPreview: any = undefined + const applyFavoriteToCached = (c: any) => { + const cId = String(c?.id || c?.slug || c?.name || "") + if (cId !== id) return c + return { ...c, extensions: nextExtensions ?? {} } + } + const updateCachedList = (old: any): any => { + if (Array.isArray(old)) return old.map(applyFavoriteToCached) + if (old && Array.isArray(old.items)) { + return { ...old, items: old.items.map(applyFavoriteToCached) } + } + return old + } try { - previousData = qc.getQueryData?.(["tldw:listCharacters"]) - qc.setQueryData?.(["tldw:listCharacters"], (old: any) => { - if (!Array.isArray(old)) return old - return old.map((c: any) => { - const cId = String(c?.id || c?.slug || c?.name || "") - if (cId !== id) return c - return { ...c, extensions: nextExtensions ?? {} } - }) - }) + previousEntries = + (qc.getQueriesData?.({ queryKey: ["tldw:listCharacters"] }) as + | [readonly unknown[], unknown][] + | undefined) ?? [] + qc.setQueriesData?.( + { queryKey: ["tldw:listCharacters"] }, + updateCachedList + ) } catch { // Optimistic update not available } @@ -958,8 +987,8 @@ export function useCharacterCrud(deps: UseCharacterCrudDeps) { ) qc.invalidateQueries({ queryKey: ["tldw:listCharacters"] }) } catch (error: any) { - if (previousData !== undefined) { - try { qc.setQueryData?.(["tldw:listCharacters"], previousData) } catch { /* noop */ } + for (const [key, prev] of previousEntries) { + try { qc.setQueryData?.(key, prev) } catch { /* noop */ } } if (previousPreview !== undefined) { setPreviewCharacter(previousPreview) diff --git a/apps/packages/ui/src/components/Option/Characters/utils.ts b/apps/packages/ui/src/components/Option/Characters/utils.ts index f7078daa44..505513cacb 100644 --- a/apps/packages/ui/src/components/Option/Characters/utils.ts +++ b/apps/packages/ui/src/components/Option/Characters/utils.ts @@ -42,6 +42,13 @@ export const CHARACTER_FOLDER_TOKEN_PREFIXES = [ "__tldw_folder:" ] as const +/** + * Maximum size for an imported character card. A larger file is rejected at the + * preview boundary so a huge card can't freeze the tab on `file.text()` + + * synchronous `JSON.parse`. + */ +export const MAX_IMPORT_FILE_BYTES = 10 * 1024 * 1024 + export const IMPORT_ALLOWED_EXTENSIONS = [ ".png", ".webp", @@ -492,6 +499,29 @@ export const parseCharacterImportPreview = async ( } } + if (file.size > MAX_IMPORT_FILE_BYTES) { + return { + id: `${file.name}-${file.lastModified}-${index}`, + file, + fileName: file.name, + format, + name: defaultName, + description: "", + tagCount: 0, + fieldCount: 0, + avatarUrl: null, + parseError: { + key: "settings:manageCharacters.import.previewTooLarge", + fallback: + "File is too large to import ({{sizeMb}} MB). Maximum allowed is {{maxMb}} MB.", + values: { + sizeMb: Math.round((file.size / (1024 * 1024)) * 10) / 10, + maxMb: Math.floor(MAX_IMPORT_FILE_BYTES / (1024 * 1024)) + } + } + } + } + if (IMPORT_IMAGE_EXTENSIONS.has(extension)) { const avatarUrl = typeof URL !== "undefined" && typeof URL.createObjectURL === "function" diff --git a/apps/packages/ui/src/components/Option/Collections/ReadingList/ReadingItemCard.tsx b/apps/packages/ui/src/components/Option/Collections/ReadingList/ReadingItemCard.tsx index 3315ef15d3..87e77d4148 100644 --- a/apps/packages/ui/src/components/Option/Collections/ReadingList/ReadingItemCard.tsx +++ b/apps/packages/ui/src/components/Option/Collections/ReadingList/ReadingItemCard.tsx @@ -13,6 +13,7 @@ import { import type { TFunction } from "i18next" import { useTranslation } from "react-i18next" import { useCollectionsStore } from "@/store/collections" +import { openExternalUrl } from "@/utils/safe-external-url" import { useTldwApiClient } from "@/hooks/useTldwApiClient" import type { ReadingItemSummary, ReadingStatus } from "@/types/collections" import { StatusBadge } from "../common/StatusBadge" @@ -110,7 +111,7 @@ export const ReadingItemCard: React.FC = ({ key: "open", icon: , label: t("collections:reading.openOriginal", "Open original"), - onClick: () => item.url && window.open(item.url, "_blank"), + onClick: () => item.url && openExternalUrl(item.url, "_blank"), disabled: !item.url }, { type: "divider" }, diff --git a/apps/packages/ui/src/components/Option/Collections/ReadingList/ReadingItemDetail.tsx b/apps/packages/ui/src/components/Option/Collections/ReadingList/ReadingItemDetail.tsx index 46fa08c3fa..c503171d5e 100644 --- a/apps/packages/ui/src/components/Option/Collections/ReadingList/ReadingItemDetail.tsx +++ b/apps/packages/ui/src/components/Option/Collections/ReadingList/ReadingItemDetail.tsx @@ -29,6 +29,7 @@ import { } from "lucide-react" import { useTranslation } from "react-i18next" import { useCollectionsStore } from "@/store/collections" +import { safeExternalUrl } from "@/utils/safe-external-url" import { useTldwApiClient } from "@/hooks/useTldwApiClient" import type { Highlight, HighlightColor, ReadingNoteLink, ReadingStatus } from "@/types/collections" import { TagSelector } from "../common/TagSelector" @@ -1027,7 +1028,7 @@ export const ReadingItemDetail: React.FC = ({
{currentItem.domain && ( { {items.map((item) => { const selected = selectedItemIds.includes(item.id) const publishedLabel = formatDateOnlyLabel(item.published_at) + const itemSafeUrl = safeExternalUrl(item.url) return (
{
{item.domain && {item.domain}} {publishedLabel && {publishedLabel}} - {item.url && ( + {itemSafeUrl && ( { if (url) { - window.open(url, "_blank", "noopener,noreferrer") + openExternalUrl(url, "_blank", "noopener,noreferrer") } }, [url]) diff --git a/apps/packages/ui/src/components/Option/KnowledgeQA/SourceViewerModal.tsx b/apps/packages/ui/src/components/Option/KnowledgeQA/SourceViewerModal.tsx index 667a2ddbdc..89173df6bd 100644 --- a/apps/packages/ui/src/components/Option/KnowledgeQA/SourceViewerModal.tsx +++ b/apps/packages/ui/src/components/Option/KnowledgeQA/SourceViewerModal.tsx @@ -5,6 +5,7 @@ import React, { useEffect } from "react" import { ExternalLink, X } from "lucide-react" import { cn } from "@/libs/utils" +import { openExternalUrl } from "@/utils/safe-external-url" import type { RagResult } from "./types" import { getEvidenceOrigin, @@ -96,7 +97,7 @@ export function SourceViewerModal({ {url && (
- ))} + ) + })} )}
diff --git a/apps/packages/ui/src/components/Option/ResearchWorkspace/SourcesPane/index.tsx b/apps/packages/ui/src/components/Option/ResearchWorkspace/SourcesPane/index.tsx index e44eaf5f84..70c19398b9 100644 --- a/apps/packages/ui/src/components/Option/ResearchWorkspace/SourcesPane/index.tsx +++ b/apps/packages/ui/src/components/Option/ResearchWorkspace/SourcesPane/index.tsx @@ -29,6 +29,7 @@ import { Modal } from "antd" import { READY_STATE_LABEL, getDesignSystemState } from "@/design-system" +import { safeExternalUrl } from "@/utils/safe-external-url" import { isWorkspaceSourcePartiallyQueryable, isWorkspaceSourceSelectable @@ -2398,6 +2399,7 @@ export const SourcesPane: React.FC = ({ } ) : null + const previewSafeUrl = safeExternalUrl(previewSource.url) return (
@@ -2406,14 +2408,14 @@ export const SourcesPane: React.FC = ({

{previewSource.type} / {previewStatusLabel}

- {previewSource.url && ( + {previewSafeUrl && (
- {previewSource.url} + {previewSafeUrl} )}
diff --git a/apps/packages/ui/src/components/Option/Speech/SpeechPlaygroundPage.tsx b/apps/packages/ui/src/components/Option/Speech/SpeechPlaygroundPage.tsx index 710fea2bb1..6fad22518b 100644 --- a/apps/packages/ui/src/components/Option/Speech/SpeechPlaygroundPage.tsx +++ b/apps/packages/ui/src/components/Option/Speech/SpeechPlaygroundPage.tsx @@ -346,6 +346,7 @@ export const SpeechPlaygroundPage: React.FC = ({ const [recordingStream, setRecordingStream] = React.useState(null) const recorderRef = React.useRef(null) const recordingStreamRef = React.useRef(null) + const startingRecordingRef = React.useRef(false) const captureOwnerRef = React.useRef(false) const chunksRef = React.useRef([]) const startedAtRef = React.useRef(null) @@ -581,6 +582,11 @@ export const SpeechPlaygroundPage: React.FC = ({ setIsTranscribing(true) return } + // Synchronous re-entry guard: a double-clicked record button must not run a + // second getUserMedia (and orphan the first stream) while a start is still + // in flight. Checked/set synchronously before the first await. + if (startingRecordingRef.current) return + startingRecordingRef.current = true try { setRecordingError(null) reserveCaptureOwner() @@ -589,9 +595,11 @@ export const SpeechPlaygroundPage: React.FC = ({ ? { deviceId: { exact: captureDeviceId } } : true }) + // Assign the stream ref BEFORE constructing the recorder so a ctor throw + // is caught below and stopRecordingStreamTracks() can stop its tracks. + recordingStreamRef.current = stream const recorder = new MediaRecorder(stream) recorderRef.current = recorder - recordingStreamRef.current = stream chunksRef.current = [] startedAtRef.current = Date.now() liveTextRef.current = "" @@ -744,6 +752,8 @@ export const SpeechPlaygroundPage: React.FC = ({ releaseCaptureOwner() setIsRecording(false) setIsTranscribing(false) + } finally { + startingRecordingRef.current = false } } diff --git a/apps/packages/ui/src/components/Option/Watchlists/AlertsTab/AlertsTab.tsx b/apps/packages/ui/src/components/Option/Watchlists/AlertsTab/AlertsTab.tsx index 63413fe281..e9d42dd534 100644 --- a/apps/packages/ui/src/components/Option/Watchlists/AlertsTab/AlertsTab.tsx +++ b/apps/packages/ui/src/components/Option/Watchlists/AlertsTab/AlertsTab.tsx @@ -20,6 +20,7 @@ import { updateWatchlistContentAlertRule } from "@/services/watchlists" import { Alert } from "@/components/ui/primitives" +import { safeExternalUrl } from "@/utils/safe-external-url" import { useWatchlistsStore } from "@/store/watchlists" import type { WatchlistContentAlert, @@ -614,8 +615,8 @@ export const AlertsTab: React.FC = () => { ) : ( alerts.map((alert) => { const sourceName = alert.evidence?.source_name || `Source ${alert.source_id}` - const sourceUrl = alert.evidence?.source_url || null - const itemUrl = alert.evidence?.url || null + const sourceUrl = safeExternalUrl(alert.evidence?.source_url) + const itemUrl = safeExternalUrl(alert.evidence?.url) const rule = rules.find((candidate) => candidate.id === alert.rule_id) const nextReadStatus: WatchlistContentAlertStatus = alert.status === "read" ? "unread" : "read" return ( diff --git a/apps/packages/ui/src/components/Option/Watchlists/ItemsTab/ItemsTab.tsx b/apps/packages/ui/src/components/Option/Watchlists/ItemsTab/ItemsTab.tsx index 7ca6c39b96..a0c9916336 100644 --- a/apps/packages/ui/src/components/Option/Watchlists/ItemsTab/ItemsTab.tsx +++ b/apps/packages/ui/src/components/Option/Watchlists/ItemsTab/ItemsTab.tsx @@ -56,6 +56,7 @@ import type { WatchlistSource } from "@/types/watchlists" import { formatRelativeTime } from "@/utils/dateFormatters" +import { openExternalUrl } from "@/utils/safe-external-url" import { buildServerItemViewCreatePayload, buildDefaultItemsViewPresets, @@ -1945,7 +1946,7 @@ export const ItemsTab: React.FC = () => { const openSelectedItemOriginal = useCallback(() => { if (!selectedItem?.url) return - window.open(selectedItem.url, "_blank", "noopener,noreferrer") + openExternalUrl(selectedItem.url, "_blank", "noopener,noreferrer") }, [selectedItem]) const navigateHome = useCallback(() => { diff --git a/apps/packages/ui/src/components/Option/Watchlists/OutputsTab/OutputPreviewDrawer.tsx b/apps/packages/ui/src/components/Option/Watchlists/OutputsTab/OutputPreviewDrawer.tsx index fbb7cb28c6..6a1ecdbecc 100644 --- a/apps/packages/ui/src/components/Option/Watchlists/OutputsTab/OutputPreviewDrawer.tsx +++ b/apps/packages/ui/src/components/Option/Watchlists/OutputsTab/OutputPreviewDrawer.tsx @@ -322,7 +322,10 @@ export const OutputPreviewDrawer: React.FC = ({ // Open in new tab (for HTML) const handleOpenInNewTab = () => { if (!content || output?.format !== "html") return - const safeHtml = sanitizedHtml || content + // Never fall back to raw `content`: when DOMPurify strips everything it + // returns "", and re-injecting unsanitized HTML into the same-origin + // blob: tab would reintroduce the XSS. Match the rendered view (:638). + const safeHtml = sanitizedHtml || "" const blob = new Blob([safeHtml], { type: "text/html" }) const url = URL.createObjectURL(blob) window.open(url, "_blank") diff --git a/apps/packages/ui/src/components/Option/Watchlists/OutputsTab/ReportEvidencePanel.tsx b/apps/packages/ui/src/components/Option/Watchlists/OutputsTab/ReportEvidencePanel.tsx index a88c00973e..cb9e875c72 100644 --- a/apps/packages/ui/src/components/Option/Watchlists/OutputsTab/ReportEvidencePanel.tsx +++ b/apps/packages/ui/src/components/Option/Watchlists/OutputsTab/ReportEvidencePanel.tsx @@ -4,6 +4,7 @@ import type { ColumnsType } from "antd/es/table" import { useTranslation } from "react-i18next" import { Alert } from "@/components/ui" import { getWatchlistOutputEvidence } from "@/services/watchlists" +import { safeExternalUrl } from "@/utils/safe-external-url" import type { WatchlistOutputEvidenceResponse, WatchlistReportEvidenceItem, @@ -137,11 +138,14 @@ export const ReportEvidencePanel: React.FC = ({ { title: t("watchlists:reports.evidence.columns.link", "Link"), key: "link", - render: (_, item) => item.url ? ( - + render: (_, item) => { + const safeUrl = safeExternalUrl(item.url) + return safeUrl ? ( + {t("watchlists:reports.evidence.openSource", "Open source")} ) : "-" + } } ], [t] @@ -178,7 +182,9 @@ export const ReportEvidencePanel: React.FC = ({ const renderConstrainedIncludedEvidence = (items: WatchlistReportEvidenceItem[]) => (
- {items.map((item) => ( + {items.map((item) => { + const safeUrl = safeExternalUrl(item.url) + return (
= ({
- {item.url ? ( + {safeUrl ? ( ) : null} - ))} + ) + })} ) diff --git a/apps/packages/ui/src/components/Option/Watchlists/RunsTab/RunDetailDrawer.tsx b/apps/packages/ui/src/components/Option/Watchlists/RunsTab/RunDetailDrawer.tsx index 2f19477410..f0b2ede460 100644 --- a/apps/packages/ui/src/components/Option/Watchlists/RunsTab/RunDetailDrawer.tsx +++ b/apps/packages/ui/src/components/Option/Watchlists/RunsTab/RunDetailDrawer.tsx @@ -41,6 +41,7 @@ import { import { tldwClient } from "@/services/tldw/TldwApiClient" import type { RunDetailResponse, ScrapedItem, WatchlistRunAudioStatus } from "@/types/watchlists" import { formatRelativeTime } from "@/utils/dateFormatters" +import { safeExternalUrl } from "@/utils/safe-external-url" import { StatusTag } from "../shared" import { isWatchlistRunTerminal } from "../shared/runStatus" import { mapWatchlistsError } from "../shared/watchlists-error" @@ -747,17 +748,19 @@ export const RunDetailDrawer: React.FC = ({ dataIndex: "title", key: "title", ellipsis: true, - render: (title: string | null, record) => ( + render: (title: string | null, record) => { + const safeUrl = safeExternalUrl(record.url) + return (
- {record.url ? ( + {safeUrl ? ( - {title || record.url} + {title || safeUrl} ) : ( title || t("watchlists:runs.detail.itemsUntitled", "Untitled") @@ -767,7 +770,8 @@ export const RunDetailDrawer: React.FC = ({
{record.summary}
)}
- ) + ) + } }, { title: t("watchlists:runs.detail.itemsColumns.status", "Status"), @@ -884,6 +888,7 @@ export const RunDetailDrawer: React.FC = ({
{items.map((item) => { const title = getItemTitle(item) + const itemSafeUrl = safeExternalUrl(item.url) return (
= ({ : t("watchlists:runs.detail.needsReview", "Needs review")} - {item.url ? ( - + {itemSafeUrl ? ( + {t("watchlists:runs.detail.openSource", "Open source")} ) : null} diff --git a/apps/packages/ui/src/components/Option/Watchlists/SourcesTab/SourcesTab.tsx b/apps/packages/ui/src/components/Option/Watchlists/SourcesTab/SourcesTab.tsx index 7422f09ebb..bb3e322381 100644 --- a/apps/packages/ui/src/components/Option/Watchlists/SourcesTab/SourcesTab.tsx +++ b/apps/packages/ui/src/components/Option/Watchlists/SourcesTab/SourcesTab.tsx @@ -55,6 +55,7 @@ import type { SourceType } from "@/types/watchlists" import { formatRelativeTime } from "@/utils/dateFormatters" +import { safeExternalUrl } from "@/utils/safe-external-url" import { SourceFormModal } from "./SourceFormModal" import { GroupsTree } from "./GroupsTree" import { SourcesBulkImport } from "./SourcesBulkImport" @@ -1239,14 +1240,16 @@ export const SourcesTab: React.FC = () => { dataIndex: "name", key: "name", width: 260, - render: (name: string, record) => ( + render: (name: string, record) => { + const safeUrl = safeExternalUrl(record.url) + return (
{name} - {record.url && ( + {safeUrl && ( {
)}
- ) + ) + } }, { title: t("watchlists:sources.columns.type", "Type"), @@ -1494,6 +1498,7 @@ export const SourcesTab: React.FC = () => { const targetIds = resolveCheckNowTargetIds(source.id) const checkNowLoading = targetIds.some((id) => checkingSourceIds.includes(id)) const isSelected = selectedRowKeys.some((key) => String(key) === String(source.id)) + const sourceSafeUrl = safeExternalUrl(source.url) return (
{ /> {source.name} - {source.url ? ( + {sourceSafeUrl ? ( - {source.url} + {sourceSafeUrl} ) : null} diff --git a/apps/packages/ui/src/components/Sidepanel/Chat/TtsClipsDrawer.tsx b/apps/packages/ui/src/components/Sidepanel/Chat/TtsClipsDrawer.tsx index 652252925d..5fe4261c1c 100644 --- a/apps/packages/ui/src/components/Sidepanel/Chat/TtsClipsDrawer.tsx +++ b/apps/packages/ui/src/components/Sidepanel/Chat/TtsClipsDrawer.tsx @@ -132,6 +132,10 @@ export const TtsClipsDrawer: React.FC = ({ open, onClose }) return } + // Stop/abort any clip that is already playing before starting a different + // one, otherwise both play simultaneously. + stopPlayback() + const ordered = [...clip.segments].sort((a, b) => a.index - b.index) if (!ordered.length) return diff --git a/apps/packages/ui/src/components/Sidepanel/Chat/__tests__/TtsClipsDrawer.overlap.test.tsx b/apps/packages/ui/src/components/Sidepanel/Chat/__tests__/TtsClipsDrawer.overlap.test.tsx new file mode 100644 index 0000000000..9a8b183fa6 --- /dev/null +++ b/apps/packages/ui/src/components/Sidepanel/Chat/__tests__/TtsClipsDrawer.overlap.test.tsx @@ -0,0 +1,112 @@ +import React from "react" +import { act, fireEvent, render } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { TtsClipsDrawer } from "@/components/Sidepanel/Chat/TtsClipsDrawer" + +const h = vi.hoisted(() => ({ clips: [] as any[] })) + +vi.mock("dexie-react-hooks", () => ({ + useLiveQuery: () => h.clips +})) + +vi.mock("@/db/dexie/schema", () => ({ db: { ttsClips: {} } })) + +vi.mock("@/db/dexie/tts-clips", () => ({ + clearTtsClips: vi.fn(), + deleteTtsClip: vi.fn() +})) + +vi.mock("@/utils/download-blob", () => ({ downloadBlob: vi.fn() })) + +vi.mock("@/hooks/useAntdNotification", () => ({ + useAntdNotification: () => ({ error: vi.fn(), warning: vi.fn() }) +})) + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: unknown) => + typeof fallback === "string" ? fallback : key + }) +})) + +// Audio whose play() never settles so a clip stays "playing" until aborted, +// letting us reproduce the overlapping-playback scenario. +class HangingAudio { + src: string + playbackRate = 1 + currentTime = 0 + onended: (() => void) | null = null + onerror: (() => void) | null = null + error: unknown = null + constructor(src?: string) { + this.src = src ?? "" + } + play() { + return new Promise(() => {}) + } + pause() {} +} + +const makeClip = (id: string, createdAt: number) => ({ + id, + createdAt, + provider: "tldw", + voice: "Bella", + playbackSpeed: 1, + utterance: `Clip ${id}`, + textPreview: `Clip ${id}`, + segments: [ + { + id: `${id}:0`, + index: 0, + text: id, + format: "mp3", + mimeType: "audio/mpeg", + blob: new Blob([id]), + sizeBytes: 1 + } + ] +}) + +describe("TtsClipsDrawer overlapping playback", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + h.clips = [] + }) + + it("stops/aborts the currently-playing clip before starting a different one", async () => { + h.clips = [makeClip("A", 2), makeClip("B", 1)] + + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:clip") + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}) + vi.stubGlobal("Audio", HangingAudio) + const abortSpy = vi.spyOn(AbortController.prototype, "abort") + + render( {}} />) + + const rows = Array.from( + document.querySelectorAll(".rounded-xl") + ) + expect(rows.length).toBe(2) + + const playA = rows[0].querySelector("button") as HTMLButtonElement + const playB = rows[1].querySelector("button") as HTMLButtonElement + expect(playA).toBeTruthy() + expect(playB).toBeTruthy() + + // Start clip A. It stays "playing" because play() never settles. + await act(async () => { + fireEvent.click(playA) + }) + expect(abortSpy).not.toHaveBeenCalled() + + // Start clip B. The previously playing clip A must be aborted first. + await act(async () => { + fireEvent.click(playB) + }) + + expect(abortSpy).toHaveBeenCalled() + }) +}) diff --git a/apps/packages/ui/src/components/Sidepanel/Chat/hooks/useRagResultsDisplay.tsx b/apps/packages/ui/src/components/Sidepanel/Chat/hooks/useRagResultsDisplay.tsx index 988b04551e..1b1ca71c59 100644 --- a/apps/packages/ui/src/components/Sidepanel/Chat/hooks/useRagResultsDisplay.tsx +++ b/apps/packages/ui/src/components/Sidepanel/Chat/hooks/useRagResultsDisplay.tsx @@ -6,6 +6,7 @@ import { type RagPinnedResult } from "@/utils/rag-format" import { withFullMediaTextIfAvailable } from "@/components/Knowledge/hooks" +import { openExternalUrl } from "@/utils/safe-external-url" import type { RagSettings } from "@/services/rag/unified-rag" import type { MenuProps } from "antd" @@ -190,7 +191,7 @@ export function useRagResultsDisplay(deps: UseRagResultsDisplayDeps) { const handleOpen = (item: RagResult) => { const url = getResultUrl(item) if (!url) return - window.open(String(url), "_blank") + openExternalUrl(String(url), "_blank") } const handlePin = (item: RagResult) => { diff --git a/apps/packages/ui/src/db/dexie/__tests__/message-target-by-id.test.ts b/apps/packages/ui/src/db/dexie/__tests__/message-target-by-id.test.ts new file mode 100644 index 0000000000..eb1c676560 --- /dev/null +++ b/apps/packages/ui/src/db/dexie/__tests__/message-target-by-id.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +// ── In-memory Dexie message store shared with the mocked PageAssistDatabase ── +// TASK-12104: in a greeting-led character chat the greeting seed sits at UI +// index 0 but is never written to Dexie, so the UI list [greeting, user, +// assistant] and the Dexie list [user, assistant] are off by one. These tests +// prove the id-addressed helpers target the correct stored row regardless of +// that offset, and contrast them with the buggy index-addressed helpers. + +const { store } = vi.hoisted(() => { + const store = { messages: [] as any[] } + return { store } +}) + +vi.mock("@/db/dexie/chat", () => { + class PageAssistDatabase { + async getChatHistory(history_id: string) { + return store.messages + .filter((m) => m.history_id === history_id) + .map((m) => ({ ...m })) + } + async removeMessage(_history_id: string, message_id: string) { + const idx = store.messages.findIndex((m) => m.id === message_id) + if (idx >= 0) store.messages.splice(idx, 1) + } + async updateMessage(_history_id: string, message_id: string, content: string) { + const target = store.messages.find((m) => m.id === message_id) + if (target) target.content = content + } + } + return { PageAssistDatabase } +}) + +import { + removeMessageById, + updateMessageById, + deleteChatAfterMessageId, + removeMessageByIndex, + updateMessageByIndex +} from "../helpers" + +const HISTORY_ID = "history-1" + +// Dexie holds only the persisted user + assistant rows (no greeting). +const seedDexie = () => { + store.messages = [ + { + id: "user-1", + history_id: HISTORY_ID, + role: "user", + content: "hello", + createdAt: 1000 + }, + { + id: "assistant-1", + history_id: HISTORY_ID, + role: "assistant", + content: "hi there", + createdAt: 2000 + } + ] +} + +// The UI list carries the non-persisted greeting seed at index 0, so UI indexes +// are offset by one relative to Dexie. +const uiMessages = [ + { id: "greeting-1", role: "assistant", message: "Greetings, traveler." }, + { id: "user-1", role: "user", message: "hello" }, + { id: "assistant-1", role: "assistant", message: "hi there" } +] + +describe("TASK-12104 message targeting by stable id", () => { + beforeEach(() => { + seedDexie() + }) + + it("deleting the user bubble (UI index 1) removes the user row and preserves the assistant row", async () => { + // deleteMessage resolves the target's stable id from the UI list. + const targetId = uiMessages[1].id + + await removeMessageById(HISTORY_ID, targetId) + + const remaining = store.messages.map((m) => m.id) + expect(remaining).toContain("assistant-1") + expect(remaining).not.toContain("user-1") + }) + + it("deleting the greeting bubble (UI index 0) does not touch any Dexie row", async () => { + const targetId = uiMessages[0].id // greeting is not persisted + + await removeMessageById(HISTORY_ID, targetId) + + expect(store.messages.map((m) => m.id)).toEqual(["user-1", "assistant-1"]) + }) + + it("editing the user bubble updates the user row, not the assistant row", async () => { + const targetId = uiMessages[1].id + + await updateMessageById(HISTORY_ID, targetId, "hello (edited)") + + const user = store.messages.find((m) => m.id === "user-1") + const assistant = store.messages.find((m) => m.id === "assistant-1") + expect(user?.content).toBe("hello (edited)") + expect(assistant?.content).toBe("hi there") + }) + + it("deleteChatAfterMessageId removes rows after the edited user row (regenerate flow)", async () => { + const targetId = uiMessages[1].id + + await deleteChatAfterMessageId(HISTORY_ID, targetId) + + // Assistant row (which followed the user row) is cleared for regeneration. + expect(store.messages.map((m) => m.id)).toEqual(["user-1"]) + }) + + it("regression: the old index-addressed helpers corrupt the wrong row under a greeting offset", async () => { + // Passing the UI index (1) into the index helper hits Dexie[1] = assistant. + await removeMessageByIndex(HISTORY_ID, 1) + expect(store.messages.map((m) => m.id)).toEqual(["user-1"]) // assistant wrongly removed + + seedDexie() + await updateMessageByIndex(HISTORY_ID, 1, "hello (edited)") + const assistant = store.messages.find((m) => m.id === "assistant-1") + expect(assistant?.content).toBe("hello (edited)") // assistant wrongly overwritten + }) +}) diff --git a/apps/packages/ui/src/db/dexie/helpers.ts b/apps/packages/ui/src/db/dexie/helpers.ts index 45dabfbe65..cdefc1eedd 100644 --- a/apps/packages/ui/src/db/dexie/helpers.ts +++ b/apps/packages/ui/src/db/dexie/helpers.ts @@ -412,6 +412,55 @@ export const removeMessageByIndex = async ( } } +// Id-addressed variants (TASK-12104). Prefer these over the index-addressed +// helpers above: the UI message list can contain non-persisted seed rows (e.g. +// a character greeting at UI index 0) that are absent from Dexie, so an array +// index does not line up with the Dexie row order. Addressing by the stable +// message id avoids deleting/overwriting the wrong row. Rows whose id is not in +// Dexie (like an unsaved greeting) are simply left untouched. +export const updateMessageById = async ( + history_id: string, + message_id: string, + message: string +) => { + try { + const db = new PageAssistDatabase() + await db.updateMessage(history_id, message_id, message) + } catch { + // temp chat will break + } +} + +export const removeMessageById = async ( + history_id: string, + message_id: string +) => { + try { + const db = new PageAssistDatabase() + await db.removeMessage(history_id, message_id) + } catch { + // temp chat will break + } +} + +// Delete every persisted message ordered after `message_id` (used by the edit + +// regenerate flow). Falls back to a no-op when the id is not found in Dexie. +export const deleteChatAfterMessageId = async ( + history_id: string, + message_id: string +) => { + const db = new PageAssistDatabase() + const chatHistory = await db.getChatHistory(history_id) + const sortedHistory = chatHistory.sort((a, b) => a.createdAt - b.createdAt) + const startIndex = sortedHistory.findIndex((m) => m.id === message_id) + if (startIndex < 0) return + + const messagesToDelete = sortedHistory.slice(startIndex + 1) + for (const message of messagesToDelete) { + await db.removeMessage(history_id, message.id) + } +} + export const deleteChatForEdit = async (history_id: string, index: number) => { const db = new PageAssistDatabase() const chatHistory = await db.getChatHistory(history_id) diff --git a/apps/packages/ui/src/entries/__tests__/background-session-store.test.ts b/apps/packages/ui/src/entries/__tests__/background-session-store.test.ts new file mode 100644 index 0000000000..ac4a39bc5a --- /dev/null +++ b/apps/packages/ui/src/entries/__tests__/background-session-store.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, it } from "vitest" +import { + SESSION_STATE_STORAGE_KEY, + createSerializedSessionStateWriter, + deserializeSessionState, + emptySessionState, + isInterruptedPreSubmissionIngestSession, + readPersistedSessionState, + selectInterruptedIngestFunnelIds, + serializeIngestSessions, + serializePendingReplay, + serializeQuickIngestBatches, + serializeQuickIngestSessions, + writePersistedSessionState, + type PersistedQuickIngestBatch, + type PersistedSessionState, + type SessionStorageArea +} from "@/entries/background-session-store" + +const createMemoryArea = (): SessionStorageArea & { + store: Record +} => { + const store: Record = {} + return { + store, + get: async (key: string) => + key in store ? { [key]: store[key] } : {}, + set: async (items: Record) => { + Object.assign(store, items) + } + } +} + +describe("background-session-store serialization", () => { + it("round-trips ingest sessions, pending replay, and quick-ingest sessions", () => { + const ingest = new Map>([ + [ + "ingest-1", + { + funnelId: "ingest-1", + url: "https://example.test/a", + status: "queued", + jobIds: [11, 22] + } + ] + ]) + const replay = new Set(["ingest-1", "ingest-1", " "]) + const quick = new Map< + string, + { sessionId: string; cancelled: boolean; abortControllers: Set } + >([ + [ + "qi-1", + { sessionId: "qi-1", cancelled: true, abortControllers: new Set() } + ] + ]) + + const state = { + ingestSessions: serializeIngestSessions(ingest), + pendingAuthReplay: serializePendingReplay(replay), + quickIngestSessions: serializeQuickIngestSessions(quick) + } + + // AbortControllers are dropped (not serializable). + expect(state.quickIngestSessions).toEqual([ + { sessionId: "qi-1", cancelled: true } + ]) + // Blank + duplicate replay ids are collapsed. + expect(state.pendingAuthReplay).toEqual(["ingest-1"]) + + const restored = deserializeSessionState(state) + expect(restored.ingestSessions["ingest-1"]).toMatchObject({ + funnelId: "ingest-1", + status: "queued", + jobIds: [11, 22] + }) + expect(restored.pendingAuthReplay).toEqual(["ingest-1"]) + expect(restored.quickIngestSessions).toEqual([ + { sessionId: "qi-1", cancelled: true } + ]) + }) + + it("deserializes malformed input to an empty state", () => { + expect(deserializeSessionState(null)).toEqual(emptySessionState()) + expect(deserializeSessionState("nope")).toEqual(emptySessionState()) + expect( + deserializeSessionState({ ingestSessions: 5, pendingAuthReplay: {} }) + ).toEqual(emptySessionState()) + }) + + it("persists to and rehydrates from an injected storage area", async () => { + const area = createMemoryArea() + const written = { + ingestSessions: { + "ingest-9": { funnelId: "ingest-9", status: "running", jobIds: [7] } + }, + pendingAuthReplay: ["ingest-9"], + quickIngestSessions: [] + } + + await writePersistedSessionState(written, area) + expect(area.store[SESSION_STATE_STORAGE_KEY]).toEqual(written) + + const restored = await readPersistedSessionState(area) + expect(restored.ingestSessions["ingest-9"]).toMatchObject({ + status: "running", + jobIds: [7] + }) + expect(restored.pendingAuthReplay).toEqual(["ingest-9"]) + }) + + it("returns empty state when no storage area is available", async () => { + expect(await readPersistedSessionState(null)).toEqual(emptySessionState()) + // Should not throw when writing without an area. + await expect( + writePersistedSessionState(emptySessionState(), null) + ).resolves.toBeUndefined() + }) + + it("defaults quickIngestBatches to an empty array for legacy persisted state", () => { + const restored = deserializeSessionState({ + ingestSessions: {}, + pendingAuthReplay: [], + quickIngestSessions: [] + }) + expect(restored.quickIngestBatches).toEqual([]) + }) +}) + +describe("quick-ingest batch resume persistence", () => { + const buildBatch = (): PersistedQuickIngestBatch => ({ + sessionId: "qi-batch-1", + totalCount: 3, + processedCount: 1, + ingestTimeoutMs: 300000, + remoteJobs: [ + { jobId: 11, batchId: "batch-a", meta: { id: "e1", type: "video", url: "https://x.test/a" } }, + { jobId: 12, batchId: "batch-a", meta: { id: "e2", type: "audio", fileName: "clip.wav" } } + ], + collectedResults: [{ id: "e0", status: "ok", type: "html" }], + plannedConferenceItems: [ + { key: "e1", collectionId: 7, itemId: 70, idempotencyKey: "idem-1" } + ] + }) + + it("serializes a batch record map and drops malformed remote jobs", () => { + const map = new Map([ + ["qi-batch-1", buildBatch()], + // Malformed: no sessionId -> dropped entirely. + ["", { ...buildBatch(), sessionId: "" }] + ]) + // Inject a malformed remote job that must be pruned. + map.get("qi-batch-1")!.remoteJobs.push({ + jobId: 0, + batchId: "", + meta: {} + } as never) + + const serialized = serializeQuickIngestBatches(map) + expect(serialized).toHaveLength(1) + expect(serialized[0].sessionId).toBe("qi-batch-1") + // The jobId:0/batchId:"" entry is dropped; the two valid jobs survive. + expect(serialized[0].remoteJobs.map((j) => j.jobId)).toEqual([11, 12]) + }) + + it("round-trips a batch through persist -> restart -> rehydrate", async () => { + const area = createMemoryArea() + const batches = serializeQuickIngestBatches([buildBatch()]) + + await writePersistedSessionState( + { ...emptySessionState(), quickIngestBatches: batches }, + area + ) + + // Simulate a fresh worker: read the persisted state back from storage. + const restored = await readPersistedSessionState(area) + expect(restored.quickIngestBatches).toHaveLength(1) + const batch = restored.quickIngestBatches[0] + expect(batch.sessionId).toBe("qi-batch-1") + // Progress cursor + queued/remote job ids survive so the poll can resume. + expect(batch.processedCount).toBe(1) + expect(batch.totalCount).toBe(3) + expect(batch.remoteJobs.map((j) => j.jobId)).toEqual([11, 12]) + expect(batch.remoteJobs[0].meta).toMatchObject({ id: "e1", type: "video" }) + expect(batch.collectedResults).toEqual([ + { id: "e0", status: "ok", type: "html" } + ]) + expect(batch.plannedConferenceItems[0]).toMatchObject({ + key: "e1", + collectionId: 7, + itemId: 70, + idempotencyKey: "idem-1" + }) + }) +}) + +describe("interrupted pre-submission ingest sessions", () => { + it("classifies a queued no-jobIds session as interrupted", () => { + expect( + isInterruptedPreSubmissionIngestSession({ + funnelId: "ingest-1", + status: "queued", + jobIds: [] + }) + ).toBe(true) + expect( + isInterruptedPreSubmissionIngestSession({ + funnelId: "ingest-2", + status: "running" + // jobIds absent entirely + }) + ).toBe(true) + }) + + it("does not classify resumable, awaiting-auth, or terminal sessions", () => { + // Has jobIds -> resumable by the poll, not interrupted. + expect( + isInterruptedPreSubmissionIngestSession({ + status: "queued", + jobIds: [7] + }) + ).toBe(false) + // Awaiting auth -> driven by the auth-replay path. + expect( + isInterruptedPreSubmissionIngestSession({ + status: "running", + jobIds: [], + awaitingAuth: true + }) + ).toBe(false) + // Terminal states are already reported. + expect( + isInterruptedPreSubmissionIngestSession({ status: "failed", jobIds: [] }) + ).toBe(false) + expect( + isInterruptedPreSubmissionIngestSession({ + status: "completed", + jobIds: [] + }) + ).toBe(false) + expect(isInterruptedPreSubmissionIngestSession(null)).toBe(false) + expect(isInterruptedPreSubmissionIngestSession(undefined)).toBe(false) + }) + + it("selects only stranded funnelIds and excludes auth-replay ids", () => { + const ingestSessions = { + "ingest-stranded": { + funnelId: "ingest-stranded", + status: "queued", + jobIds: [] + }, + "ingest-polling": { + funnelId: "ingest-polling", + status: "running", + jobIds: [42] + }, + "ingest-auth": { + funnelId: "ingest-auth", + status: "queued", + jobIds: [] + } + } + // ingest-auth is queued for auth replay; it must not be reported failed. + const selected = selectInterruptedIngestFunnelIds(ingestSessions, [ + "ingest-auth" + ]) + expect(selected).toEqual(["ingest-stranded"]) + }) + + it("reports + clears a persisted no-jobIds queued session on resume", async () => { + // Mirror the background rehydrate flow: read persisted state, select the + // stranded funnelIds, then run the report-and-clear loop against a live Map. + const area = createMemoryArea() + await writePersistedSessionState( + { + ...emptySessionState(), + ingestSessions: { + "ingest-stuck": { + funnelId: "ingest-stuck", + url: "https://example.test/stuck", + status: "queued", + jobIds: [] + } + } + }, + area + ) + + const restored = await readPersistedSessionState(area) + const funnelIds = selectInterruptedIngestFunnelIds( + restored.ingestSessions, + restored.pendingAuthReplay + ) + expect(funnelIds).toEqual(["ingest-stuck"]) + + // Simulate a fresh worker's in-memory Map + reporter. + const live = new Map>( + Object.entries(restored.ingestSessions) + ) + const reported: Array<{ funnelId: string; status: string }> = [] + for (const funnelId of funnelIds) { + reported.push({ funnelId, status: "failed" }) + live.delete(funnelId) + } + + expect(reported).toEqual([{ funnelId: "ingest-stuck", status: "failed" }]) + // Session is cleared so the sidepanel is not left stuck. + expect(live.has("ingest-stuck")).toBe(false) + }) +}) + +describe("serialized session state writer", () => { + const versioned = (tag: string): PersistedSessionState => ({ + ...emptySessionState(), + pendingAuthReplay: [tag] + }) + + it("coalesces rapid persists and ends on the latest snapshot", async () => { + const store: Record = {} + const setOrder: string[] = [] + let setCount = 0 + // The first write is deliberately SLOW; a naive parallel writer would let + // that stale early snapshot land last. The latch must serialize so only the + // newest snapshot ("v5") is written last. + const area: SessionStorageArea = { + get: async (key) => (key in store ? { [key]: store[key] } : {}), + set: async (items) => { + setCount++ + const state = items[SESSION_STATE_STORAGE_KEY] as PersistedSessionState + const tag = state.pendingAuthReplay[0] + await new Promise((r) => setTimeout(r, tag === "v1" ? 25 : 1)) + Object.assign(store, items) + setOrder.push(tag) + } + } + + const write = createSerializedSessionStateWriter(area) + for (let i = 1; i <= 5; i++) { + write(versioned(`v${i}`)) + } + // Let the write chain drain. + await new Promise((r) => setTimeout(r, 60)) + + const stored = store[SESSION_STATE_STORAGE_KEY] as PersistedSessionState + expect(stored.pendingAuthReplay).toEqual(["v5"]) + // Only the in-flight first write + one coalesced final write happen. + expect(setOrder).toEqual(["v1", "v5"]) + expect(setCount).toBe(2) + }) + + it("no-ops without a storage area and reports write errors", async () => { + // No area -> writePersistedSessionState no-ops; must not throw. + const writeNull = createSerializedSessionStateWriter(null) + expect(() => writeNull(emptySessionState())).not.toThrow() + + const errors: unknown[] = [] + const failingArea: SessionStorageArea = { + get: async () => ({}), + set: async () => { + throw new Error("boom") + } + } + const write = createSerializedSessionStateWriter(failingArea, (e) => + errors.push(e) + ) + write(versioned("v1")) + await new Promise((r) => setTimeout(r, 5)) + expect(errors).toHaveLength(1) + expect((errors[0] as Error).message).toBe("boom") + }) +}) diff --git a/apps/packages/ui/src/entries/background-session-store.ts b/apps/packages/ui/src/entries/background-session-store.ts new file mode 100644 index 0000000000..1aad86c8a1 --- /dev/null +++ b/apps/packages/ui/src/entries/background-session-store.ts @@ -0,0 +1,356 @@ +// Durable, worker-survivable storage for MV3 background session state. +// +// Chrome suspends idle MV3 service workers (~30s), which wipes the in-memory +// Maps/Set that `entries/background.ts` uses to track in-flight ingest +// sessions, pending 401 auth-replays, and quick-ingest modal sessions. This +// module serialises that state to `chrome.storage.session` (falling back to +// `chrome.storage.local` when session storage is unavailable) so it can be +// rehydrated when the worker restarts. +// +// The serialise/deserialise helpers are pure (no browser dependency) so they +// are directly unit-testable; the async read/write wrappers accept an injected +// storage area for the same reason. + +export type PersistedIngestSession = Record + +export type PersistedQuickIngestSession = { + sessionId: string + cancelled: boolean +} + +export type PersistedQuickIngestRemoteJob = { + jobId: number + batchId: string + meta: Record +} + +export type PersistedQuickIngestPlannedItem = { + key: string + collectionId: number + itemId: number + idempotencyKey?: string | null +} + +// A quick-ingest batch that has finished submitting its remote ingest jobs and +// is (or was) polling them to completion. Persisted so the remote-job polling +// phase can be resumed and finalized after an MV3 worker restart, even though +// the in-flight multipart UPLOAD phase that produced these job ids cannot be +// resumed (there is no live fetch to abort/resume). +export type PersistedQuickIngestBatch = { + sessionId: string + totalCount: number + processedCount: number + ingestTimeoutMs: number + remoteJobs: PersistedQuickIngestRemoteJob[] + collectedResults: Array> + plannedConferenceItems: PersistedQuickIngestPlannedItem[] +} + +export type PersistedSessionState = { + ingestSessions: Record + pendingAuthReplay: string[] + quickIngestSessions: PersistedQuickIngestSession[] + quickIngestBatches: PersistedQuickIngestBatch[] +} + +export const SESSION_STATE_STORAGE_KEY = "tldw:backgroundSessionStateV1" + +export type SessionStorageArea = { + get: (key: string) => Promise> + set: (items: Record) => Promise +} + +export const emptySessionState = (): PersistedSessionState => ({ + ingestSessions: {}, + pendingAuthReplay: [], + quickIngestSessions: [], + quickIngestBatches: [] +}) + +const toFiniteInt = (value: unknown): number | null => { + const parsed = Number(value) + if (!Number.isFinite(parsed)) return null + return Math.trunc(parsed) +} + +// Validate + normalize a single quick-ingest batch record. Shared by the +// serialize (Map -> array) and deserialize (storage -> array) paths so both drop +// the same malformed data instead of persisting/rehydrating junk. +const normalizeQuickIngestBatch = ( + value: unknown +): PersistedQuickIngestBatch | null => { + if (!value || typeof value !== "object") return null + const record = value as Record + const sessionId = String(record.sessionId || "").trim() + if (!sessionId) return null + + const remoteJobs: PersistedQuickIngestRemoteJob[] = [] + if (Array.isArray(record.remoteJobs)) { + for (const raw of record.remoteJobs) { + if (!raw || typeof raw !== "object") continue + const job = raw as Record + const jobId = toFiniteInt(job.jobId) + const batchId = String(job.batchId || "").trim() + if (jobId == null || jobId <= 0 || !batchId) continue + remoteJobs.push({ + jobId, + batchId, + meta: + job.meta && typeof job.meta === "object" + ? (job.meta as Record) + : {} + }) + } + } + + const plannedConferenceItems: PersistedQuickIngestPlannedItem[] = [] + if (Array.isArray(record.plannedConferenceItems)) { + for (const raw of record.plannedConferenceItems) { + if (!raw || typeof raw !== "object") continue + const item = raw as Record + const collectionId = toFiniteInt(item.collectionId) + const itemId = toFiniteInt(item.itemId) + if (collectionId == null || itemId == null) continue + plannedConferenceItems.push({ + key: String(item.key || "").trim(), + collectionId, + itemId, + idempotencyKey: + typeof item.idempotencyKey === "string" ? item.idempotencyKey : null + }) + } + } + + const collectedResults = Array.isArray(record.collectedResults) + ? (record.collectedResults.filter( + (entry) => entry && typeof entry === "object" + ) as Array>) + : [] + + return { + sessionId, + totalCount: Math.max(0, toFiniteInt(record.totalCount) ?? 0), + processedCount: Math.max(0, toFiniteInt(record.processedCount) ?? 0), + ingestTimeoutMs: Math.max(0, toFiniteInt(record.ingestTimeoutMs) ?? 0), + remoteJobs, + collectedResults, + plannedConferenceItems + } +} + +export const serializeIngestSessions = ( + sessions: Map +): Record => { + const out: Record = {} + for (const [key, value] of sessions) { + const funnelId = String(key || "").trim() + if (funnelId && value && typeof value === "object") { + out[funnelId] = value + } + } + return out +} + +export const serializePendingReplay = (ids: Set): string[] => + Array.from(ids, (id) => String(id || "").trim()).filter( + (id) => id.length > 0 + ) + +export const serializeQuickIngestSessions = ( + sessions: Map +): PersistedQuickIngestSession[] => { + const out: PersistedQuickIngestSession[] = [] + for (const [key, value] of sessions) { + const sessionId = String(value?.sessionId || key || "").trim() + if (sessionId) { + out.push({ sessionId, cancelled: Boolean(value?.cancelled) }) + } + } + return out +} + +export const serializeQuickIngestBatches = ( + batches: + | Map + | Iterable +): PersistedQuickIngestBatch[] => { + const source = + batches instanceof Map ? Array.from(batches.values()) : Array.from(batches) + const out: PersistedQuickIngestBatch[] = [] + for (const batch of source) { + const normalized = normalizeQuickIngestBatch(batch) + if (normalized) out.push(normalized) + } + return out +} + +export const deserializeSessionState = (raw: unknown): PersistedSessionState => { + const state = emptySessionState() + if (!raw || typeof raw !== "object") return state + const record = raw as Record + + const ingest = record.ingestSessions + if (ingest && typeof ingest === "object") { + for (const [key, value] of Object.entries( + ingest as Record + )) { + const funnelId = String(key || "").trim() + if (funnelId && value && typeof value === "object") { + state.ingestSessions[funnelId] = value as PersistedIngestSession + } + } + } + + if (Array.isArray(record.pendingAuthReplay)) { + const seen = new Set() + for (const entry of record.pendingAuthReplay) { + const id = String(entry || "").trim() + if (id && !seen.has(id)) { + seen.add(id) + state.pendingAuthReplay.push(id) + } + } + } + + if (Array.isArray(record.quickIngestSessions)) { + for (const entry of record.quickIngestSessions) { + if (entry && typeof entry === "object") { + const sessionId = String( + (entry as Record).sessionId || "" + ).trim() + if (sessionId) { + state.quickIngestSessions.push({ + sessionId, + cancelled: Boolean((entry as Record).cancelled) + }) + } + } + } + } + + if (Array.isArray(record.quickIngestBatches)) { + for (const entry of record.quickIngestBatches) { + const normalized = normalizeQuickIngestBatch(entry) + if (normalized) state.quickIngestBatches.push(normalized) + } + } + + return state +} + +// Resolve a promise-based storage area, preferring session storage (cleared on +// browser restart, but survives service-worker suspension) and falling back to +// local storage. Returns null when neither is available (e.g. non-extension +// contexts) so callers can no-op gracefully. +export const getSessionStorageArea = (): SessionStorageArea | null => { + try { + const chromeApi = (globalThis as { chrome?: any }).chrome + const area = chromeApi?.storage?.session || chromeApi?.storage?.local + if (area && typeof area.get === "function" && typeof area.set === "function") { + return area as SessionStorageArea + } + } catch { + // fall through + } + return null +} + +export const readPersistedSessionState = async ( + area: SessionStorageArea | null = getSessionStorageArea() +): Promise => { + if (!area) return emptySessionState() + try { + const result = await area.get(SESSION_STATE_STORAGE_KEY) + return deserializeSessionState(result?.[SESSION_STATE_STORAGE_KEY]) + } catch { + return emptySessionState() + } +} + +export const writePersistedSessionState = async ( + state: PersistedSessionState, + area: SessionStorageArea | null = getSessionStorageArea() +): Promise => { + if (!area) return + await area.set({ [SESSION_STATE_STORAGE_KEY]: state }) +} + +// True when a persisted ingest session is one a restarted worker cannot resume: +// it is still in a pre-submission lifecycle state (queued/running) but has no +// jobIds to poll and is not awaiting an auth replay. Such a session was +// interrupted (e.g. mid "Checking for existing media...") before its +// non-resumable job submission completed, so it must be reported as failed — +// otherwise the sidepanel is left waiting forever. +export const isInterruptedPreSubmissionIngestSession = ( + session: PersistedIngestSession | null | undefined +): boolean => { + if (!session || typeof session !== "object") return false + const record = session as Record + const status = record.status + if (status !== "queued" && status !== "running") return false + if (record.awaitingAuth === true) return false + const jobIds = record.jobIds + if (Array.isArray(jobIds) && jobIds.length > 0) return false + return true +} + +// Given the persisted ingest sessions and the pending-auth-replay ids, return +// the funnelIds that were interrupted mid pre-submission and therefore cannot be +// resumed by the jobId-driven poll. Auth-replay ids are excluded because those +// sessions are re-driven by the auth replay path once credentials return. +export const selectInterruptedIngestFunnelIds = ( + ingestSessions: Record, + pendingAuthReplay: Iterable = [] +): string[] => { + const replay = new Set() + for (const id of pendingAuthReplay) { + const trimmed = String(id || "").trim() + if (trimmed) replay.add(trimmed) + } + const out: string[] = [] + if (ingestSessions && typeof ingestSessions === "object") { + for (const [funnelId, session] of Object.entries(ingestSessions)) { + if (replay.has(funnelId)) continue + if (isInterruptedPreSubmissionIngestSession(session)) out.push(funnelId) + } + } + return out +} + +// Serialize fire-and-forget writes to a single storage area so rapid, overlapping +// persists cannot land out of order (an older snapshot overwriting a newer one). +// Each call records the latest snapshot; while a write is in flight, further +// calls only update the pending snapshot, and one final write is issued with the +// newest snapshot once the in-flight write settles ("write again if dirty"). +// Callers are never blocked — the returned function is synchronous. +export const createSerializedSessionStateWriter = ( + area: SessionStorageArea | null = getSessionStorageArea(), + onError?: (error: unknown) => void +): ((state: PersistedSessionState) => void) => { + let pending: PersistedSessionState | null = null + let inFlight: Promise | null = null + + const drain = async (): Promise => { + // Keep writing while newer snapshots have arrived. There is no `await` + // between the loop's `pending` check and clearing `inFlight`, so a caller + // can only ever run while we are suspended on the write below (inFlight set) + // or once we are fully idle (inFlight null) — never in between. + while (pending) { + const next = pending + pending = null + try { + await writePersistedSessionState(next, area) + } catch (error) { + onError?.(error) + } + } + inFlight = null + } + + return (state: PersistedSessionState) => { + pending = state + if (!inFlight) { + inFlight = drain() + } + } +} diff --git a/apps/packages/ui/src/entries/background.ts b/apps/packages/ui/src/entries/background.ts index a14502b265..114b7ab0c2 100644 --- a/apps/packages/ui/src/entries/background.ts +++ b/apps/packages/ui/src/entries/background.ts @@ -65,6 +65,21 @@ import { buildConferenceCollectionCreatePayload, buildConferenceCollectionItemPayload, } from "@/services/tldw/conference-collections"; +import { + ABSOLUTE_URL_BLOCK_ERROR, + evaluateAbsoluteUrlAccess, +} from "@/utils/absolute-url-guard"; +import { + createSerializedSessionStateWriter, + getSessionStorageArea, + readPersistedSessionState, + selectInterruptedIngestFunnelIds, + serializeIngestSessions, + serializePendingReplay, + serializeQuickIngestBatches, + serializeQuickIngestSessions, + type PersistedQuickIngestBatch, +} from "@/entries/background-session-store"; type BackgroundDiagnostics = { startedAt: number; @@ -286,6 +301,16 @@ type QuickIngestModalSession = { abortControllers: Set; }; +// Metadata carried alongside each remotely-queued ingest job so results can be +// mapped back to their originating entry/file. Defined at module scope so both +// the live quick-ingest run and the post-restart resume path share one type. +type QuickIngestRemoteResultMeta = { + id: string; + type: string; + url?: string; + fileName?: string; +}; + const warmModels = async ( force = false, throwOnError = false, @@ -407,6 +432,9 @@ export default defineBackground({ err, ); }); + // Rehydrate persisted ingest/auth-replay/quick-ingest session state and + // resume any polling interrupted by a worker suspension (TASK-12094). + void rehydrateSessionState(); } catch (error) { console.error("Error in initLogic:", error); } @@ -444,6 +472,112 @@ export default defineBackground({ const ingestSessions = new Map(); const pendingAuthReplay = new Set(); const quickIngestModalSessions = new Map(); + // Durable per-session record of a quick-ingest batch's remote-job polling + // phase (job ids + progress cursor + already-collected results). Persisted so + // the polling phase can be resumed/finalized after a worker restart even + // though the multipart UPLOAD that produced the job ids cannot be resumed. + const quickIngestBatchRecords = new Map(); + + // MV3 worker-survivability (TASK-12094): the maps above are wiped when + // Chrome suspends an idle service worker (~30s). We mirror them into + // chrome.storage.session (fallback local) on mutation and rehydrate them on + // worker restart, and drive ingest polling from a chrome.alarms backstop so + // completed ingests still notify / cancel + retry still find the session. + const INGEST_POLL_ALARM_NAME = "tldw:ingest-poll"; + const INGEST_POLL_ALARM_PERIOD_MINUTES = 0.5; + // Backstop alarm that resumes an interrupted quick-ingest remote-job poll + // after the worker is suspended mid-batch. + const QUICK_INGEST_POLL_ALARM_NAME = "tldw:quick-ingest-poll"; + const QUICK_INGEST_POLL_ALARM_PERIOD_MINUTES = 0.5; + // Funnel ids currently being polled inside this live worker; used to avoid + // starting a duplicate poll from the alarm/rehydrate backstop. + const activeIngestPollFunnelIds = new Set(); + // Quick-ingest session ids whose remote-job poll is running (live or resumed) + // in this worker; avoids a duplicate resume from the alarm/rehydrate backstop. + const activeQuickIngestBatchSessionIds = new Set(); + let sessionStateHydrated = false; + + // Serialize the actual storage writes so that the many rapid, fire-and-forget + // persist calls below cannot land out of order (an older Map snapshot + // clobbering a newer one). The snapshot is taken synchronously at call time; + // the writer coalesces overlapping writes so only the latest snapshot wins. + const writeSessionStateSerialized = createSerializedSessionStateWriter( + getSessionStorageArea(), + (error) => logBackgroundError("persist session state", error), + ); + + const persistSessionState = (): void => { + try { + writeSessionStateSerialized({ + ingestSessions: serializeIngestSessions(ingestSessions), + pendingAuthReplay: serializePendingReplay(pendingAuthReplay), + quickIngestSessions: + serializeQuickIngestSessions(quickIngestModalSessions), + quickIngestBatches: + serializeQuickIngestBatches(quickIngestBatchRecords), + }); + } catch (error) { + logBackgroundError("persist session state", error); + } + }; + + const hasActiveIngestPollSessions = (): boolean => { + for (const session of ingestSessions.values()) { + if ( + (session.status === "queued" || session.status === "running") && + !session.awaitingAuth && + Array.isArray(session.jobIds) && + session.jobIds.length > 0 + ) { + return true; + } + } + return false; + }; + + // Keep a chrome.alarms backstop alive whenever there is an ingest still + // being polled. If the worker is suspended mid-poll, the alarm wakes it so + // polling resumes and the completed ingest still notifies the UI. + const scheduleIngestPollAlarm = async (): Promise => { + try { + if (!browser?.alarms) return; + if (!hasActiveIngestPollSessions()) { + await browser.alarms.clear(INGEST_POLL_ALARM_NAME); + return; + } + const existing = await browser.alarms + .get(INGEST_POLL_ALARM_NAME) + .catch(() => null); + if (existing) return; + await browser.alarms.create(INGEST_POLL_ALARM_NAME, { + periodInMinutes: INGEST_POLL_ALARM_PERIOD_MINUTES, + }); + } catch (error) { + logBackgroundError("schedule ingest poll alarm", error); + } + }; + + // Keep a chrome.alarms backstop alive whenever a quick-ingest batch has a + // persisted remote-job polling phase outstanding, so a suspended worker is + // woken to resume and finalize it. + const scheduleQuickIngestBatchAlarm = async (): Promise => { + try { + if (!browser?.alarms) return; + if (quickIngestBatchRecords.size === 0) { + await browser.alarms.clear(QUICK_INGEST_POLL_ALARM_NAME); + return; + } + const existing = await browser.alarms + .get(QUICK_INGEST_POLL_ALARM_NAME) + .catch(() => null); + if (existing) return; + await browser.alarms.create(QUICK_INGEST_POLL_ALARM_NAME, { + periodInMinutes: QUICK_INGEST_POLL_ALARM_PERIOD_MINUTES, + }); + } catch (error) { + logBackgroundError("schedule quick ingest poll alarm", error); + } + }; const INGEST_FUNNEL_METRICS_KEY = "tldw:ingestFunnelMetrics"; const INGEST_FUNNEL_METRICS_LIMIT = 200; @@ -660,6 +794,9 @@ export default defineBackground({ canRetry, timestampSeconds: session.timestampSeconds ?? undefined, }); + // Persist after every status change so the session survives suspension + // and cancel/retry can find it after a worker restart. + void persistSessionState(); }; const sendIngestReadyMessage = async ( @@ -1052,7 +1189,13 @@ export default defineBackground({ responseType, } = payload || {}; const cfg = await storage.get("tldwConfig"); - const isAbsolute = typeof path === "string" && /^https?:/i.test(path); + const absoluteAccess = evaluateAbsoluteUrlAccess(path, cfg); + const isAbsolute = absoluteAccess.isAbsolute; + // Mirror the request-path guard: refuse cross-origin absolute URLs that + // are not explicitly allowlisted before any fetch or credential use. + if (absoluteAccess.blocked) { + return { ok: false, status: 400, error: ABSOLUTE_URL_BLOCK_ERROR }; + } const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => { if (bytes.buffer instanceof ArrayBuffer) { return bytes.buffer.slice( @@ -1141,30 +1284,34 @@ export default defineBackground({ } } const headers: Record = {}; - if (cfg?.authMode === "single-user") { - const key = (cfg?.apiKey || "").trim(); - if (!key) { - return { - ok: false, - status: 401, - error: - "Add or update your API key in Settings → tldw server, then try again.", - }; + // Skip credentials for allowlisted-but-cross-origin absolute URLs, + // matching request-core's `shouldSkipAuth` behavior. + if (!absoluteAccess.skipAuth) { + if (cfg?.authMode === "single-user") { + const key = (cfg?.apiKey || "").trim(); + if (!key) { + return { + ok: false, + status: 401, + error: + "Add or update your API key in Settings → tldw server, then try again.", + }; + } + headers["X-API-KEY"] = key; + } + if (cfg?.authMode === "multi-user") { + const token = (cfg?.accessToken || "").trim(); + if (!token) + return { + ok: false, + status: 401, + error: "Not authenticated. Please login under Settings > tldw.", + }; + headers["Authorization"] = `Bearer ${token}`; + } + if (cfg?.orgId) { + headers["X-TLDW-Org-Id"] = String(cfg.orgId); } - headers["X-API-KEY"] = key; - } - if (cfg?.authMode === "multi-user") { - const token = (cfg?.accessToken || "").trim(); - if (!token) - return { - ok: false, - status: 401, - error: "Not authenticated. Please login under Settings > tldw.", - }; - headers["Authorization"] = `Bearer ${token}`; - } - if (cfg?.orgId) { - headers["X-TLDW-Org-Id"] = String(cfg.orgId); } const controller = new AbortController(); const quickIngestSessionId = String( @@ -1272,6 +1419,333 @@ export default defineBackground({ return await runTldwRequest(payload); }; + // Best-effort PATCH of a planned conference-collection item. Shared by the + // live quick-ingest run and the post-restart resume path. + const patchConferenceCollectionItem = async ( + planned: + | { collectionId: number; itemId: number } + | null + | undefined, + body: Record, + timeoutMs: number, + ) => { + if (!planned) return; + await handleTldwRequest({ + path: `/api/v1/media/collections/${encodeURIComponent( + String(planned.collectionId), + )}/items/${encodeURIComponent(String(planned.itemId))}`, + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body, + timeoutMs, + }).catch((error) => { + logBackgroundError("quick ingest collection item patch", error); + }); + }; + + // Cancel the outstanding remote ingest batches tracked by `tracker`. + const cancelRemoteIngestBatches = async ( + tracker: ReturnType< + typeof createIngestJobsTracker + >, + reason: string, + ) => { + await tracker.cancelTrackedBatches(async (batchId) => { + try { + await handleTldwRequest({ + path: `/api/v1/media/ingest/jobs/cancel?batch_id=${encodeURIComponent( + batchId, + )}&reason=${encodeURIComponent(reason || "user_cancelled")}`, + method: "POST", + timeoutMs: 10_000, + }); + } catch (error) { + logBackgroundError(`cancel quick ingest batch ${batchId}`, error); + } + }); + }; + + // Poll the remote ingest jobs held by `tracker` to terminal state. This is + // the single implementation used by both the live quick-ingest run and the + // resume-after-restart path, so both map results identically. + const pollRemoteIngestJobs = (opts: { + tracker: ReturnType< + typeof createIngestJobsTracker + >; + ingestTimeoutMs: number; + isCancelled: () => boolean; + onPendingJobIds?: (jobIds: number[]) => void; + }): Promise => { + return pollTrackedIngestJobs({ + tracker: opts.tracker, + timeoutMs: opts.ingestTimeoutMs, + pollIntervalMs: 1200, + isCancelled: opts.isCancelled, + onCancel: async () => { + await cancelRemoteIngestBatches(opts.tracker, "user_cancelled"); + }, + onPendingJobIds: opts.onPendingJobIds, + fetchJob: async (jobId) => + (await handleTldwRequest({ + path: `/api/v1/media/ingest/jobs/${jobId}`, + method: "GET", + timeoutMs: 4200, + })) as + | { ok: boolean; status?: number; data?: any; error?: string } + | undefined, + mapRequestError: (item, response) => { + if ( + isLikelyAuthError(Number(response?.status || 0), response?.error) + ) { + return { + id: item.meta.id, + status: "error", + url: item.meta.url, + fileName: item.meta.fileName, + type: item.meta.type, + error: response?.error || "Authentication required.", + data: undefined, + }; + } + return undefined; + }, + mapCompleted: (item, data) => ({ + id: item.meta.id, + status: "ok", + url: item.meta.url, + fileName: item.meta.fileName, + type: item.meta.type, + data, + }), + mapCancelled: (item) => ({ + id: item.meta.id, + status: "error", + url: item.meta.url, + fileName: item.meta.fileName, + type: item.meta.type, + error: "Cancelled by user.", + data: undefined, + }), + mapFailure: (item, details) => ({ + id: item.meta.id, + status: "error", + url: item.meta.url, + fileName: item.meta.fileName, + type: item.meta.type, + error: String(details.error || `Ingest ${details.status || "failed"}`), + data: details.data, + }), + }); + }; + + // Resume + finalize a quick-ingest batch's remote-job polling phase from its + // persisted record after a worker restart. Re-polls ALL originally-submitted + // jobs (server job status is idempotent, so already-completed jobs return + // immediately) and re-emits progress + a terminal message so the sidepanel is + // not left stuck and a post-restart cancel is honored. + const resumeQuickIngestBatch = async ( + record: PersistedQuickIngestBatch, + ): Promise => { + const sessionId = String(record.sessionId || "").trim(); + if (!sessionId) return; + if (activeQuickIngestBatchSessionIds.has(sessionId)) return; + activeQuickIngestBatchSessionIds.add(sessionId); + + const isCancelled = () => isQuickIngestCancelled(sessionId); + const totalCount = record.totalCount; + const ingestTimeoutMs = Math.max(record.ingestTimeoutMs || 0, 60_000); + const out: any[] = Array.isArray(record.collectedResults) + ? record.collectedResults.slice() + : []; + let processedCount = record.processedCount; + const plannedItems = new Map< + string, + { collectionId: number; itemId: number; idempotencyKey?: string | null } + >(); + for (const planned of record.plannedConferenceItems || []) { + plannedItems.set(planned.key, { + collectionId: planned.collectionId, + itemId: planned.itemId, + idempotencyKey: planned.idempotencyKey ?? null, + }); + } + + const emitProgress = (result: any) => { + processedCount += 1; + void emitBackgroundMessage(undefined, "tldw:quick-ingest/progress", { + sessionId, + result, + processedCount, + totalCount, + }); + }; + + try { + const tracker = + createIngestJobsTracker(); + for (const job of record.remoteJobs || []) { + try { + tracker.trackJobs(job.batchId, [job.jobId], { + id: String((job.meta as any)?.id || ""), + type: String((job.meta as any)?.type || ""), + url: + typeof (job.meta as any)?.url === "string" + ? (job.meta as any).url + : undefined, + fileName: + typeof (job.meta as any)?.fileName === "string" + ? (job.meta as any).fileName + : undefined, + }); + } catch (error) { + logBackgroundError( + `resume quick ingest track job ${job.jobId}`, + error, + ); + } + } + + if (tracker.hasItems()) { + const remoteResults = await pollRemoteIngestJobs({ + tracker, + ingestTimeoutMs, + isCancelled, + }); + for (const result of remoteResults) { + await patchConferenceCollectionItem( + plannedItems.get(String(result?.id || "")), + { + status: result?.status === "ok" ? "completed" : "failed", + media_id: + result?.status === "ok" + ? extractCompletedIngestJobMediaId(result?.data) + : undefined, + error_summary: + result?.status === "ok" + ? undefined + : String(result?.error || "Ingest failed"), + }, + ingestTimeoutMs, + ); + out.push(result); + emitProgress(result); + } + } + + if (isCancelled()) { + await emitBackgroundMessage(undefined, "tldw:quick-ingest/cancelled", { + sessionId, + reason: "user_cancelled", + jobIds: (record.remoteJobs || []).map((job) => job.jobId), + }); + } else { + await emitBackgroundMessage(undefined, "tldw:quick-ingest/completed", { + sessionId, + results: out, + summary: { resumedAfterRestart: true }, + }); + } + } catch (error) { + logBackgroundError(`resume quick ingest ${sessionId}`, error); + await emitBackgroundMessage(undefined, "tldw:quick-ingest/failed", { + sessionId, + error: + error instanceof Error + ? error.message + : String(error || "Quick ingest failed."), + }); + } finally { + quickIngestBatchRecords.delete(sessionId); + quickIngestModalSessions.delete(sessionId); + activeQuickIngestBatchSessionIds.delete(sessionId); + void persistSessionState(); + void scheduleQuickIngestBatchAlarm(); + } + }; + + // On worker restart / alarm fire: resume any persisted remote-job polling + // batch. Safe to call repeatedly — the active-session guard prevents a live + // or in-flight resume from being started twice. + const resumeQuickIngestBatches = async (): Promise => { + for (const record of Array.from(quickIngestBatchRecords.values())) { + if (activeQuickIngestBatchSessionIds.has(record.sessionId)) continue; + void resumeQuickIngestBatch(record); + } + await scheduleQuickIngestBatchAlarm(); + }; + + // Report-as-interrupted the specified quick-ingest modal sessions that were + // rehydrated from persisted state but have NO resumable remote-job batch — + // their multipart upload phase was killed mid-flight (no live fetch to + // resume), so we emit a terminal message to unblock the sidepanel. Only ever + // called from rehydrate (once per worker) and only for previous-worker + // sessions, so it never races a freshly-started or completing live batch. + const reportInterruptedQuickIngestUploads = async ( + sessionIds: string[], + ): Promise => { + for (const sessionId of sessionIds) { + if (quickIngestBatchRecords.has(sessionId)) continue; + if (activeQuickIngestBatchSessionIds.has(sessionId)) continue; + const session = quickIngestModalSessions.get(sessionId); + if (!session) continue; + activeQuickIngestBatchSessionIds.add(sessionId); + try { + if (session.cancelled) { + await emitBackgroundMessage( + undefined, + "tldw:quick-ingest/cancelled", + { sessionId, reason: "user_cancelled", jobIds: [] }, + ); + } else { + await emitBackgroundMessage(undefined, "tldw:quick-ingest/failed", { + sessionId, + error: + "Quick ingest was interrupted by a browser/service-worker restart before it finished.", + }); + } + } finally { + quickIngestModalSessions.delete(sessionId); + activeQuickIngestBatchSessionIds.delete(sessionId); + void persistSessionState(); + } + } + }; + + // Regular ingest sessions restored from a previous worker that are still in a + // pre-submission state (queued/running, no jobIds, not awaiting auth) were + // interrupted before their non-resumable job submission finished. + // resumeActiveIngestSessions() cannot resume them (there are no jobIds to + // poll), so report each once as failed/interrupted and clear it — otherwise + // the sidepanel is left stuck on "Checking for existing media…" forever. + // Mirrors reportInterruptedQuickIngestUploads for the no-remote-batch case. + const reportInterruptedIngestSessions = async ( + funnelIds: string[], + ): Promise => { + for (const funnelId of funnelIds) { + const session = ingestSessions.get(funnelId); + if (!session) continue; + if (activeIngestPollFunnelIds.has(funnelId)) continue; + // Re-check the live session in case it advanced since the snapshot. + if (session.status !== "queued" && session.status !== "running") { + continue; + } + if (session.awaitingAuth) continue; + if (Array.isArray(session.jobIds) && session.jobIds.length > 0) continue; + const msg = + "Ingest was interrupted by a browser/service-worker restart before it finished."; + session.lastError = msg; + await emitIngestStatus(session, { + status: "failed", + error: msg, + progressMessage: msg, + canCancel: false, + canRetry: false, + }); + ingestSessions.delete(funnelId); + void persistSessionState(); + } + }; + const extractIngestJobIds = (data: any): number[] => { const jobs = Array.isArray(data?.jobs) ? data.jobs : []; const ids: number[] = []; @@ -1529,6 +2003,73 @@ export default defineBackground({ return { ok: true }; }; + // Terminal-state handling for an ingest poll result. Extracted so both the + // inline (live-worker) poll and the alarm/rehydrate resume path share the + // exact same completion / auth / cancel / failure behavior. + const finalizeIngestSession = async ( + session: IngestSession, + pollResult: Awaited>, + ): Promise => { + if (pollResult.finalStatus === "completed" && pollResult.mediaId != null) { + session.mediaId = pollResult.mediaId; + await appendIngestFunnelMetric("media_completed", session.funnelId, { + mediaId: pollResult.mediaId, + deduped: false, + }); + await emitIngestStatus(session, { + status: "completed", + mediaId: pollResult.mediaId, + progressPercent: 100, + progressMessage: "Ingest complete. Opening media-scoped chat.", + canCancel: false, + canRetry: false, + }); + await sendIngestReadyMessage(session.tabId, { + funnelId: session.funnelId, + mediaId: String(pollResult.mediaId), + url: session.url, + mode: "rag_media", + timestampSeconds: + typeof session.timestampSeconds === "number" && + session.timestampSeconds >= 0 + ? session.timestampSeconds + : undefined, + }); + notify("tldw_server", "Ready. Opened media-scoped chat in sidebar."); + } else if (pollResult.finalStatus === "auth_required") { + await queueAuthRecovery( + session, + pollResult.error || "Authentication required.", + ); + } else if (pollResult.finalStatus === "cancelled") { + await emitIngestStatus(session, { + status: "cancelled", + progressMessage: "Ingest cancelled.", + canCancel: false, + canRetry: true, + }); + notify("tldw_server", "Ingest was cancelled."); + } else { + const failureMessage = + pollResult.error || + (pollResult.finalStatus === "timeout" + ? "Ingest timed out. Retry or open Media to inspect job status." + : "No completed media yet. Open Media to check job status."); + session.lastError = failureMessage; + await emitIngestStatus(session, { + status: "failed", + error: failureMessage, + progressMessage: failureMessage, + canCancel: false, + canRetry: true, + }); + notify("tldw_server", failureMessage); + } + // Reconcile the poll alarm now that this session has reached a terminal + // state (clears the alarm once no active ingest remains). + await scheduleIngestPollAlarm(); + }; + const startContextMenuIngest = async ( session: IngestSession, options?: { @@ -1713,75 +2254,20 @@ export default defineBackground({ "Queued for processing. Preparing chat when ready…", ); - const pollResult = await pollIngestJobsForSession(session, { - timeoutMs: 10 * 60 * 1000, - intervalMs: 1500, - }); - if ( - pollResult.finalStatus === "completed" && - pollResult.mediaId != null - ) { - session.mediaId = pollResult.mediaId; - await appendIngestFunnelMetric("media_completed", session.funnelId, { - mediaId: pollResult.mediaId, - deduped: false, - }); - await emitIngestStatus(session, { - status: "completed", - mediaId: pollResult.mediaId, - progressPercent: 100, - progressMessage: "Ingest complete. Opening media-scoped chat.", - canCancel: false, - canRetry: false, - }); - await sendIngestReadyMessage(session.tabId, { - funnelId: session.funnelId, - mediaId: String(pollResult.mediaId), - url: session.url, - mode: "rag_media", - timestampSeconds: - typeof session.timestampSeconds === "number" && - session.timestampSeconds >= 0 - ? session.timestampSeconds - : undefined, - }); - notify("tldw_server", "Ready. Opened media-scoped chat in sidebar."); - return; - } - - if (pollResult.finalStatus === "auth_required") { - await queueAuthRecovery( - session, - pollResult.error || "Authentication required.", - ); - return; - } - - if (pollResult.finalStatus === "cancelled") { - await emitIngestStatus(session, { - status: "cancelled", - progressMessage: "Ingest cancelled.", - canCancel: false, - canRetry: true, + // Register an alarm backstop before polling so that, if Chrome suspends + // the worker mid-poll, the alarm resumes polling on wake (TASK-12094). + activeIngestPollFunnelIds.add(session.funnelId); + void scheduleIngestPollAlarm(); + let pollResult: Awaited>; + try { + pollResult = await pollIngestJobsForSession(session, { + timeoutMs: 10 * 60 * 1000, + intervalMs: 1500, }); - notify("tldw_server", "Ingest was cancelled."); - return; + } finally { + activeIngestPollFunnelIds.delete(session.funnelId); } - - const failureMessage = - pollResult.error || - (pollResult.finalStatus === "timeout" - ? "Ingest timed out. Retry or open Media to inspect job status." - : "No completed media yet. Open Media to check job status."); - session.lastError = failureMessage; - await emitIngestStatus(session, { - status: "failed", - error: failureMessage, - progressMessage: failureMessage, - canCancel: false, - canRetry: true, - }); - notify("tldw_server", failureMessage); + await finalizeIngestSession(session, pollResult); }; const retryIngestSessionById = async ( @@ -1794,6 +2280,7 @@ export default defineBackground({ return { ok: false, error: "Ingest is already in progress." }; } pendingAuthReplay.delete(funnelId); + void persistSessionState(); session.retryCount += 1; void startContextMenuIngest(session, { trackContextClick: false, @@ -1816,6 +2303,100 @@ export default defineBackground({ reason: "auth_replay", }); } + void persistSessionState(); + }; + + // Re-poll a rehydrated ingest session (after a worker restart / alarm wake) + // using its persisted jobIds, then run the shared terminal handling. + const resumeIngestSessionPoll = async ( + session: IngestSession, + ): Promise => { + if (activeIngestPollFunnelIds.has(session.funnelId)) return; + if (session.status !== "queued" && session.status !== "running") return; + if (session.awaitingAuth) return; + if (!Array.isArray(session.jobIds) || session.jobIds.length === 0) return; + activeIngestPollFunnelIds.add(session.funnelId); + try { + const pollResult = await pollIngestJobsForSession(session, { + timeoutMs: 10 * 60 * 1000, + intervalMs: 1500, + }); + await finalizeIngestSession(session, pollResult); + } catch (error) { + logBackgroundError(`resume ingest ${session.funnelId}`, error); + } finally { + activeIngestPollFunnelIds.delete(session.funnelId); + } + }; + + const resumeActiveIngestSessions = async (): Promise => { + const candidates = Array.from(ingestSessions.values()).filter( + (session) => + (session.status === "queued" || session.status === "running") && + !session.awaitingAuth && + Array.isArray(session.jobIds) && + session.jobIds.length > 0 && + !activeIngestPollFunnelIds.has(session.funnelId), + ); + for (const session of candidates) { + void resumeIngestSessionPoll(session); + } + await scheduleIngestPollAlarm(); + }; + + // Rehydrate persisted session state after a worker restart, then resume any + // interrupted ingest polling and replay pending 401 auth-recoveries. Runs at + // most once per worker lifetime. + const rehydrateSessionState = async (): Promise => { + if (sessionStateHydrated) return; + sessionStateHydrated = true; + let state; + try { + state = await readPersistedSessionState(); + } catch (error) { + logBackgroundError("read session state", error); + return; + } + for (const [funnelId, value] of Object.entries(state.ingestSessions)) { + if (!ingestSessions.has(funnelId)) { + ingestSessions.set(funnelId, value as unknown as IngestSession); + } + } + for (const funnelId of state.pendingAuthReplay) { + pendingAuthReplay.add(funnelId); + } + for (const entry of state.quickIngestSessions) { + if (!quickIngestModalSessions.has(entry.sessionId)) { + quickIngestModalSessions.set(entry.sessionId, { + sessionId: entry.sessionId, + cancelled: entry.cancelled, + abortControllers: new Set(), + }); + } + } + for (const batch of state.quickIngestBatches) { + if (!quickIngestBatchRecords.has(batch.sessionId)) { + quickIngestBatchRecords.set(batch.sessionId, batch); + } + } + // Quick-ingest modal sessions restored from a previous worker that have no + // resumable remote-job batch were interrupted during their (non-resumable) + // upload phase — report those once so the sidepanel is not left stuck. + const interruptedUploadSessionIds = state.quickIngestSessions + .map((entry) => entry.sessionId) + .filter((sessionId) => !quickIngestBatchRecords.has(sessionId)); + // Snapshot the ingest sessions that were interrupted mid pre-submission + // (queued/running with no jobIds and not awaiting auth) up front, before + // the auth-replay path can begin re-driving any of them. + const interruptedIngestFunnelIds = selectInterruptedIngestFunnelIds( + state.ingestSessions, + state.pendingAuthReplay, + ); + await resumeActiveIngestSessions(); + void replayPendingAuthSessions(); + await resumeQuickIngestBatches(); + await reportInterruptedQuickIngestUploads(interruptedUploadSessionIds); + await reportInterruptedIngestSessions(interruptedIngestFunnelIds); }; const runQuickIngestBatch = async ( @@ -1864,12 +2445,6 @@ export default defineBackground({ const totalCount = entries.length + files.length; let processedCount = 0; const out: any[] = []; - type QuickIngestRemoteResultMeta = { - id: string; - type: string; - url?: string; - fileName?: string; - }; const queuedRemoteJobs = createIngestJobsTracker(); const conferenceBatchMetadata = @@ -2086,18 +2661,7 @@ export default defineBackground({ planned: PlannedConferenceCollectionItem | undefined, body: Record, ) => { - if (!planned) return; - await handleTldwRequest({ - path: `/api/v1/media/collections/${encodeURIComponent( - String(planned.collectionId), - )}/items/${encodeURIComponent(String(planned.itemId))}`, - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body, - timeoutMs: ingestTimeoutMs, - }).catch((error) => { - logBackgroundError("quick ingest collection item patch", error); - }); + await patchConferenceCollectionItem(planned, body, ingestTimeoutMs); }; const applyPlannedConferenceFields = ( @@ -2270,87 +2834,54 @@ export default defineBackground({ return trackedJobIds.length; }; - const cancelQueuedRemoteBatches = async (reason: string) => { - await queuedRemoteJobs.cancelTrackedBatches(async (batchId) => { - try { - await handleTldwRequest({ - path: `/api/v1/media/ingest/jobs/cancel?batch_id=${encodeURIComponent( - batchId, - )}&reason=${encodeURIComponent(reason || "user_cancelled")}`, - method: "POST", - timeoutMs: 10_000, - }); - } catch (error) { - logBackgroundError(`cancel quick ingest batch ${batchId}`, error); - } - }); - }; - - const pollQueuedRemoteJobs = async (): Promise => { - return await pollTrackedIngestJobs({ + // Poll the queued remote jobs to completion using the shared implementation + // (also used by the post-restart resume path) so both map results the same. + const pollQueuedRemoteJobs = async (): Promise => + pollRemoteIngestJobs({ tracker: queuedRemoteJobs, - timeoutMs: ingestTimeoutMs, - pollIntervalMs: 1200, + ingestTimeoutMs, isCancelled, - onCancel: async () => { - await cancelQueuedRemoteBatches("user_cancelled"); - }, onPendingJobIds: (jobIds) => { runtimeContext?.setJobIds(jobIds); }, - fetchJob: async (jobId) => - (await handleTldwRequest({ - path: `/api/v1/media/ingest/jobs/${jobId}`, - method: "GET", - timeoutMs: 4200, - })) as - | { ok: boolean; status?: number; data?: any; error?: string } - | undefined, - mapRequestError: (item, response) => { - if ( - isLikelyAuthError(Number(response?.status || 0), response?.error) - ) { - return { - id: item.meta.id, - status: "error", - url: item.meta.url, - fileName: item.meta.fileName, - type: item.meta.type, - error: response?.error || "Authentication required.", - data: undefined, - }; - } - return undefined; - }, - mapCompleted: (item, data) => ({ - id: item.meta.id, - status: "ok", - url: item.meta.url, - fileName: item.meta.fileName, - type: item.meta.type, - data, - }), - mapCancelled: (item) => ({ - id: item.meta.id, - status: "error", - url: item.meta.url, - fileName: item.meta.fileName, - type: item.meta.type, - error: "Cancelled by user.", - data: undefined, - }), - mapFailure: (item, details) => ({ - id: item.meta.id, - status: "error", - url: item.meta.url, - fileName: item.meta.fileName, - type: item.meta.type, - error: String( - details.error || `Ingest ${details.status || "failed"}`, - ), - data: details.data, - }), }); + + // Snapshot the resumable remote-job phase so a worker restart can re-poll + // and finalize it. Only the queued/remote-job phase is durable — the raw + // multipart uploads that produced these job ids cannot be resumed. + const persistQuickIngestBatchRecord = () => { + if (!sessionId) return; + quickIngestBatchRecords.set(sessionId, { + sessionId, + totalCount, + processedCount, + ingestTimeoutMs, + remoteJobs: queuedRemoteJobs.getItems().map((item) => ({ + jobId: item.jobId, + batchId: item.batchId, + meta: item.meta as unknown as Record, + })), + collectedResults: out.slice(), + plannedConferenceItems: Array.from( + plannedConferenceItems.entries(), + ).map(([key, value]) => ({ + key, + collectionId: value.collectionId, + itemId: value.itemId, + idempotencyKey: value.idempotencyKey ?? null, + })), + }); + activeQuickIngestBatchSessionIds.add(sessionId); + void persistSessionState(); + void scheduleQuickIngestBatchAlarm(); + }; + + const clearQuickIngestBatchRecord = () => { + if (!sessionId) return; + quickIngestBatchRecords.delete(sessionId); + activeQuickIngestBatchSessionIds.delete(sessionId); + void persistSessionState(); + void scheduleQuickIngestBatchAlarm(); }; await createPlannedConferenceItems(); @@ -2559,24 +3090,33 @@ export default defineBackground({ } if (shouldStoreRemote && queuedRemoteJobs.hasItems()) { - const remoteResults = await pollQueuedRemoteJobs(); - for (const result of remoteResults) { - await patchPlannedConferenceItem( - plannedConferenceItems.get(String(result?.id || "")), - { - status: result?.status === "ok" ? "completed" : "failed", - media_id: - result?.status === "ok" - ? extractCompletedIngestJobMediaId(result?.data) - : undefined, - error_summary: - result?.status === "ok" - ? undefined - : String(result?.error || "Ingest failed"), - }, - ); - out.push(result); - emitProgress(result); + // Persist the resumable remote-job phase before we start polling so a + // worker suspension mid-poll can be resumed from the alarm/startup path. + persistQuickIngestBatchRecord(); + try { + const remoteResults = await pollQueuedRemoteJobs(); + for (const result of remoteResults) { + await patchPlannedConferenceItem( + plannedConferenceItems.get(String(result?.id || "")), + { + status: result?.status === "ok" ? "completed" : "failed", + media_id: + result?.status === "ok" + ? extractCompletedIngestJobMediaId(result?.data) + : undefined, + error_summary: + result?.status === "ok" + ? undefined + : String(result?.error || "Ingest failed"), + }, + ); + out.push(result); + emitProgress(result); + } + } finally { + // The live poll finished (or threw) in this worker; drop the durable + // record so the alarm/startup path does not re-poll it. + clearQuickIngestBatchRecord(); } } @@ -2600,6 +3140,7 @@ export default defineBackground({ const sessionId = String((payload as any)?.sessionId || "").trim(); if (sessionId) { quickIngestModalSessions.delete(sessionId); + void persistSessionState(); } } }, @@ -2641,6 +3182,7 @@ export default defineBackground({ cancelled: false, abortControllers: new Set(), }); + void persistSessionState(); } return startAck; } @@ -2654,6 +3196,7 @@ export default defineBackground({ const session = getQuickIngestModalSession(sessionId); if (session) { session.cancelled = true; + void persistSessionState(); for (const controller of Array.from(session.abortControllers)) { try { controller.abort(); @@ -2760,6 +3303,24 @@ export default defineBackground({ return undefined; }; + // Defense-in-depth: only honor privileged runtime messages/ports that + // originate from this extension's own contexts. A compromised content + // script still shares our runtime id, but this rejects any sender whose id + // differs from ours (e.g. another extension). When the id cannot be + // determined (non-extension test/web contexts), we do not block. + const isTrustedRuntimeSender = ( + sender: { id?: string } | null | undefined, + ): boolean => { + try { + const selfId = (browser as any)?.runtime?.id; + const senderId = sender?.id; + if (selfId && senderId && senderId !== selfId) return false; + } catch { + // Fall through: identity indeterminate, do not block. + } + return true; + }; + browser.storage.onChanged.addListener((changes, areaName) => { if (areaName !== "local") return; if ( @@ -2772,6 +3333,14 @@ export default defineBackground({ }); browser.runtime.onConnect.addListener((port) => { + if (!isTrustedRuntimeSender(port.sender)) { + try { + port.disconnect(); + } catch (error) { + logBackgroundError("reject untrusted port", error); + } + return; + } if (port.name === "pgCopilot") { isCopilotRunning = true; backgroundDiagnostics.ports.copilot += 1; @@ -2814,7 +3383,11 @@ export default defineBackground({ "Not authenticated. Configure tldw credentials in Settings > tldw.", ); } - const url = `${base}/api/v1/audio/stream/transcribe?token=${encodeURIComponent(token)}`; + // TASK-12106: keep the auth token OUT of the URL (URLs leak into + // access/proxy logs and history). The transcribe WS authenticates + // from an {type:"auth", token} first frame sent below on open + // (streaming_service.py:641-647 multi-user / 720-723 single-user). + const url = `${base}/api/v1/audio/stream/transcribe`; ws = new WebSocket(url); ws.binaryType = "arraybuffer"; connectTimer = setTimeout(() => { @@ -2837,6 +3410,13 @@ export default defineBackground({ clearTimeout(connectTimer); connectTimer = null; } + // Send auth as the first frame, before the content script starts + // streaming audio (it only sends audio after the "open" event). + try { + ws?.send(JSON.stringify({ type: "auth", token })); + } catch (error) { + logBackgroundError("stt websocket send auth", error); + } safePost({ event: "open" }); }; ws.onmessage = (ev) => safePost({ event: "data", data: ev.data }); @@ -3211,6 +3791,14 @@ export default defineBackground({ // Stream handler via Port API browser.runtime.onConnect.addListener((port) => { + if (!isTrustedRuntimeSender(port.sender)) { + try { + port.disconnect(); + } catch (error) { + logBackgroundError("reject untrusted stream port", error); + } + return; + } if (port.name === "tldw:stream") { backgroundDiagnostics.ports.stream += 1; backgroundDiagnostics.lastStreamAt = Date.now(); @@ -3232,7 +3820,14 @@ export default defineBackground({ if (!cfg?.serverUrl) throw new Error("tldw server not configured"); const baseUrl = String(cfg.serverUrl).replace(/\/$/, ""); const path = msg.path as string; - const url = path.startsWith("http") + const streamAccess = evaluateAbsoluteUrlAccess(path, cfg); + // Mirror the request-path guard: refuse cross-origin absolute URLs + // that are not explicitly allowlisted before opening the stream. + if (streamAccess.blocked) { + safePost({ event: "error", message: ABSOLUTE_URL_BLOCK_ERROR }); + return; + } + const url = streamAccess.isAbsolute ? path : `${baseUrl}${path.startsWith("/") ? "" : "/"}${path}`; const headers: Record = { ...(msg.headers || {}) }; @@ -3241,27 +3836,31 @@ export default defineBackground({ if (kl === "x-api-key" || kl === "authorization") delete headers[k]; } - if (cfg.authMode === "single-user") { - const key = (cfg.apiKey || "").trim(); - if (!key) { - safePost({ - event: "error", - message: - "Add or update your API key in Settings → tldw server, then try again.", - }); - return; - } - headers["X-API-KEY"] = key; - } else if (cfg.authMode === "multi-user") { - const token = (cfg.accessToken || "").trim(); - if (token) headers["Authorization"] = `Bearer ${token}`; - else { - safePost({ - event: "error", - message: - "Not authenticated. Please login under Settings > tldw.", - }); - return; + // Skip credentials for allowlisted-but-cross-origin absolute URLs, + // matching request-core's `shouldSkipAuth` behavior. + if (!streamAccess.skipAuth) { + if (cfg.authMode === "single-user") { + const key = (cfg.apiKey || "").trim(); + if (!key) { + safePost({ + event: "error", + message: + "Add or update your API key in Settings → tldw server, then try again.", + }); + return; + } + headers["X-API-KEY"] = key; + } else if (cfg.authMode === "multi-user") { + const token = (cfg.accessToken || "").trim(); + if (token) headers["Authorization"] = `Bearer ${token}`; + else { + safePost({ + event: "error", + message: + "Not authenticated. Please login under Settings > tldw.", + }); + return; + } } } headers["Accept"] = "text/event-stream"; @@ -3304,6 +3903,7 @@ export default defineBackground({ signal: abort.signal, }); if ( + !streamAccess.skipAuth && resp.status === 401 && cfg.authMode === "multi-user" && cfg.refreshToken @@ -3472,6 +4072,9 @@ export default defineBackground({ runtimeOnMessage.addListener( (message: any, sender: any, sendResponse: any) => { + if (!isTrustedRuntimeSender(sender)) { + return false; + } backgroundDiagnostics.runtimeMessageCount += 1; backgroundDiagnostics.lastRuntimeMessageType = typeof message?.type === "string" ? message.type : null; @@ -3511,6 +4114,28 @@ export default defineBackground({ if (browser?.alarms) { browser.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === INGEST_POLL_ALARM_NAME) { + backgroundDiagnostics.alarmFires += 1; + backgroundDiagnostics.lastAlarmAt = Date.now(); + // Ensure state is hydrated (worker may have just woken), then resume + // any ingest polling interrupted by suspension (TASK-12094). + void (async () => { + await rehydrateSessionState(); + await resumeActiveIngestSessions(); + })(); + return; + } + if (alarm.name === QUICK_INGEST_POLL_ALARM_NAME) { + backgroundDiagnostics.alarmFires += 1; + backgroundDiagnostics.lastAlarmAt = Date.now(); + // Ensure state is hydrated (worker may have just woken), then resume + // any interrupted quick-ingest remote-job polling (TASK-12094). + void (async () => { + await rehydrateSessionState(); + await resumeQuickIngestBatches(); + })(); + return; + } if (alarm.name !== MODEL_WARM_ALARM_NAME) return; backgroundDiagnostics.alarmFires += 1; backgroundDiagnostics.lastAlarmAt = Date.now(); @@ -3518,6 +4143,12 @@ export default defineBackground({ }); } + if (browser?.runtime?.onStartup) { + browser.runtime.onStartup.addListener(() => { + void rehydrateSessionState(); + }); + } + void ensureInitialized(); }, persistent: false, diff --git a/apps/packages/ui/src/hooks/__tests__/useAudioRecorder.test.ts b/apps/packages/ui/src/hooks/__tests__/useAudioRecorder.test.ts index 6a22e213df..425d681f22 100644 --- a/apps/packages/ui/src/hooks/__tests__/useAudioRecorder.test.ts +++ b/apps/packages/ui/src/hooks/__tests__/useAudioRecorder.test.ts @@ -10,6 +10,13 @@ const mockGetUserMedia = vi.fn().mockResolvedValue({ getTracks: () => [{ stop: mockTrackStop }] }) +const AUDIO_CAPTURE_COORDINATOR_KEY = Symbol.for( + "tldw.audioCaptureSessionCoordinator" +) + +// Track constructed recorders so tests can trigger lifecycle events (onerror). +const recorderInstances: MockMediaRecorder[] = [] + class MockMediaRecorder { ondataavailable: ((e: { data: Blob }) => void) | null = null onstop: (() => void) | null = null @@ -17,6 +24,10 @@ class MockMediaRecorder { mimeType = "audio/webm" state = "inactive" as "inactive" | "recording" + constructor() { + recorderInstances.push(this) + } + start = vi.fn(() => { this.state = "recording" }) @@ -32,6 +43,11 @@ class MockMediaRecorder { this.onstop() } }) + + emitError() { + this.state = "inactive" + this.onerror?.(new Event("error")) + } } vi.stubGlobal("MediaRecorder", MockMediaRecorder) @@ -46,6 +62,18 @@ describe("useAudioRecorder", () => { beforeEach(() => { vi.useFakeTimers() vi.clearAllMocks() + recorderInstances.length = 0 + mockGetUserMedia.mockResolvedValue({ + getTracks: () => [{ stop: mockTrackStop }] + }) + // Reset the process-global capture coordinator so an owner left claimed by + // one test (e.g. a still-"recording" hook) cannot make the next test's + // reserveCaptureOwner() throw a capture-busy error. + delete ( + globalThis as typeof globalThis & { + [AUDIO_CAPTURE_COORDINATOR_KEY]?: unknown + } + )[AUDIO_CAPTURE_COORDINATOR_KEY] }) afterEach(() => { @@ -205,4 +233,83 @@ describe("useAudioRecorder", () => { expect(mockTrackStop).toHaveBeenCalled() }) + + it("stops media tracks and returns to idle when the recorder errors", async () => { + const { result } = renderHook(() => useAudioRecorder()) + + await act(async () => { + await result.current.startRecording() + }) + expect(result.current.status).toBe("recording") + + // A MediaRecorder error that does not also fire onstop must still stop the + // mic track (privacy: browser indicator must go off). + act(() => { + recorderInstances[recorderInstances.length - 1].emitError() + }) + + expect(mockTrackStop).toHaveBeenCalled() + expect(result.current.status).toBe("idle") + }) + + it("releases the capture owner on recorder error so a new recording can start", async () => { + const { result } = renderHook(() => useAudioRecorder()) + + await act(async () => { + await result.current.startRecording() + }) + act(() => { + recorderInstances[recorderInstances.length - 1].emitError() + }) + + // If the owner was released, a fresh start must acquire the mic again + // instead of throwing a capture-busy error. + await act(async () => { + await result.current.startRecording() + }) + + expect(result.current.status).toBe("recording") + expect(mockGetUserMedia).toHaveBeenCalledTimes(2) + }) + + it("does not orphan a stream on a rapid double-start", async () => { + const streams = [ + { getTracks: () => [{ stop: vi.fn() }] }, + { getTracks: () => [{ stop: vi.fn() }] } + ] + let call = 0 + mockGetUserMedia.mockImplementation(() => + Promise.resolve(streams[call++] ?? streams[0]) + ) + + const { result } = renderHook(() => useAudioRecorder()) + + await act(async () => { + // Fire two starts before the first getUserMedia resolves. + const first = result.current.startRecording() + const second = result.current.startRecording() + await Promise.all([first, second]) + }) + + // The synchronous re-entry guard must short-circuit the second start, so + // only one stream is ever acquired (no orphaned, un-stopped track). + expect(mockGetUserMedia).toHaveBeenCalledTimes(1) + expect(recorderInstances).toHaveLength(1) + expect(result.current.status).toBe("recording") + }) + + it("ignores a start while already recording", async () => { + const { result } = renderHook(() => useAudioRecorder()) + + await act(async () => { + await result.current.startRecording() + }) + await act(async () => { + await result.current.startRecording() + }) + + // A start while already recording must not acquire a second stream. + expect(mockGetUserMedia).toHaveBeenCalledTimes(1) + expect(recorderInstances).toHaveLength(1) + }) }) diff --git a/apps/packages/ui/src/hooks/__tests__/useAudiobookGeneration.abort.test.tsx b/apps/packages/ui/src/hooks/__tests__/useAudiobookGeneration.abort.test.tsx new file mode 100644 index 0000000000..421b9e05ca --- /dev/null +++ b/apps/packages/ui/src/hooks/__tests__/useAudiobookGeneration.abort.test.tsx @@ -0,0 +1,83 @@ +import { act, renderHook, waitFor } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { useAudiobookGeneration } from "@/hooks/useAudiobookGeneration" + +const h = vi.hoisted(() => ({ + synthesize: vi.fn((_text: string, _opts?: { signal?: AbortSignal }) => + // Never resolves: keeps the chapter "in flight" so cancel can abort it. + new Promise<{ buffer: ArrayBuffer; mimeType: string; format: string }>( + () => {} + ) + ), + storeState: { + updateChapter: vi.fn(), + setIsGenerating: vi.fn(), + setCurrentGeneratingId: vi.fn(), + setGenerationQueue: vi.fn(), + generationQueue: [] as string[] + } +})) + +vi.mock("@/services/tts-provider", () => ({ + resolveTtsProviderContext: vi.fn(async () => ({ + supported: true, + provider: "tldw", + utterance: "chapter text", + synthesize: h.synthesize + })) +})) + +vi.mock("@/utils/tts-speed", () => ({ + applyVoiceSpeedOverrides: (config: unknown) => config +})) + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }) +})) + +vi.mock("@/hooks/useAntdNotification", () => ({ + useAntdNotification: () => ({ warning: vi.fn(), error: vi.fn() }) +})) + +vi.mock("@/store/audiobook-studio", () => ({ + useAudiobookStudioStore: Object.assign( + (selector: (state: typeof h.storeState) => unknown) => selector(h.storeState), + { getState: () => h.storeState } + ) +})) + +describe("useAudiobookGeneration cancel aborts in-flight synthesis", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it("threads an abort signal into synthesize and aborts it on cancelGeneration", async () => { + const chapter = { + id: "c1", + title: "Chapter 1", + content: "hello", + order: 0, + status: "pending", + voiceConfig: {} + } as any + + const { result } = renderHook(() => useAudiobookGeneration()) + + act(() => { + void result.current.generateAllChapters({ chapters: [chapter] }) + }) + + await waitFor(() => expect(h.synthesize).toHaveBeenCalled()) + + const options = h.synthesize.mock.calls[0][1] + expect(options?.signal).toBeInstanceOf(AbortSignal) + expect(options?.signal?.aborted).toBe(false) + + act(() => { + result.current.cancelGeneration() + }) + + expect(options?.signal?.aborted).toBe(true) + }) +}) diff --git a/apps/packages/ui/src/hooks/__tests__/usePersonaLiveControl.test.tsx b/apps/packages/ui/src/hooks/__tests__/usePersonaLiveControl.test.tsx index 6f7782b256..b2861c7518 100644 --- a/apps/packages/ui/src/hooks/__tests__/usePersonaLiveControl.test.tsx +++ b/apps/packages/ui/src/hooks/__tests__/usePersonaLiveControl.test.tsx @@ -9,7 +9,10 @@ const mocks = vi.hoisted(() => ({ createPersonaLiveSession: vi.fn(), focusPersonaLiveSession: vi.fn(), stopPersonaLiveSession: vi.fn(), - buildPersonaWebSocketUrl: vi.fn(() => "ws://persona.test/api/v1/persona/stream"), + buildPersonaWebSocketUrl: vi.fn(() => ({ + url: "ws://persona.test/api/v1/persona/stream", + protocols: ["bearer", "test-key"] + })), ensureConfigForRequest: vi.fn() })) @@ -50,7 +53,10 @@ class MockWebSocket { onclose: (() => void) | null = null onerror: (() => void) | null = null - constructor(public readonly url: string) { + constructor( + public readonly url: string, + public readonly protocols?: string | string[] + ) { MockWebSocket.instances.push(this) } @@ -415,4 +421,77 @@ describe("usePersonaLiveControl", () => { expect(result.current.canSendText).toBe(true) expect(result.current.voiceAvailable).toBe(false) }) + + it("opens the stream with the auth subprotocol and no token in the url", async () => { + mocks.listPersonaLiveSessions.mockResolvedValueOnce({ + sessions: [session({ sessionId: "sess-proto", isFocused: true })], + focusedSessionId: "sess-proto" + }) + + const { result } = renderHook(() => usePersonaLiveControl()) + await waitFor(() => + expect(result.current.focusedSession?.sessionId).toBe("sess-proto") + ) + + act(() => { + void result.current.sendText("hi", { clientMessageId: "proto-msg" }) + }) + + await waitFor(() => expect(MockWebSocket.instances).toHaveLength(1)) + const ws = MockWebSocket.instances[0] + expect(ws.url).not.toContain("token") + expect(ws.url).not.toContain("api_key") + expect(ws.url).not.toContain("test-key") + expect(ws.protocols).toEqual(["bearer", "test-key"]) + + act(() => { + ws.emitOpen() + }) + }) + + it("times out the handshake when onopen never fires and reports a connect failure", async () => { + mocks.listPersonaLiveSessions.mockResolvedValueOnce({ + sessions: [session({ sessionId: "sess-timeout", isFocused: true })], + focusedSessionId: "sess-timeout" + }) + + const { result } = renderHook(() => usePersonaLiveControl()) + await waitFor(() => + expect(result.current.focusedSession?.sessionId).toBe("sess-timeout") + ) + + vi.useFakeTimers() + + let sendPromise!: Promise<{ + ok: boolean + clientMessageId: string + error?: string + }> + act(() => { + sendPromise = result.current.sendText("hi", { + clientMessageId: "timeout-msg" + }) + }) + + // Flush the ensureConfigForRequest microtask so the socket + timer exist. + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + expect(MockWebSocket.instances).toHaveLength(1) + expect(result.current.streamState).toBe("connecting") + + // Advance past the handshake timeout without ever firing onopen. + await act(async () => { + await vi.advanceTimersByTimeAsync(10000) + }) + + await expect(sendPromise).resolves.toEqual({ + ok: false, + clientMessageId: "timeout-msg", + error: "Persona live stream failed to connect" + }) + expect(result.current.streamState).toBe("error") + expect(MockWebSocket.instances[0].readyState).toBe(MockWebSocket.CLOSED) + }) }) diff --git a/apps/packages/ui/src/hooks/__tests__/useServerDictation.source.test.tsx b/apps/packages/ui/src/hooks/__tests__/useServerDictation.source.test.tsx index 0ed2c6867e..cf32e704f0 100644 --- a/apps/packages/ui/src/hooks/__tests__/useServerDictation.source.test.tsx +++ b/apps/packages/ui/src/hooks/__tests__/useServerDictation.source.test.tsx @@ -36,6 +36,10 @@ vi.mock("@/services/tldw/TldwApiClient", () => ({ } })) +// When set, the next MediaRecorder construction throws (simulates a ctor +// failure after getUserMedia has already acquired a live stream). +let recorderCtorShouldThrow = false + class MockMediaRecorder { ondataavailable: ((event: { data: Blob }) => void) | null = null onerror: ((event: Event) => void) | null = null @@ -43,6 +47,13 @@ class MockMediaRecorder { mimeType = "audio/webm" state = "inactive" + constructor() { + if (recorderCtorShouldThrow) { + recorderCtorShouldThrow = false + throw new Error("MediaRecorder construction failed") + } + } + start = vi.fn(() => { this.state = "recording" }) @@ -94,6 +105,7 @@ const createMockStream = () => describe("useServerDictation selected source handling", () => { beforeEach(() => { vi.clearAllMocks() + recorderCtorShouldThrow = false mockGetUserMedia.mockResolvedValue(createMockStream()) mockTranscribeAudio.mockResolvedValue({ text: "transcript" }) delete ( @@ -169,4 +181,54 @@ describe("useServerDictation selected source handling", () => { expect(onError).toHaveBeenCalledWith(startupError) expect(mockNotificationError).toHaveBeenCalledTimes(1) }) + + it("stops the acquired stream when the MediaRecorder constructor throws", async () => { + recorderCtorShouldThrow = true + const onError = vi.fn() + const { result } = renderHook(() => buildHook({ onError })) + + await act(async () => { + await result.current.startServerDictation({ + sourceKind: "default_mic", + deviceId: null + }) + }) + + // The stream was acquired before the ctor threw; its tracks must be stopped + // via the ref-held stream so the mic indicator does not stay on. + expect(mockGetUserMedia).toHaveBeenCalledTimes(1) + expect(mockTrackStop).toHaveBeenCalled() + expect(onError).toHaveBeenCalledTimes(1) + expect(result.current.isServerDictating).toBe(false) + + // Owner released: a subsequent start acquires the mic again. + await act(async () => { + await result.current.startServerDictation({ + sourceKind: "default_mic", + deviceId: null + }) + }) + expect(mockGetUserMedia).toHaveBeenCalledTimes(2) + }) + + it("does not orphan a stream on a rapid double-start", async () => { + const { result } = renderHook(() => buildHook()) + + await act(async () => { + // Fire two starts before the first getUserMedia resolves. + const first = result.current.startServerDictation({ + sourceKind: "default_mic", + deviceId: null + }) + const second = result.current.startServerDictation({ + sourceKind: "default_mic", + deviceId: null + }) + await Promise.all([first, second]) + }) + + // The synchronous re-entry guard must short-circuit the second start. + expect(mockGetUserMedia).toHaveBeenCalledTimes(1) + expect(result.current.isServerDictating).toBe(true) + }) }) diff --git a/apps/packages/ui/src/hooks/__tests__/useStreamingAudioPlayer.leak.test.tsx b/apps/packages/ui/src/hooks/__tests__/useStreamingAudioPlayer.leak.test.tsx new file mode 100644 index 0000000000..b18dcb7fd8 --- /dev/null +++ b/apps/packages/ui/src/hooks/__tests__/useStreamingAudioPlayer.leak.test.tsx @@ -0,0 +1,93 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { useStreamingAudioPlayer } from "@/hooks/useStreamingAudioPlayer" + +// MediaSource that reports streaming as supported but never opens its source +// buffer, so we rely on the play() rejection below to flip into the fallback. +class MockMediaSource { + static isTypeSupported() { + return true + } + readyState = "closed" + addEventListener() {} + removeEventListener() {} + addSourceBuffer() { + return { + updating: false, + onupdateend: null, + appendBuffer() {} + } + } + endOfStream() {} +} + +// Audio whose play() rejects, which flips streamFailedRef and forces finish() +// down the buffered-fallback path. +class RejectingAudio { + src = "" + autoplay = false + onended: (() => void) | null = null + onerror: (() => void) | null = null + play() { + return Promise.reject(new Error("blocked")) + } + pause() {} + canPlayType() { + return "probably" + } +} + +describe("useStreamingAudioPlayer stream->buffer fallback", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it("revokes the MediaSource blob URL before overwriting it with the fallback blob URL", async () => { + const created: string[] = [] + let counter = 0 + vi.spyOn(URL, "createObjectURL").mockImplementation(() => { + const url = `blob:mock-${counter++}` + created.push(url) + return url + }) + const revokeSpy = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => {}) + + vi.stubGlobal("MediaSource", MockMediaSource) + vi.stubGlobal("Audio", RejectingAudio) + + const { result } = renderHook(() => useStreamingAudioPlayer()) + + await act(async () => { + result.current.start("mp3", true) + // Flush the play() rejection so streamFailedRef flips to true. + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + }) + + // The first URL created is the MediaSource blob URL. + const mediaSourceUrl = created[0] + expect(mediaSourceUrl).toBe("blob:mock-0") + expect(revokeSpy).not.toHaveBeenCalledWith(mediaSourceUrl) + + await act(async () => { + result.current.finish() + }) + + // The fallback path must revoke the previous (MediaSource) URL, not leak it. + expect(revokeSpy).toHaveBeenCalledWith(mediaSourceUrl) + // And a fresh fallback blob URL is now in use. + const fallbackUrl = created[created.length - 1] + expect(fallbackUrl).not.toBe(mediaSourceUrl) + + // Cleanup on stop revokes the fallback URL too (no dangling blob). + await act(async () => { + result.current.stop() + }) + expect(revokeSpy).toHaveBeenCalledWith(fallbackUrl) + }) +}) diff --git a/apps/packages/ui/src/hooks/__tests__/useTTS.cancel.test.tsx b/apps/packages/ui/src/hooks/__tests__/useTTS.cancel.test.tsx new file mode 100644 index 0000000000..4419936168 --- /dev/null +++ b/apps/packages/ui/src/hooks/__tests__/useTTS.cancel.test.tsx @@ -0,0 +1,118 @@ +import { act, renderHook, waitFor } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { useTTS } from "@/hooks/useTTS" + +const mocks = vi.hoisted(() => ({ + synthesize: vi.fn(async () => ({ + buffer: new ArrayBuffer(8), + mimeType: "audio/mpeg", + format: "mp3" + })) +})) + +vi.mock("@/services/tts-provider", () => ({ + resolveTtsProviderContext: vi.fn(async () => ({ + provider: "openai", + utterance: "Hello world.", + playbackSpeed: 1, + synthesize: mocks.synthesize, + supported: true, + formatInfo: { resolved: "mp3" } + })) +})) + +vi.mock("@/utils/tts", () => ({ + splitMessageContent: () => ["Hello world."] +})) + +vi.mock("@/services/tts", () => ({ + getElevenLabsModel: vi.fn(async () => "el-model"), + getElevenLabsVoiceId: vi.fn(async () => "el-voice"), + getOpenAITTSModel: vi.fn(async () => "oa-model"), + getOpenAITTSVoice: vi.fn(async () => "oa-voice"), + getTldwTTSModel: vi.fn(async () => "tldw-model"), + getTldwTTSVoice: vi.fn(async () => "tldw-voice"), + getVoice: vi.fn(async () => "voice") +})) + +vi.mock("@/config/platform", () => ({ isChromiumTarget: false })) + +vi.mock("@/db/dexie/tts-clips", () => ({ saveTtsClip: vi.fn() })) + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }) +})) + +vi.mock("@/hooks/useAntdNotification", () => ({ + useAntdNotification: () => ({ + error: vi.fn(), + warning: vi.fn(), + success: vi.fn(), + info: vi.fn(), + open: vi.fn() + }) +})) + +// Audio whose play() never settles, simulating playback still in progress when +// the user hits Stop. +class HangingAudio { + src: string + playbackRate = 1 + currentTime = 0 + onended: (() => void) | null = null + onerror: (() => void) | null = null + error: unknown = null + constructor(src?: string) { + this.src = src ?? "" + } + canPlayType() { + return "probably" + } + play() { + return new Promise(() => {}) + } + pause() {} +} + +describe("useTTS cancel-during-playback", () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it("frees the segment object URL and settles the playback promise when cancelled mid-playback", async () => { + const createObjectURLSpy = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:seg-0") + const revokeSpy = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => {}) + vi.stubGlobal("Audio", HangingAudio) + + const { result } = renderHook(() => useTTS()) + + let speakPromise: Promise | undefined + act(() => { + speakPromise = result.current.speak({ utterance: "Hello world." }) + }) + + // Wait until playback has actually started (object URL created for the segment). + await waitFor(() => expect(createObjectURLSpy).toHaveBeenCalled()) + expect(revokeSpy).not.toHaveBeenCalled() + + // Stop mid-playback: the URL must be revoked and the in-flight promise settled. + act(() => { + result.current.cancel() + }) + + expect(revokeSpy).toHaveBeenCalledWith("blob:seg-0") + + // The generator must unwind rather than hang forever. + await act(async () => { + await speakPromise + }) + + expect(result.current.isSpeaking).toBe(false) + }) +}) diff --git a/apps/packages/ui/src/hooks/__tests__/useVoiceChatStream.interrupt.test.tsx b/apps/packages/ui/src/hooks/__tests__/useVoiceChatStream.interrupt.test.tsx index 7dd7895172..2e1dc0727d 100644 --- a/apps/packages/ui/src/hooks/__tests__/useVoiceChatStream.interrupt.test.tsx +++ b/apps/packages/ui/src/hooks/__tests__/useVoiceChatStream.interrupt.test.tsx @@ -110,15 +110,18 @@ class MockWebSocket { readyState = 0 binaryType = "blob" + bufferedAmount = 0 sent: string[] = [] onopen: (() => void) | null = null onmessage: ((event: { data: string | ArrayBuffer }) => void) | null = null onerror: (() => void) | null = null onclose: (() => void) | null = null url: string + protocols?: string | string[] - constructor(url: string) { + constructor(url: string, protocols?: string | string[]) { this.url = url + this.protocols = protocols MockWebSocket.instances.push(this) } @@ -230,8 +233,11 @@ describe("useVoiceChatStream interrupt handling", () => { }) expect(MockWebSocket.instances[0]?.url).toBe( - "ws://127.0.0.1:8080/api/v1/audio/chat/stream?token=test-key" + "ws://127.0.0.1:8080/api/v1/audio/chat/stream" ) + // TASK-12106: token must not appear in the connection URL. + expect(MockWebSocket.instances[0]?.url).not.toContain("token") + expect(MockWebSocket.instances[0]?.url).not.toContain("test-key") }) it("does not open a websocket when auth is missing", async () => { @@ -282,10 +288,14 @@ describe("useVoiceChatStream interrupt handling", () => { }) await waitFor(() => { - expect(ws.sent[0]).toBeDefined() + expect(ws.sent.length).toBeGreaterThan(1) }) - const configFrame = JSON.parse(ws.sent[0]!) + const frames = ws.sent.map((raw) => JSON.parse(raw)) + // Auth is the first frame; config follows. + expect(frames[0]).toEqual({ type: "auth", token: "test-key" }) + const configFrame = frames.find((frame) => frame.type === "config") + expect(configFrame).toBeDefined() expect(configFrame.llm.model).toBeUndefined() expect(configFrame.llm.provider).toBeUndefined() }) @@ -600,4 +610,201 @@ describe("useVoiceChatStream interrupt handling", () => { expect(onError).toHaveBeenCalledWith("Voice chat disconnected") }) + + it("sends the auth frame before the config frame on open", async () => { + renderHook(() => + useVoiceChatStream({ + active: true + }) + ) + + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + const ws = MockWebSocket.instances[0] + expect(ws).toBeDefined() + + await act(async () => { + ws.triggerOpen() + await Promise.resolve() + }) + + await waitFor(() => { + expect(ws.sent.length).toBeGreaterThan(1) + }) + + const frames = ws.sent.map((raw) => JSON.parse(raw)) + expect(frames[0]).toEqual({ type: "auth", token: "test-key" }) + expect(frames[1]?.type).toBe("config") + }) + + it("sends the barge-in interrupt only once per speaking turn", async () => { + renderHook(() => + useVoiceChatStream({ + active: true + }) + ) + + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + const ws = MockWebSocket.instances[0] + expect(ws).toBeDefined() + + await act(async () => { + ws.triggerOpen() + await Promise.resolve() + }) + + await act(async () => { + ws.triggerJson({ type: "tts_start", format: "mp3" }) + await Promise.resolve() + }) + + const priorSendCount = ws.sent.length + await act(async () => { + micState.callback?.(new ArrayBuffer(8)) + micState.callback?.(new ArrayBuffer(8)) + micState.callback?.(new ArrayBuffer(8)) + await Promise.resolve() + }) + + const newFrames = ws.sent.slice(priorSendCount).map((raw) => JSON.parse(raw)) + const interruptFrames = newFrames.filter((frame) => frame.type === "interrupt") + expect(interruptFrames).toHaveLength(1) + + // A new speaking turn re-arms the guard. + await act(async () => { + ws.triggerJson({ type: "interrupted", phase: "both" }) + ws.triggerJson({ type: "tts_start", format: "mp3" }) + await Promise.resolve() + }) + const secondTurnStart = ws.sent.length + await act(async () => { + micState.callback?.(new ArrayBuffer(8)) + micState.callback?.(new ArrayBuffer(8)) + await Promise.resolve() + }) + const secondTurnFrames = ws.sent + .slice(secondTurnStart) + .map((raw) => JSON.parse(raw)) + expect( + secondTurnFrames.filter((frame) => frame.type === "interrupt") + ).toHaveLength(1) + }) + + it("stops buffered TTS audio when the server interrupts", async () => { + renderHook(() => + useVoiceChatStream({ + active: true + }) + ) + + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + const ws = MockWebSocket.instances[0] + expect(ws).toBeDefined() + + await act(async () => { + ws.triggerOpen() + await Promise.resolve() + }) + + await act(async () => { + ws.triggerJson({ type: "tts_start", format: "mp3" }) + await Promise.resolve() + }) + + audioPlayerState.stop.mockClear() + + await act(async () => { + ws.triggerJson({ type: "interrupted", phase: "both" }) + await Promise.resolve() + }) + + expect(audioPlayerState.stop).toHaveBeenCalled() + }) + + it("drops mic frames when the socket buffer is backed up", async () => { + renderHook(() => + useVoiceChatStream({ + active: true + }) + ) + + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + const ws = MockWebSocket.instances[0] + expect(ws).toBeDefined() + + await act(async () => { + ws.triggerOpen() + await Promise.resolve() + await Promise.resolve() + }) + + // Simulate a slow uplink whose outbound buffer has backed up. + ws.bufferedAmount = 2 * 1024 * 1024 + const priorSendCount = ws.sent.length + + await act(async () => { + micState.callback?.(new ArrayBuffer(8)) + await Promise.resolve() + }) + + const newFrames = ws.sent.slice(priorSendCount).map((raw) => JSON.parse(raw)) + expect(newFrames.some((frame) => frame.type === "audio")).toBe(false) + + // Once the buffer drains, frames flow again. + ws.bufferedAmount = 0 + await act(async () => { + micState.callback?.(new ArrayBuffer(8)) + await Promise.resolve() + }) + const drainedFrames = ws.sent + .slice(priorSendCount) + .map((raw) => JSON.parse(raw)) + expect(drainedFrames.some((frame) => frame.type === "audio")).toBe(true) + }) + + it("times out the handshake and surfaces an error when onopen never fires", async () => { + const onError = vi.fn() + vi.useFakeTimers() + try { + const { result } = renderHook(() => + useVoiceChatStream({ + active: false, + onError + }) + ) + + await act(async () => { + await result.current.start() + await Promise.resolve() + await Promise.resolve() + }) + + const ws = MockWebSocket.instances[0] + expect(ws).toBeDefined() + + // Never fire onopen; advance past the handshake timeout. + await act(async () => { + await vi.advanceTimersByTimeAsync(10000) + }) + + expect(onError).toHaveBeenCalledWith("Voice chat connection timed out") + } finally { + vi.useRealTimers() + } + }) }) diff --git a/apps/packages/ui/src/hooks/chat-modes/__tests__/chatModePipeline.abort-lifecycle.test.ts b/apps/packages/ui/src/hooks/chat-modes/__tests__/chatModePipeline.abort-lifecycle.test.ts new file mode 100644 index 0000000000..b85f22fae5 --- /dev/null +++ b/apps/packages/ui/src/hooks/chat-modes/__tests__/chatModePipeline.abort-lifecycle.test.ts @@ -0,0 +1,308 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + pageAssistModel: vi.fn(), + saveMessageOnSuccess: vi.fn(async () => "history-1"), + saveMessageOnError: vi.fn(async () => "history-1"), + setMessages: vi.fn(), + setHistory: vi.fn(), + setIsProcessing: vi.fn(), + setStreaming: vi.fn(), + setAbortController: vi.fn(), + setHistoryId: vi.fn() +})) + +vi.mock("@/models", () => ({ + pageAssistModel: (...args: unknown[]) => mocks.pageAssistModel(...args) +})) + +vi.mock("@/db/dexie/helpers", () => ({ + generateID: vi.fn(() => "generated-id") +})) + +vi.mock("@/db/dexie/nickname", () => ({ + getModelNicknameByID: vi.fn(async () => null) +})) + +vi.mock("@/utils/mcp-disclosure", () => ({ + applyMcpModuleDisclosureFromToolCalls: vi.fn() +})) + +vi.mock("@/store/option", () => ({ + useStoreMessageOption: { + getState: () => ({ + setHistory: vi.fn() + }) + } +})) + +import { + runChatPipeline, + type ChatModeDefinition, + type ChatModeParamsBase +} from "../chatModePipeline" + +const mode: ChatModeDefinition = { + id: "normal", + buildUserMessage: (ctx) => ({ + isBot: false, + name: "You", + message: ctx.message, + sources: [], + createdAt: ctx.createdAt, + id: ctx.resolvedUserMessageId + }), + buildAssistantMessage: (ctx) => ({ + isBot: true, + name: "Assistant", + message: "▋", + sources: [], + createdAt: ctx.createdAt, + id: ctx.resolvedAssistantMessageId + }), + preparePrompt: async () => ({ + chatHistory: [{ role: "system", content: "existing system" }], + humanMessage: { role: "user", content: "Tell me a story" }, + sources: [] + }) +} + +const buildParams = (overrides: Record = {}) => ({ + selectedModel: "test-model", + useOCR: false, + setMessages: mocks.setMessages, + saveMessageOnSuccess: mocks.saveMessageOnSuccess, + saveMessageOnError: mocks.saveMessageOnError, + setHistory: mocks.setHistory, + setIsProcessing: mocks.setIsProcessing, + setStreaming: mocks.setStreaming, + setAbortController: mocks.setAbortController, + historyId: "history-1", + setHistoryId: mocks.setHistoryId, + ...overrides +}) + +describe("runChatPipeline abort lifecycle", () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.pageAssistModel.mockResolvedValue({ + saveToDb: false, + stream: async function* () { + yield "hello world" + } + }) + }) + + it("discards the empty assistant bubble when aborted before any token", async () => { + mocks.pageAssistModel.mockResolvedValue({ + saveToDb: false, + stream: async function* () { + // No tokens arrive before the abort. + } + }) + + const controller = new AbortController() + controller.abort() + + const result = await runChatPipeline( + mode, + "Tell me a story", + "", + false, + [], + [], + controller.signal, + buildParams() + ) + + expect(result).toMatchObject({ status: "skipped" }) + // Never persisted as a complete answer nor as an empty error bubble. + expect(mocks.saveMessageOnSuccess).not.toHaveBeenCalled() + expect(mocks.saveMessageOnError).not.toHaveBeenCalled() + }) + + it("saves a partially-streamed abort as interrupted, never via the success path", async () => { + const controller = new AbortController() + mocks.pageAssistModel.mockResolvedValue({ + saveToDb: false, + stream: async function* () { + yield "partial answer" + // User aborts after the first token arrives. + controller.abort() + } + }) + + const result = await runChatPipeline( + mode, + "Tell me a story", + "", + false, + [], + [], + controller.signal, + buildParams() + ) + + expect(result).toMatchObject({ status: "skipped" }) + expect(mocks.saveMessageOnSuccess).not.toHaveBeenCalled() + expect(mocks.saveMessageOnError).toHaveBeenCalledWith( + expect.objectContaining({ + botMessage: expect.stringContaining("partial answer") + }) + ) + }) + + it("marks a stream_transport_interrupted answer as interrupted, never via saveMessageOnSuccess", async () => { + let messagesState: any[] = [] + const setMessages = vi.fn((updater: any) => { + messagesState = + typeof updater === "function" ? updater(messagesState) : updater + }) + + mocks.pageAssistModel.mockResolvedValue({ + saveToDb: false, + stream: async function* () { + yield "partial answer" + // Extension port dropped after the first byte: the background proxy + // synthesizes this sentinel, which ChatTldw re-emits as an object chunk. + yield { + event: "stream_transport_interrupted", + detail: "Extension port disconnected", + partial_response_saved: true + } + } + }) + + const result = await runChatPipeline( + mode, + "Tell me a story", + "", + false, + [], + [], + new AbortController().signal, + buildParams({ + setMessages, + userMessageId: "user-1", + assistantMessageId: "asst-1" + }) + ) + + expect(result).toMatchObject({ status: "skipped" }) + // Never finalized as a complete answer (which would mirror to the server). + expect(mocks.saveMessageOnSuccess).not.toHaveBeenCalled() + // Persisted via the interrupted/error path with the partial text preserved. + expect(mocks.saveMessageOnError).toHaveBeenCalledWith( + expect.objectContaining({ + botMessage: expect.stringContaining("partial answer") + }) + ) + const assistant = messagesState.find((m) => m.id === "asst-1") + expect(assistant?.message).toBe("partial answer") + expect(assistant?.generationInfo).toMatchObject({ + interrupted: true, + streamTransportInterrupted: true, + partialResponseSaved: true + }) + }) + + it("discards the empty variant and restores the prior one when a regenerate is aborted before the first token", async () => { + const originalAssistant = { + id: "orig-assistant", + isBot: true, + name: "Assistant", + message: "original answer", + sources: [], + createdAt: 2 + } + let messagesState: any[] = [ + { + id: "user-1", + isBot: false, + name: "You", + message: "Tell me a story", + sources: [], + createdAt: 1 + } + ] + const setMessages = vi.fn((updater: any) => { + messagesState = + typeof updater === "function" ? updater(messagesState) : updater + }) + + mocks.pageAssistModel.mockResolvedValue({ + saveToDb: false, + stream: async function* () { + // No tokens arrive before the abort. + } + }) + + const controller = new AbortController() + controller.abort() + + const result = await runChatPipeline( + mode, + "Tell me a story", + "", + true, + messagesState, + [], + controller.signal, + buildParams({ + setMessages, + assistantMessageId: "orig-assistant", + regenerateFromMessage: originalAssistant + }) + ) + + expect(result).toMatchObject({ status: "skipped" }) + // Cleanly discarded — neither a success nor an interrupted variant persisted. + expect(mocks.saveMessageOnSuccess).not.toHaveBeenCalled() + expect(mocks.saveMessageOnError).not.toHaveBeenCalled() + + const assistant = messagesState.find((m) => m.isBot) + expect(assistant).toBeTruthy() + // The empty new variant is gone and the prior variant is restored/active. + expect(assistant.variants?.length ?? 0).toBeLessThanOrEqual(1) + expect(assistant.activeVariantIndex ?? 0).toBe(0) + expect(assistant.message).toBe("original answer") + }) + + it("does not reset shared streaming state when the turn no longer owns the controller", async () => { + const result = await runChatPipeline( + mode, + "Tell me a story", + "", + false, + [], + [], + new AbortController().signal, + buildParams({ releaseAbortControllerIfOwned: () => false }) + ) + + expect(result).toMatchObject({ status: "submitted" }) + expect(mocks.setStreaming).not.toHaveBeenCalledWith(false) + expect(mocks.setAbortController).not.toHaveBeenCalledWith(null) + }) + + it("resets shared streaming state when the turn still owns the controller", async () => { + const releaseAbortControllerIfOwned = vi.fn(() => true) + + const result = await runChatPipeline( + mode, + "Tell me a story", + "", + false, + [], + [], + new AbortController().signal, + buildParams({ releaseAbortControllerIfOwned }) + ) + + expect(result).toMatchObject({ status: "submitted" }) + expect(releaseAbortControllerIfOwned).toHaveBeenCalled() + expect(mocks.setStreaming).toHaveBeenCalledWith(false) + expect(mocks.setAbortController).toHaveBeenCalledWith(null) + }) +}) diff --git a/apps/packages/ui/src/hooks/chat-modes/chatModePipeline.ts b/apps/packages/ui/src/hooks/chat-modes/chatModePipeline.ts index 205621002f..79725b2185 100644 --- a/apps/packages/ui/src/hooks/chat-modes/chatModePipeline.ts +++ b/apps/packages/ui/src/hooks/chat-modes/chatModePipeline.ts @@ -64,6 +64,11 @@ export type ChatModeParamsBase = { setIsProcessing: (value: boolean) => void setStreaming: (value: boolean) => void setAbortController: (controller: AbortController | null) => void + // Returns true (and releases ownership) only if this turn still owns the + // shared abort controller identified by `signal`. Used so a finishing turn + // does not clobber a newer in-flight turn's streaming flag / controller. + // When omitted, callers get the previous unconditional reset behavior. + releaseAbortControllerIfOwned?: (signal: AbortSignal) => boolean historyId: string | null setHistoryId: (id: string) => void actorSettings?: ActorSettings @@ -221,6 +226,20 @@ export const runChatPipeline = async ( isRegenerate && regenerateFromMessage ? normalizeMessageVariants(regenerateFromMessage) : [] + // The variant that was active before this regenerate began. Used to restore + // prior state if the regenerate is cancelled before any token arrives. + const priorActiveVariantIndex = + isRegenerate && regenerateFromMessage && regenerateVariants.length > 0 + ? Math.min( + Math.max( + 0, + typeof regenerateFromMessage.activeVariantIndex === "number" + ? regenerateFromMessage.activeVariantIndex + : regenerateVariants.length - 1 + ), + regenerateVariants.length - 1 + ) + : 0 const context: ChatModeContext = { ...params, @@ -375,6 +394,49 @@ export const runChatPipeline = async ( const abortCancelStreamingUpdate = () => cancelStreamingUpdate() signal.addEventListener("abort", abortCancelStreamingUpdate) + // Regenerate appends a new (initially empty) variant. If the regenerate is + // cancelled before any token arrives, drop that empty variant and restore the + // previously-active variant instead of leaving an empty interrupted variant. + // Returns true when it handled the discard (so callers can finalize as + // skipped), false when there was nothing to restore. + const discardEmptyRegenerateVariant = (): boolean => { + if (!isRegenerate) return false + if (regenerateVariants.length === 0) { + // No prior variant to restore to: drop the empty assistant message. + setMessagesWithTransition((prev) => + prev.filter((msg) => msg.id !== generateMessageId) + ) + return true + } + const restoreIndex = Math.min( + Math.max(0, priorActiveVariantIndex), + regenerateVariants.length - 1 + ) + const restoredVariant = regenerateVariants[restoreIndex] + setMessagesWithTransition((prev) => + prev.map((msg) => { + if (msg.id !== generateMessageId) return msg + const variants = Array.isArray(msg.variants) + ? (msg.variants as MessageVariant[]) + : [] + // Only trim when the appended empty stub is still the trailing variant. + const trimmed = + variants.length > regenerateVariants.length + ? variants.slice(0, regenerateVariants.length) + : variants + const base: Message = { + ...msg, + variants: trimmed, + activeVariantIndex: restoreIndex + } + return restoredVariant + ? applyVariantToMessage(base, restoredVariant, restoreIndex) + : base + }) + ) + return true + } + if (mode.setupMessages) { const setup = mode.setupMessages(context) generateMessageId = setup.targetMessageId @@ -629,6 +691,38 @@ export const runChatPipeline = async ( count++ } + // User abort: never fall through to the success path (which would save the + // partial as a complete answer). Route through the interrupted path, or + // discard entirely if nothing was streamed before the abort. + if (signal.aborted) { + cancelStreamingUpdate() + signal.removeEventListener("abort", abortCancelStreamingUpdate) + const abortedBeforeAnyContent = + count === 0 && + fullText.trim().length === 0 && + !isImageGenerationTurn + if (abortedBeforeAnyContent) { + if (isRegenerate) { + // Regenerate cancelled before any token arrived: discard the empty + // new variant and restore the previously-active variant instead of + // persisting an empty interrupted variant. + if (discardEmptyRegenerateVariant()) { + return chatSubmitSkipped("Request cancelled") + } + } else { + // Nothing arrived before the user aborted: drop the empty assistant + // stub instead of persisting an empty/error bubble. + setMessagesWithTransition((prev) => + prev.filter((msg) => msg.id !== generateMessageId) + ) + return chatSubmitSkipped("Request cancelled") + } + } + const abortError = new Error("Request cancelled") + abortError.name = "AbortError" + throw abortError + } + if ( !signal.aborted && count === 0 && @@ -645,6 +739,7 @@ export const runChatPipeline = async ( signal.removeEventListener("abort", abortCancelStreamingUpdate) const toolCalls = extractToolCalls(generationInfo) if ( + !signal.aborted && fullText.trim().length === 0 && (!Array.isArray(toolCalls) || toolCalls.length === 0) && !isImageGenerationTurn @@ -691,6 +786,46 @@ export const runChatPipeline = async ( ) ) + // Stream transport interrupted mid-answer (extension port dropped after the + // first byte): the assistant message is already marked interrupted above. + // Persist the partial via the interrupted/error path — never + // saveMessageOnSuccess, which would finalize a truncated answer as COMPLETE + // and mirror it to the server. This mirrors character chat's handling of the + // same `stream_transport_interrupted` sentinel. + if (streamTransportInterrupted) { + const interruptionReason = + streamTransportInterruptionReason || + "Stream transport interrupted; partial response saved." + await saveMessageOnError({ + e: new Error(interruptionReason), + botMessage: fullText, + history, + historyId, + image, + selectedModel, + setHistory: setHistorySafely, + setHistoryId, + userMessage: message, + isRegenerating: isRegenerate, + userMessageType, + assistantMessageType, + clusterId, + modelId: resolvedModelId, + userModelId, + userMessageId: resolvedUserMessageId, + assistantMessageId: resolvedAssistantMessageId, + userParentMessageId: userParentMessageId ?? null, + assistantParentMessageId: resolvedAssistantParentMessageId ?? null, + documents, + isContinue: mode.isContinue, + prompt_content: promptContent, + prompt_id: promptId, + userMetadataExtra: !isRegenerate ? params.userMetadataExtra : undefined, + assistantMetadataExtra + }) + return chatSubmitSkipped(interruptionReason) + } + setHistorySafely( mode.updateHistory ? mode.updateHistory(context, fullText) @@ -736,6 +871,23 @@ export const runChatPipeline = async ( cancelStreamingUpdate() signal.removeEventListener("abort", abortCancelStreamingUpdate) const isAbort = signal.aborted || isAbortLikeError(e) + if (isAbort && !isImageGenerationTurn && fullText.trim().length === 0) { + // Aborted before any content arrived (the pull was still in flight). + if (isRegenerate) { + // Regenerate: discard the empty new variant and restore the + // previously-active variant instead of persisting an empty interrupted + // variant. + if (discardEmptyRegenerateVariant()) { + return chatSubmitSkipped("Request cancelled") + } + } else { + // Drop the empty assistant stub instead of persisting an empty bubble. + setMessagesWithTransition((prev) => + prev.filter((msg) => msg.id !== generateMessageId) + ) + return chatSubmitSkipped("Request cancelled") + } + } const assistantContent = isAbort ? fullText : buildAssistantErrorContent(fullText, e) @@ -807,8 +959,16 @@ export const runChatPipeline = async ( return chatSubmitFailed(interruptionReason) } finally { signal.removeEventListener("abort", abortCancelStreamingUpdate) - setIsProcessing(false) - setStreaming(false) - setAbortController(null) + // Only reset the shared streaming flag / controller if this turn still owns + // it. A newer in-flight turn may have replaced the controller; clobbering it + // would re-enable the send button and orphan the newer turn's Stop button. + const stillOwnsTurn = params.releaseAbortControllerIfOwned + ? params.releaseAbortControllerIfOwned(signal) + : true + if (stillOwnsTurn) { + setIsProcessing(false) + setStreaming(false) + setAbortController(null) + } } } diff --git a/apps/packages/ui/src/hooks/chat/__tests__/characterChatMode.watchdog.guard.test.ts b/apps/packages/ui/src/hooks/chat/__tests__/characterChatMode.watchdog.guard.test.ts new file mode 100644 index 0000000000..340d056510 --- /dev/null +++ b/apps/packages/ui/src/hooks/chat/__tests__/characterChatMode.watchdog.guard.test.ts @@ -0,0 +1,52 @@ +import fs from "node:fs" +import path from "node:path" +import { describe, expect, it } from "vitest" + +/** + * Guard test: ensures the stream-inactivity watchdog actually lives in the two + * shipped (LIVE) character-streaming paths, not just the extracted (formerly + * unused) copy. Task-12108 / round-2 audit finding R5. + */ +const readSource = (relativePath: string) => { + const file = path.resolve(__dirname, relativePath) + try { + return fs.readFileSync(file, "utf8") + } catch { + return "" + } +} + +const LIVE_SOURCES: Array<{ name: string; source: string }> = [ + { + // Playground / Option path + name: "useChatActions.ts", + source: readSource("../useChatActions.ts") + }, + { + // Sidepanel path + name: "useMessage.tsx", + source: readSource("../../useMessage.tsx") + } +] + +describe("live characterChatMode stream-inactivity watchdog guard", () => { + it.each(LIVE_SOURCES)( + "$name arms a 60s inactivity watchdog that aborts a stalled stream", + ({ source }) => { + expect(source.length).toBeGreaterThan(0) + // Watchdog wiring + expect(source).toContain("STREAM_INACTIVITY_TIMEOUT_MS = 60_000") + expect(source).toContain("resetInactivityTimer") + expect(source).toContain("inactivityAborted = true") + // The watchdog must reset on each received chunk and re-throw on timeout. + expect(source).toContain("StreamInactivityTimeout") + // The abort must be surfaced/recovered, not swallowed. + expect(source).toContain("buildCharacterChatAssistantErrorContent") + } + ) + + it("keeps the watchdog value aligned with the extracted reference copy", () => { + const extracted = readSource("../useCharacterChatMode.ts") + expect(extracted).toContain("STREAM_INACTIVITY_TIMEOUT_MS = 60_000") + }) +}) diff --git a/apps/packages/ui/src/hooks/chat/__tests__/useCharacterChatMode.watchdog.test.ts b/apps/packages/ui/src/hooks/chat/__tests__/useCharacterChatMode.watchdog.test.ts new file mode 100644 index 0000000000..2db57ef6f3 --- /dev/null +++ b/apps/packages/ui/src/hooks/chat/__tests__/useCharacterChatMode.watchdog.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + addChatMessageMock: vi.fn(), + createChatMock: vi.fn(), + detectCharacterMoodMock: vi.fn(), + generateIDMock: vi.fn(), + getModelNicknameByIDMock: vi.fn(), + initializeMock: vi.fn(), + persistCharacterCompletionMock: vi.fn(), + resolveApiProviderForModelMock: vi.fn(), + resolveExplicitProviderForSelectedModelMock: vi.fn(), + streamCharacterChatCompletionMock: vi.fn(), + consumeStreamingChunkMock: vi.fn() +})) + +vi.mock("@/db/dexie/helpers", () => ({ + generateID: () => mocks.generateIDMock() +})) + +vi.mock("@/db/dexie/nickname", () => ({ + getModelNicknameByID: (modelId: string) => mocks.getModelNicknameByIDMock(modelId) +})) + +vi.mock("@/services/tldw/TldwApiClient", () => ({ + tldwClient: { + addChatMessage: (...args: unknown[]) => mocks.addChatMessageMock(...args), + createChat: (...args: unknown[]) => mocks.createChatMock(...args), + initialize: () => mocks.initializeMock(), + persistCharacterCompletion: (...args: unknown[]) => + mocks.persistCharacterCompletionMock(...args), + streamCharacterChatCompletion: (...args: unknown[]) => + mocks.streamCharacterChatCompletionMock(...args) + } +})) + +vi.mock("@/utils/character-mood", () => ({ + detectCharacterMood: (...args: unknown[]) => mocks.detectCharacterMoodMock(...args) +})) + +vi.mock("@/utils/resolve-api-provider", () => ({ + resolveApiProviderForModel: (...args: unknown[]) => + mocks.resolveApiProviderForModelMock(...args), + resolveExplicitProviderForSelectedModel: (...args: unknown[]) => + mocks.resolveExplicitProviderForSelectedModelMock(...args) +})) + +vi.mock("@/utils/streaming-chunks", () => ({ + consumeStreamingChunk: (...args: unknown[]) => mocks.consumeStreamingChunkMock(...args) +})) + +import { createCharacterChatMode } from "../useCharacterChatMode" + +// Must match the watchdog value baked into useCharacterChatMode.ts and the two +// live inline copies (useChatActions.ts, useMessage.tsx). +const STREAM_INACTIVITY_TIMEOUT_MS = 60_000 + +const createSetterBundle = () => ({ + setAbortController: vi.fn(), + setHistory: vi.fn(), + setHistoryId: vi.fn(), + setIsProcessing: vi.fn(), + setMessages: vi.fn(), + setServerChatCharacterId: vi.fn(), + setServerChatClusterId: vi.fn(), + setServerChatExternalRef: vi.fn(), + setServerChatId: vi.fn(), + setServerChatMetaLoaded: vi.fn(), + setServerChatSource: vi.fn(), + setServerChatState: vi.fn(), + setServerChatTitle: vi.fn(), + setServerChatTopic: vi.fn(), + setServerChatVersion: vi.fn(), + setStreaming: vi.fn() +}) + +const translate = ( + key: string, + fallbackOrOptions?: string | { defaultValue?: string; name?: string } +) => + typeof fallbackOrOptions === "string" + ? fallbackOrOptions + : fallbackOrOptions?.defaultValue || key + +describe("createCharacterChatMode stream-inactivity watchdog", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + + let id = 0 + mocks.generateIDMock.mockImplementation(() => { + id += 1 + return `generated-${id}` + }) + mocks.initializeMock.mockResolvedValue(null) + mocks.getModelNicknameByIDMock.mockResolvedValue(null) + mocks.resolveExplicitProviderForSelectedModelMock.mockReturnValue("openai") + mocks.resolveApiProviderForModelMock.mockResolvedValue("openai") + mocks.createChatMock.mockResolvedValue({ + id: "chat-77", + title: "Mira session", + character_id: 42, + state: "in-progress", + version: 3 + }) + mocks.addChatMessageMock.mockResolvedValue({ id: "msg-user-1", version: 1 }) + mocks.persistCharacterCompletionMock.mockResolvedValue({ + assistant_message_id: "msg-assistant-1", + version: 2 + }) + mocks.consumeStreamingChunkMock.mockImplementation((state) => ({ + fullText: state.fullText, + contentToSave: state.contentToSave, + token: "", + apiReasoning: state.apiReasoning + })) + mocks.detectCharacterMoodMock.mockReturnValue({ + label: "neutral", + confidence: 0.2, + topic: "reply" + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("aborts a stalled stream after the inactivity timeout instead of hanging", async () => { + // A stream that never yields a chunk. It only settles once the watchdog + // aborts the shared controller, mirroring how a real fetch-backed stream + // unwinds on abort. + mocks.streamCharacterChatCompletionMock.mockImplementation( + async function* ( + _chatId: unknown, + _options: unknown, + transport: { signal: AbortSignal } + ) { + const { signal } = transport + await new Promise((resolve) => { + if (signal.aborted) { + resolve() + return + } + signal.addEventListener("abort", () => resolve(), { once: true }) + }) + // Never yields: the stalled stream ends only because it was aborted. + } + ) + + const setters = createSetterBundle() + let messagesState: any[] = [] + setters.setMessages.mockImplementation((next) => { + messagesState = typeof next === "function" ? next(messagesState) : next + }) + const saveMessageOnError = vi.fn(async (_payload: any) => null) + const notification = { error: vi.fn() } + const controller = new AbortController() + + const mode = createCharacterChatMode({ + ...setters, + t: translate as any, + notification, + selectedCharacter: { id: 42, name: "Mira" } as any, + temporaryChat: false, + historyId: "history-1", + serverChatId: null, + serverChatCharacterId: null, + serverChatState: "in-progress", + serverChatTopic: null, + serverChatClusterId: null, + serverChatSource: null, + serverChatExternalRef: null, + currentChatModelSettings: { apiProvider: "openai", setSystemPrompt: vi.fn() }, + invalidateServerChatHistory: vi.fn(), + greetingEnabled: false, + greetingSelectionId: null, + greetingsChecksum: null, + useCharacterDefault: false, + directedCharacterId: null, + resolvedMessageSteeringPrompts: null, + getEffectiveSelectedModel: vi.fn(() => "tldw:test-model"), + saveMessageOnSuccess: vi.fn(async () => "history-1"), + saveMessageOnError, + discardCurrentTurnOnAbortRef: { current: false } + } as any) + + const runPromise = mode({ + message: "Hello Mira", + image: "", + isRegenerate: false, + messages: [], + history: [], + signal: controller.signal, + model: "tldw:test-model", + controller, + messageSteering: { + continueAsUser: false, + impersonateUser: false, + forceNarrate: false + } + }) + + // Let the async setup (initialize/createChat/addChatMessage) settle and the + // watchdog timer get scheduled before we advance fake time. + await vi.advanceTimersByTimeAsync(0) + expect(controller.signal.aborted).toBe(false) + expect(saveMessageOnError).not.toHaveBeenCalled() + + // Fast-forward past the inactivity window: the watchdog must fire. + await vi.advanceTimersByTimeAsync(STREAM_INACTIVITY_TIMEOUT_MS) + await runPromise + + expect(controller.signal.aborted).toBe(true) + expect(saveMessageOnError).toHaveBeenCalledTimes(1) + const errorArg = saveMessageOnError.mock.calls[0][0]?.e + expect((errorArg as any)?.name).toBe("StreamInactivityTimeout") + expect(notification.error).toHaveBeenCalledWith( + expect.objectContaining({ message: "Stream timed out" }) + ) + expect(setters.setStreaming).toHaveBeenLastCalledWith(false) + }) +}) diff --git a/apps/packages/ui/src/hooks/chat/useCharacterChatMode.ts b/apps/packages/ui/src/hooks/chat/useCharacterChatMode.ts index d65d218e66..ec65e96cd9 100644 --- a/apps/packages/ui/src/hooks/chat/useCharacterChatMode.ts +++ b/apps/packages/ui/src/hooks/chat/useCharacterChatMode.ts @@ -269,7 +269,7 @@ export const classifyCharacterChatFailureRecovery = ( }; }; -const buildCharacterChatAssistantErrorContent = ( +export const buildCharacterChatAssistantErrorContent = ( botMessage: string | undefined, rawError: unknown, t: TFunction, diff --git a/apps/packages/ui/src/hooks/chat/useChatActions.ts b/apps/packages/ui/src/hooks/chat/useChatActions.ts index c2aa4f75e4..919d932cab 100644 --- a/apps/packages/ui/src/hooks/chat/useChatActions.ts +++ b/apps/packages/ui/src/hooks/chat/useChatActions.ts @@ -35,6 +35,7 @@ import { import { generateBranchFromMessageIds } from "@/db/dexie/branch" import { type UploadedFile } from "@/db/dexie/types" import { buildAssistantErrorContent } from "@/utils/chat-error-message" +import { buildCharacterChatAssistantErrorContent } from "./useCharacterChatMode" import { detectCharacterMood } from "@/utils/character-mood" import { WEBUI_CHARACTER_CHAT_SOURCE } from "@/utils/character-chat-session" import { @@ -591,6 +592,23 @@ export const useChatActions = ({ ) const messagesRef = React.useRef(messages) const discardCurrentTurnOnAbortRef = React.useRef(false) + // Tracks the abort controller owned by the most recently started turn. Used so + // a finishing turn only resets the shared streaming flag / controller if it + // still owns it (a newer in-flight turn may have taken ownership). + const activeAbortControllerRef = React.useRef(null) + const releaseAbortControllerIfOwned = React.useCallback( + (turnSignal: AbortSignal): boolean => { + if ( + activeAbortControllerRef.current && + activeAbortControllerRef.current.signal === turnSignal + ) { + activeAbortControllerRef.current = null + return true + } + return false + }, + [] + ) React.useEffect(() => { messagesRef.current = messages @@ -1091,6 +1109,7 @@ export const useChatActions = ({ setIsProcessing, setStreaming, setAbortController, + releaseAbortControllerIfOwned, historyId: resolvedHistoryId ?? historyId, setHistoryId, fileRetrievalEnabled, @@ -1320,6 +1339,7 @@ export const useChatActions = ({ let pendingStreamingText = "" let pendingReasoningTime = 0 let lastStreamingUpdateAt = 0 + let inactivityTimer: ReturnType | null = null const flushStreamingUpdate = () => { if (pendingStreamingText.length === 0) return @@ -1714,6 +1734,23 @@ export const useChatActions = ({ normalizedModel.length > 0 ? normalizedModel : resolvedModel const shouldPersistToServer = !temporaryChat + // Stream-inactivity watchdog: if no chunk arrives within the timeout, abort + // the underlying stream so a stalled character response cannot hang forever. + const STREAM_INACTIVITY_TIMEOUT_MS = 60_000 + let inactivityAborted = false + const turnController = + activeAbortControllerRef.current && + activeAbortControllerRef.current.signal === signal + ? activeAbortControllerRef.current + : null + const resetInactivityTimer = () => { + if (inactivityTimer) clearTimeout(inactivityTimer) + inactivityTimer = setTimeout(() => { + inactivityAborted = true + turnController?.abort() + }, STREAM_INACTIVITY_TIMEOUT_MS) + } + resetInactivityTimer() for await (const chunk of tldwClient.streamCharacterChatCompletion( chatId, { @@ -1731,6 +1768,7 @@ export const useChatActions = ({ }, { signal } )) { + resetInactivityTimer() const interruptionEvent = chunk && typeof chunk === "object" && !Array.isArray(chunk) ? (chunk as Record) @@ -1784,6 +1822,15 @@ export const useChatActions = ({ } cancelStreamingUpdate() flushStreamingUpdate() + if (inactivityTimer) clearTimeout(inactivityTimer) + + if (inactivityAborted) { + const timeoutError = new Error( + "Stream timed out: no data received for 60 seconds" + ) + ;(timeoutError as any).name = "StreamInactivityTimeout" + throw timeoutError + } if (signal?.aborted) { const abortError = new Error("AbortError") @@ -2118,7 +2165,11 @@ export const useChatActions = ({ return true } }) - const assistantContent = buildAssistantErrorContent(fullText, e) + const assistantContent = buildCharacterChatAssistantErrorContent( + fullText, + e, + t + ) const interruptionReason = e instanceof Error ? e.message : t("somethingWentWrong") if (generateMessageId) { @@ -2155,8 +2206,12 @@ export const useChatActions = ({ }) if (!errorSave) { + const isInactivityTimeout = + e instanceof Error && (e as any).name === "StreamInactivityTimeout" notification.error({ - message: t("error"), + message: isInactivityTimeout + ? t("playground:streamTimeout", { defaultValue: "Stream timed out" }) + : t("error"), description: e instanceof Error ? e.message : t("somethingWentWrong") }) } @@ -2166,7 +2221,12 @@ export const useChatActions = ({ } finally { discardCurrentTurnOnAbortRef.current = false cancelStreamingUpdate() - setAbortController(null) + if (inactivityTimer) clearTimeout(inactivityTimer) + // Only null the shared controller if this turn still owns it, so a newer + // in-flight turn's controller is not clobbered (and stays cancellable). + if (releaseAbortControllerIfOwned(signal)) { + setAbortController(null) + } } } @@ -2426,9 +2486,11 @@ export const useChatActions = ({ const newController = new AbortController() signal = newController.signal setAbortController(newController) + activeAbortControllerRef.current = newController } else { setAbortController(controller) signal = controller.signal + activeAbortControllerRef.current = controller } const messageSteeringForTurn = messageSteeringOverride @@ -2475,26 +2537,8 @@ export const useChatActions = ({ dynamicUIRequest ?? requestOverrides?.dynamicUIRequest const turnUserMetadataExtra = userMetadataExtra ?? requestOverrides?.userMetadataExtra - const chatModeParams = await buildChatModeParams({ - ...(requestOverrides ?? {}), - ragMediaIds: turnRagMediaIds, - fileRetrievalEnabled: turnFileRetrievalEnabled, - selectedKnowledge: turnSelectedKnowledge, - selectedModel: effectiveSelectedModel, - messageSteering: messageSteeringForTurn, - userMessageType, - assistantMessageType, - imageGenerationRequest, - imageGenerationRefine, - imageGenerationPromptMode, - imageGenerationSource, - imageEventSyncPolicy, - researchContext, - dynamicUIRequest: turnDynamicUIRequest, - userMetadataExtra: turnUserMetadataExtra - }) - const baseMessages = chatHistory || messages - const baseHistory = memory || history + // Declared before the try so the catch/finally can still read them even if + // a pre-stream await throws below. const capturedReplyTargetId = replyTarget?.id ?? null const replyActive = Boolean(replyTarget) && @@ -2502,27 +2546,50 @@ export const useChatActions = ({ !isRegenerate && !isContinue && !selectedCharacter?.id - const replyOverrides = replyActive - ? (() => { - const userMessageId = generateID() - const assistantMessageId = generateID() - return { - userMessageId, - assistantMessageId, - userParentMessageId: replyTarget?.id ?? null, - assistantParentMessageId: userMessageId - } - })() - : {} - const chatModeParamsWithReply = replyActive - ? { ...chatModeParams, ...replyOverrides } - : chatModeParams - const chatModeParamsWithRegen = { - ...chatModeParamsWithReply, - regenerateFromMessage: isRegenerate ? regenerateFromMessage : undefined - } try { + // Pre-stream awaits run inside the try so a failure resets streaming state + // (and lets the caller drain its queue) instead of stranding the UI. + const chatModeParams = await buildChatModeParams({ + ...(requestOverrides ?? {}), + ragMediaIds: turnRagMediaIds, + fileRetrievalEnabled: turnFileRetrievalEnabled, + selectedKnowledge: turnSelectedKnowledge, + selectedModel: effectiveSelectedModel, + messageSteering: messageSteeringForTurn, + userMessageType, + assistantMessageType, + imageGenerationRequest, + imageGenerationRefine, + imageGenerationPromptMode, + imageGenerationSource, + imageEventSyncPolicy, + researchContext, + dynamicUIRequest: turnDynamicUIRequest, + userMetadataExtra: turnUserMetadataExtra + }) + const baseMessages = chatHistory || messages + const baseHistory = memory || history + const replyOverrides = replyActive + ? (() => { + const userMessageId = generateID() + const assistantMessageId = generateID() + return { + userMessageId, + assistantMessageId, + userParentMessageId: replyTarget?.id ?? null, + assistantParentMessageId: userMessageId + } + })() + : {} + const chatModeParamsWithReply = replyActive + ? { ...chatModeParams, ...replyOverrides } + : chatModeParams + const chatModeParamsWithRegen = { + ...chatModeParamsWithReply, + regenerateFromMessage: isRegenerate ? regenerateFromMessage : undefined + } + if (isContinue) { const continueMessages = chatHistory || messages const continueHistory = memory || history @@ -2985,6 +3052,9 @@ export const useChatActions = ({ setStreaming: () => {}, setIsProcessing: () => {}, setAbortController: () => {}, + // Compare owns the shared controller for the whole turn (see the + // single reset after Promise.all). Sub-turns must not release it. + releaseAbortControllerIfOwned: () => false, messageSteering: messageSteeringForTurn }) const compareEnhancedParams = { @@ -3033,6 +3103,7 @@ export const useChatActions = ({ setIsProcessing(false) setStreaming(false) setAbortController(null) + activeAbortControllerRef.current = null return aggregateChatSubmitResults(compareResults) } } @@ -3043,6 +3114,11 @@ export const useChatActions = ({ message: t("error"), description: errorMessage }) + // If this turn still owns the shared controller (e.g. a pre-stream failure + // before any chat mode ran), release it so the UI is not left streaming. + if (releaseAbortControllerIfOwned(signal)) { + setAbortController(null) + } setIsProcessing(false) setStreaming(false) return chatSubmitFailed(errorMessage) @@ -3092,6 +3168,7 @@ export const useChatActions = ({ setStreaming(true) const newController = new AbortController() setAbortController(newController) + activeAbortControllerRef.current = newController const signal = newController.signal const baseMessages = messages @@ -3204,9 +3281,13 @@ export const useChatActions = ({ description: errorMessage }) } finally { - setStreaming(false) - setIsProcessing(false) - setAbortController(null) + // Only reset shared streaming state if this turn still owns the + // controller (an inner chat mode may already have released it). + if (releaseAbortControllerIfOwned(signal)) { + setStreaming(false) + setIsProcessing(false) + setAbortController(null) + } if (shouldConsumeSteering) { clearMessageSteering() } diff --git a/apps/packages/ui/src/hooks/document-workspace/__tests__/useDocumentTTS.test.tsx b/apps/packages/ui/src/hooks/document-workspace/__tests__/useDocumentTTS.test.tsx index d3d8ea51c2..9af30ebde1 100644 --- a/apps/packages/ui/src/hooks/document-workspace/__tests__/useDocumentTTS.test.tsx +++ b/apps/packages/ui/src/hooks/document-workspace/__tests__/useDocumentTTS.test.tsx @@ -190,6 +190,70 @@ describe("useDocumentTTS", () => { expect(result.current.voice).toBe("Jasper") }) + it("revokes the object URL when audio playback errors", async () => { + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:tts-error") + const revokeSpy = vi + .spyOn(URL, "revokeObjectURL") + .mockImplementation(() => {}) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + blob: vi.fn().mockResolvedValue(new Blob(["audio"])) + }) + vi.stubGlobal("fetch", fetchMock) + + let lastAudio: any = null + class MockAudio { + paused = true + ended = false + duration = 0 + currentTime = 0 + volume = 1 + onended: (() => void) | null = null + onerror: (() => void) | null = null + onplay: (() => void) | null = null + onpause: (() => void) | null = null + ontimeupdate: (() => void) | null = null + + constructor(public src: string) { + lastAudio = this + } + + pause() { + this.paused = true + this.onpause?.() + } + + play() { + this.paused = false + this.onplay?.() + return Promise.resolve() + } + } + + vi.stubGlobal("Audio", MockAudio) + + const { result } = renderHook(() => useDocumentTTS(), { + wrapper: buildWrapper() + }) + + await waitFor(() => { + expect(result.current.voicesLoading).toBe(false) + }) + + await act(async () => { + await result.current.speak("Broken audio") + }) + + expect(revokeSpy).not.toHaveBeenCalledWith("blob:tts-error") + + act(() => { + lastAudio?.onerror?.() + }) + + expect(revokeSpy).toHaveBeenCalledWith("blob:tts-error") + expect(result.current.state.error).toBe("Failed to play audio") + }) + it("does not overwrite a user-selected voice when configured voice resolves later", async () => { let resolveVoice: ((value: string) => void) | undefined const pendingVoice = new Promise((resolve) => { diff --git a/apps/packages/ui/src/hooks/document-workspace/useDocumentTTS.ts b/apps/packages/ui/src/hooks/document-workspace/useDocumentTTS.ts index eb72b9b363..b9d73832ff 100644 --- a/apps/packages/ui/src/hooks/document-workspace/useDocumentTTS.ts +++ b/apps/packages/ui/src/hooks/document-workspace/useDocumentTTS.ts @@ -69,6 +69,17 @@ export interface UseDocumentTTSReturn { const DEFAULT_VOICE = DEFAULT_TLDW_TTS_VOICE const DEFAULT_SPEED = 1.0 +/** Detach all listeners we attach in speak() so a stopped/replaced audio element + * can't fire onpause/onerror/ontimeupdate and clobber state after teardown. */ +const detachAudioListeners = (audio: HTMLAudioElement | null): void => { + if (!audio) return + audio.onended = null + audio.onerror = null + audio.onplay = null + audio.onpause = null + audio.ontimeupdate = null +} + const readStoredVoicePreference = (): string => { if (typeof window === "undefined") return "" try { @@ -158,6 +169,7 @@ export function useDocumentTTS(): UseDocumentTTSReturn { useEffect(() => { return () => { if (audioRef.current) { + detachAudioListeners(audioRef.current) audioRef.current.pause() audioRef.current = null } @@ -209,6 +221,7 @@ export function useDocumentTTS(): UseDocumentTTSReturn { const speak = useCallback(async (text: string) => { // Stop any current playback if (audioRef.current) { + detachAudioListeners(audioRef.current) audioRef.current.pause() audioRef.current = null } @@ -273,6 +286,11 @@ export function useDocumentTTS(): UseDocumentTTSReturn { } audio.onerror = () => { + // Free the blob URL on the error path too (onended isn't fired on error). + if (audioUrlRef.current) { + URL.revokeObjectURL(audioUrlRef.current) + audioUrlRef.current = null + } setState((prev) => ({ ...prev, isPlaying: false, @@ -308,7 +326,16 @@ export function useDocumentTTS(): UseDocumentTTSReturn { audio.volume = volume - await audio.play() + try { + await audio.play() + } catch (playErr) { + // A Stop/pause issued right after Speak aborts the play() promise; that + // is expected teardown, not a playback error worth surfacing. + if (playErr instanceof DOMException && playErr.name === "AbortError") { + return + } + throw playErr + } } catch (err) { const errorMessage = err instanceof Error ? err.message : "TTS failed" setState((prev) => ({ @@ -339,6 +366,9 @@ export function useDocumentTTS(): UseDocumentTTSReturn { // Stop playback const stop = useCallback(() => { if (audioRef.current) { + // Detach first so the pause() below can't fire onpause and leave isPaused + // stale after we reset state. + detachAudioListeners(audioRef.current) audioRef.current.pause() audioRef.current.currentTime = 0 audioRef.current = null diff --git a/apps/packages/ui/src/hooks/useAudioRecorder.ts b/apps/packages/ui/src/hooks/useAudioRecorder.ts index 555ae80024..a97038afe8 100644 --- a/apps/packages/ui/src/hooks/useAudioRecorder.ts +++ b/apps/packages/ui/src/hooks/useAudioRecorder.ts @@ -58,6 +58,7 @@ export function useAudioRecorder(): AudioRecorderResult { const timerRef = useRef | null>(null) const startTimeRef = useRef(0) const captureOwnerRef = useRef(false) + const startingRef = useRef(false) const stopTimer = useCallback(() => { if (timerRef.current !== null) { @@ -98,8 +99,13 @@ export function useAudioRecorder(): AudioRecorderResult { }, []) const startRecording = useCallback(async (options: AudioRecorderOptions = {}) => { - chunksRef.current = [] + // Synchronous re-entry guard: a rapid double-start (or a start while + // already recording) must not run a second getUserMedia and orphan the + // first stream. Refs are checked/set synchronously before the first await. + if (startingRef.current || recorderRef.current) return reserveCaptureOwner() + startingRef.current = true + chunksRef.current = [] try { const stream = await navigator.mediaDevices.getUserMedia({ @@ -116,6 +122,16 @@ export function useAudioRecorder(): AudioRecorderResult { } } + recorder.onerror = () => { + // A MediaRecorder error may fire without a matching onstop (device + // unplugged, hardware glitch). Mirror onstop's teardown so the mic + // track is stopped and the capture owner released on every path. + stopTimer() + stopMediaTracks() + setStatus("idle") + recorderRef.current = null + } + recorder.onstop = () => { const recordedBlob = new Blob(chunksRef.current, { type: recorder.mimeType || "audio/webm" @@ -140,6 +156,8 @@ export function useAudioRecorder(): AudioRecorderResult { stopTimer() stopMediaTracks() throw error + } finally { + startingRef.current = false } }, [reserveCaptureOwner, stopTimer, stopMediaTracks]) diff --git a/apps/packages/ui/src/hooks/useAudiobookGeneration.tsx b/apps/packages/ui/src/hooks/useAudiobookGeneration.tsx index bbd6a8b23b..3230e1a1b3 100644 --- a/apps/packages/ui/src/hooks/useAudiobookGeneration.tsx +++ b/apps/packages/ui/src/hooks/useAudiobookGeneration.tsx @@ -87,7 +87,11 @@ export const useAudiobookGeneration = () => { throw new Error("TTS synthesis function not available") } - const result = await context.synthesize(context.utterance) + // Thread the abort signal through so cancelGeneration() aborts the + // in-flight chapter synthesis, not just the between-chapter poll. + const result = await context.synthesize(context.utterance, { + signal: abortControllerRef.current?.signal + }) const blob = new Blob([result.buffer], { type: result.mimeType }) return blob } catch (error) { diff --git a/apps/packages/ui/src/hooks/useMessage.tsx b/apps/packages/ui/src/hooks/useMessage.tsx index 52b4a69b18..12c90acb11 100644 --- a/apps/packages/ui/src/hooks/useMessage.tsx +++ b/apps/packages/ui/src/hooks/useMessage.tsx @@ -8,15 +8,19 @@ import { getContentFromCurrentTab } from "~/libs/get-html"; import { ChatHistory } from "@/store/option"; import { deleteChatForEdit, + deleteChatAfterMessageId, generateID, getPromptById, removeMessageByIndex, + removeMessageById, updateMessageByIndex, + updateMessageById, } from "@/db/dexie/helpers"; import { useTranslation } from "react-i18next"; import { usePageAssist } from "@/context"; import { formatDocs } from "@/utils/format-docs"; import { buildAssistantErrorContent } from "@/utils/chat-error-message"; +import { buildCharacterChatAssistantErrorContent } from "@/hooks/chat/useCharacterChatMode"; import { detectCharacterMood } from "@/utils/character-mood"; import { useStorage } from "@plasmohq/storage/hook"; import { useStoreChatModelSettings } from "@/store/model"; @@ -1227,6 +1231,7 @@ export const useMessage = () => { characterOverride?: Character | null, regenerateFromMessage?: Message, serverChatIdOverride?: string | null, + controller?: AbortController, ) => { setStreaming(true); const activeCharacter = characterOverride ?? selectedCharacter; @@ -1285,6 +1290,7 @@ export const useMessage = () => { : history; let fullText = ""; let contentToSave = ""; + let inactivityTimer: ReturnType | null = null; const resolvedAssistantMessageId = generateID(); const resolvedUserMessageId = !isRegenerate ? generateID() : undefined; let persistedUserServerMessageId: string | undefined; @@ -1599,6 +1605,18 @@ export const useMessage = () => { normalizedModel.length > 0 ? normalizedModel : model; const shouldPersistToServer = !temporaryChat; + // Stream-inactivity watchdog: if no chunk arrives within the timeout, abort + // the underlying stream so a stalled character response cannot hang forever. + const STREAM_INACTIVITY_TIMEOUT_MS = 60_000; + let inactivityAborted = false; + const resetInactivityTimer = () => { + if (inactivityTimer) clearTimeout(inactivityTimer); + inactivityTimer = setTimeout(() => { + inactivityAborted = true; + controller?.abort(); + }, STREAM_INACTIVITY_TIMEOUT_MS); + }; + resetInactivityTimer(); for await (const chunk of tldwClient.streamCharacterChatCompletion( chatId, { @@ -1609,6 +1627,7 @@ export const useMessage = () => { }, { signal }, )) { + resetInactivityTimer(); const loopEvent = extractChatLoopEvent(chunk); if (loopEvent) { dispatchChatLoopEvent(loopEvent); @@ -1653,6 +1672,16 @@ export const useMessage = () => { count++; if (signal?.aborted) break; } + if (inactivityTimer) clearTimeout(inactivityTimer); + + if (inactivityAborted) { + const timeoutError = new Error( + "Stream timed out: no data received for 60 seconds", + ); + (timeoutError as any).name = "StreamInactivityTimeout"; + throw timeoutError; + } + if (signal?.aborted) { const abortError = new Error("AbortError"); (abortError as any).name = "AbortError"; @@ -1901,7 +1930,11 @@ export const useMessage = () => { return; } - const assistantContent = buildAssistantErrorContent(fullText, e); + const assistantContent = buildCharacterChatAssistantErrorContent( + fullText, + e, + t, + ); if (generateMessageId) { setMessages((prev) => prev.map((msg) => @@ -1930,8 +1963,14 @@ export const useMessage = () => { }); if (!errorSave) { + const isInactivityTimeout = + e instanceof Error && (e as any).name === "StreamInactivityTimeout"; notification.error({ - message: t("error"), + message: isInactivityTimeout + ? t("playground:streamTimeout", { + defaultValue: "Stream timed out", + }) + : t("error"), description: e?.message || t("somethingWentWrong"), }); } @@ -1939,6 +1978,7 @@ export const useMessage = () => { setStreaming(false); } finally { discardCurrentTurnOnAbortRef.current = false; + if (inactivityTimer) clearTimeout(inactivityTimer); setAbortController(null); } }; @@ -2367,11 +2407,14 @@ export const useMessage = () => { : resolvedSelectedModel ).trim() || "image-generation"; let signal: AbortSignal; + let activeController: AbortController; if (!controller) { const newController = new AbortController(); + activeController = newController; signal = newController.signal; setAbortController(newController); } else { + activeController = controller; setAbortController(controller); signal = controller.signal; } @@ -2550,6 +2593,7 @@ export const useMessage = () => { trackedCharacterForSend, regenerateFromMessage, serverChatIdOverride, + activeController, ); } else if (sendMode === "tracked_persona" && isPersonaAssistantSelection(effectiveSelectedAssistant)) { const personaServerChat = await ensurePersonaServerChat({ @@ -2778,7 +2822,13 @@ export const useMessage = () => { idx === index ? { ...item, content: message } : item, ); setHistory(updatedHistory); - await updateMessageByIndex(historyId, index, message); + // TASK-12104: address the Dexie row by stable id (not UI array index), + // which is offset by any non-persisted greeting seed at UI index 0. + if (currentHumanMessage?.id) { + await updateMessageById(historyId, currentHumanMessage.id, message); + } else { + await updateMessageByIndex(historyId, index, message); + } if ( serverChatId && (selectedCharacter?.id || serverChatAssistantKind === "persona") && @@ -2805,8 +2855,15 @@ export const useMessage = () => { setMessages(previousMessages); const previousHistory = newHistory.slice(0, index); setHistory(previousHistory); - await updateMessageByIndex(historyId, index, message); - await deleteChatForEdit(historyId, index); + // TASK-12104: id-address the edited row (and delete what follows it) so a + // greeting-offset UI index cannot overwrite/keep the wrong Dexie rows. + if (currentHumanMessage?.id) { + await updateMessageById(historyId, currentHumanMessage.id, message); + await deleteChatAfterMessageId(historyId, currentHumanMessage.id); + } else { + await updateMessageByIndex(historyId, index, message); + await deleteChatForEdit(historyId, index); + } // Server-backed edit and cleanup if ( serverChatId && @@ -2870,7 +2927,12 @@ export const useMessage = () => { idx === index ? { ...item, content: message } : item, ); setHistory(updatedHistory); - await updateMessageByIndex(historyId, index, message); + // TASK-12104: address the assistant Dexie row by stable id. + if (currentAssistant?.id) { + await updateMessageById(historyId, currentAssistant.id, message); + } else { + await updateMessageByIndex(historyId, index, message); + } // Server-backed: update assistant server message too if ( serverChatId && @@ -2926,7 +2988,14 @@ export const useMessage = () => { } if (historyId) { - await removeMessageByIndex(historyId, index); + // TASK-12104: remove the Dexie row by stable id so a non-persisted + // greeting at UI index 0 does not shift us onto the wrong row. An + // unsaved greeting's id is absent from Dexie, so this is a safe no-op. + if (target.id) { + await removeMessageById(historyId, target.id); + } else { + await removeMessageByIndex(historyId, index); + } } setMessages(messages.filter((_, idx) => idx !== index)); diff --git a/apps/packages/ui/src/hooks/usePersonaLiveControl.tsx b/apps/packages/ui/src/hooks/usePersonaLiveControl.tsx index 8c15035be1..9736131823 100644 --- a/apps/packages/ui/src/hooks/usePersonaLiveControl.tsx +++ b/apps/packages/ui/src/hooks/usePersonaLiveControl.tsx @@ -38,6 +38,9 @@ export type PersonaLiveSendTextOptions = { const SEND_TEXT_ACTION = "send_text_ws" const STREAM_CONNECT_ERROR = "Persona live stream failed to connect" +// Abort the handshake if `onopen` never fires so the connect promise rejects and +// the caller can retry instead of hanging in "connecting". +const STREAM_CONNECT_TIMEOUT_MS = 10000 const terminalLifecycles = new Set(["stopping", "stopped", "error"]) @@ -118,6 +121,7 @@ export function usePersonaLiveControl(options: PersonaLiveControlOptions = {}) { const focusedSessionIdRef = React.useRef(focusedSessionId) const wsRef = React.useRef(null) const streamConnectPromiseRef = React.useRef | null>(null) + const mountedRef = React.useRef(true) React.useEffect(() => { sessionsRef.current = sessions @@ -176,11 +180,19 @@ export function usePersonaLiveControl(options: PersonaLiveControlOptions = {}) { React.useEffect( () => () => { + mountedRef.current = false streamConnectPromiseRef.current = null const ws = wsRef.current wsRef.current = null - if (ws && ws.readyState < WebSocket.CLOSING) { - ws.close() + if (ws) { + // Detach handlers so a late onopen/onclose can't run state updates after + // unmount, then close. + ws.onopen = null + ws.onerror = null + ws.onclose = null + if (ws.readyState < WebSocket.CLOSING) { + ws.close() + } } }, [] @@ -264,25 +276,48 @@ export function usePersonaLiveControl(options: PersonaLiveControlOptions = {}) { const connectPromise = tldwClient .ensureConfigForRequest(true) .then((config) => { - const url = buildPersonaWebSocketUrl(config) - const ws = new WebSocket(url) + // Bail if the hook unmounted during the awaits so we don't create a + // socket that nothing will ever close. + if (!mountedRef.current) { + streamConnectPromiseRef.current = null + throw new Error(STREAM_CONNECT_ERROR) + } + const { url, protocols } = buildPersonaWebSocketUrl(config) + const ws = new WebSocket(url, protocols) wsRef.current = ws return new Promise((resolve, reject) => { let settled = false + let connectTimer: ReturnType | null = setTimeout(() => { + connectTimer = null + failConnect() + }, STREAM_CONNECT_TIMEOUT_MS) + const clearConnectTimer = () => { + if (connectTimer) { + clearTimeout(connectTimer) + connectTimer = null + } + } const failConnect = () => { if (settled) return settled = true + clearConnectTimer() streamConnectPromiseRef.current = null if (wsRef.current === ws) { wsRef.current = null } + try { + ws.close() + } catch { + // ignore close errors + } setStreamState("error") reject(new Error(STREAM_CONNECT_ERROR)) } ws.onopen = () => { settled = true + clearConnectTimer() streamConnectPromiseRef.current = null setStreamState("open") resolve(ws) @@ -293,6 +328,7 @@ export function usePersonaLiveControl(options: PersonaLiveControlOptions = {}) { failConnect() return } + clearConnectTimer() if (wsRef.current === ws) { setStreamState("closed") } diff --git a/apps/packages/ui/src/hooks/useServerDictation.tsx b/apps/packages/ui/src/hooks/useServerDictation.tsx index c5bc5702d1..8e898517d0 100644 --- a/apps/packages/ui/src/hooks/useServerDictation.tsx +++ b/apps/packages/ui/src/hooks/useServerDictation.tsx @@ -71,8 +71,10 @@ export const useServerDictation = ( } = options const serverRecorderRef = React.useRef(null) + const serverStreamRef = React.useRef(null) const serverChunksRef = React.useRef([]) const captureOwnerRef = React.useRef(false) + const startingRef = React.useRef(false) const [isServerDictating, setIsServerDictating] = React.useState(false) const reportError = React.useCallback( @@ -114,6 +116,10 @@ export const useServerDictation = ( const startServerDictation = React.useCallback(async ( source?: AudioCaptureRequestedSource ) => { + // Synchronous re-entry guard: a double-clicked dictation button must not + // run a second getUserMedia (and orphan the first stream) while a start is + // still in flight. Checked/set synchronously before the first await. + if (startingRef.current) return if (isServerDictating) { stopServerDictation() return @@ -144,12 +150,17 @@ export const useServerDictation = ( return } + startingRef.current = true try { const requestedDeviceId = source?.sourceKind === "mic_device" ? source.deviceId : null const stream = await navigator.mediaDevices.getUserMedia({ audio: buildAudioConstraints(requestedDeviceId) }) + // Hold the acquired stream in a ref so the synchronous catch below can + // stop its tracks if `new MediaRecorder(stream)` or recorder.start() + // throws. + serverStreamRef.current = stream const recorder = new MediaRecorder(stream) serverChunksRef.current = [] @@ -171,6 +182,7 @@ export const useServerDictation = ( try { stream.getTracks().forEach((trk) => trk.stop()) } catch {} + serverStreamRef.current = null serverRecorderRef.current = null releaseCaptureOwner() setIsServerDictating(false) @@ -290,6 +302,7 @@ export const useServerDictation = ( try { stream.getTracks().forEach((trk) => trk.stop()) } catch {} + serverStreamRef.current = null serverRecorderRef.current = null releaseCaptureOwner() setIsServerDictating(false) @@ -325,9 +338,17 @@ export const useServerDictation = (
) }) + // Stop any stream acquired before the throw (e.g. a MediaRecorder ctor + // or recorder.start() failure) so the mic indicator does not stay on. + try { + serverStreamRef.current?.getTracks().forEach((trk) => trk.stop()) + } catch {} + serverStreamRef.current = null serverRecorderRef.current = null setIsServerDictating(false) releaseCaptureOwner() + } finally { + startingRef.current = false } }, [ canUseServerStt, diff --git a/apps/packages/ui/src/hooks/useStreamingAudioPlayer.tsx b/apps/packages/ui/src/hooks/useStreamingAudioPlayer.tsx index 75fcb80b11..c1b8a4e739 100644 --- a/apps/packages/ui/src/hooks/useStreamingAudioPlayer.tsx +++ b/apps/packages/ui/src/hooks/useStreamingAudioPlayer.tsx @@ -269,6 +269,10 @@ export const useStreamingAudioPlayer = () => { audioRef.current = nextAudio } + // Revoke any previously-held object URL (e.g. the MediaSource blob URL from + // start()) before overwriting the ref with the buffered fallback blob URL, + // otherwise the stream->buffer fallback path leaks one URL per failed stream. + revokeObjectUrl() const blob = new Blob(allChunksRef.current, { type: mime }) const url = URL.createObjectURL(blob) objectUrlRef.current = url @@ -283,7 +287,7 @@ export const useStreamingAudioPlayer = () => { }) } setState((prev) => ({ ...prev, playing: true })) - }, [flushPending]) + }, [flushPending, revokeObjectUrl]) const getBufferedBlob = React.useCallback(() => { if (!allChunksRef.current.length) return null diff --git a/apps/packages/ui/src/hooks/useTTS.tsx b/apps/packages/ui/src/hooks/useTTS.tsx index cfc3cc92a3..4c2c13bc2c 100644 --- a/apps/packages/ui/src/hooks/useTTS.tsx +++ b/apps/packages/ui/src/hooks/useTTS.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react" +import { useEffect, useRef, useState } from "react" import { getElevenLabsModel, getElevenLabsVoiceId, @@ -36,10 +36,18 @@ export const useTTS = () => { const [audioElement, setAudioElement] = useState( null ) + // Refs (stable across renders) let cancel()/unmount always free the in-flight + // segment: the current object URL, the current audio element, and a settler + // that resolves the pending playAudio() promise so speak()'s loop unwinds. + const currentUrlRef = useRef(null) + const currentAudioRef = useRef(null) + const settlePlaybackRef = useRef<(() => void) | null>(null) + const cancelledRef = useRef(false) const notification = useAntdNotification() const { t } = useTranslation("playground") const speak = async ({ utterance, saveClip, clipMeta }: VoiceOptions) => { + cancelledRef.current = false let debugMeta: { provider?: string; mimeType?: string; size?: number } | null = null try { @@ -230,6 +238,7 @@ export const useTTS = () => { let nextAudioPromise: Promise | null = null for (let i = 0; i < sentences.length; i++) { + if (cancelledRef.current) break setIsSpeaking(true) let currentAudioData: AudioResult @@ -281,17 +290,20 @@ export const useTTS = () => { }) } const url = URL.createObjectURL(blob) + currentUrlRef.current = url const audio = new Audio(url) audio.playbackRate = playbackSpeed const canPlay = audio.canPlayType(currentAudioData.mimeType) if (!canPlay) { URL.revokeObjectURL(url) + currentUrlRef.current = null throw new Error( `Your browser cannot play ${currentAudioData.mimeType}. Try MP3 or WAV.` ) } + currentAudioRef.current = audio setAudioElement(audio) const playAudio = () => @@ -300,11 +312,15 @@ export const useTTS = () => { const finish = (err?: unknown) => { if (done) return done = true + settlePlaybackRef.current = null audio.onended = null audio.onerror = null if (err) reject(err) else resolve() } + // Allow cancel()/unmount to resolve this promise even though + // audio.pause() fires neither onended nor onerror. + settlePlaybackRef.current = () => finish() audio.onended = () => finish() audio.onerror = () => finish( @@ -327,11 +343,18 @@ export const useTTS = () => { .catch(console.error) || Promise.resolve() ]) } finally { + settlePlaybackRef.current = null URL.revokeObjectURL(url) + if (currentUrlRef.current === url) currentUrlRef.current = null } } - if (shouldSaveClip && clipId && savedSegments.length > 0) { + if ( + shouldSaveClip && + clipId && + savedSegments.length > 0 && + !cancelledRef.current + ) { const textPreview = processedUtterance.replace(/\s+/g, " ").trim() const preview = textPreview.length > 160 @@ -367,9 +390,11 @@ export const useTTS = () => { } } + currentAudioRef.current = null setIsSpeaking(false) setAudioElement(null) } catch (error) { + currentAudioRef.current = null setIsSpeaking(false) setAudioElement(null) // eslint-disable-next-line no-console @@ -388,9 +413,32 @@ export const useTTS = () => { } const cancel = () => { - if (audioElement) { - audioElement.pause() - audioElement.currentTime = 0 + cancelledRef.current = true + + // Settle the in-flight playAudio() promise so speak()'s loop unwinds and its + // `finally { URL.revokeObjectURL }` runs (pause() alone fires no event). + const settle = settlePlaybackRef.current + settlePlaybackRef.current = null + if (settle) settle() + + // Free the current segment URL directly as a safety net (idempotent with the + // finally block; double-revoke is harmless). + if (currentUrlRef.current) { + try { + URL.revokeObjectURL(currentUrlRef.current) + } catch {} + currentUrlRef.current = null + } + + // Prefer the ref (stable) so unmount can stop the latest audio even when the + // captured `audioElement` state closure is stale. + const activeAudio = currentAudioRef.current || audioElement + if (activeAudio) { + try { + activeAudio.pause() + activeAudio.currentTime = 0 + } catch {} + currentAudioRef.current = null setAudioElement(null) setIsSpeaking(false) return diff --git a/apps/packages/ui/src/hooks/useVoiceChatStream.tsx b/apps/packages/ui/src/hooks/useVoiceChatStream.tsx index 33dc59bbc1..84c4edff36 100644 --- a/apps/packages/ui/src/hooks/useVoiceChatStream.tsx +++ b/apps/packages/ui/src/hooks/useVoiceChatStream.tsx @@ -42,6 +42,13 @@ type VoiceChatOptions = VoiceChatCallbacks & { active: boolean } +// Drop mic frames once the socket's outbound buffer backs up past this many +// bytes so a slow uplink can't grow the buffer until the browser force-closes. +const MAX_WS_BUFFERED_BYTES = 512 * 1024 +// Abort the handshake if `onopen` never fires so the UI can recover instead of +// wedging in "connecting". Mirrors the extension STT worker connect timer. +const VOICE_WS_CONNECT_TIMEOUT_MS = 10000 + const formatToMime = (format: string): string => { const normalized = String(format || "").trim().toLowerCase() switch (normalized) { @@ -144,10 +151,12 @@ export const useVoiceChatStream = ({ const connectingRef = React.useRef(false) const closingRef = React.useRef(false) const triggeredRef = React.useRef(false) + const bargeInSentRef = React.useRef(false) const pendingResumeRef = React.useRef(false) const resolvedTtsFormatRef = React.useRef(null) const errorRef = React.useRef(null) const startAttemptRef = React.useRef(0) + const connectTimeoutRef = React.useRef | null>(null) const didRunActiveEffectRef = React.useRef(false) const manualSessionRef = React.useRef(false) @@ -219,7 +228,21 @@ export const useVoiceChatStream = ({ if (!ws || ws.readyState !== WebSocket.OPEN) return try { if (voiceChatBargeIn && stateRef.current === "speaking") { - ws.send(JSON.stringify({ type: "interrupt", reason: "barge_in" })) + // Send the barge-in interrupt once per speaking turn instead of on + // every mic frame (~4/sec). The guard is cleared on tts_start and + // when the server acks the interruption. + if (!bargeInSentRef.current) { + bargeInSentRef.current = true + ws.send(JSON.stringify({ type: "interrupt", reason: "barge_in" })) + } + } + // Backpressure: drop this mic frame when the outbound buffer is backed + // up rather than growing it unbounded on a slow uplink. + if ( + typeof ws.bufferedAmount === "number" && + ws.bufferedAmount > MAX_WS_BUFFERED_BYTES + ) { + return } const base64 = arrayBufferToBase64(chunk) ws.send(JSON.stringify({ type: "audio", data: base64 })) @@ -267,6 +290,10 @@ export const useVoiceChatStream = ({ const cleanupSession = React.useCallback(() => { manualSessionRef.current = false startAttemptRef.current += 1 + if (connectTimeoutRef.current) { + clearTimeout(connectTimeoutRef.current) + connectTimeoutRef.current = null + } try { micStop() } catch {} @@ -276,6 +303,7 @@ export const useVoiceChatStream = ({ wsRef.current = null connectingRef.current = false triggeredRef.current = false + bargeInSentRef.current = false pendingResumeRef.current = false resolvedTtsFormatRef.current = null setConnected(false) @@ -291,6 +319,14 @@ export const useVoiceChatStream = ({ ws.send(JSON.stringify({ type: "stop" })) } catch {} } + // Detach handlers before closing so a late onclose/onerror can't fire state + // updates after the session (or the component) has gone away. + if (ws) { + ws.onopen = null + ws.onmessage = null + ws.onerror = null + ws.onclose = null + } try { ws?.close() } catch {} @@ -381,6 +417,8 @@ export const useVoiceChatStream = ({ if (msgType === "tts_start") { const format = String(data.format || resolvedTtsFormatRef.current || "mp3") + // New speaking turn: allow one barge-in interrupt for this turn. + bargeInSentRef.current = false audioStart(format, voiceChatTtsMode === "stream") updateState("speaking") if (!voiceChatBargeIn) { @@ -399,6 +437,12 @@ export const useVoiceChatStream = ({ if (msgType === "interrupted") { pendingResumeRef.current = false + bargeInSentRef.current = false + // Barge-in: stop the already-buffered assistant audio immediately so it + // does not keep playing over the user. + try { + audioStop() + } catch {} updateState(activeRef.current ? "listening" : "idle") return } @@ -418,6 +462,7 @@ export const useVoiceChatStream = ({ [ audioFinish, audioStart, + audioStop, handleError, micStop, normalizedTriggers, @@ -477,6 +522,18 @@ export const useVoiceChatStream = ({ ws.binaryType = "arraybuffer" wsRef.current = ws + // Handshake timeout: if onopen never fires the UI would otherwise wedge in + // "connecting" forever. Fail fast so the restart guard is released. + if (connectTimeoutRef.current) { + clearTimeout(connectTimeoutRef.current) + } + connectTimeoutRef.current = setTimeout(() => { + connectTimeoutRef.current = null + if (!isStartAttemptCurrent(attemptId)) return + if (ws.readyState === WebSocket.OPEN) return + handleError("Voice chat connection timed out") + }, VOICE_WS_CONNECT_TIMEOUT_MS) + ws.onmessage = (event) => { if (typeof event.data === "string") { try { @@ -506,10 +563,17 @@ export const useVoiceChatStream = ({ updateState("error") return } - updateState("idle") + // Don't clobber a surfaced error state by resetting to idle on close. + if (!errorRef.current) { + updateState("idle") + } } ws.onopen = () => { + if (connectTimeoutRef.current) { + clearTimeout(connectTimeoutRef.current) + connectTimeoutRef.current = null + } void (async () => { try { if (!isStartAttemptCurrent(attemptId)) { @@ -518,6 +582,14 @@ export const useVoiceChatStream = ({ } catch {} return } + // TASK-12106: authenticate before any config/audio so the server + // authorizes the connection first. The audio WS endpoint reads an + // {type:"auth", token} first frame via receive_text() + // (streaming_service.py:641-647 multi-user / 720-723 single-user). + // `token` is the JWT (multi-user) or API key (single-user). + if (token) { + ws.send(JSON.stringify({ type: "auth", token })) + } const sttConfig: Record = { enable_vad: true, min_silence_ms: voiceChatPauseMs, diff --git a/apps/packages/ui/src/models/ChatTldw.ts b/apps/packages/ui/src/models/ChatTldw.ts index 460dcffa6f..2d6824a6fe 100644 --- a/apps/packages/ui/src/models/ChatTldw.ts +++ b/apps/packages/ui/src/models/ChatTldw.ts @@ -14,6 +14,7 @@ import { import type { ToolCall } from "@/types/tool-calls" import { publishChatLoopEvent } from "@/services/chat-loop/bridge" import { extractChatLoopEvent } from "@/services/chat-loop/stream" +import { extractStreamTransportInterruption } from "@/utils/extract-token-from-chunk" import type { ChatRequestDebugMetadata } from "@/services/tldw/chat-request-debug" export interface ChatTldwOptions { @@ -120,6 +121,11 @@ export class ChatTldw { const tldwMessages = this.convertToTldwMessages(messages) const toolCalls: ToolCall[] = [] + // Captures the background transport's `stream_transport_interrupted` + // sentinel. TldwChat surfaces it via onChunk (not as a text token), so we + // hold onto the raw chunk here and re-emit it after the token loop so the + // chat pipeline can finalize a truncated answer as interrupted. + let interruptionChunk: Record | null = null const applyToolCallDelta = (deltas: any[]) => { deltas.forEach((delta, fallbackIndex) => { @@ -169,6 +175,10 @@ export class ChatTldw { publishChatLoopEvent(loopEvent) } + if (extractStreamTransportInterruption(chunk)) { + interruptionChunk = chunk as Record + } + const deltas = chunk?.choices?.[0]?.delta?.tool_calls ?? chunk?.choices?.[0]?.tool_calls ?? @@ -181,6 +191,9 @@ export class ChatTldw { const stream = tldwChat.streamMessage( tldwMessages, { + // Thread the UI AbortSignal so Stop aborts the underlying request in + // all modes (not just polling `signal.aborted` at the loop top). + signal, model: this.model, temperature: this.temperature, maxTokens: this.maxTokens, @@ -221,6 +234,13 @@ export class ChatTldw { // string keeps the simple path working (`typeof chunk === 'string'`). yield token } + // The extension port can drop after the first byte; TldwChat surfaces a + // synthesized `stream_transport_interrupted` sentinel via onChunk rather + // than as a text token. Re-emit it (as an object chunk) so downstream + // chat-modes finalize the partial answer as interrupted, not complete. + if (interruptionChunk && !signal?.aborted) { + yield interruptionChunk + } } finally { // Synthesize a minimal LangChain-style result for handleLLMEnd if (callbacks && callbacks.length > 0) { diff --git a/apps/packages/ui/src/models/__tests__/ChatTldw.abort-signal.test.ts b/apps/packages/ui/src/models/__tests__/ChatTldw.abort-signal.test.ts new file mode 100644 index 0000000000..11a9edb8f1 --- /dev/null +++ b/apps/packages/ui/src/models/__tests__/ChatTldw.abort-signal.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + streamMessage: vi.fn() +})) + +vi.mock("@/services/tldw", async () => { + const actual = + await vi.importActual("@/services/tldw") + return { + ...actual, + tldwChat: { + ...actual.tldwChat, + streamMessage: mocks.streamMessage + } + } +}) + +import { ChatTldw } from "@/models/ChatTldw" +import { HumanMessage } from "@/types/messages" + +describe("ChatTldw abort signal threading", () => { + it("threads the UI AbortSignal into tldwChat.streamMessage options", async () => { + mocks.streamMessage.mockImplementation(async function* () { + yield "hi" + }) + + const controller = new AbortController() + const model = new ChatTldw({ model: "tldw:gpt-test", streaming: true }) + + for await (const _token of await model.stream([new HumanMessage("Hi")], { + signal: controller.signal + })) { + // consume the stream + } + + expect(mocks.streamMessage).toHaveBeenCalledTimes(1) + const options = mocks.streamMessage.mock.calls[0][1] as { + signal?: AbortSignal + } + expect(options.signal).toBe(controller.signal) + }) +}) diff --git a/apps/packages/ui/src/models/__tests__/ChatTldw.stream-transport-interrupted.test.ts b/apps/packages/ui/src/models/__tests__/ChatTldw.stream-transport-interrupted.test.ts new file mode 100644 index 0000000000..6cb0255d63 --- /dev/null +++ b/apps/packages/ui/src/models/__tests__/ChatTldw.stream-transport-interrupted.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + streamMessage: vi.fn() +})) + +vi.mock("@/services/tldw", async () => { + const actual = + await vi.importActual("@/services/tldw") + return { + ...actual, + tldwChat: { + ...actual.tldwChat, + streamMessage: mocks.streamMessage + } + } +}) + +import { ChatTldw } from "@/models/ChatTldw" +import { HumanMessage } from "@/types/messages" + +describe("ChatTldw stream transport interruption", () => { + it("re-emits the stream_transport_interrupted sentinel after the token stream", async () => { + mocks.streamMessage.mockImplementation( + async function* ( + _messages: unknown[], + _options: unknown, + onChunk?: (chunk: unknown) => void + ) { + yield "partial" + // Mirrors TldwChat: the sentinel arrives via onChunk (it carries no + // assistant text, so it is never yielded as a token). + onChunk?.({ + event: "stream_transport_interrupted", + detail: "port dropped", + partial_response_saved: true + }) + } + ) + + const model = new ChatTldw({ model: "tldw:gpt-test", streaming: true }) + const chunks: unknown[] = [] + + for await (const chunk of await model.stream([new HumanMessage("Hi")])) { + chunks.push(chunk) + } + + expect(chunks[0]).toBe("partial") + expect(chunks[chunks.length - 1]).toMatchObject({ + event: "stream_transport_interrupted", + detail: "port dropped" + }) + }) + + it("does not re-emit the sentinel when the caller aborted the stream", async () => { + const controller = new AbortController() + mocks.streamMessage.mockImplementation( + async function* ( + _messages: unknown[], + _options: unknown, + onChunk?: (chunk: unknown) => void + ) { + yield "partial" + controller.abort() + onChunk?.({ + event: "stream_transport_interrupted", + detail: "port dropped", + partial_response_saved: true + }) + } + ) + + const model = new ChatTldw({ model: "tldw:gpt-test", streaming: true }) + const chunks: unknown[] = [] + + for await (const chunk of await model.stream([new HumanMessage("Hi")], { + signal: controller.signal + })) { + chunks.push(chunk) + } + + // Abort takes precedence: no interruption sentinel is surfaced. + expect( + chunks.some( + (chunk) => + chunk && + typeof chunk === "object" && + (chunk as Record).event === + "stream_transport_interrupted" + ) + ).toBe(false) + }) +}) diff --git a/apps/packages/ui/src/public/_locales/en/sources.json b/apps/packages/ui/src/public/_locales/en/sources.json new file mode 100644 index 0000000000..0209c95b6b --- /dev/null +++ b/apps/packages/ui/src/public/_locales/en/sources.json @@ -0,0 +1,47 @@ +{ + "title": { + "message": "Sources" + }, + "description": { + "message": "Manage local folders and archive snapshots that sync into notes or media." + }, + "offline": { + "message": "Server is offline. Connect to manage ingestion sources." + }, + "actions_new": { + "message": "New source" + }, + "actions_sync": { + "message": "Sync now" + }, + "actions_uploadArchive": { + "message": "Upload archive" + }, + "actions_reattach": { + "message": "Reattach" + }, + "states_unsupported": { + "message": "This server does not advertise ingestion source support." + }, + "states_empty": { + "message": "No ingestion sources yet." + }, + "form_sourceType": { + "message": "Source type" + }, + "form_localDirectory": { + "message": "Local directory" + }, + "form_archiveSnapshot": { + "message": "Archive snapshot" + }, + "form_path": { + "message": "Server directory path" + }, + "form_pathHelp": { + "message": "This is a path on the tldw server host, not a local browser or extension folder." + }, + "form_archiveHint": { + "message": "Upload archive after creation" + } +} diff --git a/apps/packages/ui/src/routes/hooks/usePersonaLiveSession.tsx b/apps/packages/ui/src/routes/hooks/usePersonaLiveSession.tsx index d8d1a40579..ba6bb4c8aa 100644 --- a/apps/packages/ui/src/routes/hooks/usePersonaLiveSession.tsx +++ b/apps/packages/ui/src/routes/hooks/usePersonaLiveSession.tsx @@ -258,6 +258,12 @@ export function usePersonaLiveSession(deps: UsePersonaLiveSessionDeps) { const [pendingRecoveryReconnectToken, setPendingRecoveryReconnectToken] = React.useState(0) + // Guards for the multi-await connect(): a superseding connect or an unmount + // during the awaits must prevent the pending attempt from creating a socket + // that nothing will ever close (mirrors useVoiceChatStream's attempt guard). + const connectAttemptRef = React.useRef(0) + const mountedRef = React.useRef(true) + // ── Companion mode side-effects ── React.useEffect(() => { if (!isCompanionMode) return @@ -414,6 +420,8 @@ export function usePersonaLiveSession(deps: UsePersonaLiveSessionDeps) { if (!confirmDiscardUnsavedStateDrafts("connect")) return setConnecting(true) setError(null) + const attemptId = connectAttemptRef.current + 1 + connectAttemptRef.current = attemptId try { disconnect({ force: true }) @@ -587,7 +595,15 @@ export function usePersonaLiveSession(deps: UsePersonaLiveSessionDeps) { ]) } - const ws = new WebSocket(buildPersonaWebSocketUrl(config)) + // If this attempt was superseded by a newer connect() or the hook + // unmounted during the awaits above, don't open a socket that nothing + // will ever close. + if (!mountedRef.current || connectAttemptRef.current !== attemptId) { + return + } + + const { url, protocols } = buildPersonaWebSocketUrl(config) + const ws = new WebSocket(url, protocols) ws.binaryType = "arraybuffer" wsRef.current = ws manuallyClosingRef.current = false @@ -621,6 +637,13 @@ export function usePersonaLiveSession(deps: UsePersonaLiveSessionDeps) { ws.onerror = () => { setError("Persona stream error") + // Close the half-broken socket so we don't leave it in a "connected" + // limbo; onclose then runs the normal teardown. + try { + ws.close() + } catch { + // ignore close errors + } } ws.onclose = () => { @@ -701,10 +724,20 @@ export function usePersonaLiveSession(deps: UsePersonaLiveSessionDeps) { // ── Cleanup on unmount ── React.useEffect(() => { return () => { + // Supersede any in-flight connect() so it can't create a socket after we + // unmount. + mountedRef.current = false + connectAttemptRef.current += 1 const ws = wsRef.current flushLiveVoiceSessionAnalytics({ finalize: true }) if (!ws) return manuallyClosingRef.current = true + // Detach handlers so a late onclose/onerror can't fire state updates after + // unmount. + ws.onopen = null + ws.onmessage = null + ws.onerror = null + ws.onclose = null try { ws.close() } catch { diff --git a/apps/packages/ui/src/services/__tests__/background-proxy.test.ts b/apps/packages/ui/src/services/__tests__/background-proxy.test.ts index f81509dcdd..5f14f0289b 100644 --- a/apps/packages/ui/src/services/__tests__/background-proxy.test.ts +++ b/apps/packages/ui/src/services/__tests__/background-proxy.test.ts @@ -859,7 +859,7 @@ describe("background proxy fallback safety", () => { expect(mocks.tldwRequest).not.toHaveBeenCalled() }) - it("falls back to direct stream when port errors before first data chunk", async () => { + it("does not replay a non-idempotent POST when port errors before first data chunk", async () => { mocks.sendMessage.mockResolvedValue({ ok: true }) const onMessageListeners = new Set<(msg: any) => void>() const onDisconnectListeners = new Set<() => void>() @@ -907,14 +907,83 @@ describe("background proxy fallback safety", () => { vi.stubGlobal("fetch", fetchSpy as any) const { bgStream } = await importProxy() - const chunks: string[] = [] - - try { - for await (const chunk of bgStream({ + const consume = async () => { + for await (const _chunk of bgStream({ path: "/api/v1/chat/completions", method: "POST", headers: { "Content-Type": "application/json" }, body: { stream: true, messages: [] } + })) { + // no-op + } + } + + try { + // The POST must NOT be re-sent (no duplicate generation); a transport + // interruption is surfaced instead. + await expect(consume()).rejects.toMatchObject({ code: "STREAM_INTERRUPTED" }) + expect(fetchSpy).not.toHaveBeenCalled() + expect(mocks.connect).toHaveBeenCalledTimes(1) + } finally { + vi.unstubAllGlobals() + } + }) + + it("replays an idempotent GET stream via direct fetch when port errors before first data chunk", async () => { + mocks.sendMessage.mockResolvedValue({ ok: true }) + const onMessageListeners = new Set<(msg: any) => void>() + const onDisconnectListeners = new Set<() => void>() + const port = { + onMessage: { + addListener: (listener: (msg: any) => void) => onMessageListeners.add(listener), + removeListener: (listener: (msg: any) => void) => onMessageListeners.delete(listener) + }, + onDisconnect: { + addListener: (listener: () => void) => onDisconnectListeners.add(listener), + removeListener: (listener: () => void) => onDisconnectListeners.delete(listener) + }, + postMessage: vi.fn(() => { + onMessageListeners.forEach((listener) => + listener({ + event: "error", + message: "Could not establish connection. Receiving end does not exist." + }) + ) + }), + disconnect: vi.fn(() => { + onDisconnectListeners.forEach((listener) => listener()) + }) + } + mocks.connect.mockReturnValue(port as any) + mocks.storageGet.mockImplementation(async (key: string) => { + if (key === "tldwConfig") { + return { + serverUrl: "http://127.0.0.1:8000", + authMode: "single-user", + apiKey: "not-a-real-key" + } + } + return null + }) + const fetchSpy = vi.fn(async () => + new Response( + 'data: {"event":"run_started","run_id":"run_1","seq":1,"data":{}}\n\ndata: [DONE]\n\n', + { + status: 200, + headers: { "content-type": "text/event-stream" } + } + ) + ) + vi.stubGlobal("fetch", fetchSpy as any) + + const { bgStream } = await importProxy() + const chunks: string[] = [] + + try { + for await (const chunk of bgStream({ + path: "/api/v1/chat/completions" as unknown as `/${string}`, + method: "GET", + headers: { "Content-Type": "application/json" } })) { chunks.push(chunk) } @@ -922,11 +991,82 @@ describe("background proxy fallback safety", () => { vi.unstubAllGlobals() } + // GET is idempotent, so a direct-fetch replay is safe. expect(fetchSpy).toHaveBeenCalledTimes(1) expect(mocks.connect).toHaveBeenCalledTimes(1) expect(chunks.some((chunk) => chunk.includes('"event":"run_started"'))).toBe(true) }) + it("does not replay a non-idempotent POST after a connection (first-token) timeout", async () => { + vi.useFakeTimers() + mocks.sendMessage.mockResolvedValue({ ok: true }) + mocks.storageGet.mockImplementation(async (key: string) => { + if (key === "tldwConfig") { + return { + serverUrl: "http://127.0.0.1:8000", + authMode: "single-user", + apiKey: "not-a-real-key", + // Small idle timeout so the connection window is easy to advance past. + streamIdleTimeoutMs: 1000 + } + } + return null + }) + const onMessageListeners = new Set<(msg: any) => void>() + const onDisconnectListeners = new Set<() => void>() + const port = { + onMessage: { + addListener: (listener: (msg: any) => void) => onMessageListeners.add(listener), + removeListener: (listener: (msg: any) => void) => onMessageListeners.delete(listener) + }, + onDisconnect: { + addListener: (listener: () => void) => onDisconnectListeners.add(listener), + removeListener: (listener: () => void) => onDisconnectListeners.delete(listener) + }, + // Never emit any data: simulate a slow/stuck first token. + postMessage: vi.fn(), + disconnect: vi.fn(() => { + onDisconnectListeners.forEach((listener) => listener()) + }) + } + mocks.connect.mockReturnValue(port as any) + const fetchSpy = vi.fn(async () => + new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" } + }) + ) + vi.stubGlobal("fetch", fetchSpy as any) + + const { bgStream } = await importProxy() + const consume = async () => { + for await (const _chunk of bgStream({ + path: "/api/v1/chats/abc/complete-v2", + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { stream: true } + })) { + // no-op + } + } + + try { + const pending = consume() + const assertion = expect(pending).rejects.toMatchObject({ + code: "STREAM_INTERRUPTED" + }) + // Advance past the derived connection timeout to fire the disconnect. + await vi.advanceTimersByTimeAsync(1001) + // Let the drain loop's 10ms poll observe done + throw. + await vi.advanceTimersByTimeAsync(20) + await assertion + expect(fetchSpy).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + vi.unstubAllGlobals() + } + }) + it("classifies direct stream aborts as AbortError", async () => { mocks.sendMessage.mockResolvedValue({ ok: false }) mocks.storageGet.mockImplementation(async (key: string) => { diff --git a/apps/packages/ui/src/services/__tests__/background-proxy.web-refresh.test.ts b/apps/packages/ui/src/services/__tests__/background-proxy.web-refresh.test.ts new file mode 100644 index 0000000000..35d3411a99 --- /dev/null +++ b/apps/packages/ui/src/services/__tests__/background-proxy.web-refresh.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +// This suite exercises the REAL request-core (no tldwRequest mock) through the +// web/direct fallback of background-proxy, to verify token refresh is wired in +// the browser and single-flighted. `runtime` is stubbed with no id/sendMessage +// so bgRequest skips the extension messaging path and uses the direct fallback. +const mocks = vi.hoisted(() => ({ + store: {} as Record, + storageGet: vi.fn(), + storageSet: vi.fn() +})) + +vi.mock("wxt/browser", () => ({ + browser: { + // No runtime.id / sendMessage → hasRuntimeMessage is false → direct fallback. + runtime: {} + } +})) + +vi.mock("@/utils/safe-storage", () => ({ + createSafeStorage: () => ({ + get: (...args: unknown[]) => (mocks.storageGet as any)(...args), + set: (...args: unknown[]) => (mocks.storageSet as any)(...args) + }) +})) + +const importProxy = async () => import("@/services/background-proxy") + +const originalDeploymentMode = process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE + +describe("background proxy web token refresh", () => { + beforeEach(() => { + vi.resetModules() + vi.useRealTimers() + delete process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE + mocks.store = { + tldwConfig: { + serverUrl: "https://api.example.com", + authMode: "multi-user", + accessToken: "stale-access", + refreshToken: "refresh-token" + } + } + mocks.storageGet.mockReset() + mocks.storageSet.mockReset() + mocks.storageGet.mockImplementation(async (key: string) => mocks.store[key] ?? null) + mocks.storageSet.mockImplementation(async (key: string, value: unknown) => { + mocks.store[key] = value + }) + }) + + afterEach(() => { + if (originalDeploymentMode === undefined) { + delete process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE + } else { + process.env.NEXT_PUBLIC_TLDW_DEPLOYMENT_MODE = originalDeploymentMode + } + vi.unstubAllGlobals() + }) + + it("refreshes the access token and retries after a 401 in the browser direct path", async () => { + let refreshHits = 0 + const fetchSpy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith("/api/v1/auth/refresh")) { + refreshHits += 1 + 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 result = await bgRequest<{ ok: boolean }>({ + path: "/api/v1/notes/search/" as unknown as `/${string}`, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { q: "hello" } + }) + + expect(result).toEqual({ ok: true }) + expect(refreshHits).toBe(1) + // Rotated refresh token persisted to storage. + expect((mocks.store.tldwConfig as Record).accessToken).toBe( + "fresh-access" + ) + expect((mocks.store.tldwConfig as Record).refreshToken).toBe( + "rotated-refresh" + ) + }) + + it("signals refresh failure when /auth/refresh returns no access_token (no masking with stale token)", async () => { + let refreshHits = 0 + let staleRetryHits = 0 + const fetchSpy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith("/api/v1/auth/refresh")) { + refreshHits += 1 + // Refresh "succeeds" at the HTTP level but returns no access_token. + return new Response(JSON.stringify({ detail: "expired" }), { + status: 200, + headers: { "content-type": "application/json" } + }) + } + const auth = new Headers(init?.headers).get("Authorization") ?? "" + if (auth === "Bearer stale-access") { + staleRetryHits += 1 + 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() + + // Because refreshAuthDirect signals failure, request-core marks the refresh + // as failed and the still-401 retry surfaces "Session expired" rather than + // resolving as if the (stale-token) retry had succeeded. + await expect( + bgRequest<{ ok: boolean }>({ + path: "/api/v1/notes/search/" as unknown as `/${string}`, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { q: "hello" } + }) + ).rejects.toThrow(/session expired/i) + + expect(refreshHits).toBe(1) + // The retry ran with the stale token and 401'd; it must NOT have been + // treated as a success, and no bogus token was persisted. + expect(staleRetryHits).toBeGreaterThanOrEqual(1) + expect((mocks.store.tldwConfig as Record).accessToken).toBe( + "stale-access" + ) + }) + + it("signals refresh failure when /auth/refresh itself returns 401", async () => { + let refreshHits = 0 + const fetchSpy = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith("/api/v1/auth/refresh")) { + refreshHits += 1 + return new Response("unauthorized", { status: 401 }) + } + 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() + + await expect( + bgRequest<{ ok: boolean }>({ + path: "/api/v1/notes/search/" as unknown as `/${string}`, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: { q: "hello" } + }) + ).rejects.toThrow(/session expired/i) + + expect(refreshHits).toBe(1) + expect((mocks.store.tldwConfig as Record).accessToken).toBe( + "stale-access" + ) + }) + + it("single-flights concurrent 401 refreshes into one refresh call", async () => { + let refreshHits = 0 + let releaseRefresh: () => void = () => {} + const refreshGate = new Promise((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