setup local dev env

This commit is contained in:
2026-03-10 17:13:23 +01:00
parent b27db93a90
commit f4d3fade9b
61 changed files with 6695 additions and 228 deletions

2842
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,19 @@
[package]
name = "campaign-manager-backend"
version = "0.1.0"
edition = "2021"
[workspace]
members = ["common", "campaign-service", "content-service"]
resolver = "2"
[dependencies]
[workspace.dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["postgres", "uuid", "runtime-tokio-native-tls", "chrono"] }
chrono = { version = "0.4", features = ["serde", "clock"] }
dotenvy = "0.15"
jsonwebtoken = "9"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
jsonwebtoken = "9"
argon2 = "0.5"
uuid = { version = "1", features = ["v4", "serde"] }
sqlx = { version = "0.8", features = ["postgres", "uuid", "runtime-tokio-native-tls", "chrono"] }
thiserror = "1"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
uuid = { version = "1", features = ["v4", "serde"] }
argon2 = "0.5"

View File

@@ -0,0 +1,18 @@
[package]
name = "campaign-service"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
argon2 = { workspace = true }
common = { path = "../common" }
dotenvy = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
tokio = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,14 @@
FROM rust:alpine AS builder
WORKDIR /app
RUN apk add --no-cache build-base pkgconfig openssl-dev
COPY backend ./backend
RUN cargo build --release --manifest-path backend/Cargo.toml -p campaign-service
FROM alpine:3.23
RUN apk add --no-cache ca-certificates openssl libgcc libstdc++
COPY --from=builder /app/backend/target/release/campaign-service /usr/local/bin/campaign-service
EXPOSE 3000
CMD ["campaign-service"]

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
dm_user_id UUID NOT NULL,
ruleset_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS group_members (
group_id UUID NOT NULL REFERENCES groups(id),
user_id UUID NOT NULL,
role TEXT NOT NULL,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (group_id, user_id)
);

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS refresh_sessions (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,118 @@
use axum::{
extract::State,
http::StatusCode,
response::IntoResponse,
routing::{any, get, post},
Json, Router,
};
use common::{issue_access_token, AppResult};
use serde::Serialize;
use sqlx::{postgres::PgPoolOptions, PgPool};
use tower_http::cors::CorsLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Clone)]
struct AppState {
users_pool: PgPool,
campaign_pool: PgPool,
jwt_secret: String,
}
#[derive(Serialize)]
struct HealthResponse {
service: &'static str,
status: &'static str,
}
#[derive(Serialize)]
struct RefreshResponse {
#[serde(rename = "accessToken")]
access_token: String,
#[serde(rename = "refreshToken")]
refresh_token: String,
}
#[tokio::main]
async fn main() {
let _ = dotenvy::dotenv();
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "campaign_service=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let users_database_url =
std::env::var("USERS_DATABASE_URL").expect("USERS_DATABASE_URL must be set");
let campaign_database_url =
std::env::var("CAMPAIGN_DATABASE_URL").expect("CAMPAIGN_DATABASE_URL must be set");
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let port: u16 = std::env::var("CAMPAIGN_PORT")
.or_else(|_| std::env::var("PORT"))
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("PORT must be a valid u16");
let users_pool = PgPoolOptions::new()
.max_connections(10)
.connect_lazy(&users_database_url)
.expect("invalid USERS_DATABASE_URL");
let campaign_pool = PgPoolOptions::new()
.max_connections(10)
.connect_lazy(&campaign_database_url)
.expect("invalid CAMPAIGN_DATABASE_URL");
let state = AppState {
users_pool,
campaign_pool,
jwt_secret,
};
let app = Router::new()
.route("/health", get(health))
.route("/v1/auth/refresh", post(refresh_token))
.route("/v1/auth/login", post(not_implemented))
.route("/v1/auth/register", post(not_implemented))
.route("/v1/auth/logout", post(not_implemented))
.route("/v1/*path", any(not_implemented))
.with_state(state)
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.expect("failed to bind");
tracing::info!(
"campaign-service listening on {}",
listener.local_addr().unwrap()
);
axum::serve(listener, app).await.expect("server error");
}
async fn health(State(state): State<AppState>) -> impl IntoResponse {
let _ = &state.users_pool;
let _ = &state.campaign_pool;
Json(HealthResponse {
service: "campaign-service",
status: "ok",
})
}
async fn refresh_token(State(state): State<AppState>) -> AppResult<Json<RefreshResponse>> {
// Stub response until real refresh-token/session persistence is implemented.
let access_token = issue_access_token(&state.jwt_secret, "dev-user", vec!["user".to_string()])?;
let refresh_token = uuid::Uuid::new_v4().to_string();
Ok(Json(RefreshResponse {
access_token,
refresh_token,
}))
}
async fn not_implemented() -> impl IntoResponse {
(StatusCode::NOT_IMPLEMENTED, "not implemented")
}

12
backend/common/Cargo.toml Normal file
View File

@@ -0,0 +1,12 @@
[package]
name = "common"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
chrono = { workspace = true }
jsonwebtoken = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@@ -0,0 +1,40 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Serialize)]
pub struct ApiErrorBody {
pub error: String,
}
#[derive(Debug, Error)]
pub enum AppError {
#[error("unauthorized: {0}")]
Unauthorized(String),
#[error("bad request: {0}")]
BadRequest(String),
#[error("internal error: {0}")]
Internal(String),
}
pub type AppResult<T> = Result<T, AppError>;
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match self {
AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = ApiErrorBody {
error: self.to_string(),
};
(status, Json(body)).into_response()
}
}

