Files
SalastilandClaude Sonnet 5.5 a0a8ce7e09
Build and push Docker images / backend (push) Failing after 30s
Build and push Docker images / frontend (push) Failing after 31s
Make ADMIN_API_KEY work as a chosen admin password under Docker
Setting ADMIN_API_KEY in the Unraid project's .env had no effect: the deployed compose.yaml
was a stale copy of docker-compose.prod.yml from before that file forwarded the variable, so
nothing referenced it, it never reached the container, and the backend invented a random key
on every recreate. The repo's prod file already had the line; the fix on the host is to bring
the deployed copy back in line with it.

The deployed copy had also drifted the other way: the extra_hosts entry that keeps the
frontend's server-side rendering on the LAN had only ever been applied on the host. It is
now in docker-compose.prod.yml, so the repo is the source of truth again and a re-copy of
the file can't silently drop it.

Backend changes so a chosen password behaves sensibly:
- A key supplied through the environment is no longer printed at startup. The banner echoed
  it into the container log on every start, leaving a copy of the password in `docker logs`.
  Generated and file-persisted keys are still printed, since that is the only way to learn
  one you didn't choose.
- The env value is trimmed. A password pasted into a compose or env editor routinely picks up
  a stray space or newline and then never matches, which looks exactly like the setting being
  ignored.
