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,34 @@
[package]
name = "newsfeed-api"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
description = "REST/JSON API daemon for newsfeed (axum)."
[[bin]]
name = "newsfeed-api"
path = "src/main.rs"
[dependencies]
newsfeed-entities.workspace = true
newsfeed-core.workspace = true
newsfeed-data.workspace = true
tokio.workspace = true
axum.workspace = true
tower.workspace = true
tower-http.workspace = true
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
thiserror.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
figment.workspace = true
clap.workspace = true

View File

@@ -0,0 +1,98 @@
//! Authentication extractors and cookie helpers.
//!
//! [`CurrentUser`] authenticates a browser via the `nf_session` cookie; [`ApiPrincipal`]
//! authenticates an agentic/algorithmic producer via a `Bearer` API token. Both resolve
//! through `newsfeed-core::service`, so the crypto and lookup live in one place.
use axum::extract::FromRef;
use axum::extract::FromRequestParts;
use axum::http::header::{AUTHORIZATION, COOKIE};
use axum::http::request::Parts;
use newsfeed_core::service;
use newsfeed_entities::auth::ApiTokenInfo;
use newsfeed_entities::user::User;
use crate::error::ApiError;
use crate::state::AppState;
/// Name of the session cookie.
pub const SESSION_COOKIE: &str = "nf_session";
/// Build a `Set-Cookie` header value for a freshly minted session.
pub fn session_cookie(value: &str, ttl_days: i64, secure: bool) -> String {
let max_age = ttl_days.max(0) * 24 * 60 * 60;
let mut c =
format!("{SESSION_COOKIE}={value}; HttpOnly; SameSite=Lax; Path=/; Max-Age={max_age}");
if secure {
c.push_str("; Secure");
}
c
}
/// Build a `Set-Cookie` header value that clears the session cookie.
pub fn clear_session_cookie(secure: bool) -> String {
let mut c = format!("{SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0");
if secure {
c.push_str("; Secure");
}
c
}
/// Extract the value of a named cookie from a `Cookie` header.
fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> {
let header = parts.headers.get(COOKIE)?.to_str().ok()?;
header.split(';').find_map(|pair| {
let (k, v) = pair.trim().split_once('=')?;
(k == name).then_some(v)
})
}
/// Extract a `Bearer` token from the `Authorization` header.
fn bearer(parts: &Parts) -> Option<&str> {
let header = parts.headers.get(AUTHORIZATION)?.to_str().ok()?;
header.strip_prefix("Bearer ").map(str::trim)
}
/// An authenticated human user, resolved from the session cookie.
pub struct CurrentUser(pub User);
impl<S> FromRequestParts<S> for CurrentUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app = AppState::from_ref(state);
let value = cookie_value(parts, SESSION_COOKIE).ok_or_else(ApiError::unauthorized)?;
let user = service::authenticate_session(app.store.as_ref(), value).await?;
Ok(CurrentUser(user))
}
}
/// An authenticated API-token principal (agentic/algorithmic producer).
pub struct ApiPrincipal(pub ApiTokenInfo);
impl ApiPrincipal {
/// The user the token belongs to.
pub fn user_id(&self) -> uuid::Uuid {
self.0.user_id
}
}
impl<S> FromRequestParts<S> for ApiPrincipal
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app = AppState::from_ref(state);
let token = bearer(parts).ok_or_else(ApiError::unauthorized)?;
let info = service::authenticate_token(app.store.as_ref(), token).await?;
Ok(ApiPrincipal(info))
}
}

View File

