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

View File

@@ -0,0 +1,74 @@
//! Router assembly. Public API is versioned under `/v1` from day one; `/health` is
//! unversioned for infrastructure probes.
mod auth_routes;
mod feed;
mod health;
mod ingest;
mod interests;
mod sources;
mod tokens;
use axum::Router;
use axum::http::{Method, header};
use axum::routing::{delete, get, post};
use tower_http::compression::CompressionLayer;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use crate::state::AppState;
/// Build the full application router.
pub fn router(state: AppState) -> Router {
let cors = build_cors(&state);
let v1 = Router::new()
// auth
.route("/auth/register", post(auth_routes::register))
.route("/auth/login", post(auth_routes::login))
.route("/auth/logout", post(auth_routes::logout))
.route("/auth/me", get(auth_routes::me))
// feed
.route("/feed", get(feed::get_feed))
.route("/feed/signals", post(feed::post_signal))
// sources
.route("/sources", get(sources::list).post(sources::create))
.route("/sources/{id}", delete(sources::remove))
// interests
.route("/interests", get(interests::list).put(interests::upsert))
.route("/interests/{id}", delete(interests::remove))
// api tokens
.route("/tokens", get(tokens::list).post(tokens::create))
.route("/tokens/{id}", delete(tokens::revoke))
// ingest (bearer-token authenticated)
.route("/ingest/candidates", post(ingest::submit));
Router::new()
.route("/health", get(health::health))
.nest("/v1", v1)
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(cors)
.with_state(state)
}
/// CORS is only needed in development, where the Vite dev server is a different origin
/// from the API. In production nginx serves the SPA and proxies the API under one origin,
/// so `cors_origins` is empty and no cross-origin headers are emitted.
fn build_cors(state: &AppState) -> CorsLayer {
if state.config.cors_origins.is_empty() {
return CorsLayer::new();
}
let origins = state
.config
.cors_origins
.iter()
.filter_map(|o| o.parse().ok())
.collect::<Vec<_>>();
// Credentialed CORS forbids wildcard method/header lists, so enumerate them.
CorsLayer::new()
.allow_origin(origins)
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
.allow_credentials(true)
}