- Startup warns when a supplied key is shorter than 16 characters. Nothing limits repeated
  wrong guesses on /api/admin/*, so a short hand-picked key is genuinely guessable.

The README, backend README and .env.example still described a fresh random key on every
start that "isn't configurable via .env", which stopped being true when the key began to
persist; they now describe the three ways it is resolved and the Docker forwarding gotcha.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-10 23:38:11 -04:00
..
2026-07-17 10:33:20 -04:00
2026-07-17 10:33:20 -04:00

Homefeed backend

Node.js + TypeScript, per project-structure.md. Ingests RSS/API/Telegram sources, clusters and synthesizes them into merged articles via a self-hosted Ollama instance, and serves the same /api/feed, /api/article/:id, /api/tags, /api/events contract the frontend already consumes from the mock backend — plus the full /api/admin/* surface behind session auth.

Setup

cp .env.example .env
npm install
npm run dev

Runs on :4000 by default. Point the frontend's VITE_BACKEND_URL at it instead of the mock backend and everything else keeps working unchanged — same API contract.

Every /api/admin/* request must send an admin API key as an X-Api-Key header (enforced in api/auth.ts); the admin login page just asks for this key and stashes it in the browser's localStorage rather than issuing its own session. The key comes from, in order (see api/apiKey.ts):

  1. the ADMIN_API_KEY environment variable — set this to choose your own password. It's used exactly as given (surrounding whitespace is trimmed) and, because you already know it, it is not printed at startup. Use 16+ characters: nothing limits repeated wrong guesses, and the backend warns when the key is shorter than that.
  2. an ADMIN_API_KEY= line already saved in backend/.env by a previous launch;
  3. otherwise a random one is generated on the first launch, printed to the console and appended to backend/.env so it stays the same on later launches.

In Docker the environment variable has to be forwarded by the compose file (ADMIN_API_KEY: ${ADMIN_API_KEY:-} under backend.environment) — a value sitting in the project's .env that no compose line references never reaches the container.

You'll also need a running Ollama instance (see AI_SERVICE_HOST/AI_SERVICE_PORT in the admin panel's Connections tab, or PATCH /api/admin/settings directly) with at minimum an embedding model (e.g. nomic-embed-text) and a generation model (e.g. qwen2.5:7b-instruct-q4_K_M) pulled. Without it reachable, ingestion still runs, but the synthesis tick quietly skips each cycle until the AI service comes back — nothing crashes, articles just don't get published.

Full-article capture

RSS descriptions are frequently truncated teasers or ad-mangled snippets, not the actual article. For RSS and API sources, every newly-ingested item's link gets followed and the real page content extracted via Mozilla's Readability (the same approach behind Firefox Reader View) — ingestion/articleFetcher.ts. When extraction succeeds, the feed's own title/summary/body/image get replaced with what was actually on the page; nav, ads, sidebars, comments, and footers are excluded, not just stripped of tags. Also pulls the page's og:image/twitter:image meta tag as a hero image when the feed itself didn't provide one.

Extraction fails constantly in the real world — paywalls, bot detection, JS-rendered pages, odd markup — so this is deliberately non-fatal: on any failure it falls back to whatever the feed itself provided (title/description), logged at warn level so it's visible in the admin panel's Logs tab rather than silent. Telegram sources skip this entirely, since a Telegram message is the content — there's no separate page to follow.

This adds two dependencies (jsdom, @mozilla/readability) — the one deliberate exception to the "no heavy dependencies" preference elsewhere in this backend, since a hand-rolled content-extraction heuristic would be meaningfully less reliable across the range of real-world site markup this needs to handle.

Behavior before Ollama is set up

Per the "assume Ollama arrives after the backend launches" requirement: ingestion and publishing don't wait for it.

  • Adding a source polls it immediately, not on the next scheduler tick — you see results right away instead of waiting up to a minute.
  • With no AI service reachable, every eligible item publishes directly and immediately — no hold-before-publish wait (that window exists to let corroborating sources arrive before an AI merge locks in, which doesn't apply when nothing's being merged). No rewriting, no cross-source merging, no tags (there's no LLM to extract them yet), but the page populates right away instead of staying empty.
  • Passthrough articles show the original feed publish date (pubDate/isoDate from the source), not when they were ingested or published locally. Only AI-synthesized/merged articles get a "modified" timeframe reflecting when the merge actually happened — see publishDirect vs publishCluster in pipeline/publish.ts.
  • Feed content gets cleaned before storage — RSS/API content is frequently raw (or double-escaped) HTML; ingestion/clean.ts strips tags and decodes entities into readable plain text with paragraph breaks preserved, applied centrally in adapters/base.ts so every adapter benefits without duplicating the logic.
  • No image or video in the feed item? Falls back to the site's favicon ({origin}/favicon.ico, downloaded and locally hosted like any other image) rather than leaving the article with no art at all. Doesn't parse the page's <head> for a proper <link rel="icon"> — just the conventional path, which covers most sites without an extra HTTP round trip. See faviconUrlFor in pipeline/image-selection.ts.
  • Once Ollama becomes reachable, the real pipeline (embed → cluster → synthesize → tag) takes back over for anything ingested from that point on. Articles already published via the passthrough path aren't retroactively rewritten or merged — they stand as they are, but new related coverage can still link to them as a follow-up via the normal tag-based thread detection.
  • Tracked-event recaps are the one thing that still waits for AI — summarizing a day's worth of Telegram messages isn't something worth faking with a passthrough; those simply don't fire until the AI service is available.

What's real vs. stubbed

Built and verified end-to-end (see the test run in this conversation — real SQLite, real RSS parsing, real HTTP calls to a stub Ollama server, real media download to disk, real tag dedup across separate synthesis calls):

  • SQLite schema + repository layer for every entity in homefeed-data-schema.md
  • Admin API key auth (your own ADMIN_API_KEY, or a random one generated once and persisted, checked via a timing-safe comparison against an X-Api-Key header on every request, CORS locked to the configured frontend origin) protecting all /api/admin/* routes
  • RSS adapter (real parsing, images/video extraction) and a generic JSON API adapter (configurable field mapping)
  • Poller respecting per-source poll intervals
  • Embedding → clustering → synthesis → tag extraction/dedup → image selection → media download → publish pipeline, talking to Ollama over HTTP
  • Priority queue ordering by admin-ranked category before clustering
  • Retention sweep (age-based for both raw items and published articles, size-based FIFO cap, tag expiry) running hourly
  • Full admin API: settings, sources, tracked events, live model catalog from Ollama, AI service status

Stubbed / simplified, worth knowing before relying on it:

  • Telegram adapter (ingestion/adapters/telegram.ts) is a stub — logs a warning and returns nothing. Needs a real implementation (grammY, per the architecture doc) keyed off source.config.telegramChannelId.
  • Image selection picks the highest-resolution candidate rather than using a vision model to judge relevance — cheap and deterministic, but doesn't catch a technically-large image that's actually a poor choice (watermarked, wrong subject). A vision-model pass is a natural upgrade once this needs to be smarter.
  • Follow-up thread detection matches on shared tags within a 3-day lookback rather than comparing article-level embeddings directly. Works, but a cluster with no tags extracted can't be linked to a prior thread at all.
  • Storage-cap FIFO eviction deletes oldest articles but doesn't yet cascade-delete their associated media rows/files in the same pass — there's a comment in queue/retention.ts explaining the gap and the backstop that partially covers it.
  • node:sqlite is still an experimental Node API. If a future Node version changes its behavior, better-sqlite3 is a drop-in-shaped alternative (same synchronous .prepare().run()/.get()/.all() style).

None of these block running the backend end-to-end — they're the specific spots worth hardening next, roughly in the order listed.