@@ -0,0 +1,59 @@
//! Layered configuration: built-in defaults → optional TOML file → environment
//! (`NEWSFEED_*`). Matches the figment-style layering in architecture `generic.md` §3.
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use figment::Figment;
use figment::providers::{Env, Format, Serialized, Toml};
use serde::{Deserialize, Serialize};
/// API daemon configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
/// Address to bind the HTTP listener. TLS terminates upstream at the site nginx.
pub bind: SocketAddr,
/// Path to the SQLite database file (shared with the worker on the same host).
pub database_path: PathBuf,
/// Session lifetime in days.
pub session_ttl_days: i64,
/// Maximum SQLite connections in the pool.
pub max_db_connections: u32,
/// Allowed CORS origins. Empty means same-origin only (the production shape, where
/// nginx serves the SPA and proxies the API under one origin).
pub cors_origins: Vec<String>,
/// Set `Secure` on the session cookie. True in production (HTTPS via nginx); can be
/// disabled for plain-HTTP local development.
pub cookie_secure: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
bind: "127.0.0.1:8081".parse().expect("valid default bind addr"),
database_path: PathBuf::from("/var/lib/newsfeed/newsfeed.db"),
session_ttl_days: 30,
max_db_connections: 5,
cors_origins: Vec::new(),
cookie_secure: true,
}
}
}
impl Config {
/// Load configuration, layering an optional TOML file and the environment over the
/// defaults. Env vars are `NEWSFEED_` prefixed (e.g. `NEWSFEED_BIND=0.0.0.0:8081`).
pub fn load(file: Option<&Path>) -> anyhow::Result<Self> {
let mut fig = Figment::from(Serialized::defaults(Config::default()));
if let Some(path) = file {
fig = fig.merge(Toml::file(path));
}
let cfg: Config = fig.merge(Env::prefixed("NEWSFEED_")).extract()?;
Ok(cfg)
}
/// Session lifetime as a `chrono::Duration`.
pub fn session_ttl(&self) -> chrono::Duration {
chrono::Duration::days(self.session_ttl_days)
}
}

View File

@@ -0,0 +1,81 @@
//! HTTP error type. Maps [`CoreError`] and domain validation onto status codes and a
//! small JSON error envelope.
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use newsfeed_core::error::CoreError;
/// An API error carrying an HTTP status and a client-safe message.
#[derive(Debug)]
pub struct ApiError {
pub status: StatusCode,
pub message: String,
}
impl ApiError {
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self {
status,
message: message.into(),
}
}
pub fn unauthorized() -> Self {
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
}
pub fn not_found() -> Self {
Self::new(StatusCode::NOT_FOUND, "not found")
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self::new(StatusCode::BAD_REQUEST, message)
}
}
#[derive(Serialize)]
struct ErrorBody {
error: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
// Log server-side faults; client errors are self-explanatory.
if self.status.is_server_error() {
tracing::error!(status = %self.status, message = %self.message, "request failed");
}
(
self.status,
Json(ErrorBody {
error: self.message,
}),
)
.into_response()
}
}
impl From<CoreError> for ApiError {
fn from(e: CoreError) -> Self {
match e {
CoreError::Domain(d) => ApiError::bad_request(d.to_string()),
CoreError::NotFound => ApiError::not_found(),
CoreError::Conflict(m) => ApiError::new(StatusCode::CONFLICT, m),
CoreError::Unauthorized => ApiError::unauthorized(),
CoreError::Crypto(_) | CoreError::Storage(_) => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
}
}
}
impl From<newsfeed_entities::Error> for ApiError {
fn from(e: newsfeed_entities::Error) -> Self {
ApiError::bad_request(e.to_string())
}
}
/// Convenient result alias for handlers.
pub type ApiResult<T> = Result<T, ApiError>;

View File

