fix(mana/web/news): Bearer token + Dexie v4 schema upgrade

Two distinct bugs surfaced by the first browser-side end-to-end test
of the News module against the locally-managed cloudflared tunnel.

═══ 1. Onboarding loop on reload ═══

The news tables were originally added to db.version(1).stores(),
which violates Dexie's "never edit a published version" contract.
Existing browsers stuck at db.version(3) (after the body + who
upgrades) never trigger an upgrade for v1 changes, so the news tables
silently never get created on those IndexedDB instances. Writes to
preferencesTable.add() / .update() failed at the storage layer, the
preferences row was never persisted, and on reload usePreferences()
returned the DEFAULT_PREFERENCES fallback (onboardingCompleted: false)
which re-rendered the onboarding wizard.

Fix: move the five news tables out of db.version(1) into a fresh
db.version(4).stores({…}) block. Dexie sees the bumped version number
and runs the additive upgrade transaction on existing v3 IndexedDBs,
creating the missing tables. Brand-new IndexedDBs go straight to v4
and pick up the union of all four version blocks. Both paths now
have the news tables present.

═══ 2. /api/v1/news/feed → 401 Missing authorization header ═══

The news api.ts client was passing `credentials: 'include'` thinking
the cookie alone would carry auth through to mana-api. It does not —
apps/api's authMiddleware() reads the Authorization header and
ignores cookies. Every browser-side fetch returned 401, the feed
cache stayed empty, and the wizard's "Fertig" → ranked feed flow
silently failed.

Fix: add a small `authHeader()` helper that pulls the JWT from
authStore.getAccessToken() and attaches it as
`Authorization: Bearer …`, mirroring the pattern in
modules/planta/api.ts. Both `fetchFeed()` and `extractFromUrl()` now
go through it. Drops the cookie credential entirely since it was a
no-op anyway.

Also tidies a Svelte 5 `$props()` warning in modules/news/ListView.svelte
(empty destructure instead of binding to a `_props` const).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 18:42:49 +02:00
parent 9bf73fffa3
commit 7ba381fde8
3 changed files with 61 additions and 26 deletions

View file

