fix(mana/web/news): use client-side API URL + snapshot $state arrays

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.
This commit is contained in:
Till JS 2026-04-09 17:16:44 +02:00
parent 59b5114348
commit 4fab323234
3 changed files with 28 additions and 19 deletions

View file

@ -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<ExtractedArticleDto> {
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' },

View file

@ -33,10 +33,13 @@ export const preferencesStore = {
blockedSources?: string[];
}): Promise<void> {
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<LocalPreferences> = {
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<void> {
await ensureRow();
const diff: Partial<LocalPreferences> = {
selectedTopics: topics,
selectedTopics: [...topics],
updatedAt: new Date().toISOString(),
};
await encryptRecord('newsPreferences', diff);
@ -57,7 +60,7 @@ export const preferencesStore = {
async setLanguages(languages: Language[]): Promise<void> {
await ensureRow();
const diff: Partial<LocalPreferences> = {
preferredLanguages: languages,
preferredLanguages: [...languages],
updatedAt: new Date().toISOString(),
};
await encryptRecord('newsPreferences', diff);

View file

@ -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.
}