feat: scaffold newsfeed — user-controlled news feed
Some checks failed
deploy / build (push) Failing after 7s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped

A self-hosted feed where the user owns the ranking: per-source and signed
per-interest weights, decayed by recency, via a transparent deterministic
scorer. Content is sourced algorithmically (worker RSS/Atom polling) and
agentically (per-user API tokens POSTing candidates to the ingest endpoint).
Single-user today, multi-user by construction (every row keyed on user_id).

Rust cargo workspace + Vite/React/SWC/TS SPA:
- newsfeed-entities: DTOs (ts-rs bindings -> web/src/api/bindings)
- newsfeed-core: ranking, auth primitives, ingest, data-access ports
- newsfeed-data: SQLite adapters (sqlx, runtime queries)
- newsfeed-api: axum REST/JSON daemon
- newsfeed-worker: RSS polling + rescoring loop
- web: responsive, mobile-first SPA (React Query, generated types)

Deploy (Gitea Actions, build static musl + SPA, rsync as gitea_ci):
api+worker -> slartibartfast, SPA -> oolon (nginx serves + proxies /v1).

Deliberate deviations from house conventions (documented in CLAUDE.md/readme):
- SQLite instead of Postgres; api+worker co-locate sharing one DB file.
- Runtime sqlx queries instead of query! macros (SQLite dynamic typing;
  keeps CI database-free).

Verified end-to-end: auth, token ingest, interest-weighted ranking, signals,
pagination (curl + browser); cargo fmt/clippy -D/test and pnpm build/lint pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP
This commit is contained in:
2026-07-08 11:36:57 +03:00
commit 5ce52fff4d
104 changed files with 10963 additions and 0 deletions

69
CLAUDE.md Normal file
View File

@@ -0,0 +1,69 @@
# CLAUDE.md — newsfeed
Agent-facing notes for working in this repo. Read the workspace-wide conventions in
`~/git/architecture/*.md` first; this file records only what's specific to newsfeed,
especially the deliberate deviations.
## What this is
A self-hosted, user-controlled news feed. The core idea: the user owns the ranking. Never
introduce an implicit/opaque signal that overrides the user's explicit weights. Signals
(view/click/save/dismiss) may *inform* future ranking but must remain subordinate to the
per-source and per-interest weights the user sets.
## Layout & boundaries (strict)
- `newsfeed-entities` — types/DTOs only, no I/O. Wire DTOs derive `ts_rs::TS` with
`#[ts(export)]`; `cargo test -p newsfeed-entities` regenerates `web/src/api/bindings/`
(path set by `.cargo/config.toml`'s `TS_RS_EXPORT_DIR`). Don't hand-edit bindings.
- `newsfeed-core` — business logic + data-access **ports** (traits). Pure where possible
(ranking, auth hashing). No DB or network calls.
- `newsfeed-data` — SQLite **adapters** implementing the core ports.
- `newsfeed-api` / `newsfeed-worker` — thin binaries; wire config/logging/signals and the
concrete store. No business logic that could live in a library crate.
New types → entities. New logic → core. New I/O → data. Add a port to
`core::ports`, implement it in `data::store`.
## Deliberate deviations from `generic.md`
1. **SQLite instead of Postgres (§5).** Explicit project choice. Because SQLite is
single-file/single-host, **api and worker co-locate** and share `/var/lib/newsfeed/newsfeed.db`.
There is no central DB cluster, no mTLS DB auth, no `pg_ident` mapping. The worker does
in-process scheduling — the `FOR UPDATE SKIP LOCKED` guidance does not apply.
2. **Runtime sqlx queries, not `query!` macros (§5).** SQLite's dynamic typing makes
compile-time checking low-value and forces a live DB or fiddly offline cache into CI.
We use `sqlx::query`/`query_as` with `FromRow` row structs (see `data/src/rows.rs`) and
map to entities explicitly. No `.sqlx/` cache; CI builds need no database.
Both are documented at their site (`data/src/lib.rs` header, `readme.md`) — keep them in
sync if you change the approach.
## Datastore conventions
TEXT UUIDs (hyphenated), RFC3339 timestamps, INTEGER booleans, JSON-in-TEXT for small
collections (`tags`, `media`). Migrations are immutable once committed (§5): add a new
`crates/newsfeed-data/migrations/NNNN_*.sql`, never edit a landed one. `RETURNING` and
`ON CONFLICT` are used, so the target needs SQLite ≥ 3.35.
## Auth model
- Humans: Argon2id password hash + opaque session cookie (`nf_session`), hash stored.
- Producers: per-user bearer API tokens (`nf_<prefix>_<random>`), SHA-256 hashed at rest,
shown once. Ingest is attributed to the token's owner.
Crypto/token logic lives in `core::auth`; the flow orchestration in `core::service`.
## Deploy topology
api + worker → `slartibartfast.kosherinata.internal`; SPA → `oolon.kosherinata.internal`
(nginx serves + proxies `/v1` to the API over the mesh; TLS terminates at oolon). API
port 8081, plain HTTP behind firewalld. See `.gitea/workflows/deploy.yml` (infra truth)
and `script/infra-setup.sh` (one-time host prep). `Type=exec`, not `notify`, because axum
doesn't `sd_notify`.
## Before you commit
- `cargo fmt --all` · `cargo clippy --workspace --all-targets -- -D warnings` · `cargo test --workspace`
- `pnpm --dir web build` (tsc typecheck + vite) · `pnpm --dir web lint`
- If you touched an entity DTO, regenerate bindings and include them in the commit.
- Conventional Commits; scope = crate/area (`feat(api):`, `fix(core):`, …).