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,61 @@
//! Session and API-token DTOs.
//!
//! Humans authenticate with an opaque session cookie; agentic and algorithmic sources
//! authenticate with per-user bearer API tokens. In both cases only a hash of the
//! secret is ever persisted — see `newsfeed-core::auth`.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use uuid::Uuid;
/// An authenticated browser session, keyed by the hash of an opaque cookie value.
#[derive(Debug, Clone)]
pub struct Session {
pub id: Uuid,
pub user_id: Uuid,
pub created_at: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
}
/// Public metadata about an API token. The secret itself is shown exactly once, at
/// creation, and is never retrievable afterwards.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ApiTokenInfo {
pub id: Uuid,
pub user_id: Uuid,
pub name: String,
/// Short non-secret prefix (e.g. `nf_a1b2c3`) to help the user identify the token.
pub prefix: String,
pub created_at: DateTime<Utc>,
pub last_used_at: Option<DateTime<Utc>>,
pub revoked_at: Option<DateTime<Utc>>,
}
/// Request to mint a new API token.
#[derive(Debug, Clone, Deserialize, TS)]
#[ts(export)]
pub struct CreateApiToken {
pub name: String,
}
/// Response returned once when a token is created. `secret` is the full bearer token
/// (`nf_<prefix>_<random>`) and is never persisted in the clear.
#[derive(Debug, Clone, Serialize, TS)]
#[ts(export)]
pub struct CreatedApiToken {
#[serde(flatten)]
pub info: ApiTokenInfo,
/// The full secret. Present only in this response.
pub secret: String,
}
/// The current authenticated principal, returned by `GET /v1/auth/me`.
#[derive(Debug, Clone, Serialize, TS)]
#[ts(export)]
pub struct Me {
pub id: Uuid,
pub username: String,
pub email: String,
}

View File

@@ -0,0 +1,30 @@
//! Domain-level error type. Validation and invariant failures live here; I/O errors
//! belong to the crates that perform the I/O (`newsfeed-data`, the binaries).
use thiserror::Error;
/// Errors produced while constructing or validating domain values.
#[derive(Debug, Error)]
pub enum Error {
/// A user-supplied value failed validation (empty, malformed, out of range).
#[error("invalid {field}: {reason}")]
Invalid {
/// The field that failed validation.
field: &'static str,
/// Why it failed.
reason: String,
},
}
impl Error {
/// Convenience constructor for [`Error::Invalid`].
pub fn invalid(field: &'static str, reason: impl Into<String>) -> Self {
Self::Invalid {
field,
reason: reason.into(),
}
}
}
/// Result alias for domain operations.
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,54 @@
//! Feed query/response DTOs and interaction signals. Signals are the feedback loop:
//! what the user does with an item feeds back into future ranking — but only ever
//! according to weights the user themselves controls.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use uuid::Uuid;
use crate::item::ContentItem;
/// Query parameters for `GET /v1/feed`.
#[derive(Debug, Clone, Deserialize, TS)]
#[ts(export)]
pub struct FeedQuery {
/// Max items to return. Defaults applied server-side.
pub limit: Option<u32>,
/// Opaque cursor for pagination (score+id of the last item seen).
pub cursor: Option<String>,
/// When true, include saved items; otherwise only live feed items.
#[serde(default)]
pub include_saved: bool,
}
/// A page of ranked feed items.
#[derive(Debug, Clone, Serialize, TS)]
#[ts(export)]
pub struct FeedPage {
pub items: Vec<ContentItem>,
/// Cursor to pass back for the next page, or `None` at the end.
pub next_cursor: Option<String>,
}
/// The kinds of interaction the user can have with an item.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum SignalAction {
/// The item scrolled into view.
View,
/// The user opened/clicked through.
Click,
/// The user saved it.
Save,
/// The user dismissed it.
Dismiss,
}
/// A recorded interaction, posted to `POST /v1/feed/signals`.
#[derive(Debug, Clone, Deserialize, TS)]
#[ts(export)]
pub struct Signal {
pub item_id: Uuid,
pub action: SignalAction,
}

View File