@ -269,23 +269,12 @@ db.version(1).stores({
'id, startDate, kind, type, sourceModule, sourceId, parentBlockId, [sourceModule+sourceId], [type+startDate], [kind+startDate], [parentBlockId+recurrenceDate]',
timeBlockTags: 'id, blockId, tagId, [blockId+tagId]',
// ─── News (appId: 'news') ───
// `newsArticles` is the user's personal reading list (saved articles
// from the curated pool plus user-pasted URLs). `newsCategories` is
// user-defined folders for the reading list. `newsPreferences` is a
// singleton row holding selected topics, blocklist, language and the
// learned topic/source weights. `newsReactions` records per-article
// feedback (interested / not_interested / source_blocked / hidden)
// and is what the feed engine uses to suppress already-rated items.
// `newsCachedFeed` is a local mirror of the latest curated pool from
// the server — capped to ~200 entries for offline reading. It is
// intentionally NOT in module.config.ts and therefore not synced.
newsArticles:
'id, type, isArchived, isRead, isFavorite, categoryId, originalUrl, sourceCuratedId, [type+isArchived], [categoryId+createdAt]',
newsCategories: 'id, sortOrder',
newsPreferences: 'id',
newsReactions: 'id, articleId, reaction, sourceSlug, topic, [reaction+createdAt]',
newsCachedFeed: 'id, topic, sourceSlug, language, publishedAt, [topic+publishedAt]',
// ─── News tables intentionally NOT in v1 ───
// Originally added here, but that violates Dexie's "never edit a
// published version" rule. Existing browsers stuck at db.version(3)
// would never trigger an upgrade for v1 changes, so the news tables
// would only appear on a fresh-cleared IndexedDB. Moved into
// db.version(4) below — see comment there for rationale + indexes.
// ─── Shared: Global Tags (appId: 'tags') ───
globalTags: 'id, name, groupId',
@ -329,6 +318,38 @@ db.version(3).stores({
whoMessages: 'id, gameId, sender, createdAt, [gameId+createdAt]',
});
// Schema version 4 — adds the News module (curated public feed + saved
// reading list + per-user preferences/reactions + a local cache of the
// server pool). Additive only; no v1/v2/v3 tables touched.
//
// `newsArticles` is the user's personal reading list (saved from the
// curated pool or pasted URLs). `newsCategories` are user-defined
// folders. `newsPreferences` is a singleton row keyed on 'singleton'
// holding selected topics, blocklist, languages and the learned topic
// + source weights. `newsReactions` records per-article feedback
// (interested / not_interested / source_blocked / hidden) and is what
// the feed engine uses to suppress already-rated items. `newsCachedFeed`
// is a local mirror of the server's curated pool, capped to ~400 rows
// for offline reading — intentionally NOT in module.config.ts and
// therefore not synced.
//
// Index strategy:
// - newsArticles indexes type/categoryId/sourceCuratedId for the
// reading-list filter strip and the saveFromCurated() dedupe lookup
// ([type+isArchived] for the unread/archive tab queries)
// - newsReactions indexes [reaction+createdAt] so the feed engine can
// range-scan "what did the user dismiss" without loading every row
// - newsCachedFeed indexes [topic+publishedAt] so the topic-filter
// pass in rankFeed() can do a single index walk instead of N scans
db.version(4).stores({
newsArticles:
'id, type, isArchived, isRead, isFavorite, categoryId, originalUrl, sourceCuratedId, [type+isArchived], [categoryId+createdAt]',
newsCategories: 'id, sortOrder',
newsPreferences: 'id',
newsReactions: 'id, articleId, reaction, sourceSlug, topic, [reaction+createdAt]',
newsCachedFeed: 'id, topic, sourceSlug, language, publishedAt, [topic+publishedAt]',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -32,9 +32,10 @@
// We accept ViewProps for protocol compatibility but the workbench
// view doesn't navigate within itself — every "open" jumps to the
// dedicated /news routes.
let _props: ViewProps = $props();
void _props;
// dedicated /news routes. Empty destructure satisfies the $props()
// declaration without referencing the props object (which would
// trigger Svelte's "captured initial value" warning).
const {}: ViewProps = $props();
const prefs$ = usePreferences();
const pool$ = useCachedFeed();

View file

@ -13,13 +13,22 @@
* 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).
* Auth is the unified Mana JWT pulled from `authStore.getAccessToken()`
* and attached as a `Authorization: Bearer …` header. The credentials/
* cookie path does NOT work the apps/api authMiddleware only reads
* the Authorization header. Initially we passed `credentials: 'include'`
* thinking the cookie alone was enough, which made every browser-side
* fetch return 401 because mana-api never sees a token.
*/
import { authStore } from '$lib/stores/auth.svelte';
import { getManaApiUrl } from '$lib/api/config';
async function authHeader(): Promise<Record<string, string>> {
const token = await authStore.getAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
export interface FeedArticleDto {
id: string;
originalUrl: string;
@ -61,7 +70,9 @@ export async function fetchFeed(
if (query.offset != null) params.set('offset', String(query.offset));
const url = `${getManaApiUrl()}/api/v1/news/feed${params.toString() ? `?${params}` : ''}`;
const response = await fetchImpl(url, { credentials: 'include' });
const response = await fetchImpl(url, {
headers: await authHeader(),
});
if (!response.ok) {
throw new Error(`fetchFeed failed: ${response.status}`);
}
@ -92,8 +103,10 @@ export async function extractFromUrl(
): Promise<ExtractedArticleDto> {
const response = await fetchImpl(`${getManaApiUrl()}/api/v1/news/extract/save`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(await authHeader()),
},
body: JSON.stringify({ url }),
});
if (!response.ok) {