@@ -0,0 +1,100 @@
//! newsfeed REST/JSON API daemon.
//!
//! Thin binary: parse CLI/config, initialise tracing, open the SQLite store (shared with
//! the worker on the same host), build the axum router, and serve until SIGTERM.
mod auth;
mod config;
mod error;
mod routes;
mod state;
use std::path::PathBuf;
use anyhow::Context;
use clap::Parser;
use tokio::net::TcpListener;
use tokio::signal;
use crate::config::Config;
use crate::state::AppState;
#[derive(Parser)]
#[command(
name = "newsfeed-api",
version,
about = "newsfeed REST/JSON API daemon"
)]
struct Cli {
/// Path to a TOML config file. Env (`NEWSFEED_*`) overrides file values.
#[arg(long, default_value = "/etc/newsfeed/config.toml")]
config: PathBuf,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
init_tracing();
let cli = Cli::parse();
let config_file = cli.config.exists().then_some(cli.config.as_path());
let config = Config::load(config_file).context("loading configuration")?;
tracing::info!(bind = %config.bind, db = %config.database_path.display(), "starting newsfeed-api");
if let Some(parent) = config.database_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let store = newsfeed_data::connect(&config.database_path, config.max_db_connections)
.await
.context("opening database")?;
let bind = config.bind;
let state = AppState::new(store, config);
let app = routes::router(state);
let listener = TcpListener::bind(bind)
.await
.with_context(|| format!("binding {bind}"))?;
tracing::info!(%bind, "listening");
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("server error")?;
tracing::info!("shut down cleanly");
Ok(())
}
/// JSON logs when running under journald (`JOURNAL_STREAM` set), pretty logs on a TTY.
fn init_tracing() {
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let registry = tracing_subscriber::registry().with(filter);
if std::env::var_os("JOURNAL_STREAM").is_some() {
registry
.with(fmt::layer().json().flatten_event(true))
.init();
} else {
registry.with(fmt::layer()).init();
}
}
/// Resolve on SIGTERM (systemd) or Ctrl-C.
async fn shutdown_signal() {
let ctrl_c = async {
signal::ctrl_c().await.expect("install Ctrl-C handler");
};
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal received");
}

View File

@@ -0,0 +1,83 @@
//! Registration, login, logout, and the current-principal endpoint.
use axum::Json;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::IntoResponse;
use newsfeed_core::service;
use newsfeed_entities::auth::Me;
use newsfeed_entities::user::{LoginRequest, RegisterRequest, User};
use crate::auth::{CurrentUser, clear_session_cookie, session_cookie};
use crate::error::ApiResult;
use crate::state::AppState;
/// `POST /v1/auth/register` — create an account. Does not log the user in.
pub async fn register(
State(app): State<AppState>,
Json(req): Json<RegisterRequest>,
) -> ApiResult<impl IntoResponse> {
let user = service::register(app.store.as_ref(), &req).await?;
Ok((StatusCode::CREATED, Json(to_me(user))))
}
/// `POST /v1/auth/login` — authenticate and open a session, setting the cookie.
pub async fn login(
State(app): State<AppState>,
Json(req): Json<LoginRequest>,
) -> ApiResult<impl IntoResponse> {
let outcome = service::login(app.store.as_ref(), &req, app.config.session_ttl()).await?;
let cookie = session_cookie(
&outcome.session_cookie,
app.config.session_ttl_days,
app.config.cookie_secure,
);
let mut headers = HeaderMap::new();
headers.insert(
header::SET_COOKIE,
cookie.parse().expect("valid cookie header"),
);
Ok((headers, Json(to_me(outcome.user))))
}
/// `POST /v1/auth/logout` — end the current session and clear the cookie.
pub async fn logout(
State(app): State<AppState>,
headers: HeaderMap,
) -> ApiResult<impl IntoResponse> {
if let Some(value) = session_cookie_value(&headers) {
service::logout(app.store.as_ref(), &value).await?;
}
let mut out = HeaderMap::new();
out.insert(
header::SET_COOKIE,
clear_session_cookie(app.config.cookie_secure)
.parse()
.expect("valid cookie header"),
);
Ok((StatusCode::NO_CONTENT, out))
}
/// `GET /v1/auth/me` — the authenticated user.
pub async fn me(CurrentUser(user): CurrentUser) -> Json<Me> {
Json(to_me(user))
}
fn to_me(user: User) -> Me {
Me {
id: user.id,
username: user.username,
email: user.email,
}
}
/// Read the raw session cookie value from request headers (logout, before the session is
/// resolved).
fn session_cookie_value(headers: &HeaderMap) -> Option<String> {
let header = headers.get(header::COOKIE)?.to_str().ok()?;
header.split(';').find_map(|pair| {
let (k, v) = pair.trim().split_once('=')?;
(k == crate::auth::SESSION_COOKIE).then(|| v.to_string())
})
}

View File