@@ -0,0 +1,45 @@
//! Interests: the user-controlled weightings that drive ranking. A user tells the system
//! what they care about and how much; the ranker (see `newsfeed-core::ranking`) uses
//! these weights — and nothing else's opinion — to score their feed.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use uuid::Uuid;
use crate::error::{Error, Result};
/// A weighted topic of interest belonging to a single user.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Interest {
pub id: Uuid,
pub user_id: Uuid,
/// Free-text label matched (case-insensitively) against item title/summary/tags.
pub label: String,
/// How strongly this interest pulls matching items up the feed, `-1.0..=1.0`.
/// Negative weights actively bury matching content.
pub weight: f64,
pub created_at: DateTime<Utc>,
}
/// Create or update an interest.
#[derive(Debug, Clone, Deserialize, TS)]
#[ts(export)]
pub struct UpsertInterest {
pub label: String,
pub weight: f64,
}
impl UpsertInterest {
/// Validate an interest upsert.
pub fn validate(&self) -> Result<()> {
if self.label.trim().is_empty() {
return Err(Error::invalid("label", "must not be empty"));
}
if !(-1.0..=1.0).contains(&self.weight) {
return Err(Error::invalid("weight", "must be within -1.0..=1.0"));
}
Ok(())
}
}

View File

@@ -0,0 +1,89 @@
//! Content items: the units that flow through curation into a user's feed. An item
//! enters as a *candidate* (either polled by the worker or pushed to the ingest
//! endpoint), gets scored, and then lives in the feed until the user acts on it.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use uuid::Uuid;
/// Lifecycle state of a content item.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum ItemState {
/// Newly ingested, awaiting scoring/curation.
Candidate,
/// Scored and live in the feed.
Feed,
/// The user explicitly saved/bookmarked it.
Saved,
/// The user dismissed it; kept for negative signal, hidden from the feed.
Dismissed,
}
impl ItemState {
/// Stable string form used in the database and on the wire.
pub fn as_str(self) -> &'static str {
match self {
ItemState::Candidate => "candidate",
ItemState::Feed => "feed",
ItemState::Saved => "saved",
ItemState::Dismissed => "dismissed",
}
}
}
/// A media attachment (image/video/audio) associated with an item.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Media {
pub kind: String,
pub url: String,
pub width: Option<u32>,
pub height: Option<u32>,
}
/// A content item owned by a user.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct ContentItem {
pub id: Uuid,
pub user_id: Uuid,
pub source_id: Option<Uuid>,
/// De-duplication key from the origin (feed guid, tweet id, URL, …).
pub external_id: String,
pub url: Option<String>,
pub title: String,
pub summary: Option<String>,
pub author: Option<String>,
pub tags: Vec<String>,
pub media: Vec<Media>,
pub published_at: Option<DateTime<Utc>>,
pub ingested_at: DateTime<Utc>,
pub state: ItemState,
/// Cached ranking score; recomputed by the ranker as weights/signals change.
pub score: f64,
}
/// A candidate pushed to `POST /v1/ingest/candidates` by an agentic or algorithmic
/// source. The authenticated API token determines the owning user; `source` names the
/// producer for attribution and per-source weighting.
#[derive(Debug, Clone, Deserialize, TS)]
#[ts(export)]
pub struct CandidateSubmission {
/// Stable de-dup key from the producer. Re-submitting the same key is a no-op.
pub external_id: String,
pub url: Option<String>,
pub title: String,
pub summary: Option<String>,
pub author: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub media: Vec<Media>,
pub published_at: Option<DateTime<Utc>>,
/// Optional name of the source that produced this candidate. If it matches an
/// existing agentic source for the user it is linked; otherwise ingested unlinked.
pub source: Option<String>,
}

View File

@@ -0,0 +1,22 @@
//! Domain types, DTOs and error enums shared across the newsfeed workspace.
//!
//! This crate is intentionally free of I/O and async-runtime dependencies. Everything
//! downstream (`newsfeed-core`, `newsfeed-data`, the binaries and — via generated
//! TypeScript bindings — the web frontend) depends on these types.
//!
//! Wire types derive [`ts_rs::TS`] with `#[ts(export)]` so `cargo test -p
//! newsfeed-entities` regenerates the frontend bindings under `web/src/api/bindings/`
//! (path set by `TS_RS_EXPORT_DIR` in `.cargo/config.toml`).
pub mod auth;
pub mod error;
pub mod feed;
pub mod interest;
pub mod item;
pub mod source;
pub mod user;
pub use error::{Error, Result};
/// Newtype over a [`uuid::Uuid`] identifier, re-exported for convenience.
pub type Id = uuid::Uuid;

