From 661b76c1444205f18980f0120c801f7bf52e94b6 Mon Sep 17 00:00:00 2001 From: Robert Date: Thu, 2 Jul 2026 14:45:09 -0700 Subject: [PATCH 1/5] fix(frontend): remediate frontend + extension audit findings Address the Critical and High findings from the 2026-07-02 frontend/extension audit (apps/FRONTEND_AUDIT.md), plus config hardening and dead-code cleanup. Critical: - Stop the chat-completion "sanitizer" from corrupting successful non-streaming replies that mention "error"/"exception"/a file path (removed at the runtime source in domains/chat-rag.ts and the base class). - Stop persisting auth headers and login tokens to the localStorage request history; redact centrally and clear history on logout. High: - DOM-XSS: shared safeExternalUrl/openExternalUrl guard at all source-URL anchor and window.open sinks; add a CSP (script-src SHA-256-hash-allowlisted, drop 'unsafe-inline') plus X-Content-Type-Options/Referrer-Policy/X-Frame-Options/ Permissions-Policy. - MV3: persist ingest/quick-ingest/auth-replay session state and drive long polls via chrome.alarms so they survive service-worker suspension; resume remote-job polling after a restart. - Extension: origin allowlist + sender.id guard before attaching credentials on tldw:upload/tldw:stream; consolidate the guard into absolute-url-guard.ts. - Web token refresh wired with single-flight; post-refresh FormData retry fix. - Chat: Stop aborts the transport; per-call abort controllers (fixes Compare mode); ownership-guarded resets; user abort no longer saved as a complete answer; stream_transport_interrupted surfaced through the normal-mode pipeline. - Stream connection timeout no longer replays non-idempotent chat POSTs; generation request timeouts raised 10s -> 120s (and messaging-ack decoupled). - Zustand: connection-store stale-snapshot race, workspace silent-mutation hydration, folder sticky-404; storage shims (scoped clear, memory-only session, cross-instance change propagation, dynamic-route useSearchParams). Config / cleanup: - version/migrate baseline on 8 persisted stores; add a typecheck script. - Delete the dead web-auth provider stack (useAuth/useConfig/useIsAdmin/Header/ Layout); document the runtime-unused extension/routes mirror. Verification: new unit tests pass across all touched areas (~167 tests). Four pre-existing test failures are unrelated (confirmed on baseline dev). Backlog tasks task-12091..task-12103 track the work; remaining follow-ups (TS strict gate, CSP unsafe-eval, extension/routes removal) are documented in apps/FRONTEND_AUDIT.md section 0.6. Co-Authored-By: Claude Opus 4.8 --- apps/FRONTEND_AUDIT.md | 329 +++--- .../Common/Playground/MessageSource.tsx | 10 +- .../Knowledge/hooks/useFileSearch.ts | 3 +- .../Knowledge/hooks/useKnowledgeSearch.ts | 3 +- .../notes-manager-utils.sanitize-url.test.ts | 30 + .../components/Notes/notes-manager-utils.ts | 11 +- .../ReadingList/ReadingItemCard.tsx | 3 +- .../ReadingList/ReadingItemDetail.tsx | 3 +- .../Option/Items/ItemsWorkspace.tsx | 6 +- .../Option/KnowledgeQA/SourceCard.tsx | 3 +- .../Option/KnowledgeQA/SourceViewerModal.tsx | 3 +- .../src/components/Option/Processed/index.tsx | 11 +- .../ResearchWorkspace/SourcesPane/index.tsx | 8 +- .../Option/Watchlists/AlertsTab/AlertsTab.tsx | 5 +- .../Option/Watchlists/ItemsTab/ItemsTab.tsx | 3 +- .../OutputsTab/OutputPreviewDrawer.tsx | 5 +- .../OutputsTab/ReportEvidencePanel.tsx | 19 +- .../Watchlists/RunsTab/RunDetailDrawer.tsx | 19 +- .../Watchlists/SourcesTab/SourcesTab.tsx | 19 +- .../Chat/hooks/useRagResultsDisplay.tsx | 3 +- .../background-session-store.test.ts | 191 ++++ .../src/entries/background-session-store.ts | 276 +++++ apps/packages/ui/src/entries/background.ts | 1014 +++++++++++++---- .../chatModePipeline.abort-lifecycle.test.ts | 308 +++++ .../src/hooks/chat-modes/chatModePipeline.ts | 166 ++- .../ui/src/hooks/chat/useChatActions.ts | 129 ++- apps/packages/ui/src/models/ChatTldw.ts | 20 + .../__tests__/ChatTldw.abort-signal.test.ts | 43 + ...tTldw.stream-transport-interrupted.test.ts | 93 ++ .../ui/src/public/_locales/en/sources.json | 47 + .../__tests__/background-proxy.test.ts | 150 ++- .../background-proxy.web-refresh.test.ts | 153 +++ .../ui/src/services/background-proxy.ts | 243 ++-- .../ui/src/services/tldw/TldwApiClient.ts | 104 +- .../packages/ui/src/services/tldw/TldwAuth.ts | 41 +- .../packages/ui/src/services/tldw/TldwChat.ts | 52 +- .../__tests__/TldwApiClient.sanitizer.test.ts | 132 +++ .../tldw/__tests__/TldwAuth.refresh.test.ts | 123 ++ .../tldw/__tests__/TldwChat.abort.test.ts | 124 ++ .../request-core.refresh-timeout.test.ts | 159 +++ .../ui/src/services/tldw/domains/chat-rag.ts | 77 +- .../src/services/tldw/domains/models-audio.ts | 13 +- .../ui/src/services/tldw/request-core.ts | 136 +-- .../ui/src/store/__tests__/connection.test.ts | 86 ++ .../ui/src/store/__tests__/folder.test.ts | 142 +++ .../ui/src/store/__tests__/workspace.test.ts | 78 ++ apps/packages/ui/src/store/acp-sessions.ts | 4 + apps/packages/ui/src/store/actor.tsx | 4 + apps/packages/ui/src/store/connection.tsx | 82 +- apps/packages/ui/src/store/feedback.tsx | 4 + apps/packages/ui/src/store/folder.tsx | 24 +- apps/packages/ui/src/store/notes-dock.tsx | 4 + .../ui/src/store/persona-buddy-shell.ts | 4 + .../ui/src/store/playground-session.tsx | 4 + .../ui/src/store/quick-ingest-session.ts | 4 + apps/packages/ui/src/store/ui-mode.tsx | 4 + apps/packages/ui/src/store/workspace.ts | 35 +- .../__tests__/absolute-url-guard.test.ts | 121 ++ .../utils/__tests__/safe-external-url.test.ts | 50 + .../ui/src/utils/absolute-url-guard.ts | 157 +++ .../ui/src/utils/extract-token-from-chunk.ts | 22 + .../ui/src/utils/safe-external-url.ts | 65 ++ apps/tldw-frontend/AGENTS.md | 25 +- apps/tldw-frontend/CLAUDE.md | 25 +- .../extension/plasmo-storage-watch.test.tsx | 76 ++ .../extension/wxt-browser-storage.test.ts | 106 ++ .../__tests__/header-runs-gating.test.tsx | 125 -- ...eact-router-search-params-dynamic.test.tsx | 53 + .../pages/admin-maintenance.test.tsx | 11 - .../__tests__/pages/content-review.test.tsx | 5 - .../components/layout/Header.tsx | 116 -- .../components/layout/Layout.tsx | 17 - .../extension/routes/_RUNTIME_UNUSED.md | 16 + .../extension/shims/plasmo-storage-hook.tsx | 37 +- .../extension/shims/plasmo-storage.ts | 98 +- .../extension/shims/react-router-dom.tsx | 8 +- .../extension/shims/wxt-browser.ts | 150 ++- .../__tests__/useConfig.networking.test.tsx | 294 ----- apps/tldw-frontend/hooks/useAuth.tsx | 100 -- apps/tldw-frontend/hooks/useConfig.tsx | 356 ------ apps/tldw-frontend/hooks/useIsAdmin.ts | 11 - .../lib/__tests__/history-redaction.test.ts | 131 +++ apps/tldw-frontend/lib/api.ts | 5 +- apps/tldw-frontend/lib/auth.ts | 4 + apps/tldw-frontend/lib/history.ts | 88 +- apps/tldw-frontend/next.config.mjs | 87 ++ apps/tldw-frontend/package.json | 1 + apps/tldw-frontend/pages/_document.tsx | 6 + ...upting-successful-non-streaming-replies.md | 38 + ...entials-to-localStorage-request-history.md | 37 + ...L-DOM-XSS-in-source-anchors-and-add-CSP.md | 76 ++ ...-session-state-across-worker-suspension.md | 40 + ...als-in-extension-upload-stream-handlers.md | 38 + ...token-refresh-into-the-web-request-path.md | 39 + ...7 - Fix-chat-abort-and-stream-lifecycle.md | 42 + ...-chat-POST-on-stream-connection-timeout.md | 36 + ...e-store-races-and-sticky-failure-states.md | 39 + ...0 - Fix-web-build-browser-storage-shims.md | 41 + ...imeouts-aborting-normal-LLM-generations.md | 38 + ...ble-frontend-type-safety-and-lint-gates.md | 41 + ...th-stack-and-dead-extension-routes-tree.md | 36 + 101 files changed, 5664 insertions(+), 2012 deletions(-) create mode 100644 apps/packages/ui/src/components/Notes/__tests__/notes-manager-utils.sanitize-url.test.ts create mode 100644 apps/packages/ui/src/entries/__tests__/background-session-store.test.ts create mode 100644 apps/packages/ui/src/entries/background-session-store.ts create mode 100644 apps/packages/ui/src/hooks/chat-modes/__tests__/chatModePipeline.abort-lifecycle.test.ts create mode 100644 apps/packages/ui/src/models/__tests__/ChatTldw.abort-signal.test.ts create mode 100644 apps/packages/ui/src/models/__tests__/ChatTldw.stream-transport-interrupted.test.ts create mode 100644 apps/packages/ui/src/public/_locales/en/sources.json create mode 100644 apps/packages/ui/src/services/__tests__/background-proxy.web-refresh.test.ts create mode 100644 apps/packages/ui/src/services/tldw/__tests__/TldwApiClient.sanitizer.test.ts create mode 100644 apps/packages/ui/src/services/tldw/__tests__/TldwAuth.refresh.test.ts create mode 100644 apps/packages/ui/src/services/tldw/__tests__/TldwChat.abort.test.ts create mode 100644 apps/packages/ui/src/services/tldw/__tests__/request-core.refresh-timeout.test.ts create mode 100644 apps/packages/ui/src/store/__tests__/folder.test.ts create mode 100644 apps/packages/ui/src/utils/__tests__/absolute-url-guard.test.ts create mode 100644 apps/packages/ui/src/utils/__tests__/safe-external-url.test.ts create mode 100644 apps/packages/ui/src/utils/absolute-url-guard.ts create mode 100644 apps/packages/ui/src/utils/safe-external-url.ts create mode 100644 apps/tldw-frontend/__tests__/extension/plasmo-storage-watch.test.tsx create mode 100644 apps/tldw-frontend/__tests__/extension/wxt-browser-storage.test.ts delete mode 100644 apps/tldw-frontend/__tests__/header-runs-gating.test.tsx create mode 100644 apps/tldw-frontend/__tests__/navigation/react-router-search-params-dynamic.test.tsx delete mode 100644 apps/tldw-frontend/components/layout/Header.tsx delete mode 100644 apps/tldw-frontend/components/layout/Layout.tsx create mode 100644 apps/tldw-frontend/extension/routes/_RUNTIME_UNUSED.md delete mode 100644 apps/tldw-frontend/hooks/__tests__/useConfig.networking.test.tsx delete mode 100644 apps/tldw-frontend/hooks/useAuth.tsx delete mode 100644 apps/tldw-frontend/hooks/useConfig.tsx delete mode 100644 apps/tldw-frontend/hooks/useIsAdmin.ts create mode 100644 apps/tldw-frontend/lib/__tests__/history-redaction.test.ts create mode 100644 backlog/tasks/task-12091 - Fix-chat-completion-sanitizer-corrupting-successful-non-streaming-replies.md create mode 100644 backlog/tasks/task-12092 - Stop-persisting-credentials-to-localStorage-request-history.md create mode 100644 backlog/tasks/task-12093 - Fix-javascript-URL-DOM-XSS-in-source-anchors-and-add-CSP.md create mode 100644 backlog/tasks/task-12094 - Persist-MV3-background-session-state-across-worker-suspension.md create mode 100644 backlog/tasks/task-12095 - Add-URL-allowlist-before-attaching-credentials-in-extension-upload-stream-handlers.md create mode 100644 backlog/tasks/task-12096 - Wire-token-refresh-into-the-web-request-path.md create mode 100644 backlog/tasks/task-12097 - Fix-chat-abort-and-stream-lifecycle.md create mode 100644 backlog/tasks/task-12098 - Stop-replaying-non-idempotent-chat-POST-on-stream-connection-timeout.md create mode 100644 backlog/tasks/task-12099 - Fix-connection-and-workspace-store-races-and-sticky-failure-states.md create mode 100644 backlog/tasks/task-12100 - Fix-web-build-browser-storage-shims.md create mode 100644 backlog/tasks/task-12101 - Fix-default-request-timeouts-aborting-normal-LLM-generations.md create mode 100644 backlog/tasks/task-12102 - Re-enable-frontend-type-safety-and-lint-gates.md create mode 100644 backlog/tasks/task-12103 - Remove-half-wired-dead-web-auth-stack-and-dead-extension-routes-tree.md diff --git a/apps/FRONTEND_AUDIT.md b/apps/FRONTEND_AUDIT.md index 8fd0e68bd7..841ff20c07 100644 --- a/apps/FRONTEND_AUDIT.md +++ b/apps/FRONTEND_AUDIT.md @@ -1,246 +1,235 @@ -## 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) + +All Critical and High findings above were **fixed** in this pass (working tree on `dev`; not yet committed). Each fix ships with focused unit tests. 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. -- … - ---- - -## 5. UX & Flows - -Key user journeys (note status: ✅ works, ⚠ rough, ❌ broken): - -- [ ] 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: - -UX notes (loading states, error messages, responsiveness, accessibility, clarity of labels): - -- … - -Top UX issues to address first: - -- [ ] … +### 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. --- ## 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**: - -Good patterns: - -- … +### 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. -Inconsistent or problematic patterns: +### 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. -Follow-ups: +### 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. --- -## 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**: - -Findings: +## 6b. Browser-API shims (web build only) -- … +The web build fakes `chrome.storage`/`browser.*` and `react-router-dom` over `localStorage`/Next router. These shims are **live** and have real bugs: -Quick wins: - -- [ ] … +- **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. --- -## 8. Performance & Bundling +## 7. Error Handling & Resilience -- **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**: +### 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). -Notes: +### 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. -Potential optimizations: +### 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. --- ## 9. Dependencies & Technical Debt -- **Key dependencies and versions** (Next, React, React Query, Tailwind, etc.): -- **Known outdated / beta / misaligned dependencies**: -- **Unused or suspicious dependencies**: - -Tech debt list (short, actionable bullets): - -- [ ] … - -Priorities (P0/P1/P2): +### 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.) -- P0: -- P1: -- P2: +### 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. --- ## 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: - -- … - -First test targets: - -- [ ] … +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. --- -## 11. Build, Deployment & Integration - -- **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**: - -Notes: +## 12. Summary & Next Steps -- … +- **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. -Follow-ups: +- **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. ---- - -## 12. Summary & Next Steps +- **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. -- **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**: - - … +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. 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/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/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/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/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/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..061e07323d --- /dev/null +++ b/apps/packages/ui/src/entries/__tests__/background-session-store.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest" +import { + SESSION_STATE_STORAGE_KEY, + deserializeSessionState, + emptySessionState, + readPersistedSessionState, + serializeIngestSessions, + serializePendingReplay, + serializeQuickIngestBatches, + serializeQuickIngestSessions, + writePersistedSessionState, + type PersistedQuickIngestBatch, + 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" + }) + }) +}) 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..b2b2096187 --- /dev/null +++ b/apps/packages/ui/src/entries/background-session-store.ts @@ -0,0 +1,276 @@ +// 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 }) +} diff --git a/apps/packages/ui/src/entries/background.ts b/apps/packages/ui/src/entries/background.ts index a14502b265..7cb352a08c 100644 --- a/apps/packages/ui/src/entries/background.ts +++ b/apps/packages/ui/src/entries/background.ts @@ -65,6 +65,19 @@ import { buildConferenceCollectionCreatePayload, buildConferenceCollectionItemPayload, } from "@/services/tldw/conference-collections"; +import { + ABSOLUTE_URL_BLOCK_ERROR, + evaluateAbsoluteUrlAccess, +} from "@/utils/absolute-url-guard"; +import { + readPersistedSessionState, + serializeIngestSessions, + serializePendingReplay, + serializeQuickIngestBatches, + serializeQuickIngestSessions, + writePersistedSessionState, + type PersistedQuickIngestBatch, +} from "@/entries/background-session-store"; type BackgroundDiagnostics = { startedAt: number; @@ -286,6 +299,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 +430,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 +470,103 @@ 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; + + const persistSessionState = async (): Promise => { + try { + await writePersistedSessionState({ + 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 +783,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 +1178,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 +1273,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 +1408,298 @@ 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(); + } + } + }; + const extractIngestJobIds = (data: any): number[] => { const jobs = Array.isArray(data?.jobs) ? data.jobs : []; const ids: number[] = []; @@ -1529,6 +1957,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 +2208,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 +2234,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 +2257,92 @@ 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)); + await resumeActiveIngestSessions(); + void replayPendingAuthSessions(); + await resumeQuickIngestBatches(); + await reportInterruptedQuickIngestUploads(interruptedUploadSessionIds); }; const runQuickIngestBatch = async ( @@ -1864,12 +2391,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 +2607,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 +2780,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 +3036,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 +3086,7 @@ export default defineBackground({ const sessionId = String((payload as any)?.sessionId || "").trim(); if (sessionId) { quickIngestModalSessions.delete(sessionId); + void persistSessionState(); } } }, @@ -2641,6 +3128,7 @@ export default defineBackground({ cancelled: false, abortControllers: new Set(), }); + void persistSessionState(); } return startAck; } @@ -2654,6 +3142,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 +3249,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 +3279,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; @@ -3211,6 +3726,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 +3755,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 +3771,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 +3838,7 @@ export default defineBackground({ signal: abort.signal, }); if ( + !streamAccess.skipAuth && resp.status === 401 && cfg.authMode === "multi-user" && cfg.refreshToken @@ -3472,6 +4007,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 +4049,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 +4078,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/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/useChatActions.ts b/apps/packages/ui/src/hooks/chat/useChatActions.ts index c2aa4f75e4..acf26169f0 100644 --- a/apps/packages/ui/src/hooks/chat/useChatActions.ts +++ b/apps/packages/ui/src/hooks/chat/useChatActions.ts @@ -591,6 +591,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 +1108,7 @@ export const useChatActions = ({ setIsProcessing, setStreaming, setAbortController, + releaseAbortControllerIfOwned, historyId: resolvedHistoryId ?? historyId, setHistoryId, fileRetrievalEnabled, @@ -2166,7 +2184,11 @@ export const useChatActions = ({ } finally { discardCurrentTurnOnAbortRef.current = false cancelStreamingUpdate() - setAbortController(null) + // 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 +2448,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 +2499,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 +2508,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 +3014,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 +3065,7 @@ export const useChatActions = ({ setIsProcessing(false) setStreaming(false) setAbortController(null) + activeAbortControllerRef.current = null return aggregateChatSubmitResults(compareResults) } } @@ -3043,6 +3076,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 +3130,7 @@ export const useChatActions = ({ setStreaming(true) const newController = new AbortController() setAbortController(newController) + activeAbortControllerRef.current = newController const signal = newController.signal const baseMessages = messages @@ -3204,9 +3243,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/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/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..61f82f5c9e --- /dev/null +++ b/apps/packages/ui/src/services/__tests__/background-proxy.web-refresh.test.ts @@ -0,0 +1,153 @@ +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("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/background-proxy.ts b/apps/packages/ui/src/services/background-proxy.ts index 8758d445fb..1b98047b7a 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,72 @@ 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() + if (!refreshToken) return + 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) return + 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 +634,7 @@ async function bgRequestImpl< abortSignal, responseType }, - { getConfig: () => storage.get("tldwConfig").catch(() => null) } + createDirectRuntime(storage) ) } const resolveArrayBufferResponse = async ( @@ -665,7 +687,7 @@ async function bgRequestImpl< abortSignal, responseType }, - { getConfig: () => storage.get("tldwConfig").catch(() => null) } + createDirectRuntime(storage) ) if (!resp?.ok) { const msg = formatErrorMessage( @@ -865,7 +887,7 @@ async function bgRequestImpl< abortSignal, responseType }, - { getConfig: () => storage.get("tldwConfig").catch(() => null) } + createDirectRuntime(storage) ) if (!resp?.ok) { const msg = formatErrorMessage( @@ -1238,6 +1260,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 +1295,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 +1380,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 +1399,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 +1482,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 +1589,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/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/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/store/__tests__/connection.test.ts b/apps/packages/ui/src/store/__tests__/connection.test.ts index 7d9fa02d87..4f4d052ac9 100644 --- a/apps/packages/ui/src/store/__tests__/connection.test.ts +++ b/apps/packages/ui/src/store/__tests__/connection.test.ts @@ -846,4 +846,90 @@ 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: Date.now(), + lastCheckedAt: Date.now() - 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) + }) }) 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..dedfe6e707 100644 --- a/apps/packages/ui/src/store/connection.tsx +++ b/apps/packages/ui/src/store/connection.tsx @@ -599,16 +599,24 @@ 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() @@ -629,18 +637,23 @@ export const useConnectionStore = createWithEqualityFn((set, ge } : prev - // Apply persisted first-run flag if not already set + // 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({ - state: currentState - }) + set((s) => ({ + state: { + ...s.state, + ...(needsFirstRunSync ? { hasCompletedFirstRun: true } : {}), + ...(needsPersonaSync ? { userPersona: persistedUserPersona } : {}) + } + })) } // Test-only hook: force a missing/unconfigured state without network calls. if (forceUnconfigured) { - set({ + set((s) => ({ state: { - ...currentState, + ...s.state, errorKind: "none", phase: ConnectionPhase.UNCONFIGURED, serverUrl: persistedServerUrl, @@ -655,7 +668,8 @@ export const useConnectionStore = createWithEqualityFn((set, ge knowledgeLastCheckedAt: null, knowledgeError: null } - }) + })) + checkInFlight = false return } @@ -669,9 +683,9 @@ export const useConnectionStore = createWithEqualityFn((set, ge currentState.serverUrl ?? "offline://local" - set({ + set((s) => ({ state: { - ...currentState, + ...s.state, phase: ConnectionPhase.CONNECTED, serverUrl, isConnected: true, @@ -686,7 +700,8 @@ export const useConnectionStore = createWithEqualityFn((set, ge knowledgeLastCheckedAt: Date.now(), knowledgeError: null } - }) + })) + checkInFlight = false return } @@ -701,14 +716,15 @@ export const useConnectionStore = createWithEqualityFn((set, ge currentState.lastCheckedAt != null && now - currentState.lastCheckedAt < CONNECTED_THROTTLE_MS ) { + checkInFlight = false return } const isBackgroundRefresh = currentState.isConnected && currentState.phase === ConnectionPhase.CONNECTED - set({ + set((s) => ({ state: { - ...currentState, + ...s.state, phase: isBackgroundRefresh ? ConnectionPhase.CONNECTED : ConnectionPhase.SEARCHING, @@ -719,7 +735,7 @@ export const useConnectionStore = createWithEqualityFn((set, ge lastError: isBackgroundRefresh ? currentState.lastError : null, checksSinceConfigChange: nextChecksSinceConfigChange } - }) + })) try { let cfg = await tldwClient.getConfig() @@ -769,9 +785,9 @@ export const useConnectionStore = createWithEqualityFn((set, ge // Users must explicitly configure their own credentials in // Settings/Onboarding before authenticated pages can function. if (missingSingleUserApiKey) { - set({ + set((s) => ({ state: { - ...currentState, + ...s.state, phase: ConnectionPhase.UNCONFIGURED, serverUrl, isConnected: false, @@ -787,14 +803,15 @@ export const useConnectionStore = createWithEqualityFn((set, ge knowledgeLastCheckedAt: null, knowledgeError: null } - }) + })) + checkInFlight = false return } if (!serverUrl) { - set({ + set((s) => ({ state: { - ...currentState, + ...s.state, phase: ConnectionPhase.UNCONFIGURED, serverUrl: null, isConnected: false, @@ -809,7 +826,8 @@ export const useConnectionStore = createWithEqualityFn((set, ge knowledgeLastCheckedAt: null, knowledgeError: null } - }) + })) + checkInFlight = false return } @@ -959,9 +977,9 @@ export const useConnectionStore = createWithEqualityFn((set, ge nextConsecutiveFailures < CONNECTED_FAILURE_THRESHOLD if (holdConnectedOnTransientFailure) { - set({ + set((s) => ({ state: { - ...currentState, + ...s.state, phase: ConnectionPhase.CONNECTED, isConnected: true, isChecking: false, @@ -972,13 +990,14 @@ export const useConnectionStore = createWithEqualityFn((set, ge errorKind: "partial", consecutiveFailures: nextConsecutiveFailures } - }) + })) + checkInFlight = false return } - set({ + set((s) => ({ state: { - ...currentState, + ...s.state, phase: ok ? ConnectionPhase.CONNECTED : ConnectionPhase.ERROR, serverUrl, isConnected: ok, @@ -999,7 +1018,7 @@ export const useConnectionStore = createWithEqualityFn((set, ge errorKind, checksSinceConfigChange: nextChecksSinceConfigChange } - }) + })) } catch (error) { const fallbackError = maybeAnnotateCorsMismatchError({ @@ -1007,9 +1026,9 @@ export const useConnectionStore = createWithEqualityFn((set, ge status: 0, serverUrl: currentState.serverUrl }) ?? "unknown-error" - set({ + set((s) => ({ state: { - ...currentState, + ...s.state, phase: ConnectionPhase.ERROR, isConnected: false, isChecking: false, @@ -1024,8 +1043,11 @@ export const useConnectionStore = createWithEqualityFn((set, ge 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 }, async setServerUrl(url: string) { 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..d294623407 100644 --- a/apps/packages/ui/src/store/folder.tsx +++ b/apps/packages/ui/src/store/folder.tsx @@ -836,12 +836,30 @@ export const useFolderStore = createWithEqualityFn()( // 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__/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/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/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..c2d5e79afb --- /dev/null +++ b/apps/tldw-frontend/__tests__/extension/plasmo-storage-watch.test.tsx @@ -0,0 +1,76 @@ +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("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/wxt-browser-storage.test.ts b/apps/tldw-frontend/__tests__/extension/wxt-browser-storage.test.ts new file mode 100644 index 0000000000..ac30ab699d --- /dev/null +++ b/apps/tldw-frontend/__tests__/extension/wxt-browser-storage.test.ts @@ -0,0 +1,106 @@ +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 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..fbaf2d751c 100644 --- a/apps/tldw-frontend/extension/shims/plasmo-storage-hook.tsx +++ b/apps/tldw-frontend/extension/shims/plasmo-storage-hook.tsx @@ -34,6 +34,14 @@ 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 setIsLoading(true) @@ -41,11 +49,7 @@ export function useStorage( .get(options.key) .then((stored) => { if (cancelled) return - if (stored === undefined) { - setValue(defaultValueRef.current) - } else { - setValue(stored) - } + applyValue(stored === undefined ? defaultValueRef.current : stored) }) .finally(() => { if (!cancelled) { @@ -53,22 +57,35 @@ export function useStorage( } }) + // Subscribe so cross-instance / cross-tab writes apply without a reload. + const unwatch = storage.watch({ + [options.key]: (change) => { + if (cancelled) return + 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..d2cc2b5325 100644 --- a/apps/tldw-frontend/extension/shims/plasmo-storage.ts +++ b/apps/tldw-frontend/extension/shims/plasmo-storage.ts @@ -64,6 +64,61 @@ 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 { + // ignore watcher errors + } + }) +} + +// 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 + notifyGlobal(event.key, { + oldValue: + event.oldValue == null + ? undefined + : defaultSerde.deserializer(event.oldValue), + newValue: + event.newValue == null + ? undefined + : defaultSerde.deserializer(event.newValue) + }) + }) +} + export class Storage { private backend: StorageBackend private serde: Required @@ -142,21 +197,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 +224,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..9d91bbae52 --- /dev/null +++ b/apps/tldw-frontend/lib/__tests__/history-redaction.test.ts @@ -0,0 +1,131 @@ +/** @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('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..65b5fd3c6e 100644 --- a/apps/tldw-frontend/lib/history.ts +++ b/apps/tldw-frontend/lib/history.ts @@ -15,42 +15,102 @@ 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', ]); -function redactRequestHeaders(headers: RequestHistoryItem['requestHeaders']): RequestHistoryItem['requestHeaders'] { +// Body keys (compared case-insensitively) whose values must never be persisted. +const SENSITIVE_BODY_KEYS = new Set(['access_token', 'refresh_token']); + +// 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; } -function sanitizeHistoryItem(item: RequestHistoryItem): RequestHistoryItem { +// 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 { + if (depth > 6) return value; + 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 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 +121,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