@@ -0,0 +1,77 @@
//! The feed read endpoint and interaction signals.
use axum::Json;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use newsfeed_core::ports::ItemStore;
use newsfeed_entities::feed::{FeedPage, FeedQuery, Signal, SignalAction};
use newsfeed_entities::item::ItemState;
use crate::auth::CurrentUser;
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
const DEFAULT_LIMIT: i64 = 50;
const MAX_LIMIT: i64 = 100;
/// `GET /v1/feed` — a ranked, keyset-paginated page of the user's feed.
pub async fn get_feed(
CurrentUser(user): CurrentUser,
State(app): State<AppState>,
Query(q): Query<FeedQuery>,
) -> ApiResult<Json<FeedPage>> {
let limit = q
.limit
.map(|l| l as i64)
.unwrap_or(DEFAULT_LIMIT)
.clamp(1, MAX_LIMIT);
// Fetch one extra to determine whether a further page exists.
let mut items = app
.store
.feed_page(user.id, limit + 1, q.cursor.as_deref(), q.include_saved)
.await?;
let next_cursor = if items.len() as i64 > limit {
items.truncate(limit as usize);
items.last().map(|it| format!("{}:{}", it.score, it.id))
} else {
None
};
Ok(Json(FeedPage { items, next_cursor }))
}
/// `POST /v1/feed/signals` — record an interaction. Save/Dismiss also move the item's
/// lifecycle state; View/Click are recorded for future ranking feedback.
pub async fn post_signal(
CurrentUser(user): CurrentUser,
State(app): State<AppState>,
Json(sig): Json<Signal>,
) -> ApiResult<StatusCode> {
// The item must belong to the caller.
if app.store.get_item(user.id, sig.item_id).await?.is_none() {
return Err(ApiError::not_found());
}
app.store
.record_signal(user.id, sig.item_id, sig.action)
.await?;
match sig.action {
SignalAction::Save => {
app.store
.set_item_state(user.id, sig.item_id, ItemState::Saved)
.await?
}
SignalAction::Dismiss => {
app.store
.set_item_state(user.id, sig.item_id, ItemState::Dismissed)
.await?
}
SignalAction::View | SignalAction::Click => {}
}
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -0,0 +1,15 @@
//! Liveness/readiness probe for systemd and the nginx upstream check.
use axum::extract::State;
use axum::http::StatusCode;
use crate::state::AppState;
/// `GET /health` — 200 when the database is reachable, 503 otherwise.
pub async fn health(State(app): State<AppState>) -> StatusCode {
if app.store.ping().await {
StatusCode::OK
} else {
StatusCode::SERVICE_UNAVAILABLE
}
}

View File

@@ -0,0 +1,29 @@
//! The inbound curation endpoint. Agentic and algorithmic producers authenticate with a
//! per-user bearer API token and POST content candidates; each is normalised, scored
//! against the owning user's interests, and promoted into their feed.
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use newsfeed_core::ingest;
use newsfeed_core::ports::InterestStore;
use newsfeed_entities::item::{CandidateSubmission, ContentItem};
use crate::auth::ApiPrincipal;
use crate::error::ApiResult;
use crate::state::AppState;
/// `POST /v1/ingest/candidates` — submit a candidate for curation into the token owner's
/// feed. Re-submitting an already-seen `external_id` returns the stored item unchanged
/// (idempotent), so producers can safely retry.
pub async fn submit(
principal: ApiPrincipal,
State(app): State<AppState>,
Json(sub): Json<CandidateSubmission>,
) -> ApiResult<(StatusCode, Json<ContentItem>)> {
let user_id = principal.user_id();
let interests = app.store.list_interests(user_id).await?;
let item = ingest::ingest_submission(app.store.as_ref(), user_id, &interests, sub).await?;
Ok((StatusCode::ACCEPTED, Json(item)))
}

View File

@@ -0,0 +1,41 @@
//! The user-controlled interest weightings that drive ranking.
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use uuid::Uuid;
use newsfeed_core::ports::InterestStore;
use newsfeed_entities::interest::{Interest, UpsertInterest};
use crate::auth::CurrentUser;
use crate::error::ApiResult;
use crate::state::AppState;
/// `GET /v1/interests` — the user's interests, strongest first.
pub async fn list(
CurrentUser(user): CurrentUser,
State(app): State<AppState>,
) -> ApiResult<Json<Vec<Interest>>> {
Ok(Json(app.store.list_interests(user.id).await?))
}
/// `PUT /v1/interests` — create or update an interest by label.
pub async fn upsert(
CurrentUser(user): CurrentUser,
State(app): State<AppState>,
Json(body): Json<UpsertInterest>,
) -> ApiResult<Json<Interest>> {
body.validate()?;
Ok(Json(app.store.upsert_interest(user.id, &body).await?))
}
/// `DELETE /v1/interests/{id}` — remove an interest.
pub async fn remove(
CurrentUser(user): CurrentUser,
State(app): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
app.store.delete_interest(user.id, id).await?;
Ok(StatusCode::NO_CONTENT)
}

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)
}

