From 4fab32323440cb82723fe218dd6355287e2a6f32 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 17:16:44 +0200 Subject: [PATCH] fix(mana/web/news): use client-side API URL + snapshot $state arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboarding's "Fertig" button was failing with two distinct errors: 1. Feed fetch hit `http://mana-api:3060/api/v1/news/feed` (the SSR-only internal Docker hostname) and was blocked by CSP. The news client was reading `$env/dynamic/public.PUBLIC_MANA_API_URL`, which on the client resolves to whatever the SSR process had — i.e. the internal hostname. Switched to the existing `getManaApiUrl()` helper, which on the client reads `window.__PUBLIC_MANA_API_URL__` (set from `PUBLIC_MANA_API_URL_CLIENT` = `https://mana-api.mana.how`). 2. `completeOnboarding` passed Svelte 5 `$state` proxy arrays directly into the preferences store, which then handed them to Dexie's update hook → `_pendingChanges.add` → `DataCloneError`. The picked arrays are now snapshotted with `$state.snapshot()` at the call site, and the store-side setters defensively spread their inputs so any future caller is safe by default. --- .../mana/apps/web/src/lib/modules/news/api.ts | 25 +++++++++++-------- .../modules/news/stores/preferences.svelte.ts | 13 ++++++---- .../web/src/routes/(app)/news/+page.svelte | 9 ++++--- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/apps/mana/apps/web/src/lib/modules/news/api.ts b/apps/mana/apps/web/src/lib/modules/news/api.ts index 72508e2e2..a2f77b7e0 100644 --- a/apps/mana/apps/web/src/lib/modules/news/api.ts +++ b/apps/mana/apps/web/src/lib/modules/news/api.ts @@ -5,17 +5,20 @@ * - GET /feed — pulls the curated pool, with topic/lang filters * - POST /extract/* — Mozilla Readability for ad-hoc URL saves * - * The base URL is read from `PUBLIC_MANA_API_URL` if set (production - * docker setup), otherwise falls back to localhost dev. Auth is the - * unified Mana JWT, picked up by the same fetch wrapper the rest of - * the app uses (cookie + Authorization header set by SvelteKit `fetch` - * via the auth-provider middleware). + * The base URL comes from `getManaApiUrl()`, which on the client reads the + * browser-injected `__PUBLIC_MANA_API_URL__` (set from + * `PUBLIC_MANA_API_URL_CLIENT` in hooks.server.ts → e.g. + * `https://mana-api.mana.how`) and on the server reads `process.env` + * directly. Reading `$env/dynamic/public.PUBLIC_MANA_API_URL` here would + * leak the SSR-side internal Docker hostname (`http://mana-api:3060`) to + * the browser and trip CSP / DNS. + * + * Auth is the unified Mana JWT, picked up by the same fetch wrapper the + * rest of the app uses (cookie + Authorization header set by SvelteKit + * `fetch` via the auth-provider middleware). */ -import { env as publicEnv } from '$env/dynamic/public'; - -const API_BASE = - publicEnv.PUBLIC_MANA_API_URL || (typeof window !== 'undefined' ? '' : 'http://localhost:3060'); +import { getManaApiUrl } from '$lib/api/config'; export interface FeedArticleDto { id: string; @@ -57,7 +60,7 @@ export async function fetchFeed( if (query.limit != null) params.set('limit', String(query.limit)); if (query.offset != null) params.set('offset', String(query.offset)); - const url = `${API_BASE}/api/v1/news/feed${params.toString() ? `?${params}` : ''}`; + const url = `${getManaApiUrl()}/api/v1/news/feed${params.toString() ? `?${params}` : ''}`; const response = await fetchImpl(url, { credentials: 'include' }); if (!response.ok) { throw new Error(`fetchFeed failed: ${response.status}`); @@ -87,7 +90,7 @@ export async function extractFromUrl( url: string, fetchImpl: typeof fetch = fetch ): Promise { - const response = await fetchImpl(`${API_BASE}/api/v1/news/extract/save`, { + const response = await fetchImpl(`${getManaApiUrl()}/api/v1/news/extract/save`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, diff --git a/apps/mana/apps/web/src/lib/modules/news/stores/preferences.svelte.ts b/apps/mana/apps/web/src/lib/modules/news/stores/preferences.svelte.ts index 6bd952e2a..83d4f1f96 100644 --- a/apps/mana/apps/web/src/lib/modules/news/stores/preferences.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/news/stores/preferences.svelte.ts @@ -33,10 +33,13 @@ export const preferencesStore = { blockedSources?: string[]; }): Promise { await ensureRow(); + // Spread the input arrays — callers in onboarding pass Svelte 5 + // `$state` proxy arrays, which IndexedDB cannot structured-clone + // (DataCloneError on the Dexie hook's _pendingChanges write). const diff: Partial = { - selectedTopics: input.topics, - preferredLanguages: input.languages, - blockedSources: input.blockedSources ?? [], + selectedTopics: [...input.topics], + preferredLanguages: [...input.languages], + blockedSources: [...(input.blockedSources ?? [])], onboardingCompleted: true, updatedAt: new Date().toISOString(), }; @@ -47,7 +50,7 @@ export const preferencesStore = { async setTopics(topics: Topic[]): Promise { await ensureRow(); const diff: Partial = { - selectedTopics: topics, + selectedTopics: [...topics], updatedAt: new Date().toISOString(), }; await encryptRecord('newsPreferences', diff); @@ -57,7 +60,7 @@ export const preferencesStore = { async setLanguages(languages: Language[]): Promise { await ensureRow(); const diff: Partial = { - preferredLanguages: languages, + preferredLanguages: [...languages], updatedAt: new Date().toISOString(), }; await encryptRecord('newsPreferences', diff); diff --git a/apps/mana/apps/web/src/routes/(app)/news/+page.svelte b/apps/mana/apps/web/src/routes/(app)/news/+page.svelte index 82ca73885..eed0da150 100644 --- a/apps/mana/apps/web/src/routes/(app)/news/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/news/+page.svelte @@ -57,10 +57,13 @@ } async function finishOnboarding() { + // $state.snapshot strips the Svelte 5 reactive proxies — without it + // the arrays travel into Dexie hooks as proxies and trip + // DataCloneError on the structured-clone into _pendingChanges. await preferencesStore.completeOnboarding({ - topics: pickedTopics, - languages: pickedLanguages, - blockedSources: pickedBlocked, + topics: $state.snapshot(pickedTopics) as Topic[], + languages: $state.snapshot(pickedLanguages) as Language[], + blockedSources: $state.snapshot(pickedBlocked) as string[], }); // The +layout effect will pick up the new prefs and refresh. }