View File

@@ -0,0 +1,92 @@
//! Content sources. A source is where candidate items come from — an RSS/Atom feed the
//! worker polls, or an agentic/external producer that pushes candidates to the ingest
//! endpoint under a user's API token.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use uuid::Uuid;
use crate::error::{Error, Result};
/// How a source produces candidates.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub enum SourceKind {
/// An RSS/Atom feed the worker polls on a schedule.
Rss,
/// A producer that pushes candidates to `POST /v1/ingest/candidates`
/// (agentic workloads, scrapers, bespoke algorithms).
Agentic,
}
impl SourceKind {
/// Stable string form used in the database and on the wire.
pub fn as_str(self) -> &'static str {
match self {
SourceKind::Rss => "rss",
SourceKind::Agentic => "agentic",
}
}
/// Parse from the database/wire representation.
pub fn parse(s: &str) -> Result<Self> {
match s {
"rss" => Ok(SourceKind::Rss),
"agentic" => Ok(SourceKind::Agentic),
other => Err(Error::invalid(
"kind",
format!("unknown source kind: {other}"),
)),
}
}
}
/// A user's configured content source.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct Source {
pub id: Uuid,
pub user_id: Uuid,
pub kind: SourceKind,
pub name: String,
/// Poll URL for [`SourceKind::Rss`]; unused/optional for agentic sources.
pub url: Option<String>,
/// Baseline weight applied to every item from this source, `0.0..=1.0`.
pub weight: f64,
pub enabled: bool,
/// When the worker last polled this source (rss only).
pub last_polled_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
/// Request to create a source.
#[derive(Debug, Clone, Deserialize, TS)]
#[ts(export)]
pub struct NewSource {
pub kind: SourceKind,
pub name: String,
pub url: Option<String>,
/// Defaults to `1.0` when omitted.
#[serde(default)]
pub weight: Option<f64>,
}
impl NewSource {
/// Validate a create-source request.
pub fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(Error::invalid("name", "must not be empty"));
}
if self.kind == SourceKind::Rss && self.url.as_deref().unwrap_or("").is_empty() {
return Err(Error::invalid("url", "rss sources require a url"));
}
if let Some(w) = self.weight {
if !(0.0..=1.0).contains(&w) {
return Err(Error::invalid("weight", "must be within 0.0..=1.0"));
}
}
Ok(())
}
}

View File

@@ -0,0 +1,54 @@
//! User accounts. Each user is fully in control of their own feed weightings.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use uuid::Uuid;
use crate::error::{Error, Result};
/// A registered user. The password hash never leaves `newsfeed-data`/`newsfeed-core`;
/// it is deliberately absent from this DTO so it can't be serialised to a client.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export)]
pub struct User {
pub id: Uuid,
pub username: String,
pub email: String,
pub created_at: DateTime<Utc>,
}
/// Registration request from the sign-up form.
#[derive(Debug, Clone, Deserialize, TS)]
#[ts(export)]
pub struct RegisterRequest {
pub username: String,
pub email: String,
pub password: String,
}
impl RegisterRequest {
/// Validate the shape of a registration request before it reaches the store.
/// Uniqueness is enforced by the database, not here.
pub fn validate(&self) -> Result<()> {
if self.username.trim().len() < 3 {
return Err(Error::invalid("username", "must be at least 3 characters"));
}
if !self.email.contains('@') {
return Err(Error::invalid("email", "must contain '@'"));
}
if self.password.len() < 8 {
return Err(Error::invalid("password", "must be at least 8 characters"));
}
Ok(())
}
}
/// Login request.
#[derive(Debug, Clone, Deserialize, TS)]
#[ts(export)]
pub struct LoginRequest {
/// Username or email.
pub identifier: String,
pub password: String,
}