49
backend/common/src/jwt.rs Normal file
View File

@@ -0,0 +1,49 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::{AppError, AppResult};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub iat: usize,
pub exp: usize,
pub global_roles: Vec<String>,
}
pub fn issue_access_token(
secret: &str,
user_id: &str,
global_roles: Vec<String>,
) -> AppResult<String> {
let now = Utc::now();
let exp = now + Duration::minutes(15);
let claims = Claims {
sub: user_id.to_owned(),
iat: now.timestamp() as usize,
exp: exp.timestamp() as usize,
global_roles,
};
encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(|err| AppError::Internal(format!("failed to encode JWT: {err}")))
}
pub fn decode_jwt(secret: &str, token: &str) -> AppResult<Claims> {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&validation,
)
.map(|data| data.claims)
.map_err(|err| AppError::Unauthorized(format!("invalid token: {err}")))
}

View File

@@ -0,0 +1,5 @@
pub mod error;
pub mod jwt;
pub use error::{ApiErrorBody, AppError, AppResult};
pub use jwt::{decode_jwt, issue_access_token, Claims};

View File

@@ -0,0 +1,16 @@
[package]
name = "content-service"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
common = { path = "../common" }
dotenvy = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
tokio = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

View File

@@ -0,0 +1,14 @@
FROM rust:alpine AS builder
WORKDIR /app
RUN apk add --no-cache build-base pkgconfig openssl-dev
COPY backend ./backend
RUN cargo build --release --manifest-path backend/Cargo.toml -p content-service
FROM alpine:3.23
RUN apk add --no-cache ca-certificates openssl libgcc libstdc++
COPY --from=builder /app/backend/target/release/content-service /usr/local/bin/content-service
EXPOSE 3001
CMD ["content-service"]

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS rulesets (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,124 @@
use axum::{
extract::State,
http::{header::AUTHORIZATION, HeaderMap, StatusCode},
response::IntoResponse,
routing::{any, get},
Json, Router,
};
use common::{decode_jwt, AppError, AppResult};
use serde::Serialize;
use sqlx::{postgres::PgPoolOptions, PgPool};
use tower_http::cors::CorsLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Clone)]
struct AppState {
content_pool: PgPool,
jwt_secret: String,
}
#[derive(Serialize)]
struct HealthResponse {
service: &'static str,
status: &'static str,
}
#[derive(Serialize)]
struct RulesetSummary {
id: &'static str,
name: &'static str,
}
#[tokio::main]
async fn main() {
let _ = dotenvy::dotenv();
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "content_service=debug,tower_http=debug".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let content_database_url =
std::env::var("CONTENT_DATABASE_URL").expect("CONTENT_DATABASE_URL must be set");
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let port: u16 = std::env::var("CONTENT_PORT")
.or_else(|_| std::env::var("PORT"))
.unwrap_or_else(|_| "3001".to_string())
.parse()
.expect("PORT must be a valid u16");
let content_pool = PgPoolOptions::new()
.max_connections(10)
.connect_lazy(&content_database_url)
.expect("invalid CONTENT_DATABASE_URL");
let state = AppState {
content_pool,
jwt_secret,
};
let app = Router::new()
.route("/health", get(health))
.route("/v1/rulesets", get(list_rulesets))
.route("/v1/*path", any(not_implemented))
.with_state(state)
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
.await
.expect("failed to bind");
tracing::info!(
"content-service listening on {}",
listener.local_addr().unwrap()
);
axum::serve(listener, app).await.expect("server error");
}
async fn health(State(state): State<AppState>) -> impl IntoResponse {
let _ = &state.content_pool;
Json(HealthResponse {
service: "content-service",
status: "ok",
})
}
async fn list_rulesets(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Vec<RulesetSummary>>> {
require_bearer(&state.jwt_secret, &headers)?;
Ok(Json(vec![
RulesetSummary {
id: "generic",
name: "Generic",
},
RulesetSummary {
id: "dsa5e",
name: "DSA5e",
},
]))
}
fn require_bearer(jwt_secret: &str, headers: &HeaderMap) -> AppResult<()> {
let header = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| AppError::Unauthorized("missing Authorization header".to_string()))?;
let token = header
.strip_prefix("Bearer ")
.ok_or_else(|| AppError::Unauthorized("expected Bearer token".to_string()))?;
let _claims = decode_jwt(jwt_secret, token)?;
Ok(())
}
async fn not_implemented() -> impl IntoResponse {
(StatusCode::NOT_IMPLEMENTED, "not implemented")
}

View File

@@ -1 +0,0 @@
// Module entrypoint — reserved for integration tests and shared types.

View File

@@ -1,43 +0,0 @@
use axum::{
http::StatusCode,
response::IntoResponse,
routing::any,
Router,
};
use tower_http::cors::CorsLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() {
// Load .env if present (dev convenience)
let _ = dotenvy::dotenv();
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "campaign_manager_backend=debug,tower_http=debug".into()))
.with(tracing_subscriber::fmt::layer())
.init();
let _database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let _jwt_secret = std::env::var("JWT_SECRET")
.expect("JWT_SECRET must be set");
// TODO: initialize sqlx connection pool
// let pool = sqlx::PgPool::connect(&database_url).await.expect("failed to connect to DB");
let app = Router::new()
.route("/v1/*path", any(not_implemented))
.layer(CorsLayer::permissive());
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("failed to bind");
tracing::info!("listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.expect("server error");
}
async fn not_implemented() -> impl IntoResponse {
(StatusCode::NOT_IMPLEMENTED, "not implemented")
}