View File

@@ -0,0 +1,42 @@
//! CRUD for a user's content sources.
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use uuid::Uuid;
use newsfeed_core::ports::SourceStore;
use newsfeed_entities::source::{NewSource, Source};
use crate::auth::CurrentUser;
use crate::error::ApiResult;
use crate::state::AppState;
/// `GET /v1/sources` — the user's sources.
pub async fn list(
CurrentUser(user): CurrentUser,
State(app): State<AppState>,
) -> ApiResult<Json<Vec<Source>>> {
Ok(Json(app.store.list_sources(user.id).await?))
}
/// `POST /v1/sources` — add a source.
pub async fn create(
CurrentUser(user): CurrentUser,
State(app): State<AppState>,
Json(new): Json<NewSource>,
) -> ApiResult<(StatusCode, Json<Source>)> {
new.validate()?;
let source = app.store.create_source(user.id, &new).await?;
Ok((StatusCode::CREATED, Json(source)))
}
/// `DELETE /v1/sources/{id}` — remove a source.
pub async fn remove(
CurrentUser(user): CurrentUser,
State(app): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
app.store.delete_source(user.id, id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -0,0 +1,45 @@
//! Per-user API tokens used by agentic/algorithmic producers to POST candidates.
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use uuid::Uuid;
use newsfeed_core::ports::TokenStore;
use newsfeed_core::service;
use newsfeed_entities::auth::{ApiTokenInfo, CreateApiToken, CreatedApiToken};
use crate::auth::CurrentUser;
use crate::error::{ApiError, ApiResult};
use crate::state::AppState;
/// `GET /v1/tokens` — list the caller's tokens (metadata only; secrets never returned).
pub async fn list(
CurrentUser(user): CurrentUser,
State(app): State<AppState>,
) -> ApiResult<Json<Vec<ApiTokenInfo>>> {
Ok(Json(app.store.list_tokens(user.id).await?))
}
/// `POST /v1/tokens` — mint a token. The full secret is returned exactly once.
pub async fn create(
CurrentUser(user): CurrentUser,
State(app): State<AppState>,
Json(body): Json<CreateApiToken>,
) -> ApiResult<(StatusCode, Json<CreatedApiToken>)> {
if body.name.trim().is_empty() {
return Err(ApiError::bad_request("token name must not be empty"));
}
let created = service::create_api_token(app.store.as_ref(), user.id, body.name.trim()).await?;
Ok((StatusCode::CREATED, Json(created)))
}
/// `DELETE /v1/tokens/{id}` — revoke a token.
pub async fn revoke(
CurrentUser(user): CurrentUser,
State(app): State<AppState>,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
app.store.revoke_token(user.id, id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -0,0 +1,24 @@
//! Shared application state. The binary wires the concrete SQLite adapter here; handlers
//! reach the ports through it. `AppState` is cheap to clone (Arc-wrapped).
use std::sync::Arc;
use newsfeed_data::SqliteStore;
use crate::config::Config;
/// State shared across all handlers.
#[derive(Clone)]
pub struct AppState {
pub store: Arc<SqliteStore>,
pub config: Arc<Config>,
}
impl AppState {
pub fn new(store: SqliteStore, config: Config) -> Self {
Self {
store: Arc::new(store),
config: Arc::new(config),
}
}
}