mirror of
https://github.com/CloudNebulaProject/webfingerd.git
synced 2026-04-10 13:10:41 +00:00
4663 lines
138 KiB
Markdown
4663 lines
138 KiB
Markdown
|
|
# webfingerd Implementation Plan
|
||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Build a multi-tenant WebFinger server (RFC 7033) with ACME-style domain onboarding, scoped service authorization, in-memory cache, and management UI.
|
||
|
|
|
||
|
|
**Architecture:** Single axum binary. In-memory DashMap cache backed by SQLite/SeaORM (write-through). Three-tier auth: domain owner → scoped service tokens → links. Background TTL reaper. Server-rendered management UI via askama.
|
||
|
|
|
||
|
|
**Tech Stack:** Rust, axum 0.8, tokio, sea-orm (SQLite), dashmap, governor, askama, argon2, config, tracing, metrics, hickory-resolver, reqwest, glob-match
|
||
|
|
|
||
|
|
**Spec:** `docs/superpowers/specs/2026-04-03-webfingerd-design.md`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## File Structure
|
||
|
|
|
||
|
|
```
|
||
|
|
Cargo.toml # Workspace root
|
||
|
|
migration/
|
||
|
|
Cargo.toml # SeaORM migration crate
|
||
|
|
src/
|
||
|
|
lib.rs # Migration registry
|
||
|
|
m20260403_000001_create_domains.rs
|
||
|
|
m20260403_000002_create_resources.rs
|
||
|
|
m20260403_000003_create_service_tokens.rs
|
||
|
|
m20260403_000004_create_links.rs
|
||
|
|
src/
|
||
|
|
main.rs # Entry point: CLI, config load, server start
|
||
|
|
lib.rs # Re-exports for tests
|
||
|
|
config.rs # Settings struct, TOML + env loading
|
||
|
|
error.rs # AppError enum, IntoResponse impl
|
||
|
|
state.rs # AppState: DbConn, cache, config, metrics
|
||
|
|
cache.rs # CachedResource, DashMap ops, hydration
|
||
|
|
auth.rs # Token hashing (argon2), extractors for owner/service tokens
|
||
|
|
entity/
|
||
|
|
mod.rs # Entity module re-exports
|
||
|
|
domains.rs # SeaORM entity: domains
|
||
|
|
resources.rs # SeaORM entity: resources
|
||
|
|
service_tokens.rs # SeaORM entity: service_tokens
|
||
|
|
links.rs # SeaORM entity: links
|
||
|
|
handler/
|
||
|
|
mod.rs # Router assembly
|
||
|
|
webfinger.rs # GET /.well-known/webfinger
|
||
|
|
host_meta.rs # GET /.well-known/host-meta
|
||
|
|
domains.rs # Domain onboarding CRUD + verify + rotate
|
||
|
|
tokens.rs # Service token CRUD
|
||
|
|
links.rs # Link registration CRUD + batch
|
||
|
|
health.rs # GET /healthz
|
||
|
|
metrics.rs # GET /metrics
|
||
|
|
challenge.rs # ChallengeVerifier trait + DNS-01/HTTP-01 impls
|
||
|
|
reaper.rs # Background TTL reaper task
|
||
|
|
middleware/
|
||
|
|
mod.rs # Middleware re-exports
|
||
|
|
rate_limit.rs # Governor-based rate limiting
|
||
|
|
request_id.rs # Request ID generation + propagation
|
||
|
|
# CORS is handled inline in handler/mod.rs via tower_http::CorsLayer
|
||
|
|
ui/
|
||
|
|
mod.rs # UI router, session auth
|
||
|
|
templates.rs # Askama template structs
|
||
|
|
handlers.rs # UI page handlers
|
||
|
|
templates/
|
||
|
|
layout.html # Base template with minimal CSS
|
||
|
|
login.html # Owner token login
|
||
|
|
dashboard.html # Domain list
|
||
|
|
domain_detail.html # Single domain view
|
||
|
|
token_management.html # Service token CRUD
|
||
|
|
link_browser.html # Read-only link list
|
||
|
|
tests/
|
||
|
|
common/mod.rs # Test helpers: setup DB, create test app
|
||
|
|
test_webfinger.rs # WebFinger query endpoint tests
|
||
|
|
test_host_meta.rs # host-meta endpoint tests
|
||
|
|
test_domains.rs # Domain onboarding + verify flow tests
|
||
|
|
test_tokens.rs # Service token CRUD tests
|
||
|
|
test_links.rs # Link registration + scope enforcement tests
|
||
|
|
test_cache.rs # Cache hydration, write-through, expiry tests
|
||
|
|
test_reaper.rs # TTL reaper tests
|
||
|
|
test_rate_limit.rs # Rate limiting tests
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 1: Project Scaffold + Configuration
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `Cargo.toml`, `src/main.rs`, `src/lib.rs`, `src/config.rs`, `src/error.rs`
|
||
|
|
- Create: `config.toml` (example config)
|
||
|
|
|
||
|
|
- [ ] **Step 1: Initialize Cargo workspace**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cargo init --name webfingerd
|
||
|
|
mkdir migration && cd migration && cargo init --lib --name migration && cd ..
|
||
|
|
```
|
||
|
|
|
||
|
|
Add to root `Cargo.toml`:
|
||
|
|
```toml
|
||
|
|
[workspace]
|
||
|
|
members = [".", "migration"]
|
||
|
|
|
||
|
|
[package]
|
||
|
|
name = "webfingerd"
|
||
|
|
version = "0.1.0"
|
||
|
|
edition = "2021"
|
||
|
|
|
||
|
|
[dependencies]
|
||
|
|
axum = { version = "0.8", features = ["macros"] }
|
||
|
|
tokio = { version = "1", features = ["full"] }
|
||
|
|
sea-orm = { version = "1", features = ["sqlx-sqlite", "runtime-tokio-rustls"] }
|
||
|
|
sea-orm-migration = "1"
|
||
|
|
dashmap = "6"
|
||
|
|
governor = "0.8"
|
||
|
|
askama = "0.12"
|
||
|
|
askama_axum = "0.4"
|
||
|
|
argon2 = "0.5"
|
||
|
|
config = "0.14"
|
||
|
|
serde = { version = "1", features = ["derive"] }
|
||
|
|
serde_json = "1"
|
||
|
|
tracing = "0.1"
|
||
|
|
tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
|
||
|
|
metrics = "0.24"
|
||
|
|
metrics-exporter-prometheus = "0.16"
|
||
|
|
hickory-resolver = "0.25"
|
||
|
|
reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false }
|
||
|
|
glob-match = "0.2"
|
||
|
|
urlencoding = "2"
|
||
|
|
async-trait = "0.1"
|
||
|
|
uuid = { version = "1", features = ["v4"] }
|
||
|
|
chrono = { version = "0.4", features = ["serde"] }
|
||
|
|
tower = "0.5"
|
||
|
|
tower-http = { version = "0.6", features = ["cors", "request-id", "trace", "util"] }
|
||
|
|
axum-extra = { version = "0.10", features = ["cookie-signed"] }
|
||
|
|
rand = "0.8"
|
||
|
|
base64 = "0.22"
|
||
|
|
thiserror = "2"
|
||
|
|
|
||
|
|
[dev-dependencies]
|
||
|
|
axum-test = "16"
|
||
|
|
tempfile = "3"
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write config.rs**
|
||
|
|
|
||
|
|
Create `src/config.rs`:
|
||
|
|
```rust
|
||
|
|
use serde::Deserialize;
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize, Clone)]
|
||
|
|
pub struct Settings {
|
||
|
|
pub server: ServerConfig,
|
||
|
|
pub database: DatabaseConfig,
|
||
|
|
pub cache: CacheConfig,
|
||
|
|
pub rate_limit: RateLimitConfig,
|
||
|
|
pub challenge: ChallengeConfig,
|
||
|
|
pub ui: UiConfig,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize, Clone)]
|
||
|
|
pub struct ServerConfig {
|
||
|
|
pub listen: String,
|
||
|
|
pub base_url: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize, Clone)]
|
||
|
|
pub struct DatabaseConfig {
|
||
|
|
pub path: String,
|
||
|
|
pub wal_mode: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize, Clone)]
|
||
|
|
pub struct CacheConfig {
|
||
|
|
pub reaper_interval_secs: u64,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize, Clone)]
|
||
|
|
pub struct RateLimitConfig {
|
||
|
|
pub public_rpm: u32,
|
||
|
|
pub api_rpm: u32,
|
||
|
|
pub batch_rpm: u32,
|
||
|
|
pub batch_max_links: usize,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize, Clone)]
|
||
|
|
pub struct ChallengeConfig {
|
||
|
|
pub dns_txt_prefix: String,
|
||
|
|
pub http_well_known_path: String,
|
||
|
|
pub challenge_ttl_secs: u64,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Deserialize, Clone)]
|
||
|
|
pub struct UiConfig {
|
||
|
|
pub enabled: bool,
|
||
|
|
pub session_secret: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Settings {
|
||
|
|
pub fn load() -> Result<Self, config::ConfigError> {
|
||
|
|
let settings = config::Config::builder()
|
||
|
|
.add_source(config::File::with_name("config").required(false))
|
||
|
|
.add_source(
|
||
|
|
config::Environment::with_prefix("WEBFINGERD")
|
||
|
|
.separator("__"),
|
||
|
|
)
|
||
|
|
.build()?;
|
||
|
|
|
||
|
|
let s: Self = settings.try_deserialize()?;
|
||
|
|
|
||
|
|
if s.ui.enabled && s.ui.session_secret.is_empty() {
|
||
|
|
return Err(config::ConfigError::Message(
|
||
|
|
"ui.session_secret is required when ui is enabled".into(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(s)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Write error.rs**
|
||
|
|
|
||
|
|
Create `src/error.rs`:
|
||
|
|
```rust
|
||
|
|
use axum::http::StatusCode;
|
||
|
|
use axum::response::{IntoResponse, Response};
|
||
|
|
use axum::Json;
|
||
|
|
use serde_json::json;
|
||
|
|
|
||
|
|
#[derive(Debug, thiserror::Error)]
|
||
|
|
pub enum AppError {
|
||
|
|
#[error("not found")]
|
||
|
|
NotFound,
|
||
|
|
#[error("bad request: {0}")]
|
||
|
|
BadRequest(String),
|
||
|
|
#[error("unauthorized")]
|
||
|
|
Unauthorized,
|
||
|
|
#[error("forbidden: {0}")]
|
||
|
|
Forbidden(String),
|
||
|
|
#[error("conflict: {0}")]
|
||
|
|
Conflict(String),
|
||
|
|
#[error("rate limited")]
|
||
|
|
RateLimited,
|
||
|
|
#[error("internal error: {0}")]
|
||
|
|
Internal(String),
|
||
|
|
#[error("database error: {0}")]
|
||
|
|
Database(#[from] sea_orm::DbErr),
|
||
|
|
}
|
||
|
|
|
||
|
|
impl IntoResponse for AppError {
|
||
|
|
fn into_response(self) -> Response {
|
||
|
|
let (status, message) = match &self {
|
||
|
|
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
||
|
|
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||
|
|
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||
|
|
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
|
||
|
|
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
|
||
|
|
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, self.to_string()),
|
||
|
|
AppError::Internal(msg) => {
|
||
|
|
tracing::error!("internal error: {msg}");
|
||
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
|
||
|
|
}
|
||
|
|
AppError::Database(err) => {
|
||
|
|
tracing::error!("database error: {err}");
|
||
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
(status, Json(json!({ "error": message }))).into_response()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub type AppResult<T> = Result<T, AppError>;
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Write minimal main.rs and lib.rs**
|
||
|
|
|
||
|
|
Create `src/lib.rs`:
|
||
|
|
```rust
|
||
|
|
pub mod config;
|
||
|
|
pub mod error;
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `src/main.rs`:
|
||
|
|
```rust
|
||
|
|
use tracing_subscriber::{fmt, EnvFilter};
|
||
|
|
use webfingerd::config::Settings;
|
||
|
|
|
||
|
|
#[tokio::main]
|
||
|
|
async fn main() {
|
||
|
|
fmt()
|
||
|
|
.with_env_filter(EnvFilter::from_default_env())
|
||
|
|
.json()
|
||
|
|
.init();
|
||
|
|
|
||
|
|
let settings = Settings::load().expect("failed to load configuration");
|
||
|
|
tracing::info!(listen = %settings.server.listen, "starting webfingerd");
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Create example config.toml**
|
||
|
|
|
||
|
|
Create `config.toml`:
|
||
|
|
```toml
|
||
|
|
[server]
|
||
|
|
listen = "0.0.0.0:8080"
|
||
|
|
base_url = "http://localhost:8080"
|
||
|
|
|
||
|
|
[database]
|
||
|
|
path = "webfingerd.db"
|
||
|
|
wal_mode = true
|
||
|
|
|
||
|
|
[cache]
|
||
|
|
reaper_interval_secs = 30
|
||
|
|
|
||
|
|
[rate_limit]
|
||
|
|
public_rpm = 60
|
||
|
|
api_rpm = 300
|
||
|
|
batch_rpm = 10
|
||
|
|
batch_max_links = 500
|
||
|
|
|
||
|
|
[challenge]
|
||
|
|
dns_txt_prefix = "_webfinger-challenge"
|
||
|
|
http_well_known_path = ".well-known/webfinger-verify"
|
||
|
|
challenge_ttl_secs = 3600
|
||
|
|
|
||
|
|
[ui]
|
||
|
|
enabled = false
|
||
|
|
session_secret = ""
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Verify it compiles**
|
||
|
|
|
||
|
|
Run: `cargo build`
|
||
|
|
Expected: Successful compilation.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A
|
||
|
|
git commit -m "feat: project scaffold with config and error types"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 2: Database Migrations
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `migration/Cargo.toml`, `migration/src/lib.rs`
|
||
|
|
- Create: `migration/src/m20260403_000001_create_domains.rs`
|
||
|
|
- Create: `migration/src/m20260403_000002_create_resources.rs`
|
||
|
|
- Create: `migration/src/m20260403_000003_create_service_tokens.rs`
|
||
|
|
- Create: `migration/src/m20260403_000004_create_links.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Set up migration crate**
|
||
|
|
|
||
|
|
`migration/Cargo.toml`:
|
||
|
|
```toml
|
||
|
|
[package]
|
||
|
|
name = "migration"
|
||
|
|
version = "0.1.0"
|
||
|
|
edition = "2021"
|
||
|
|
|
||
|
|
[dependencies]
|
||
|
|
sea-orm-migration = "1"
|
||
|
|
```
|
||
|
|
|
||
|
|
`migration/src/lib.rs`:
|
||
|
|
```rust
|
||
|
|
pub use sea_orm_migration::prelude::*;
|
||
|
|
|
||
|
|
mod m20260403_000001_create_domains;
|
||
|
|
mod m20260403_000002_create_resources;
|
||
|
|
mod m20260403_000003_create_service_tokens;
|
||
|
|
mod m20260403_000004_create_links;
|
||
|
|
|
||
|
|
pub struct Migrator;
|
||
|
|
|
||
|
|
#[async_trait::async_trait]
|
||
|
|
impl MigratorTrait for Migrator {
|
||
|
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||
|
|
vec![
|
||
|
|
Box::new(m20260403_000001_create_domains::Migration),
|
||
|
|
Box::new(m20260403_000002_create_resources::Migration),
|
||
|
|
Box::new(m20260403_000003_create_service_tokens::Migration),
|
||
|
|
Box::new(m20260403_000004_create_links::Migration),
|
||
|
|
]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write domains migration**
|
||
|
|
|
||
|
|
`migration/src/m20260403_000001_create_domains.rs`:
|
||
|
|
```rust
|
||
|
|
use sea_orm_migration::prelude::*;
|
||
|
|
|
||
|
|
#[derive(DeriveMigrationName)]
|
||
|
|
pub struct Migration;
|
||
|
|
|
||
|
|
#[async_trait::async_trait]
|
||
|
|
impl MigrationTrait for Migration {
|
||
|
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||
|
|
manager
|
||
|
|
.create_table(
|
||
|
|
Table::create()
|
||
|
|
.table(Domains::Table)
|
||
|
|
.if_not_exists()
|
||
|
|
.col(ColumnDef::new(Domains::Id).string().not_null().primary_key())
|
||
|
|
.col(ColumnDef::new(Domains::Domain).string().not_null().unique_key())
|
||
|
|
.col(ColumnDef::new(Domains::OwnerTokenHash).string().not_null())
|
||
|
|
.col(ColumnDef::new(Domains::RegistrationSecret).string().not_null())
|
||
|
|
.col(ColumnDef::new(Domains::ChallengeType).string().not_null())
|
||
|
|
.col(ColumnDef::new(Domains::ChallengeToken).string().null())
|
||
|
|
.col(ColumnDef::new(Domains::Verified).boolean().not_null().default(false))
|
||
|
|
.col(ColumnDef::new(Domains::CreatedAt).date_time().not_null())
|
||
|
|
.col(ColumnDef::new(Domains::VerifiedAt).date_time().null())
|
||
|
|
.to_owned(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||
|
|
manager.drop_table(Table::drop().table(Domains::Table).to_owned()).await
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(DeriveIden)]
|
||
|
|
pub enum Domains {
|
||
|
|
Table,
|
||
|
|
Id,
|
||
|
|
Domain,
|
||
|
|
OwnerTokenHash,
|
||
|
|
RegistrationSecret,
|
||
|
|
ChallengeType,
|
||
|
|
ChallengeToken,
|
||
|
|
Verified,
|
||
|
|
CreatedAt,
|
||
|
|
VerifiedAt,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Write resources migration**
|
||
|
|
|
||
|
|
`migration/src/m20260403_000002_create_resources.rs`:
|
||
|
|
```rust
|
||
|
|
use sea_orm_migration::prelude::*;
|
||
|
|
|
||
|
|
#[derive(DeriveMigrationName)]
|
||
|
|
pub struct Migration;
|
||
|
|
|
||
|
|
#[async_trait::async_trait]
|
||
|
|
impl MigrationTrait for Migration {
|
||
|
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||
|
|
manager
|
||
|
|
.create_table(
|
||
|
|
Table::create()
|
||
|
|
.table(Resources::Table)
|
||
|
|
.if_not_exists()
|
||
|
|
.col(ColumnDef::new(Resources::Id).string().not_null().primary_key())
|
||
|
|
.col(ColumnDef::new(Resources::DomainId).string().not_null())
|
||
|
|
.col(ColumnDef::new(Resources::ResourceUri).string().not_null().unique_key())
|
||
|
|
.col(ColumnDef::new(Resources::Aliases).string().null())
|
||
|
|
.col(ColumnDef::new(Resources::Properties).string().null())
|
||
|
|
.col(ColumnDef::new(Resources::CreatedAt).date_time().not_null())
|
||
|
|
.col(ColumnDef::new(Resources::UpdatedAt).date_time().not_null())
|
||
|
|
.foreign_key(
|
||
|
|
ForeignKey::create()
|
||
|
|
.from(Resources::Table, Resources::DomainId)
|
||
|
|
.to(super::m20260403_000001_create_domains::Domains::Table,
|
||
|
|
super::m20260403_000001_create_domains::Domains::Id)
|
||
|
|
.on_delete(ForeignKeyAction::Cascade),
|
||
|
|
)
|
||
|
|
.to_owned(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||
|
|
manager.drop_table(Table::drop().table(Resources::Table).to_owned()).await
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(DeriveIden)]
|
||
|
|
pub enum Resources {
|
||
|
|
Table,
|
||
|
|
Id,
|
||
|
|
DomainId,
|
||
|
|
ResourceUri,
|
||
|
|
Aliases,
|
||
|
|
Properties,
|
||
|
|
CreatedAt,
|
||
|
|
UpdatedAt,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Write service_tokens migration**
|
||
|
|
|
||
|
|
`migration/src/m20260403_000003_create_service_tokens.rs`:
|
||
|
|
```rust
|
||
|
|
use sea_orm_migration::prelude::*;
|
||
|
|
|
||
|
|
#[derive(DeriveMigrationName)]
|
||
|
|
pub struct Migration;
|
||
|
|
|
||
|
|
#[async_trait::async_trait]
|
||
|
|
impl MigrationTrait for Migration {
|
||
|
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||
|
|
manager
|
||
|
|
.create_table(
|
||
|
|
Table::create()
|
||
|
|
.table(ServiceTokens::Table)
|
||
|
|
.if_not_exists()
|
||
|
|
.col(ColumnDef::new(ServiceTokens::Id).string().not_null().primary_key())
|
||
|
|
.col(ColumnDef::new(ServiceTokens::DomainId).string().not_null())
|
||
|
|
.col(ColumnDef::new(ServiceTokens::Name).string().not_null())
|
||
|
|
.col(ColumnDef::new(ServiceTokens::TokenHash).string().not_null())
|
||
|
|
.col(ColumnDef::new(ServiceTokens::AllowedRels).string().not_null())
|
||
|
|
.col(ColumnDef::new(ServiceTokens::ResourcePattern).string().not_null())
|
||
|
|
.col(ColumnDef::new(ServiceTokens::CreatedAt).date_time().not_null())
|
||
|
|
.col(ColumnDef::new(ServiceTokens::RevokedAt).date_time().null())
|
||
|
|
.foreign_key(
|
||
|
|
ForeignKey::create()
|
||
|
|
.from(ServiceTokens::Table, ServiceTokens::DomainId)
|
||
|
|
.to(super::m20260403_000001_create_domains::Domains::Table,
|
||
|
|
super::m20260403_000001_create_domains::Domains::Id)
|
||
|
|
.on_delete(ForeignKeyAction::Cascade),
|
||
|
|
)
|
||
|
|
.to_owned(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||
|
|
manager.drop_table(Table::drop().table(ServiceTokens::Table).to_owned()).await
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(DeriveIden)]
|
||
|
|
pub enum ServiceTokens {
|
||
|
|
Table,
|
||
|
|
Id,
|
||
|
|
DomainId,
|
||
|
|
Name,
|
||
|
|
TokenHash,
|
||
|
|
AllowedRels,
|
||
|
|
ResourcePattern,
|
||
|
|
CreatedAt,
|
||
|
|
RevokedAt,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Write links migration**
|
||
|
|
|
||
|
|
`migration/src/m20260403_000004_create_links.rs`:
|
||
|
|
```rust
|
||
|
|
use sea_orm_migration::prelude::*;
|
||
|
|
|
||
|
|
#[derive(DeriveMigrationName)]
|
||
|
|
pub struct Migration;
|
||
|
|
|
||
|
|
#[async_trait::async_trait]
|
||
|
|
impl MigrationTrait for Migration {
|
||
|
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||
|
|
manager
|
||
|
|
.create_table(
|
||
|
|
Table::create()
|
||
|
|
.table(Links::Table)
|
||
|
|
.if_not_exists()
|
||
|
|
.col(ColumnDef::new(Links::Id).string().not_null().primary_key())
|
||
|
|
.col(ColumnDef::new(Links::ResourceId).string().not_null())
|
||
|
|
.col(ColumnDef::new(Links::ServiceTokenId).string().not_null())
|
||
|
|
.col(ColumnDef::new(Links::DomainId).string().not_null())
|
||
|
|
.col(ColumnDef::new(Links::Rel).string().not_null())
|
||
|
|
.col(ColumnDef::new(Links::Href).string().null())
|
||
|
|
.col(ColumnDef::new(Links::Type).string().null())
|
||
|
|
.col(ColumnDef::new(Links::Titles).string().null())
|
||
|
|
.col(ColumnDef::new(Links::Properties).string().null())
|
||
|
|
.col(ColumnDef::new(Links::Template).string().null())
|
||
|
|
.col(ColumnDef::new(Links::TtlSeconds).integer().null())
|
||
|
|
.col(ColumnDef::new(Links::CreatedAt).date_time().not_null())
|
||
|
|
.col(ColumnDef::new(Links::ExpiresAt).date_time().null())
|
||
|
|
.foreign_key(
|
||
|
|
ForeignKey::create()
|
||
|
|
.from(Links::Table, Links::ResourceId)
|
||
|
|
.to(super::m20260403_000002_create_resources::Resources::Table,
|
||
|
|
super::m20260403_000002_create_resources::Resources::Id)
|
||
|
|
.on_delete(ForeignKeyAction::Cascade),
|
||
|
|
)
|
||
|
|
.foreign_key(
|
||
|
|
ForeignKey::create()
|
||
|
|
.from(Links::Table, Links::ServiceTokenId)
|
||
|
|
.to(super::m20260403_000003_create_service_tokens::ServiceTokens::Table,
|
||
|
|
super::m20260403_000003_create_service_tokens::ServiceTokens::Id)
|
||
|
|
.on_delete(ForeignKeyAction::Cascade),
|
||
|
|
)
|
||
|
|
.foreign_key(
|
||
|
|
ForeignKey::create()
|
||
|
|
.from(Links::Table, Links::DomainId)
|
||
|
|
.to(super::m20260403_000001_create_domains::Domains::Table,
|
||
|
|
super::m20260403_000001_create_domains::Domains::Id)
|
||
|
|
.on_delete(ForeignKeyAction::Cascade),
|
||
|
|
)
|
||
|
|
.to_owned(),
|
||
|
|
)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
// Unique constraint for upsert behavior
|
||
|
|
manager
|
||
|
|
.create_index(
|
||
|
|
Index::create()
|
||
|
|
.name("idx_links_resource_rel_href")
|
||
|
|
.table(Links::Table)
|
||
|
|
.col(Links::ResourceId)
|
||
|
|
.col(Links::Rel)
|
||
|
|
.col(Links::Href)
|
||
|
|
.unique()
|
||
|
|
.to_owned(),
|
||
|
|
)
|
||
|
|
.await
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||
|
|
manager.drop_table(Table::drop().table(Links::Table).to_owned()).await
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(DeriveIden)]
|
||
|
|
pub enum Links {
|
||
|
|
Table,
|
||
|
|
Id,
|
||
|
|
ResourceId,
|
||
|
|
ServiceTokenId,
|
||
|
|
DomainId,
|
||
|
|
Rel,
|
||
|
|
Href,
|
||
|
|
Type,
|
||
|
|
Titles,
|
||
|
|
Properties,
|
||
|
|
Template,
|
||
|
|
TtlSeconds,
|
||
|
|
CreatedAt,
|
||
|
|
ExpiresAt,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Verify migrations compile**
|
||
|
|
|
||
|
|
Run: `cargo build -p migration`
|
||
|
|
Expected: Successful compilation.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add migration/
|
||
|
|
git commit -m "feat: add database migrations for domains, resources, service_tokens, links"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 3: SeaORM Entities
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/entity/mod.rs`, `src/entity/domains.rs`, `src/entity/resources.rs`
|
||
|
|
- Create: `src/entity/service_tokens.rs`, `src/entity/links.rs`
|
||
|
|
- Modify: `src/lib.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write domains entity**
|
||
|
|
|
||
|
|
Create `src/entity/domains.rs`:
|
||
|
|
```rust
|
||
|
|
use sea_orm::entity::prelude::*;
|
||
|
|
|
||
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||
|
|
#[sea_orm(table_name = "domains")]
|
||
|
|
pub struct Model {
|
||
|
|
#[sea_orm(primary_key, auto_increment = false)]
|
||
|
|
pub id: String,
|
||
|
|
#[sea_orm(unique)]
|
||
|
|
pub domain: String,
|
||
|
|
pub owner_token_hash: String,
|
||
|
|
pub registration_secret: String,
|
||
|
|
pub challenge_type: String,
|
||
|
|
pub challenge_token: Option<String>,
|
||
|
|
pub verified: bool,
|
||
|
|
pub created_at: chrono::NaiveDateTime,
|
||
|
|
pub verified_at: Option<chrono::NaiveDateTime>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||
|
|
pub enum Relation {
|
||
|
|
#[sea_orm(has_many = "super::resources::Entity")]
|
||
|
|
Resources,
|
||
|
|
#[sea_orm(has_many = "super::service_tokens::Entity")]
|
||
|
|
ServiceTokens,
|
||
|
|
#[sea_orm(has_many = "super::links::Entity")]
|
||
|
|
Links,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Related<super::resources::Entity> for Entity {
|
||
|
|
fn to() -> RelationDef {
|
||
|
|
Relation::Resources.def()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Related<super::service_tokens::Entity> for Entity {
|
||
|
|
fn to() -> RelationDef {
|
||
|
|
Relation::ServiceTokens.def()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Related<super::links::Entity> for Entity {
|
||
|
|
fn to() -> RelationDef {
|
||
|
|
Relation::Links.def()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl ActiveModelBehavior for ActiveModel {}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write resources entity**
|
||
|
|
|
||
|
|
Create `src/entity/resources.rs`:
|
||
|
|
```rust
|
||
|
|
use sea_orm::entity::prelude::*;
|
||
|
|
|
||
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||
|
|
#[sea_orm(table_name = "resources")]
|
||
|
|
pub struct Model {
|
||
|
|
#[sea_orm(primary_key, auto_increment = false)]
|
||
|
|
pub id: String,
|
||
|
|
pub domain_id: String,
|
||
|
|
#[sea_orm(unique)]
|
||
|
|
pub resource_uri: String,
|
||
|
|
pub aliases: Option<String>,
|
||
|
|
pub properties: Option<String>,
|
||
|
|
pub created_at: chrono::NaiveDateTime,
|
||
|
|
pub updated_at: chrono::NaiveDateTime,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||
|
|
pub enum Relation {
|
||
|
|
#[sea_orm(
|
||
|
|
belongs_to = "super::domains::Entity",
|
||
|
|
from = "Column::DomainId",
|
||
|
|
to = "super::domains::Column::Id"
|
||
|
|
)]
|
||
|
|
Domain,
|
||
|
|
#[sea_orm(has_many = "super::links::Entity")]
|
||
|
|
Links,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Related<super::domains::Entity> for Entity {
|
||
|
|
fn to() -> RelationDef {
|
||
|
|
Relation::Domain.def()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Related<super::links::Entity> for Entity {
|
||
|
|
fn to() -> RelationDef {
|
||
|
|
Relation::Links.def()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl ActiveModelBehavior for ActiveModel {}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Write service_tokens entity**
|
||
|
|
|
||
|
|
Create `src/entity/service_tokens.rs`:
|
||
|
|
```rust
|
||
|
|
use sea_orm::entity::prelude::*;
|
||
|
|
|
||
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||
|
|
#[sea_orm(table_name = "service_tokens")]
|
||
|
|
pub struct Model {
|
||
|
|
#[sea_orm(primary_key, auto_increment = false)]
|
||
|
|
pub id: String,
|
||
|
|
pub domain_id: String,
|
||
|
|
pub name: String,
|
||
|
|
pub token_hash: String,
|
||
|
|
pub allowed_rels: String,
|
||
|
|
pub resource_pattern: String,
|
||
|
|
pub created_at: chrono::NaiveDateTime,
|
||
|
|
pub revoked_at: Option<chrono::NaiveDateTime>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||
|
|
pub enum Relation {
|
||
|
|
#[sea_orm(
|
||
|
|
belongs_to = "super::domains::Entity",
|
||
|
|
from = "Column::DomainId",
|
||
|
|
to = "super::domains::Column::Id"
|
||
|
|
)]
|
||
|
|
Domain,
|
||
|
|
#[sea_orm(has_many = "super::links::Entity")]
|
||
|
|
Links,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Related<super::domains::Entity> for Entity {
|
||
|
|
fn to() -> RelationDef {
|
||
|
|
Relation::Domain.def()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Related<super::links::Entity> for Entity {
|
||
|
|
fn to() -> RelationDef {
|
||
|
|
Relation::Links.def()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl ActiveModelBehavior for ActiveModel {}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Write links entity**
|
||
|
|
|
||
|
|
Create `src/entity/links.rs`:
|
||
|
|
```rust
|
||
|
|
use sea_orm::entity::prelude::*;
|
||
|
|
|
||
|
|
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||
|
|
#[sea_orm(table_name = "links")]
|
||
|
|
pub struct Model {
|
||
|
|
#[sea_orm(primary_key, auto_increment = false)]
|
||
|
|
pub id: String,
|
||
|
|
pub resource_id: String,
|
||
|
|
pub service_token_id: String,
|
||
|
|
pub domain_id: String,
|
||
|
|
pub rel: String,
|
||
|
|
pub href: Option<String>,
|
||
|
|
#[sea_orm(column_name = "type")]
|
||
|
|
pub link_type: Option<String>,
|
||
|
|
pub titles: Option<String>,
|
||
|
|
pub properties: Option<String>,
|
||
|
|
pub template: Option<String>,
|
||
|
|
pub ttl_seconds: Option<i32>,
|
||
|
|
pub created_at: chrono::NaiveDateTime,
|
||
|
|
pub expires_at: Option<chrono::NaiveDateTime>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||
|
|
pub enum Relation {
|
||
|
|
#[sea_orm(
|
||
|
|
belongs_to = "super::resources::Entity",
|
||
|
|
from = "Column::ResourceId",
|
||
|
|
to = "super::resources::Column::Id"
|
||
|
|
)]
|
||
|
|
Resource,
|
||
|
|
#[sea_orm(
|
||
|
|
belongs_to = "super::service_tokens::Entity",
|
||
|
|
from = "Column::ServiceTokenId",
|
||
|
|
to = "super::service_tokens::Column::Id"
|
||
|
|
)]
|
||
|
|
ServiceToken,
|
||
|
|
#[sea_orm(
|
||
|
|
belongs_to = "super::domains::Entity",
|
||
|
|
from = "Column::DomainId",
|
||
|
|
to = "super::domains::Column::Id"
|
||
|
|
)]
|
||
|
|
Domain,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Related<super::resources::Entity> for Entity {
|
||
|
|
fn to() -> RelationDef {
|
||
|
|
Relation::Resource.def()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Related<super::service_tokens::Entity> for Entity {
|
||
|
|
fn to() -> RelationDef {
|
||
|
|
Relation::ServiceToken.def()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Related<super::domains::Entity> for Entity {
|
||
|
|
fn to() -> RelationDef {
|
||
|
|
Relation::Domain.def()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl ActiveModelBehavior for ActiveModel {}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Write entity mod.rs and update lib.rs**
|
||
|
|
|
||
|
|
Create `src/entity/mod.rs`:
|
||
|
|
```rust
|
||
|
|
pub mod domains;
|
||
|
|
pub mod links;
|
||
|
|
pub mod resources;
|
||
|
|
pub mod service_tokens;
|
||
|
|
```
|
||
|
|
|
||
|
|
Update `src/lib.rs`:
|
||
|
|
```rust
|
||
|
|
pub mod config;
|
||
|
|
pub mod entity;
|
||
|
|
pub mod error;
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Verify compilation**
|
||
|
|
|
||
|
|
Run: `cargo build`
|
||
|
|
Expected: Successful compilation.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/entity/
|
||
|
|
git commit -m "feat: add SeaORM entities for all tables"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 4: AppState + Database Bootstrap + Cache
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/state.rs`, `src/cache.rs`, `src/auth.rs`
|
||
|
|
- Modify: `src/lib.rs`, `src/main.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write cache.rs**
|
||
|
|
|
||
|
|
Create `src/cache.rs`:
|
||
|
|
```rust
|
||
|
|
use dashmap::DashMap;
|
||
|
|
use sea_orm::*;
|
||
|
|
use std::sync::Arc;
|
||
|
|
|
||
|
|
use crate::entity::{links, resources};
|
||
|
|
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct CachedLink {
|
||
|
|
pub rel: String,
|
||
|
|
pub href: Option<String>,
|
||
|
|
pub link_type: Option<String>,
|
||
|
|
pub titles: Option<String>,
|
||
|
|
pub properties: Option<String>,
|
||
|
|
pub template: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct CachedResource {
|
||
|
|
pub subject: String,
|
||
|
|
pub aliases: Option<Vec<String>>,
|
||
|
|
pub properties: Option<serde_json::Value>,
|
||
|
|
pub links: Vec<CachedLink>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct Cache {
|
||
|
|
inner: Arc<DashMap<String, CachedResource>>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Cache {
|
||
|
|
pub fn new() -> Self {
|
||
|
|
Self {
|
||
|
|
inner: Arc::new(DashMap::new()),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn get(&self, resource_uri: &str) -> Option<CachedResource> {
|
||
|
|
self.inner.get(resource_uri).map(|r| r.value().clone())
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn set(&self, resource_uri: String, resource: CachedResource) {
|
||
|
|
self.inner.insert(resource_uri, resource);
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn remove(&self, resource_uri: &str) {
|
||
|
|
self.inner.remove(resource_uri);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Remove all cache entries for the given resource URIs.
|
||
|
|
/// Callers should query the DB for all resource URIs belonging to a domain
|
||
|
|
/// before deleting, then pass them here. This handles non-acct: URI schemes.
|
||
|
|
pub fn remove_many(&self, resource_uris: &[String]) {
|
||
|
|
for uri in resource_uris {
|
||
|
|
self.inner.remove(uri);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Load all non-expired resources and links from DB into cache.
|
||
|
|
pub async fn hydrate(&self, db: &DatabaseConnection) -> Result<(), DbErr> {
|
||
|
|
let now = chrono::Utc::now().naive_utc();
|
||
|
|
|
||
|
|
let all_resources = resources::Entity::find().all(db).await?;
|
||
|
|
|
||
|
|
for resource in all_resources {
|
||
|
|
let resource_links = links::Entity::find()
|
||
|
|
.filter(links::Column::ResourceId.eq(&resource.id))
|
||
|
|
.filter(
|
||
|
|
Condition::any()
|
||
|
|
.add(links::Column::ExpiresAt.is_null())
|
||
|
|
.add(links::Column::ExpiresAt.gt(now)),
|
||
|
|
)
|
||
|
|
.all(db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
if resource_links.is_empty() {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
let cached = CachedResource {
|
||
|
|
subject: resource.resource_uri.clone(),
|
||
|
|
aliases: resource
|
||
|
|
.aliases
|
||
|
|
.as_deref()
|
||
|
|
.and_then(|a| serde_json::from_str(a).ok()),
|
||
|
|
properties: resource
|
||
|
|
.properties
|
||
|
|
.as_deref()
|
||
|
|
.and_then(|p| serde_json::from_str(p).ok()),
|
||
|
|
links: resource_links
|
||
|
|
.into_iter()
|
||
|
|
.map(|l| CachedLink {
|
||
|
|
rel: l.rel,
|
||
|
|
href: l.href,
|
||
|
|
link_type: l.link_type,
|
||
|
|
titles: l.titles,
|
||
|
|
properties: l.properties,
|
||
|
|
template: l.template,
|
||
|
|
})
|
||
|
|
.collect(),
|
||
|
|
};
|
||
|
|
|
||
|
|
self.set(resource.resource_uri, cached);
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Rebuild cache entry for a single resource from DB.
|
||
|
|
pub async fn refresh_resource(
|
||
|
|
&self,
|
||
|
|
db: &DatabaseConnection,
|
||
|
|
resource_uri: &str,
|
||
|
|
) -> Result<(), DbErr> {
|
||
|
|
let now = chrono::Utc::now().naive_utc();
|
||
|
|
|
||
|
|
let resource = resources::Entity::find()
|
||
|
|
.filter(resources::Column::ResourceUri.eq(resource_uri))
|
||
|
|
.one(db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
let Some(resource) = resource else {
|
||
|
|
self.remove(resource_uri);
|
||
|
|
return Ok(());
|
||
|
|
};
|
||
|
|
|
||
|
|
let resource_links = links::Entity::find()
|
||
|
|
.filter(links::Column::ResourceId.eq(&resource.id))
|
||
|
|
.filter(
|
||
|
|
Condition::any()
|
||
|
|
.add(links::Column::ExpiresAt.is_null())
|
||
|
|
.add(links::Column::ExpiresAt.gt(now)),
|
||
|
|
)
|
||
|
|
.all(db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
if resource_links.is_empty() {
|
||
|
|
self.remove(resource_uri);
|
||
|
|
return Ok(());
|
||
|
|
}
|
||
|
|
|
||
|
|
let cached = CachedResource {
|
||
|
|
subject: resource.resource_uri.clone(),
|
||
|
|
aliases: resource
|
||
|
|
.aliases
|
||
|
|
.as_deref()
|
||
|
|
.and_then(|a| serde_json::from_str(a).ok()),
|
||
|
|
properties: resource
|
||
|
|
.properties
|
||
|
|
.as_deref()
|
||
|
|
.and_then(|p| serde_json::from_str(p).ok()),
|
||
|
|
links: resource_links
|
||
|
|
.into_iter()
|
||
|
|
.map(|l| CachedLink {
|
||
|
|
rel: l.rel,
|
||
|
|
href: l.href,
|
||
|
|
link_type: l.link_type,
|
||
|
|
titles: l.titles,
|
||
|
|
properties: l.properties,
|
||
|
|
template: l.template,
|
||
|
|
})
|
||
|
|
.collect(),
|
||
|
|
};
|
||
|
|
|
||
|
|
self.set(resource.resource_uri, cached);
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write auth.rs**
|
||
|
|
|
||
|
|
Create `src/auth.rs`:
|
||
|
|
```rust
|
||
|
|
use argon2::{
|
||
|
|
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||
|
|
Argon2,
|
||
|
|
};
|
||
|
|
use base64::Engine;
|
||
|
|
use rand::Rng;
|
||
|
|
|
||
|
|
/// Generate a prefixed token: `{id}.{random_secret}`.
|
||
|
|
/// The id allows O(1) lookup; the secret is verified via argon2.
|
||
|
|
/// The `id` parameter is the entity UUID this token belongs to.
|
||
|
|
pub fn generate_token(id: &str) -> String {
|
||
|
|
let bytes: [u8; 32] = rand::thread_rng().gen();
|
||
|
|
let secret = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
|
||
|
|
format!("{id}.{secret}")
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Generate a non-prefixed secret (for registration secrets that don't need lookup).
|
||
|
|
pub fn generate_secret() -> String {
|
||
|
|
let bytes: [u8; 32] = rand::thread_rng().gen();
|
||
|
|
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Hash a token (or its secret part) with argon2.
|
||
|
|
pub fn hash_token(token: &str) -> Result<String, argon2::password_hash::Error> {
|
||
|
|
let salt = SaltString::generate(&mut OsRng);
|
||
|
|
let argon2 = Argon2::default();
|
||
|
|
Ok(argon2.hash_password(token.as_bytes(), &salt)?.to_string())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Verify a token against a stored argon2 hash.
|
||
|
|
pub fn verify_token(token: &str, hash: &str) -> bool {
|
||
|
|
let Ok(parsed_hash) = PasswordHash::new(hash) else {
|
||
|
|
return false;
|
||
|
|
};
|
||
|
|
Argon2::default()
|
||
|
|
.verify_password(token.as_bytes(), &parsed_hash)
|
||
|
|
.is_ok()
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Split a prefixed token into (id, secret).
|
||
|
|
/// Returns None if the token is not in `id.secret` format.
|
||
|
|
pub fn split_token(token: &str) -> Option<(&str, &str)> {
|
||
|
|
token.split_once('.')
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
**Token format:** All tokens use the format `{entity_id}.{random_secret}`. This enables
|
||
|
|
O(1) database lookup by ID, then a single argon2 verify against the stored hash of the
|
||
|
|
full token. This avoids the O(n) scan + argon2-per-row anti-pattern.
|
||
|
|
|
||
|
|
Callers use `auth::split_token()` to extract the ID, look up the entity by ID, then
|
||
|
|
`auth::verify_token(full_token, stored_hash)` to verify. The hash is computed over the
|
||
|
|
full `{id}.{secret}` string.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Write state.rs**
|
||
|
|
|
||
|
|
Create `src/state.rs`:
|
||
|
|
```rust
|
||
|
|
use sea_orm::DatabaseConnection;
|
||
|
|
use std::sync::Arc;
|
||
|
|
|
||
|
|
use crate::cache::Cache;
|
||
|
|
use crate::challenge::ChallengeVerifier;
|
||
|
|
use crate::config::Settings;
|
||
|
|
|
||
|
|
#[derive(Clone)]
|
||
|
|
pub struct AppState {
|
||
|
|
pub db: DatabaseConnection,
|
||
|
|
pub cache: Cache,
|
||
|
|
pub settings: Arc<Settings>,
|
||
|
|
pub challenge_verifier: Arc<dyn ChallengeVerifier>,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Update main.rs with DB bootstrap**
|
||
|
|
|
||
|
|
Update `src/main.rs`:
|
||
|
|
```rust
|
||
|
|
use sea_orm::{ConnectOptions, Database, ConnectionTrait, Statement};
|
||
|
|
use sea_orm_migration::MigratorTrait;
|
||
|
|
use std::sync::Arc;
|
||
|
|
use tracing_subscriber::{fmt, EnvFilter};
|
||
|
|
|
||
|
|
use webfingerd::cache::Cache;
|
||
|
|
use webfingerd::config::Settings;
|
||
|
|
use webfingerd::state::AppState;
|
||
|
|
|
||
|
|
#[tokio::main]
|
||
|
|
async fn main() {
|
||
|
|
fmt()
|
||
|
|
.with_env_filter(EnvFilter::from_default_env())
|
||
|
|
.json()
|
||
|
|
.init();
|
||
|
|
|
||
|
|
let settings = Settings::load().expect("failed to load configuration");
|
||
|
|
|
||
|
|
// Connect to SQLite
|
||
|
|
let db_url = format!("sqlite://{}?mode=rwc", settings.database.path);
|
||
|
|
let mut opt = ConnectOptions::new(&db_url);
|
||
|
|
opt.sqlx_logging(false);
|
||
|
|
let db = Database::connect(opt)
|
||
|
|
.await
|
||
|
|
.expect("failed to connect to database");
|
||
|
|
|
||
|
|
// Enable WAL mode
|
||
|
|
if settings.database.wal_mode {
|
||
|
|
db.execute(Statement::from_string(
|
||
|
|
sea_orm::DatabaseBackend::Sqlite,
|
||
|
|
"PRAGMA journal_mode=WAL".to_string(),
|
||
|
|
))
|
||
|
|
.await
|
||
|
|
.expect("failed to enable WAL mode");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Run migrations
|
||
|
|
migration::Migrator::up(&db, None)
|
||
|
|
.await
|
||
|
|
.expect("failed to run migrations");
|
||
|
|
|
||
|
|
// Hydrate cache
|
||
|
|
let cache = Cache::new();
|
||
|
|
cache.hydrate(&db).await.expect("failed to hydrate cache");
|
||
|
|
tracing::info!("cache hydrated");
|
||
|
|
|
||
|
|
let state = AppState {
|
||
|
|
db,
|
||
|
|
cache,
|
||
|
|
settings: Arc::new(settings.clone()),
|
||
|
|
challenge_verifier: Arc::new(webfingerd::challenge::RealChallengeVerifier),
|
||
|
|
};
|
||
|
|
|
||
|
|
let listener = tokio::net::TcpListener::bind(&settings.server.listen)
|
||
|
|
.await
|
||
|
|
.expect("failed to bind");
|
||
|
|
tracing::info!(listen = %settings.server.listen, "starting webfingerd");
|
||
|
|
|
||
|
|
axum::serve(listener, axum::Router::new().with_state(state))
|
||
|
|
.await
|
||
|
|
.expect("server error");
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Update lib.rs**
|
||
|
|
|
||
|
|
```rust
|
||
|
|
pub mod auth;
|
||
|
|
pub mod cache;
|
||
|
|
pub mod config;
|
||
|
|
pub mod entity;
|
||
|
|
pub mod error;
|
||
|
|
pub mod state;
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Verify compilation**
|
||
|
|
|
||
|
|
Run: `cargo build`
|
||
|
|
Expected: Successful compilation.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/state.rs src/cache.rs src/auth.rs src/main.rs src/lib.rs
|
||
|
|
git commit -m "feat: add AppState, in-memory cache with hydration, auth helpers"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 5: Test Helpers
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `tests/common/mod.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write test helpers**
|
||
|
|
|
||
|
|
Create `tests/common/mod.rs`:
|
||
|
|
```rust
|
||
|
|
use axum::Router;
|
||
|
|
use sea_orm::{ConnectOptions, ConnectionTrait, Database, DatabaseConnection, Statement};
|
||
|
|
use sea_orm_migration::MigratorTrait;
|
||
|
|
use std::sync::Arc;
|
||
|
|
use webfingerd::cache::Cache;
|
||
|
|
use webfingerd::config::*;
|
||
|
|
use webfingerd::state::AppState;
|
||
|
|
|
||
|
|
pub async fn setup_test_db() -> DatabaseConnection {
|
||
|
|
let opt = ConnectOptions::new("sqlite::memory:");
|
||
|
|
let db = Database::connect(opt).await.unwrap();
|
||
|
|
db.execute(Statement::from_string(
|
||
|
|
sea_orm::DatabaseBackend::Sqlite,
|
||
|
|
"PRAGMA journal_mode=WAL".to_string(),
|
||
|
|
))
|
||
|
|
.await
|
||
|
|
.unwrap();
|
||
|
|
migration::Migrator::up(&db, None).await.unwrap();
|
||
|
|
db
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn test_settings() -> Settings {
|
||
|
|
Settings {
|
||
|
|
server: ServerConfig {
|
||
|
|
listen: "127.0.0.1:0".into(),
|
||
|
|
base_url: "http://localhost:8080".into(),
|
||
|
|
},
|
||
|
|
database: DatabaseConfig {
|
||
|
|
path: ":memory:".into(),
|
||
|
|
wal_mode: true,
|
||
|
|
},
|
||
|
|
cache: CacheConfig {
|
||
|
|
reaper_interval_secs: 1,
|
||
|
|
},
|
||
|
|
rate_limit: RateLimitConfig {
|
||
|
|
public_rpm: 1000,
|
||
|
|
api_rpm: 1000,
|
||
|
|
batch_rpm: 100,
|
||
|
|
batch_max_links: 500,
|
||
|
|
},
|
||
|
|
challenge: ChallengeConfig {
|
||
|
|
dns_txt_prefix: "_webfinger-challenge".into(),
|
||
|
|
http_well_known_path: ".well-known/webfinger-verify".into(),
|
||
|
|
challenge_ttl_secs: 3600,
|
||
|
|
},
|
||
|
|
ui: UiConfig {
|
||
|
|
enabled: false,
|
||
|
|
session_secret: "test-secret-at-least-32-bytes-long-for-signing".into(),
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub async fn test_state() -> AppState {
|
||
|
|
test_state_with_settings(test_settings()).await
|
||
|
|
}
|
||
|
|
|
||
|
|
pub async fn test_state_with_settings(settings: Settings) -> AppState {
|
||
|
|
let db = setup_test_db().await;
|
||
|
|
let cache = Cache::new();
|
||
|
|
cache.hydrate(&db).await.unwrap();
|
||
|
|
AppState {
|
||
|
|
db,
|
||
|
|
cache,
|
||
|
|
settings: Arc::new(settings),
|
||
|
|
challenge_verifier: Arc::new(webfingerd::challenge::MockChallengeVerifier),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify compilation**
|
||
|
|
|
||
|
|
Run: `cargo test --no-run`
|
||
|
|
Expected: Compiles successfully.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add tests/
|
||
|
|
git commit -m "feat: add test helpers with in-memory DB and test state"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 6: WebFinger Query Endpoint
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/handler/mod.rs`, `src/handler/webfinger.rs`, `src/handler/health.rs`
|
||
|
|
- Create: `tests/test_webfinger.rs`
|
||
|
|
- Modify: `src/lib.rs`, `src/main.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write failing test for webfinger query**
|
||
|
|
|
||
|
|
Create `tests/test_webfinger.rs`:
|
||
|
|
```rust
|
||
|
|
mod common;
|
||
|
|
|
||
|
|
use axum_test::TestServer;
|
||
|
|
use webfingerd::handler;
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_webfinger_returns_404_for_unknown_resource() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state);
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:nobody@example.com")
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status_not_found();
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_webfinger_returns_400_without_resource_param() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state);
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let response = server.get("/.well-known/webfinger").await;
|
||
|
|
|
||
|
|
response.assert_status_bad_request();
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_webfinger_returns_jrd_for_known_resource() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
|
||
|
|
// Seed cache directly for this test
|
||
|
|
state.cache.set(
|
||
|
|
"acct:alice@example.com".into(),
|
||
|
|
webfingerd::cache::CachedResource {
|
||
|
|
subject: "acct:alice@example.com".into(),
|
||
|
|
aliases: Some(vec!["https://example.com/@alice".into()]),
|
||
|
|
properties: None,
|
||
|
|
links: vec![webfingerd::cache::CachedLink {
|
||
|
|
rel: "self".into(),
|
||
|
|
href: Some("https://example.com/users/alice".into()),
|
||
|
|
link_type: Some("application/activity+json".into()),
|
||
|
|
titles: None,
|
||
|
|
properties: None,
|
||
|
|
template: None,
|
||
|
|
}],
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
let app = handler::router(state);
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:alice@example.com")
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status_ok();
|
||
|
|
let body: serde_json::Value = response.json();
|
||
|
|
assert_eq!(body["subject"], "acct:alice@example.com");
|
||
|
|
assert_eq!(body["aliases"][0], "https://example.com/@alice");
|
||
|
|
assert_eq!(body["links"][0]["rel"], "self");
|
||
|
|
assert_eq!(
|
||
|
|
body["links"][0]["href"],
|
||
|
|
"https://example.com/users/alice"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_webfinger_filters_by_rel() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
|
||
|
|
state.cache.set(
|
||
|
|
"acct:alice@example.com".into(),
|
||
|
|
webfingerd::cache::CachedResource {
|
||
|
|
subject: "acct:alice@example.com".into(),
|
||
|
|
aliases: None,
|
||
|
|
properties: None,
|
||
|
|
links: vec![
|
||
|
|
webfingerd::cache::CachedLink {
|
||
|
|
rel: "self".into(),
|
||
|
|
href: Some("https://example.com/users/alice".into()),
|
||
|
|
link_type: Some("application/activity+json".into()),
|
||
|
|
titles: None,
|
||
|
|
properties: None,
|
||
|
|
template: None,
|
||
|
|
},
|
||
|
|
webfingerd::cache::CachedLink {
|
||
|
|
rel: "http://openid.net/specs/connect/1.0/issuer".into(),
|
||
|
|
href: Some("https://auth.example.com".into()),
|
||
|
|
link_type: None,
|
||
|
|
titles: None,
|
||
|
|
properties: None,
|
||
|
|
template: None,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
let app = handler::router(state);
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:alice@example.com")
|
||
|
|
.add_query_param("rel", "self")
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status_ok();
|
||
|
|
let body: serde_json::Value = response.json();
|
||
|
|
let links = body["links"].as_array().unwrap();
|
||
|
|
assert_eq!(links.len(), 1);
|
||
|
|
assert_eq!(links[0]["rel"], "self");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_webfinger_cors_headers() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
|
||
|
|
state.cache.set(
|
||
|
|
"acct:alice@example.com".into(),
|
||
|
|
webfingerd::cache::CachedResource {
|
||
|
|
subject: "acct:alice@example.com".into(),
|
||
|
|
aliases: None,
|
||
|
|
properties: None,
|
||
|
|
links: vec![],
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
let app = handler::router(state);
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:alice@example.com")
|
||
|
|
.await;
|
||
|
|
|
||
|
|
assert_eq!(
|
||
|
|
response.header("access-control-allow-origin"),
|
||
|
|
"*"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_webfinger`
|
||
|
|
Expected: FAIL — `handler` module does not exist.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement handler module + webfinger handler**
|
||
|
|
|
||
|
|
Create `src/handler/mod.rs`:
|
||
|
|
```rust
|
||
|
|
mod health;
|
||
|
|
mod webfinger;
|
||
|
|
|
||
|
|
use axum::Router;
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
pub fn router(state: AppState) -> Router {
|
||
|
|
Router::new()
|
||
|
|
.merge(webfinger::router())
|
||
|
|
.merge(health::router())
|
||
|
|
.with_state(state)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `src/handler/webfinger.rs`:
|
||
|
|
|
||
|
|
**Note:** `serde_urlencoded` (used by axum's `Query`) does not support deserializing
|
||
|
|
repeated query params (`?rel=a&rel=b`) into a `Vec`. We parse the raw query string
|
||
|
|
manually to support multiple `rel` parameters per RFC 7033 Section 4.1.
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use axum::extract::State;
|
||
|
|
use axum::http::{header, Uri};
|
||
|
|
use axum::response::{IntoResponse, Response};
|
||
|
|
use axum::routing::get;
|
||
|
|
use axum::{Json, Router};
|
||
|
|
use serde_json::json;
|
||
|
|
|
||
|
|
use crate::error::{AppError, AppResult};
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
/// Parse resource and rel params from query string manually,
|
||
|
|
/// because serde_urlencoded can't handle repeated keys into Vec.
|
||
|
|
fn parse_webfinger_query(uri: &Uri) -> (Option<String>, Vec<String>) {
|
||
|
|
let query_str = uri.query().unwrap_or("");
|
||
|
|
let mut resource = None;
|
||
|
|
let mut rels = Vec::new();
|
||
|
|
|
||
|
|
for pair in query_str.split('&') {
|
||
|
|
if let Some((key, value)) = pair.split_once('=') {
|
||
|
|
let value = urlencoding::decode(value)
|
||
|
|
.unwrap_or_default()
|
||
|
|
.into_owned();
|
||
|
|
match key {
|
||
|
|
"resource" => resource = Some(value),
|
||
|
|
"rel" => rels.push(value),
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
(resource, rels)
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn webfinger(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
uri: Uri,
|
||
|
|
) -> AppResult<Response> {
|
||
|
|
let (resource_opt, rels) = parse_webfinger_query(&uri);
|
||
|
|
|
||
|
|
let resource = resource_opt
|
||
|
|
.ok_or_else(|| AppError::BadRequest("missing resource parameter".into()))?;
|
||
|
|
|
||
|
|
let cached = state
|
||
|
|
.cache
|
||
|
|
.get(&resource)
|
||
|
|
.ok_or(AppError::NotFound)?;
|
||
|
|
|
||
|
|
let links: Vec<serde_json::Value> = cached
|
||
|
|
.links
|
||
|
|
.iter()
|
||
|
|
.filter(|link| {
|
||
|
|
if rels.is_empty() {
|
||
|
|
true
|
||
|
|
} else {
|
||
|
|
rels.iter().any(|r| r == &link.rel)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
.map(|link| {
|
||
|
|
let mut obj = serde_json::Map::new();
|
||
|
|
obj.insert("rel".into(), json!(link.rel));
|
||
|
|
if let Some(href) = &link.href {
|
||
|
|
obj.insert("href".into(), json!(href));
|
||
|
|
}
|
||
|
|
if let Some(t) = &link.link_type {
|
||
|
|
obj.insert("type".into(), json!(t));
|
||
|
|
}
|
||
|
|
if let Some(titles) = &link.titles {
|
||
|
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(titles) {
|
||
|
|
obj.insert("titles".into(), v);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if let Some(props) = &link.properties {
|
||
|
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(props) {
|
||
|
|
obj.insert("properties".into(), v);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if let Some(template) = &link.template {
|
||
|
|
obj.insert("template".into(), json!(template));
|
||
|
|
}
|
||
|
|
serde_json::Value::Object(obj)
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
let mut response_body = serde_json::Map::new();
|
||
|
|
response_body.insert("subject".into(), json!(cached.subject));
|
||
|
|
|
||
|
|
if let Some(aliases) = &cached.aliases {
|
||
|
|
response_body.insert("aliases".into(), json!(aliases));
|
||
|
|
}
|
||
|
|
|
||
|
|
if let Some(properties) = &cached.properties {
|
||
|
|
response_body.insert("properties".into(), properties.clone());
|
||
|
|
}
|
||
|
|
|
||
|
|
response_body.insert("links".into(), json!(links));
|
||
|
|
|
||
|
|
Ok((
|
||
|
|
[
|
||
|
|
(header::CONTENT_TYPE, "application/jrd+json"),
|
||
|
|
(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"),
|
||
|
|
],
|
||
|
|
Json(serde_json::Value::Object(response_body)),
|
||
|
|
)
|
||
|
|
.into_response())
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn router() -> Router<AppState> {
|
||
|
|
Router::new().route("/.well-known/webfinger", get(webfinger))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `src/handler/health.rs`:
|
||
|
|
```rust
|
||
|
|
use axum::http::StatusCode;
|
||
|
|
use axum::routing::get;
|
||
|
|
use axum::Router;
|
||
|
|
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
async fn healthz() -> StatusCode {
|
||
|
|
StatusCode::OK
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn router() -> Router<AppState> {
|
||
|
|
Router::new().route("/healthz", get(healthz))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Update lib.rs**
|
||
|
|
|
||
|
|
```rust
|
||
|
|
pub mod auth;
|
||
|
|
pub mod cache;
|
||
|
|
pub mod config;
|
||
|
|
pub mod entity;
|
||
|
|
pub mod error;
|
||
|
|
pub mod handler;
|
||
|
|
pub mod state;
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_webfinger`
|
||
|
|
Expected: All 5 tests PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/handler/ src/lib.rs tests/test_webfinger.rs
|
||
|
|
git commit -m "feat: add WebFinger query endpoint with rel filtering and CORS"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 7: host-meta Endpoint
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/handler/host_meta.rs`
|
||
|
|
- Create: `tests/test_host_meta.rs`
|
||
|
|
- Modify: `src/handler/mod.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write failing tests**
|
||
|
|
|
||
|
|
Create `tests/test_host_meta.rs`:
|
||
|
|
```rust
|
||
|
|
mod common;
|
||
|
|
|
||
|
|
use axum_test::TestServer;
|
||
|
|
use webfingerd::handler;
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_host_meta_returns_xrd_for_known_domain() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
|
||
|
|
// Seed a verified domain in DB
|
||
|
|
use sea_orm::ActiveModelTrait;
|
||
|
|
use sea_orm::Set;
|
||
|
|
use webfingerd::entity::domains;
|
||
|
|
|
||
|
|
let domain = domains::ActiveModel {
|
||
|
|
id: Set(uuid::Uuid::new_v4().to_string()),
|
||
|
|
domain: Set("example.com".into()),
|
||
|
|
owner_token_hash: Set("hash".into()),
|
||
|
|
registration_secret: Set("secret".into()),
|
||
|
|
challenge_type: Set("dns-01".into()),
|
||
|
|
challenge_token: Set(None),
|
||
|
|
verified: Set(true),
|
||
|
|
created_at: Set(chrono::Utc::now().naive_utc()),
|
||
|
|
verified_at: Set(Some(chrono::Utc::now().naive_utc())),
|
||
|
|
};
|
||
|
|
domain.insert(&state.db).await.unwrap();
|
||
|
|
|
||
|
|
let app = handler::router(state);
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.get("/.well-known/host-meta")
|
||
|
|
.add_header("Host", "example.com")
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status_ok();
|
||
|
|
let body = response.text();
|
||
|
|
assert!(body.contains("application/xrd+xml") || body.contains("XRD"));
|
||
|
|
assert!(body.contains("/.well-known/webfinger"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_host_meta_returns_404_for_unknown_domain() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state);
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.get("/.well-known/host-meta")
|
||
|
|
.add_header("Host", "unknown.example.com")
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status_not_found();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_host_meta`
|
||
|
|
Expected: FAIL.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement host_meta handler**
|
||
|
|
|
||
|
|
Create `src/handler/host_meta.rs`:
|
||
|
|
```rust
|
||
|
|
use axum::extract::{Host, State};
|
||
|
|
use axum::http::header;
|
||
|
|
use axum::response::{IntoResponse, Response};
|
||
|
|
use axum::routing::get;
|
||
|
|
use axum::Router;
|
||
|
|
use sea_orm::*;
|
||
|
|
|
||
|
|
use crate::entity::domains;
|
||
|
|
use crate::error::{AppError, AppResult};
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
async fn host_meta(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Host(hostname): Host,
|
||
|
|
) -> AppResult<Response> {
|
||
|
|
// Strip port if present
|
||
|
|
let domain = hostname.split(':').next().unwrap_or(&hostname);
|
||
|
|
|
||
|
|
// Check this domain is registered and verified
|
||
|
|
let _domain = domains::Entity::find()
|
||
|
|
.filter(domains::Column::Domain.eq(domain))
|
||
|
|
.filter(domains::Column::Verified.eq(true))
|
||
|
|
.one(&state.db)
|
||
|
|
.await?
|
||
|
|
.ok_or(AppError::NotFound)?;
|
||
|
|
|
||
|
|
let base_url = &state.settings.server.base_url;
|
||
|
|
let xrd = format!(
|
||
|
|
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||
|
|
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
|
||
|
|
<Link rel="lrdd" type="application/jrd+json" template="{base_url}/.well-known/webfinger?resource={{uri}}" />
|
||
|
|
</XRD>"#
|
||
|
|
);
|
||
|
|
|
||
|
|
Ok((
|
||
|
|
[(header::CONTENT_TYPE, "application/xrd+xml; charset=utf-8")],
|
||
|
|
xrd,
|
||
|
|
)
|
||
|
|
.into_response())
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn router() -> Router<AppState> {
|
||
|
|
Router::new().route("/.well-known/host-meta", get(host_meta))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Update handler/mod.rs**
|
||
|
|
|
||
|
|
```rust
|
||
|
|
mod health;
|
||
|
|
mod host_meta;
|
||
|
|
mod webfinger;
|
||
|
|
|
||
|
|
use axum::Router;
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
pub fn router(state: AppState) -> Router {
|
||
|
|
Router::new()
|
||
|
|
.merge(webfinger::router())
|
||
|
|
.merge(host_meta::router())
|
||
|
|
.merge(health::router())
|
||
|
|
.with_state(state)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run tests**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_host_meta`
|
||
|
|
Expected: All tests PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/handler/host_meta.rs src/handler/mod.rs tests/test_host_meta.rs
|
||
|
|
git commit -m "feat: add host-meta endpoint with domain-aware XRD response"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 8: Domain Onboarding API
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/handler/domains.rs`, `src/challenge.rs`
|
||
|
|
- Create: `tests/test_domains.rs`
|
||
|
|
- Modify: `src/handler/mod.rs`, `src/lib.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write failing tests for domain registration and verification**
|
||
|
|
|
||
|
|
Create `tests/test_domains.rs`:
|
||
|
|
```rust
|
||
|
|
mod common;
|
||
|
|
|
||
|
|
use axum_test::TestServer;
|
||
|
|
use serde_json::json;
|
||
|
|
use webfingerd::handler;
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_register_domain() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state);
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.post("/api/v1/domains")
|
||
|
|
.json(&json!({
|
||
|
|
"domain": "example.com",
|
||
|
|
"challenge_type": "dns-01"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
let body: serde_json::Value = response.json();
|
||
|
|
assert!(body["id"].is_string());
|
||
|
|
assert!(body["challenge_token"].is_string());
|
||
|
|
assert!(body["registration_secret"].is_string());
|
||
|
|
assert_eq!(body["challenge_type"], "dns-01");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_register_duplicate_domain_returns_409() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state);
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
server
|
||
|
|
.post("/api/v1/domains")
|
||
|
|
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.post("/api/v1/domains")
|
||
|
|
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status(axum::http::StatusCode::CONFLICT);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_get_domain_requires_auth() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state);
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let create_resp = server
|
||
|
|
.post("/api/v1/domains")
|
||
|
|
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
|
||
|
|
.await;
|
||
|
|
let id = create_resp.json::<serde_json::Value>()["id"]
|
||
|
|
.as_str()
|
||
|
|
.unwrap()
|
||
|
|
.to_string();
|
||
|
|
|
||
|
|
// No auth header
|
||
|
|
let response = server.get(&format!("/api/v1/domains/{id}")).await;
|
||
|
|
response.assert_status_unauthorized();
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_get_domain_with_valid_owner_token() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
// Register domain
|
||
|
|
let create_resp = server
|
||
|
|
.post("/api/v1/domains")
|
||
|
|
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
let body: serde_json::Value = create_resp.json();
|
||
|
|
let id = body["id"].as_str().unwrap();
|
||
|
|
let reg_secret = body["registration_secret"].as_str().unwrap();
|
||
|
|
|
||
|
|
// Verify (MockChallengeVerifier always succeeds)
|
||
|
|
let verify_resp = server
|
||
|
|
.post(&format!("/api/v1/domains/{id}/verify"))
|
||
|
|
.json(&json!({"registration_secret": reg_secret}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
verify_resp.assert_status_ok();
|
||
|
|
let owner_token = verify_resp.json::<serde_json::Value>()["owner_token"]
|
||
|
|
.as_str()
|
||
|
|
.unwrap()
|
||
|
|
.to_string();
|
||
|
|
|
||
|
|
// Use owner token to get domain
|
||
|
|
let response = server
|
||
|
|
.get(&format!("/api/v1/domains/{id}"))
|
||
|
|
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status_ok();
|
||
|
|
let body: serde_json::Value = response.json();
|
||
|
|
assert_eq!(body["domain"], "example.com");
|
||
|
|
assert_eq!(body["verified"], true);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_rotate_token() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
// Register domain
|
||
|
|
let create_resp = server
|
||
|
|
.post("/api/v1/domains")
|
||
|
|
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
|
||
|
|
.await;
|
||
|
|
let body: serde_json::Value = create_resp.json();
|
||
|
|
let id = body["id"].as_str().unwrap();
|
||
|
|
let reg_secret = body["registration_secret"].as_str().unwrap();
|
||
|
|
|
||
|
|
// Verify (MockChallengeVerifier always succeeds)
|
||
|
|
let verify_resp = server
|
||
|
|
.post(&format!("/api/v1/domains/{id}/verify"))
|
||
|
|
.json(&json!({"registration_secret": reg_secret}))
|
||
|
|
.await;
|
||
|
|
let old_token = verify_resp.json::<serde_json::Value>()["owner_token"]
|
||
|
|
.as_str()
|
||
|
|
.unwrap()
|
||
|
|
.to_string();
|
||
|
|
|
||
|
|
// Rotate
|
||
|
|
let rotate_resp = server
|
||
|
|
.post(&format!("/api/v1/domains/{id}/rotate-token"))
|
||
|
|
.add_header("Authorization", format!("Bearer {old_token}"))
|
||
|
|
.await;
|
||
|
|
rotate_resp.assert_status_ok();
|
||
|
|
let new_token = rotate_resp.json::<serde_json::Value>()["owner_token"]
|
||
|
|
.as_str()
|
||
|
|
.unwrap()
|
||
|
|
.to_string();
|
||
|
|
|
||
|
|
// Old token should fail
|
||
|
|
let response = server
|
||
|
|
.get(&format!("/api/v1/domains/{id}"))
|
||
|
|
.add_header("Authorization", format!("Bearer {old_token}"))
|
||
|
|
.await;
|
||
|
|
response.assert_status_unauthorized();
|
||
|
|
|
||
|
|
// New token should work
|
||
|
|
let response = server
|
||
|
|
.get(&format!("/api/v1/domains/{id}"))
|
||
|
|
.add_header("Authorization", format!("Bearer {new_token}"))
|
||
|
|
.await;
|
||
|
|
response.assert_status_ok();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_domains`
|
||
|
|
Expected: FAIL.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement challenge.rs**
|
||
|
|
|
||
|
|
Create `src/challenge.rs`:
|
||
|
|
```rust
|
||
|
|
use async_trait::async_trait;
|
||
|
|
use crate::config::ChallengeConfig;
|
||
|
|
|
||
|
|
/// Trait for challenge verification — allows mocking in tests.
|
||
|
|
#[async_trait]
|
||
|
|
pub trait ChallengeVerifier: Send + Sync {
|
||
|
|
async fn verify_dns(
|
||
|
|
&self,
|
||
|
|
domain: &str,
|
||
|
|
expected_token: &str,
|
||
|
|
config: &ChallengeConfig,
|
||
|
|
) -> Result<bool, String>;
|
||
|
|
|
||
|
|
async fn verify_http(
|
||
|
|
&self,
|
||
|
|
domain: &str,
|
||
|
|
expected_token: &str,
|
||
|
|
config: &ChallengeConfig,
|
||
|
|
) -> Result<bool, String>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Real implementation using DNS lookups and HTTP requests.
|
||
|
|
pub struct RealChallengeVerifier;
|
||
|
|
|
||
|
|
#[async_trait]
|
||
|
|
impl ChallengeVerifier for RealChallengeVerifier {
|
||
|
|
async fn verify_dns(
|
||
|
|
&self,
|
||
|
|
domain: &str,
|
||
|
|
expected_token: &str,
|
||
|
|
config: &ChallengeConfig,
|
||
|
|
) -> Result<bool, String> {
|
||
|
|
use hickory_resolver::TokioAsyncResolver;
|
||
|
|
|
||
|
|
let resolver = TokioAsyncResolver::tokio_from_system_conf()
|
||
|
|
.map_err(|e| format!("resolver error: {e}"))?;
|
||
|
|
|
||
|
|
let lookup_name = format!("{}.{}", config.dns_txt_prefix, domain);
|
||
|
|
let response = resolver
|
||
|
|
.txt_lookup(&lookup_name)
|
||
|
|
.await
|
||
|
|
.map_err(|e| format!("DNS lookup failed: {e}"))?;
|
||
|
|
|
||
|
|
for record in response.iter() {
|
||
|
|
let txt = record.to_string();
|
||
|
|
if txt.trim_matches('"') == expected_token {
|
||
|
|
return Ok(true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(false)
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn verify_http(
|
||
|
|
&self,
|
||
|
|
domain: &str,
|
||
|
|
expected_token: &str,
|
||
|
|
config: &ChallengeConfig,
|
||
|
|
) -> Result<bool, String> {
|
||
|
|
let url = format!(
|
||
|
|
"https://{}/{}/{}",
|
||
|
|
domain, config.http_well_known_path, expected_token
|
||
|
|
);
|
||
|
|
|
||
|
|
let client = reqwest::Client::builder()
|
||
|
|
.timeout(std::time::Duration::from_secs(10))
|
||
|
|
.build()
|
||
|
|
.map_err(|e| format!("HTTP client error: {e}"))?;
|
||
|
|
|
||
|
|
let response = client
|
||
|
|
.get(&url)
|
||
|
|
.send()
|
||
|
|
.await
|
||
|
|
.map_err(|e| format!("HTTP request failed: {e}"))?;
|
||
|
|
|
||
|
|
Ok(response.status().is_success())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Mock that always succeeds — for testing.
|
||
|
|
pub struct MockChallengeVerifier;
|
||
|
|
|
||
|
|
#[async_trait]
|
||
|
|
impl ChallengeVerifier for MockChallengeVerifier {
|
||
|
|
async fn verify_dns(&self, _: &str, _: &str, _: &ChallengeConfig) -> Result<bool, String> {
|
||
|
|
Ok(true)
|
||
|
|
}
|
||
|
|
async fn verify_http(&self, _: &str, _: &str, _: &ChallengeConfig) -> Result<bool, String> {
|
||
|
|
Ok(true)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add `async-trait = "0.1"` to `[dependencies]` in Cargo.toml.
|
||
|
|
|
||
|
|
The `ChallengeVerifier` trait is stored in `AppState` as `Arc<dyn ChallengeVerifier>`.
|
||
|
|
Production uses `RealChallengeVerifier`, tests use `MockChallengeVerifier`. This makes
|
||
|
|
the domain verification flow fully testable without real DNS/HTTP.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Implement handler/domains.rs**
|
||
|
|
|
||
|
|
Create `src/handler/domains.rs`:
|
||
|
|
```rust
|
||
|
|
use axum::extract::{Path, State};
|
||
|
|
use axum::http::StatusCode;
|
||
|
|
use axum::routing::{delete, get, post};
|
||
|
|
use axum::{Json, Router};
|
||
|
|
use sea_orm::*;
|
||
|
|
use serde::Deserialize;
|
||
|
|
use serde_json::json;
|
||
|
|
|
||
|
|
use crate::auth;
|
||
|
|
use crate::challenge;
|
||
|
|
use crate::entity::domains;
|
||
|
|
use crate::error::{AppError, AppResult};
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
pub struct CreateDomainRequest {
|
||
|
|
domain: String,
|
||
|
|
challenge_type: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn create_domain(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Json(req): Json<CreateDomainRequest>,
|
||
|
|
) -> AppResult<(StatusCode, Json<serde_json::Value>)> {
|
||
|
|
if req.challenge_type != "dns-01" && req.challenge_type != "http-01" {
|
||
|
|
return Err(AppError::BadRequest(
|
||
|
|
"challenge_type must be dns-01 or http-01".into(),
|
||
|
|
));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for duplicate
|
||
|
|
let existing = domains::Entity::find()
|
||
|
|
.filter(domains::Column::Domain.eq(&req.domain))
|
||
|
|
.one(&state.db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
if existing.is_some() {
|
||
|
|
return Err(AppError::Conflict("domain already registered".into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
let id = uuid::Uuid::new_v4().to_string();
|
||
|
|
let challenge_token = auth::generate_secret();
|
||
|
|
let registration_secret = auth::generate_secret();
|
||
|
|
let registration_secret_hash = auth::hash_token(®istration_secret)
|
||
|
|
.map_err(|e| AppError::Internal(format!("hash error: {e}")))?;
|
||
|
|
|
||
|
|
let domain = domains::ActiveModel {
|
||
|
|
id: Set(id.clone()),
|
||
|
|
domain: Set(req.domain.clone()),
|
||
|
|
owner_token_hash: Set(String::new()), // Set on verification
|
||
|
|
registration_secret: Set(registration_secret_hash),
|
||
|
|
challenge_type: Set(req.challenge_type.clone()),
|
||
|
|
challenge_token: Set(Some(challenge_token.clone())),
|
||
|
|
verified: Set(false),
|
||
|
|
created_at: Set(chrono::Utc::now().naive_utc()),
|
||
|
|
verified_at: Set(None),
|
||
|
|
};
|
||
|
|
|
||
|
|
domain.insert(&state.db).await?;
|
||
|
|
|
||
|
|
let instructions = match req.challenge_type.as_str() {
|
||
|
|
"dns-01" => format!(
|
||
|
|
"Create a TXT record at {}.{} with value: {}",
|
||
|
|
state.settings.challenge.dns_txt_prefix, req.domain, challenge_token
|
||
|
|
),
|
||
|
|
"http-01" => format!(
|
||
|
|
"Serve the challenge at https://{}/{}/{}",
|
||
|
|
req.domain, state.settings.challenge.http_well_known_path, challenge_token
|
||
|
|
),
|
||
|
|
_ => unreachable!(),
|
||
|
|
};
|
||
|
|
|
||
|
|
Ok((
|
||
|
|
StatusCode::CREATED,
|
||
|
|
Json(json!({
|
||
|
|
"id": id,
|
||
|
|
"challenge_token": challenge_token,
|
||
|
|
"challenge_type": req.challenge_type,
|
||
|
|
"registration_secret": registration_secret,
|
||
|
|
"instructions": instructions,
|
||
|
|
})),
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
pub struct VerifyRequest {
|
||
|
|
registration_secret: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn verify_domain(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Path(id): Path<String>,
|
||
|
|
Json(req): Json<VerifyRequest>,
|
||
|
|
) -> AppResult<Json<serde_json::Value>> {
|
||
|
|
let domain = domains::Entity::find_by_id(&id)
|
||
|
|
.one(&state.db)
|
||
|
|
.await?
|
||
|
|
.ok_or(AppError::NotFound)?;
|
||
|
|
|
||
|
|
// Verify registration secret
|
||
|
|
if !auth::verify_token(&req.registration_secret, &domain.registration_secret) {
|
||
|
|
return Err(AppError::Unauthorized);
|
||
|
|
}
|
||
|
|
|
||
|
|
if domain.verified {
|
||
|
|
return Err(AppError::Conflict("domain already verified".into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
let challenge_token = domain
|
||
|
|
.challenge_token
|
||
|
|
.as_deref()
|
||
|
|
.ok_or_else(|| AppError::BadRequest("no pending challenge".into()))?;
|
||
|
|
|
||
|
|
// Check challenge TTL
|
||
|
|
let challenge_age = chrono::Utc::now().naive_utc() - domain.created_at;
|
||
|
|
if challenge_age.num_seconds() > state.settings.challenge.challenge_ttl_secs as i64 {
|
||
|
|
return Err(AppError::BadRequest("challenge expired".into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify the challenge via the injected verifier (mockable in tests)
|
||
|
|
let verified = match domain.challenge_type.as_str() {
|
||
|
|
"dns-01" => state.challenge_verifier
|
||
|
|
.verify_dns(&domain.domain, challenge_token, &state.settings.challenge)
|
||
|
|
.await
|
||
|
|
.map_err(|e| AppError::Internal(e))?,
|
||
|
|
"http-01" => state.challenge_verifier
|
||
|
|
.verify_http(&domain.domain, challenge_token, &state.settings.challenge)
|
||
|
|
.await
|
||
|
|
.map_err(|e| AppError::Internal(e))?,
|
||
|
|
_ => return Err(AppError::Internal("unknown challenge type".into())),
|
||
|
|
};
|
||
|
|
|
||
|
|
if !verified {
|
||
|
|
return Err(AppError::BadRequest("challenge verification failed".into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate owner token (prefixed with domain ID for O(1) lookup)
|
||
|
|
let owner_token = auth::generate_token(&id);
|
||
|
|
let owner_token_hash = auth::hash_token(&owner_token)
|
||
|
|
.map_err(|e| AppError::Internal(format!("hash error: {e}")))?;
|
||
|
|
|
||
|
|
// Update domain
|
||
|
|
let mut active: domains::ActiveModel = domain.into();
|
||
|
|
active.verified = Set(true);
|
||
|
|
active.verified_at = Set(Some(chrono::Utc::now().naive_utc()));
|
||
|
|
active.owner_token_hash = Set(owner_token_hash);
|
||
|
|
active.challenge_token = Set(None);
|
||
|
|
active.registration_secret = Set(String::new()); // Invalidate
|
||
|
|
active.update(&state.db).await?;
|
||
|
|
|
||
|
|
Ok(Json(json!({
|
||
|
|
"verified": true,
|
||
|
|
"owner_token": owner_token,
|
||
|
|
})))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Extract and verify owner token from Authorization header.
|
||
|
|
/// The token format is `{domain_id}.{secret}` — the domain_id from the token
|
||
|
|
/// must match the `id` path parameter to prevent cross-domain access.
|
||
|
|
pub async fn authenticate_owner(
|
||
|
|
db: &DatabaseConnection,
|
||
|
|
id: &str,
|
||
|
|
auth_header: Option<&str>,
|
||
|
|
) -> AppResult<domains::Model> {
|
||
|
|
let full_token = auth_header
|
||
|
|
.and_then(|h| h.strip_prefix("Bearer "))
|
||
|
|
.ok_or(AppError::Unauthorized)?;
|
||
|
|
|
||
|
|
// Verify the token's embedded ID matches the requested domain
|
||
|
|
let (token_domain_id, _) = auth::split_token(full_token)
|
||
|
|
.ok_or(AppError::Unauthorized)?;
|
||
|
|
if token_domain_id != id {
|
||
|
|
return Err(AppError::Unauthorized);
|
||
|
|
}
|
||
|
|
|
||
|
|
let domain = domains::Entity::find_by_id(id)
|
||
|
|
.one(db)
|
||
|
|
.await?
|
||
|
|
.ok_or(AppError::NotFound)?;
|
||
|
|
|
||
|
|
if !domain.verified {
|
||
|
|
return Err(AppError::Forbidden("domain not verified".into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
if !auth::verify_token(full_token, &domain.owner_token_hash) {
|
||
|
|
return Err(AppError::Unauthorized);
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(domain)
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn get_domain(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Path(id): Path<String>,
|
||
|
|
headers: axum::http::HeaderMap,
|
||
|
|
) -> AppResult<Json<serde_json::Value>> {
|
||
|
|
let auth_header = headers
|
||
|
|
.get("authorization")
|
||
|
|
.and_then(|v| v.to_str().ok());
|
||
|
|
|
||
|
|
let domain = authenticate_owner(&state.db, &id, auth_header).await?;
|
||
|
|
|
||
|
|
Ok(Json(json!({
|
||
|
|
"id": domain.id,
|
||
|
|
"domain": domain.domain,
|
||
|
|
"verified": domain.verified,
|
||
|
|
"challenge_type": domain.challenge_type,
|
||
|
|
"created_at": domain.created_at.to_string(),
|
||
|
|
"verified_at": domain.verified_at.map(|v| v.to_string()),
|
||
|
|
})))
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn rotate_token(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Path(id): Path<String>,
|
||
|
|
headers: axum::http::HeaderMap,
|
||
|
|
) -> AppResult<Json<serde_json::Value>> {
|
||
|
|
let auth_header = headers
|
||
|
|
.get("authorization")
|
||
|
|
.and_then(|v| v.to_str().ok());
|
||
|
|
|
||
|
|
let domain = authenticate_owner(&state.db, &id, auth_header).await?;
|
||
|
|
|
||
|
|
let new_token = auth::generate_token(&domain.id);
|
||
|
|
let new_hash = auth::hash_token(&new_token)
|
||
|
|
.map_err(|e| AppError::Internal(format!("hash error: {e}")))?;
|
||
|
|
|
||
|
|
let mut active: domains::ActiveModel = domain.into();
|
||
|
|
active.owner_token_hash = Set(new_hash);
|
||
|
|
active.update(&state.db).await?;
|
||
|
|
|
||
|
|
Ok(Json(json!({
|
||
|
|
"owner_token": new_token,
|
||
|
|
})))
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn delete_domain(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Path(id): Path<String>,
|
||
|
|
headers: axum::http::HeaderMap,
|
||
|
|
) -> AppResult<StatusCode> {
|
||
|
|
let auth_header = headers
|
||
|
|
.get("authorization")
|
||
|
|
.and_then(|v| v.to_str().ok());
|
||
|
|
|
||
|
|
let domain = authenticate_owner(&state.db, &id, auth_header).await?;
|
||
|
|
|
||
|
|
// Query all resource URIs for this domain before deleting
|
||
|
|
use crate::entity::resources;
|
||
|
|
let resource_uris: Vec<String> = resources::Entity::find()
|
||
|
|
.filter(resources::Column::DomainId.eq(&domain.id))
|
||
|
|
.all(&state.db)
|
||
|
|
.await?
|
||
|
|
.into_iter()
|
||
|
|
.map(|r| r.resource_uri)
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
// Cascade: delete domain (FK cascades handle DB rows)
|
||
|
|
domains::Entity::delete_by_id(&domain.id)
|
||
|
|
.exec(&state.db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
// Evict cache entries for all affected resources
|
||
|
|
state.cache.remove_many(&resource_uris);
|
||
|
|
|
||
|
|
Ok(StatusCode::NO_CONTENT)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn router() -> Router<AppState> {
|
||
|
|
Router::new()
|
||
|
|
.route("/api/v1/domains", post(create_domain))
|
||
|
|
.route("/api/v1/domains/{id}", get(get_domain).delete(delete_domain))
|
||
|
|
.route("/api/v1/domains/{id}/verify", post(verify_domain))
|
||
|
|
.route("/api/v1/domains/{id}/rotate-token", post(rotate_token))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Update handler/mod.rs and lib.rs**
|
||
|
|
|
||
|
|
Update `src/handler/mod.rs`:
|
||
|
|
```rust
|
||
|
|
pub mod domains;
|
||
|
|
mod health;
|
||
|
|
mod host_meta;
|
||
|
|
mod webfinger;
|
||
|
|
|
||
|
|
use axum::Router;
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
pub fn router(state: AppState) -> Router {
|
||
|
|
Router::new()
|
||
|
|
.merge(webfinger::router())
|
||
|
|
.merge(host_meta::router())
|
||
|
|
.merge(domains::router())
|
||
|
|
.merge(health::router())
|
||
|
|
.with_state(state)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Update `src/lib.rs`:
|
||
|
|
```rust
|
||
|
|
pub mod auth;
|
||
|
|
pub mod cache;
|
||
|
|
pub mod challenge;
|
||
|
|
pub mod config;
|
||
|
|
pub mod entity;
|
||
|
|
pub mod error;
|
||
|
|
pub mod handler;
|
||
|
|
pub mod state;
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Run tests**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_domains`
|
||
|
|
Expected: All tests PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/handler/domains.rs src/challenge.rs src/handler/mod.rs src/lib.rs tests/test_domains.rs
|
||
|
|
git commit -m "feat: add domain onboarding API with ACME-style challenges"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 9: Service Token API
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/handler/tokens.rs`
|
||
|
|
- Create: `tests/test_tokens.rs`
|
||
|
|
- Modify: `src/handler/mod.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write failing tests**
|
||
|
|
|
||
|
|
Create `tests/test_tokens.rs`:
|
||
|
|
```rust
|
||
|
|
mod common;
|
||
|
|
|
||
|
|
use axum_test::TestServer;
|
||
|
|
use serde_json::json;
|
||
|
|
use webfingerd::handler;
|
||
|
|
|
||
|
|
/// Helper: register a verified domain and return (id, owner_token).
|
||
|
|
/// Uses MockChallengeVerifier (injected in test state) so no manual DB manipulation needed.
|
||
|
|
async fn setup_verified_domain(
|
||
|
|
server: &TestServer,
|
||
|
|
_state: &webfingerd::state::AppState,
|
||
|
|
domain_name: &str,
|
||
|
|
) -> (String, String) {
|
||
|
|
let create_resp = server
|
||
|
|
.post("/api/v1/domains")
|
||
|
|
.json(&json!({"domain": domain_name, "challenge_type": "dns-01"}))
|
||
|
|
.await;
|
||
|
|
let body: serde_json::Value = create_resp.json();
|
||
|
|
let id = body["id"].as_str().unwrap().to_string();
|
||
|
|
let reg_secret = body["registration_secret"].as_str().unwrap().to_string();
|
||
|
|
|
||
|
|
// MockChallengeVerifier always succeeds
|
||
|
|
let verify_resp = server
|
||
|
|
.post(&format!("/api/v1/domains/{id}/verify"))
|
||
|
|
.json(&json!({"registration_secret": reg_secret}))
|
||
|
|
.await;
|
||
|
|
let owner_token = verify_resp.json::<serde_json::Value>()["owner_token"]
|
||
|
|
.as_str()
|
||
|
|
.unwrap()
|
||
|
|
.to_string();
|
||
|
|
|
||
|
|
(id, owner_token)
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_create_service_token() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (id, owner_token) = setup_verified_domain(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.post(&format!("/api/v1/domains/{id}/tokens"))
|
||
|
|
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"name": "oxifed",
|
||
|
|
"allowed_rels": ["self"],
|
||
|
|
"resource_pattern": "acct:*@example.com"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
let body: serde_json::Value = response.json();
|
||
|
|
assert!(body["id"].is_string());
|
||
|
|
assert!(body["token"].is_string());
|
||
|
|
assert_eq!(body["name"], "oxifed");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_create_service_token_rejects_bad_pattern() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (id, owner_token) = setup_verified_domain(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
// Pattern without @ or wrong domain
|
||
|
|
let response = server
|
||
|
|
.post(&format!("/api/v1/domains/{id}/tokens"))
|
||
|
|
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"name": "evil",
|
||
|
|
"allowed_rels": ["self"],
|
||
|
|
"resource_pattern": "*"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status_bad_request();
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_list_service_tokens() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (id, owner_token) = setup_verified_domain(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
server
|
||
|
|
.post(&format!("/api/v1/domains/{id}/tokens"))
|
||
|
|
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"name": "oxifed",
|
||
|
|
"allowed_rels": ["self"],
|
||
|
|
"resource_pattern": "acct:*@example.com"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.get(&format!("/api/v1/domains/{id}/tokens"))
|
||
|
|
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status_ok();
|
||
|
|
let body: serde_json::Value = response.json();
|
||
|
|
let tokens = body.as_array().unwrap();
|
||
|
|
assert_eq!(tokens.len(), 1);
|
||
|
|
assert_eq!(tokens[0]["name"], "oxifed");
|
||
|
|
// Token hash should NOT be exposed
|
||
|
|
assert!(tokens[0].get("token_hash").is_none());
|
||
|
|
assert!(tokens[0].get("token").is_none());
|
||
|
|
}
|
||
|
|
|
||
|
|
// NOTE: test_revoke_service_token_deletes_links is in tests/test_links.rs (Task 10)
|
||
|
|
// because it depends on the link registration endpoint. It is tested there as part
|
||
|
|
// of the full link lifecycle, not here where the endpoint doesn't exist yet.
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_revoke_service_token() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (id, owner_token) = setup_verified_domain(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
let create_resp = server
|
||
|
|
.post(&format!("/api/v1/domains/{id}/tokens"))
|
||
|
|
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"name": "oxifed",
|
||
|
|
"allowed_rels": ["self"],
|
||
|
|
"resource_pattern": "acct:*@example.com"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
let body: serde_json::Value = create_resp.json();
|
||
|
|
let token_id = body["id"].as_str().unwrap().to_string();
|
||
|
|
|
||
|
|
// Revoke the token
|
||
|
|
let response = server
|
||
|
|
.delete(&format!("/api/v1/domains/{id}/tokens/{token_id}"))
|
||
|
|
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||
|
|
.await;
|
||
|
|
response.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||
|
|
|
||
|
|
// Token should no longer appear in list
|
||
|
|
let list_resp = server
|
||
|
|
.get(&format!("/api/v1/domains/{id}/tokens"))
|
||
|
|
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||
|
|
.await;
|
||
|
|
let tokens = list_resp.json::<serde_json::Value>();
|
||
|
|
let tokens = tokens.as_array().unwrap();
|
||
|
|
assert!(tokens.is_empty());
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_tokens`
|
||
|
|
Expected: FAIL.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement handler/tokens.rs**
|
||
|
|
|
||
|
|
Create `src/handler/tokens.rs`:
|
||
|
|
```rust
|
||
|
|
use axum::extract::{Path, State};
|
||
|
|
use axum::http::StatusCode;
|
||
|
|
use axum::routing::{delete, get, post};
|
||
|
|
use axum::{Json, Router};
|
||
|
|
use sea_orm::*;
|
||
|
|
use serde::Deserialize;
|
||
|
|
use serde_json::json;
|
||
|
|
|
||
|
|
use crate::auth;
|
||
|
|
use crate::entity::{domains, links, resources, service_tokens};
|
||
|
|
use crate::error::{AppError, AppResult};
|
||
|
|
use crate::handler::domains::authenticate_owner;
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
fn validate_resource_pattern(pattern: &str, domain: &str) -> Result<(), String> {
|
||
|
|
if !pattern.contains('@') {
|
||
|
|
return Err("resource_pattern must contain '@'".into());
|
||
|
|
}
|
||
|
|
if pattern == "*" {
|
||
|
|
return Err("resource_pattern '*' is too broad".into());
|
||
|
|
}
|
||
|
|
// Must end with the domain
|
||
|
|
let domain_suffix = format!("@{domain}");
|
||
|
|
if !pattern.ends_with(&domain_suffix) {
|
||
|
|
return Err(format!(
|
||
|
|
"resource_pattern must end with @{domain}"
|
||
|
|
));
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
pub struct CreateTokenRequest {
|
||
|
|
name: String,
|
||
|
|
allowed_rels: Vec<String>,
|
||
|
|
resource_pattern: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn create_token(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Path(domain_id): Path<String>,
|
||
|
|
headers: axum::http::HeaderMap,
|
||
|
|
Json(req): Json<CreateTokenRequest>,
|
||
|
|
) -> AppResult<(StatusCode, Json<serde_json::Value>)> {
|
||
|
|
let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
|
||
|
|
let domain = authenticate_owner(&state.db, &domain_id, auth_header).await?;
|
||
|
|
|
||
|
|
validate_resource_pattern(&req.resource_pattern, &domain.domain)
|
||
|
|
.map_err(|e| AppError::BadRequest(e))?;
|
||
|
|
|
||
|
|
if req.allowed_rels.is_empty() {
|
||
|
|
return Err(AppError::BadRequest("allowed_rels cannot be empty".into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
let id = uuid::Uuid::new_v4().to_string();
|
||
|
|
let token = auth::generate_token(&id);
|
||
|
|
let token_hash = auth::hash_token(&token)
|
||
|
|
.map_err(|e| AppError::Internal(format!("hash error: {e}")))?;
|
||
|
|
|
||
|
|
let service_token = service_tokens::ActiveModel {
|
||
|
|
id: Set(id.clone()),
|
||
|
|
domain_id: Set(domain_id),
|
||
|
|
name: Set(req.name.clone()),
|
||
|
|
token_hash: Set(token_hash),
|
||
|
|
allowed_rels: Set(serde_json::to_string(&req.allowed_rels).unwrap()),
|
||
|
|
resource_pattern: Set(req.resource_pattern.clone()),
|
||
|
|
created_at: Set(chrono::Utc::now().naive_utc()),
|
||
|
|
revoked_at: Set(None),
|
||
|
|
};
|
||
|
|
|
||
|
|
service_token.insert(&state.db).await?;
|
||
|
|
|
||
|
|
Ok((
|
||
|
|
StatusCode::CREATED,
|
||
|
|
Json(json!({
|
||
|
|
"id": id,
|
||
|
|
"name": req.name,
|
||
|
|
"token": token,
|
||
|
|
"allowed_rels": req.allowed_rels,
|
||
|
|
"resource_pattern": req.resource_pattern,
|
||
|
|
})),
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn list_tokens(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Path(domain_id): Path<String>,
|
||
|
|
headers: axum::http::HeaderMap,
|
||
|
|
) -> AppResult<Json<serde_json::Value>> {
|
||
|
|
let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
|
||
|
|
authenticate_owner(&state.db, &domain_id, auth_header).await?;
|
||
|
|
|
||
|
|
let tokens = service_tokens::Entity::find()
|
||
|
|
.filter(service_tokens::Column::DomainId.eq(&domain_id))
|
||
|
|
.filter(service_tokens::Column::RevokedAt.is_null())
|
||
|
|
.all(&state.db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
let result: Vec<serde_json::Value> = tokens
|
||
|
|
.into_iter()
|
||
|
|
.map(|t| {
|
||
|
|
json!({
|
||
|
|
"id": t.id,
|
||
|
|
"name": t.name,
|
||
|
|
"allowed_rels": serde_json::from_str::<serde_json::Value>(&t.allowed_rels).unwrap_or_default(),
|
||
|
|
"resource_pattern": t.resource_pattern,
|
||
|
|
"created_at": t.created_at.to_string(),
|
||
|
|
})
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
Ok(Json(json!(result)))
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn revoke_token(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Path((domain_id, token_id)): Path<(String, String)>,
|
||
|
|
headers: axum::http::HeaderMap,
|
||
|
|
) -> AppResult<StatusCode> {
|
||
|
|
let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
|
||
|
|
authenticate_owner(&state.db, &domain_id, auth_header).await?;
|
||
|
|
|
||
|
|
let token = service_tokens::Entity::find_by_id(&token_id)
|
||
|
|
.filter(service_tokens::Column::DomainId.eq(&domain_id))
|
||
|
|
.one(&state.db)
|
||
|
|
.await?
|
||
|
|
.ok_or(AppError::NotFound)?;
|
||
|
|
|
||
|
|
// Find all resource URIs affected by links from this token
|
||
|
|
let affected_links = links::Entity::find()
|
||
|
|
.filter(links::Column::ServiceTokenId.eq(&token_id))
|
||
|
|
.find_also_related(resources::Entity)
|
||
|
|
.all(&state.db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
let affected_resource_uris: Vec<String> = affected_links
|
||
|
|
.iter()
|
||
|
|
.filter_map(|(_, resource)| resource.as_ref().map(|r| r.resource_uri.clone()))
|
||
|
|
.collect::<std::collections::HashSet<_>>()
|
||
|
|
.into_iter()
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
// Delete all links for this token
|
||
|
|
links::Entity::delete_many()
|
||
|
|
.filter(links::Column::ServiceTokenId.eq(&token_id))
|
||
|
|
.exec(&state.db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
// Mark token as revoked
|
||
|
|
let mut active: service_tokens::ActiveModel = token.into();
|
||
|
|
active.revoked_at = Set(Some(chrono::Utc::now().naive_utc()));
|
||
|
|
active.update(&state.db).await?;
|
||
|
|
|
||
|
|
// Refresh cache for affected resources
|
||
|
|
for uri in affected_resource_uris {
|
||
|
|
state.cache.refresh_resource(&state.db, &uri).await?;
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(StatusCode::NO_CONTENT)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn router() -> Router<AppState> {
|
||
|
|
Router::new()
|
||
|
|
.route(
|
||
|
|
"/api/v1/domains/{id}/tokens",
|
||
|
|
post(create_token).get(list_tokens),
|
||
|
|
)
|
||
|
|
.route(
|
||
|
|
"/api/v1/domains/{id}/tokens/{tid}",
|
||
|
|
delete(revoke_token),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Make authenticate_owner public and update handler/mod.rs**
|
||
|
|
|
||
|
|
In `src/handler/domains.rs`, change `authenticate_owner` to `pub async fn`.
|
||
|
|
|
||
|
|
Update `src/handler/mod.rs`:
|
||
|
|
```rust
|
||
|
|
pub mod domains;
|
||
|
|
mod health;
|
||
|
|
mod host_meta;
|
||
|
|
pub mod tokens;
|
||
|
|
mod webfinger;
|
||
|
|
|
||
|
|
use axum::Router;
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
pub fn router(state: AppState) -> Router {
|
||
|
|
Router::new()
|
||
|
|
.merge(webfinger::router())
|
||
|
|
.merge(host_meta::router())
|
||
|
|
.merge(domains::router())
|
||
|
|
.merge(tokens::router())
|
||
|
|
.merge(health::router())
|
||
|
|
.with_state(state)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run tests**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_tokens`
|
||
|
|
Expected: All tests PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/handler/tokens.rs src/handler/mod.rs src/handler/domains.rs tests/test_tokens.rs
|
||
|
|
git commit -m "feat: add service token CRUD with pattern validation and revocation cascade"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 10: Link Registration API
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/handler/links.rs`
|
||
|
|
- Create: `tests/test_links.rs`
|
||
|
|
- Modify: `src/handler/mod.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write failing tests**
|
||
|
|
|
||
|
|
Create `tests/test_links.rs`:
|
||
|
|
```rust
|
||
|
|
mod common;
|
||
|
|
|
||
|
|
use axum_test::TestServer;
|
||
|
|
use serde_json::json;
|
||
|
|
use webfingerd::handler;
|
||
|
|
|
||
|
|
/// Helper: create verified domain + service token, return (domain_id, owner_token, service_token).
|
||
|
|
/// Uses MockChallengeVerifier — no manual DB manipulation needed.
|
||
|
|
async fn setup_domain_and_token(
|
||
|
|
server: &TestServer,
|
||
|
|
_state: &webfingerd::state::AppState,
|
||
|
|
domain_name: &str,
|
||
|
|
) -> (String, String, String) {
|
||
|
|
// Register domain
|
||
|
|
let create_resp = server
|
||
|
|
.post("/api/v1/domains")
|
||
|
|
.json(&json!({"domain": domain_name, "challenge_type": "dns-01"}))
|
||
|
|
.await;
|
||
|
|
let body: serde_json::Value = create_resp.json();
|
||
|
|
let id = body["id"].as_str().unwrap().to_string();
|
||
|
|
let reg_secret = body["registration_secret"].as_str().unwrap().to_string();
|
||
|
|
|
||
|
|
// MockChallengeVerifier always succeeds
|
||
|
|
let verify_resp = server
|
||
|
|
.post(&format!("/api/v1/domains/{id}/verify"))
|
||
|
|
.json(&json!({"registration_secret": reg_secret}))
|
||
|
|
.await;
|
||
|
|
let owner_token = verify_resp.json::<serde_json::Value>()["owner_token"]
|
||
|
|
.as_str().unwrap().to_string();
|
||
|
|
|
||
|
|
// Create service token
|
||
|
|
let token_resp = server
|
||
|
|
.post(&format!("/api/v1/domains/{id}/tokens"))
|
||
|
|
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"name": "oxifed",
|
||
|
|
"allowed_rels": ["self", "http://webfinger.net/rel/profile-page"],
|
||
|
|
"resource_pattern": "acct:*@example.com"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
let service_token = token_resp.json::<serde_json::Value>()["token"]
|
||
|
|
.as_str().unwrap().to_string();
|
||
|
|
|
||
|
|
(id, owner_token, service_token)
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_register_link() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (_, _, service_token) = setup_domain_and_token(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {service_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@example.com",
|
||
|
|
"rel": "self",
|
||
|
|
"href": "https://example.com/users/alice",
|
||
|
|
"type": "application/activity+json"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
let body: serde_json::Value = response.json();
|
||
|
|
assert!(body["id"].is_string());
|
||
|
|
|
||
|
|
// Should now be in cache and queryable
|
||
|
|
let wf = server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:alice@example.com")
|
||
|
|
.await;
|
||
|
|
wf.assert_status_ok();
|
||
|
|
let jrd: serde_json::Value = wf.json();
|
||
|
|
assert_eq!(jrd["subject"], "acct:alice@example.com");
|
||
|
|
assert_eq!(jrd["links"][0]["rel"], "self");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_register_link_rejected_for_forbidden_rel() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (_, _, service_token) = setup_domain_and_token(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {service_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@example.com",
|
||
|
|
"rel": "http://openid.net/specs/connect/1.0/issuer",
|
||
|
|
"href": "https://evil.com"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_register_link_rejected_for_wrong_domain() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (_, _, service_token) = setup_domain_and_token(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {service_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@evil.com",
|
||
|
|
"rel": "self",
|
||
|
|
"href": "https://evil.com/users/alice"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_upsert_link() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (_, _, service_token) = setup_domain_and_token(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
// First insert
|
||
|
|
server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {service_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@example.com",
|
||
|
|
"rel": "self",
|
||
|
|
"href": "https://example.com/users/alice",
|
||
|
|
"type": "application/activity+json"
|
||
|
|
}))
|
||
|
|
.await
|
||
|
|
.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
|
||
|
|
// Upsert with same (resource, rel, href) but different type
|
||
|
|
server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {service_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@example.com",
|
||
|
|
"rel": "self",
|
||
|
|
"href": "https://example.com/users/alice",
|
||
|
|
"type": "application/ld+json"
|
||
|
|
}))
|
||
|
|
.await
|
||
|
|
.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
|
||
|
|
// Should only have one link
|
||
|
|
let wf = server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:alice@example.com")
|
||
|
|
.await;
|
||
|
|
let jrd: serde_json::Value = wf.json();
|
||
|
|
let links = jrd["links"].as_array().unwrap();
|
||
|
|
assert_eq!(links.len(), 1);
|
||
|
|
assert_eq!(links[0]["type"], "application/ld+json");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_batch_link_registration() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (_, _, service_token) = setup_domain_and_token(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.post("/api/v1/links/batch")
|
||
|
|
.add_header("Authorization", format!("Bearer {service_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"links": [
|
||
|
|
{
|
||
|
|
"resource_uri": "acct:alice@example.com",
|
||
|
|
"rel": "self",
|
||
|
|
"href": "https://example.com/users/alice",
|
||
|
|
"type": "application/activity+json"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"resource_uri": "acct:bob@example.com",
|
||
|
|
"rel": "self",
|
||
|
|
"href": "https://example.com/users/bob",
|
||
|
|
"type": "application/activity+json"
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
|
||
|
|
// Both should be queryable
|
||
|
|
server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:alice@example.com")
|
||
|
|
.await
|
||
|
|
.assert_status_ok();
|
||
|
|
|
||
|
|
server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:bob@example.com")
|
||
|
|
.await
|
||
|
|
.assert_status_ok();
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_batch_all_or_nothing() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (_, _, service_token) = setup_domain_and_token(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
// Second link has forbidden rel — entire batch should fail
|
||
|
|
let response = server
|
||
|
|
.post("/api/v1/links/batch")
|
||
|
|
.add_header("Authorization", format!("Bearer {service_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"links": [
|
||
|
|
{
|
||
|
|
"resource_uri": "acct:alice@example.com",
|
||
|
|
"rel": "self",
|
||
|
|
"href": "https://example.com/users/alice"
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"resource_uri": "acct:bob@example.com",
|
||
|
|
"rel": "forbidden-rel",
|
||
|
|
"href": "https://example.com/users/bob"
|
||
|
|
}
|
||
|
|
]
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
// Batch should fail
|
||
|
|
response.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||
|
|
|
||
|
|
// alice should NOT be registered (all-or-nothing)
|
||
|
|
server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:alice@example.com")
|
||
|
|
.await
|
||
|
|
.assert_status_not_found();
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_delete_link() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (_, _, service_token) = setup_domain_and_token(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
let create_resp = server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {service_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@example.com",
|
||
|
|
"rel": "self",
|
||
|
|
"href": "https://example.com/users/alice"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
let link_id = create_resp.json::<serde_json::Value>()["id"]
|
||
|
|
.as_str().unwrap().to_string();
|
||
|
|
|
||
|
|
// Delete it
|
||
|
|
server
|
||
|
|
.delete(&format!("/api/v1/links/{link_id}"))
|
||
|
|
.add_header("Authorization", format!("Bearer {service_token}"))
|
||
|
|
.await
|
||
|
|
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||
|
|
|
||
|
|
// Should be gone
|
||
|
|
server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:alice@example.com")
|
||
|
|
.await
|
||
|
|
.assert_status_not_found();
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_link_with_ttl() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (_, _, service_token) = setup_domain_and_token(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
let response = server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {service_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@example.com",
|
||
|
|
"rel": "self",
|
||
|
|
"href": "https://example.com/users/alice",
|
||
|
|
"ttl_seconds": 300
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
let body: serde_json::Value = response.json();
|
||
|
|
assert!(body["expires_at"].is_string());
|
||
|
|
}
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_revoke_service_token_deletes_links() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
let (id, owner_token, service_token) =
|
||
|
|
setup_domain_and_token(&server, &state, "example.com").await;
|
||
|
|
|
||
|
|
// Register a link
|
||
|
|
server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {service_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@example.com",
|
||
|
|
"rel": "self",
|
||
|
|
"href": "https://example.com/users/alice",
|
||
|
|
"type": "application/activity+json"
|
||
|
|
}))
|
||
|
|
.await
|
||
|
|
.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
|
||
|
|
// Verify it exists
|
||
|
|
server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:alice@example.com")
|
||
|
|
.await
|
||
|
|
.assert_status_ok();
|
||
|
|
|
||
|
|
// Extract the token ID from the service token (format: {id}.{secret})
|
||
|
|
let token_id = service_token.split('.').next().unwrap();
|
||
|
|
|
||
|
|
// Revoke the service token via owner API
|
||
|
|
server
|
||
|
|
.delete(&format!("/api/v1/domains/{id}/tokens/{token_id}"))
|
||
|
|
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||
|
|
.await
|
||
|
|
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||
|
|
|
||
|
|
// WebFinger should no longer find the link (cascade delete + cache eviction)
|
||
|
|
server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:alice@example.com")
|
||
|
|
.await
|
||
|
|
.assert_status_not_found();
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_links`
|
||
|
|
Expected: FAIL.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement handler/links.rs**
|
||
|
|
|
||
|
|
Create `src/handler/links.rs`:
|
||
|
|
```rust
|
||
|
|
use axum::extract::{Path, Query, State};
|
||
|
|
use axum::http::StatusCode;
|
||
|
|
use axum::routing::{delete, get, post, put};
|
||
|
|
use axum::{Json, Router};
|
||
|
|
use sea_orm::*;
|
||
|
|
use serde::Deserialize;
|
||
|
|
use serde_json::json;
|
||
|
|
|
||
|
|
use crate::auth;
|
||
|
|
use crate::entity::{domains, links, resources, service_tokens};
|
||
|
|
use crate::error::{AppError, AppResult};
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
/// Authenticate a service token from the Authorization header.
|
||
|
|
/// Tokens use the format `{token_id}.{secret}` — split on `.`, look up by ID,
|
||
|
|
/// verify the full token against the stored hash. This is O(1) not O(n).
|
||
|
|
async fn authenticate_service(
|
||
|
|
db: &DatabaseConnection,
|
||
|
|
auth_header: Option<&str>,
|
||
|
|
) -> AppResult<(service_tokens::Model, domains::Model)> {
|
||
|
|
let full_token = auth_header
|
||
|
|
.and_then(|h| h.strip_prefix("Bearer "))
|
||
|
|
.ok_or(AppError::Unauthorized)?;
|
||
|
|
|
||
|
|
let (token_id, _secret) = auth::split_token(full_token)
|
||
|
|
.ok_or(AppError::Unauthorized)?;
|
||
|
|
|
||
|
|
let token = service_tokens::Entity::find_by_id(token_id)
|
||
|
|
.filter(service_tokens::Column::RevokedAt.is_null())
|
||
|
|
.one(db)
|
||
|
|
.await?
|
||
|
|
.ok_or(AppError::Unauthorized)?;
|
||
|
|
|
||
|
|
if !auth::verify_token(full_token, &token.token_hash) {
|
||
|
|
return Err(AppError::Unauthorized);
|
||
|
|
}
|
||
|
|
|
||
|
|
let domain = domains::Entity::find_by_id(&token.domain_id)
|
||
|
|
.one(db)
|
||
|
|
.await?
|
||
|
|
.ok_or(AppError::Internal("token domain not found".into()))?;
|
||
|
|
|
||
|
|
if !domain.verified {
|
||
|
|
return Err(AppError::Forbidden("domain not verified".into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok((token, domain))
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Validate that a link is allowed by the service token's scope.
|
||
|
|
fn validate_scope(
|
||
|
|
token: &service_tokens::Model,
|
||
|
|
resource_uri: &str,
|
||
|
|
rel: &str,
|
||
|
|
) -> AppResult<()> {
|
||
|
|
// Check rel is allowed
|
||
|
|
let allowed_rels: Vec<String> =
|
||
|
|
serde_json::from_str(&token.allowed_rels).unwrap_or_default();
|
||
|
|
if !allowed_rels.iter().any(|r| r == rel) {
|
||
|
|
return Err(AppError::Forbidden(format!(
|
||
|
|
"rel '{}' not in allowed_rels",
|
||
|
|
rel
|
||
|
|
)));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check resource matches pattern
|
||
|
|
if !glob_match::glob_match(&token.resource_pattern, resource_uri) {
|
||
|
|
return Err(AppError::Forbidden(format!(
|
||
|
|
"resource '{}' does not match pattern '{}'",
|
||
|
|
resource_uri, token.resource_pattern
|
||
|
|
)));
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Find or create a resource record for the given URI.
|
||
|
|
/// Accepts `&impl ConnectionTrait` for transaction support.
|
||
|
|
async fn find_or_create_resource(
|
||
|
|
db: &impl sea_orm::ConnectionTrait,
|
||
|
|
resource_uri: &str,
|
||
|
|
domain_id: &str,
|
||
|
|
) -> AppResult<resources::Model> {
|
||
|
|
if let Some(existing) = resources::Entity::find()
|
||
|
|
.filter(resources::Column::ResourceUri.eq(resource_uri))
|
||
|
|
.one(db)
|
||
|
|
.await?
|
||
|
|
{
|
||
|
|
return Ok(existing);
|
||
|
|
}
|
||
|
|
|
||
|
|
let id = uuid::Uuid::new_v4().to_string();
|
||
|
|
let now = chrono::Utc::now().naive_utc();
|
||
|
|
let resource = resources::ActiveModel {
|
||
|
|
id: Set(id),
|
||
|
|
domain_id: Set(domain_id.to_string()),
|
||
|
|
resource_uri: Set(resource_uri.to_string()),
|
||
|
|
aliases: Set(None),
|
||
|
|
properties: Set(None),
|
||
|
|
created_at: Set(now),
|
||
|
|
updated_at: Set(now),
|
||
|
|
};
|
||
|
|
Ok(resource.insert(db).await?)
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
pub struct CreateLinkRequest {
|
||
|
|
resource_uri: String,
|
||
|
|
rel: String,
|
||
|
|
href: Option<String>,
|
||
|
|
#[serde(rename = "type")]
|
||
|
|
link_type: Option<String>,
|
||
|
|
titles: Option<serde_json::Value>,
|
||
|
|
properties: Option<serde_json::Value>,
|
||
|
|
template: Option<String>,
|
||
|
|
ttl_seconds: Option<i32>,
|
||
|
|
aliases: Option<Vec<String>>,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Insert or upsert a single link. Returns the link ID and the resource URI.
|
||
|
|
/// Accepts `&impl ConnectionTrait` so it works with both `DatabaseConnection` and
|
||
|
|
/// `DatabaseTransaction` (for all-or-nothing batch semantics).
|
||
|
|
/// When `refresh_cache` is true, immediately refreshes the cache entry.
|
||
|
|
/// Batch callers pass false and refresh after commit.
|
||
|
|
async fn insert_link(
|
||
|
|
db: &impl sea_orm::ConnectionTrait,
|
||
|
|
cache: &crate::cache::Cache,
|
||
|
|
token: &service_tokens::Model,
|
||
|
|
domain: &domains::Model,
|
||
|
|
req: &CreateLinkRequest,
|
||
|
|
db_for_cache: &DatabaseConnection,
|
||
|
|
refresh_cache: bool,
|
||
|
|
) -> AppResult<(String, String)> {
|
||
|
|
validate_scope(token, &req.resource_uri, &req.rel)?;
|
||
|
|
|
||
|
|
let resource = find_or_create_resource(db, &req.resource_uri, &domain.id).await?;
|
||
|
|
|
||
|
|
// Update aliases if provided
|
||
|
|
if let Some(aliases) = &req.aliases {
|
||
|
|
let mut active: resources::ActiveModel = resource.clone().into();
|
||
|
|
active.aliases = Set(Some(serde_json::to_string(aliases).unwrap()));
|
||
|
|
active.updated_at = Set(chrono::Utc::now().naive_utc());
|
||
|
|
active.update(db).await?;
|
||
|
|
}
|
||
|
|
|
||
|
|
let now = chrono::Utc::now().naive_utc();
|
||
|
|
let expires_at = req
|
||
|
|
.ttl_seconds
|
||
|
|
.map(|ttl| now + chrono::Duration::seconds(ttl as i64));
|
||
|
|
|
||
|
|
// Check for existing link with same (resource_id, rel, href) for upsert
|
||
|
|
let existing = links::Entity::find()
|
||
|
|
.filter(links::Column::ResourceId.eq(&resource.id))
|
||
|
|
.filter(links::Column::Rel.eq(&req.rel))
|
||
|
|
.filter(
|
||
|
|
match &req.href {
|
||
|
|
Some(href) => links::Column::Href.eq(href.as_str()),
|
||
|
|
None => links::Column::Href.is_null(),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
.one(db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
let link_id = if let Some(existing) = existing {
|
||
|
|
// Upsert: update existing
|
||
|
|
let id = existing.id.clone();
|
||
|
|
let mut active: links::ActiveModel = existing.into();
|
||
|
|
active.link_type = Set(req.link_type.clone());
|
||
|
|
active.titles = Set(req.titles.as_ref().map(|t| t.to_string()));
|
||
|
|
active.properties = Set(req.properties.as_ref().map(|p| p.to_string()));
|
||
|
|
active.template = Set(req.template.clone());
|
||
|
|
active.ttl_seconds = Set(req.ttl_seconds);
|
||
|
|
active.expires_at = Set(expires_at);
|
||
|
|
active.update(db).await?;
|
||
|
|
id
|
||
|
|
} else {
|
||
|
|
// Insert new
|
||
|
|
let id = uuid::Uuid::new_v4().to_string();
|
||
|
|
let link = links::ActiveModel {
|
||
|
|
id: Set(id.clone()),
|
||
|
|
resource_id: Set(resource.id.clone()),
|
||
|
|
service_token_id: Set(token.id.clone()),
|
||
|
|
domain_id: Set(domain.id.clone()),
|
||
|
|
rel: Set(req.rel.clone()),
|
||
|
|
href: Set(req.href.clone()),
|
||
|
|
link_type: Set(req.link_type.clone()),
|
||
|
|
titles: Set(req.titles.as_ref().map(|t| t.to_string())),
|
||
|
|
properties: Set(req.properties.as_ref().map(|p| p.to_string())),
|
||
|
|
template: Set(req.template.clone()),
|
||
|
|
ttl_seconds: Set(req.ttl_seconds),
|
||
|
|
created_at: Set(now),
|
||
|
|
expires_at: Set(expires_at),
|
||
|
|
};
|
||
|
|
link.insert(db).await?;
|
||
|
|
id
|
||
|
|
};
|
||
|
|
|
||
|
|
// Refresh cache if requested (single-link mode). Batch callers skip this
|
||
|
|
// and refresh after commit to avoid reading stale data mid-transaction.
|
||
|
|
if refresh_cache {
|
||
|
|
cache.refresh_resource(db_for_cache, &req.resource_uri).await?;
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok((link_id, req.resource_uri.clone()))
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn create_link(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
headers: axum::http::HeaderMap,
|
||
|
|
Json(req): Json<CreateLinkRequest>,
|
||
|
|
) -> AppResult<(StatusCode, Json<serde_json::Value>)> {
|
||
|
|
let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
|
||
|
|
let (token, domain) = authenticate_service(&state.db, auth_header).await?;
|
||
|
|
|
||
|
|
let (link_id, _) = insert_link(&state.db, &state.cache, &token, &domain, &req, &state.db, true).await?;
|
||
|
|
|
||
|
|
let expires_at = req
|
||
|
|
.ttl_seconds
|
||
|
|
.map(|ttl| {
|
||
|
|
(chrono::Utc::now().naive_utc() + chrono::Duration::seconds(ttl as i64)).to_string()
|
||
|
|
});
|
||
|
|
|
||
|
|
Ok((
|
||
|
|
StatusCode::CREATED,
|
||
|
|
Json(json!({
|
||
|
|
"id": link_id,
|
||
|
|
"expires_at": expires_at,
|
||
|
|
})),
|
||
|
|
))
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
pub struct BatchRequest {
|
||
|
|
links: Vec<CreateLinkRequest>,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn batch_create_links(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
headers: axum::http::HeaderMap,
|
||
|
|
Json(req): Json<BatchRequest>,
|
||
|
|
) -> AppResult<(StatusCode, Json<serde_json::Value>)> {
|
||
|
|
let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
|
||
|
|
let (token, domain) = authenticate_service(&state.db, auth_header).await?;
|
||
|
|
|
||
|
|
if req.links.len() > state.settings.rate_limit.batch_max_links {
|
||
|
|
return Err(AppError::BadRequest(format!(
|
||
|
|
"batch exceeds maximum of {} links",
|
||
|
|
state.settings.rate_limit.batch_max_links
|
||
|
|
)));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Validate all scopes first (fail fast before starting transaction)
|
||
|
|
for (i, link_req) in req.links.iter().enumerate() {
|
||
|
|
if let Err(e) = validate_scope(&token, &link_req.resource_uri, &link_req.rel) {
|
||
|
|
return Err(AppError::Forbidden(format!("link[{}]: {}", i, e)));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// All-or-nothing: wrap inserts in a DB transaction
|
||
|
|
let txn = state.db.begin().await?;
|
||
|
|
let mut results = Vec::new();
|
||
|
|
let mut affected_uris = Vec::new();
|
||
|
|
for link_req in &req.links {
|
||
|
|
let (link_id, uri) =
|
||
|
|
insert_link(&txn, &state.cache, &token, &domain, link_req, &state.db, false).await?;
|
||
|
|
results.push(json!({"id": link_id}));
|
||
|
|
affected_uris.push(uri);
|
||
|
|
}
|
||
|
|
txn.commit().await?;
|
||
|
|
|
||
|
|
// Refresh cache after commit for all affected resources
|
||
|
|
for uri in &affected_uris {
|
||
|
|
state.cache.refresh_resource(&state.db, uri).await?;
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok((StatusCode::CREATED, Json(json!({"links": results}))))
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Deserialize)]
|
||
|
|
pub struct ListLinksQuery {
|
||
|
|
resource: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn list_links(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
headers: axum::http::HeaderMap,
|
||
|
|
Query(query): Query<ListLinksQuery>,
|
||
|
|
) -> AppResult<Json<serde_json::Value>> {
|
||
|
|
let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
|
||
|
|
let (token, _) = authenticate_service(&state.db, auth_header).await?;
|
||
|
|
|
||
|
|
let mut q = links::Entity::find()
|
||
|
|
.filter(links::Column::ServiceTokenId.eq(&token.id));
|
||
|
|
|
||
|
|
if let Some(resource) = &query.resource {
|
||
|
|
let resource_model = resources::Entity::find()
|
||
|
|
.filter(resources::Column::ResourceUri.eq(resource.as_str()))
|
||
|
|
.one(&state.db)
|
||
|
|
.await?;
|
||
|
|
if let Some(r) = resource_model {
|
||
|
|
q = q.filter(links::Column::ResourceId.eq(&r.id));
|
||
|
|
} else {
|
||
|
|
return Ok(Json(json!([])));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
let all_links = q.all(&state.db).await?;
|
||
|
|
|
||
|
|
let result: Vec<serde_json::Value> = all_links
|
||
|
|
.into_iter()
|
||
|
|
.map(|l| {
|
||
|
|
json!({
|
||
|
|
"id": l.id,
|
||
|
|
"resource_id": l.resource_id,
|
||
|
|
"rel": l.rel,
|
||
|
|
"href": l.href,
|
||
|
|
"type": l.link_type,
|
||
|
|
"ttl_seconds": l.ttl_seconds,
|
||
|
|
"created_at": l.created_at.to_string(),
|
||
|
|
"expires_at": l.expires_at.map(|e| e.to_string()),
|
||
|
|
})
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
Ok(Json(json!(result)))
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn update_link(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Path(link_id): Path<String>,
|
||
|
|
headers: axum::http::HeaderMap,
|
||
|
|
Json(req): Json<CreateLinkRequest>,
|
||
|
|
) -> AppResult<Json<serde_json::Value>> {
|
||
|
|
let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
|
||
|
|
let (token, _) = authenticate_service(&state.db, auth_header).await?;
|
||
|
|
|
||
|
|
let link = links::Entity::find_by_id(&link_id)
|
||
|
|
.filter(links::Column::ServiceTokenId.eq(&token.id))
|
||
|
|
.one(&state.db)
|
||
|
|
.await?
|
||
|
|
.ok_or(AppError::NotFound)?;
|
||
|
|
|
||
|
|
validate_scope(&token, &req.resource_uri, &req.rel)?;
|
||
|
|
|
||
|
|
let now = chrono::Utc::now().naive_utc();
|
||
|
|
let expires_at = req
|
||
|
|
.ttl_seconds
|
||
|
|
.map(|ttl| now + chrono::Duration::seconds(ttl as i64));
|
||
|
|
|
||
|
|
let mut active: links::ActiveModel = link.into();
|
||
|
|
active.rel = Set(req.rel.clone());
|
||
|
|
active.href = Set(req.href.clone());
|
||
|
|
active.link_type = Set(req.link_type.clone());
|
||
|
|
active.titles = Set(req.titles.as_ref().map(|t| t.to_string()));
|
||
|
|
active.properties = Set(req.properties.as_ref().map(|p| p.to_string()));
|
||
|
|
active.template = Set(req.template.clone());
|
||
|
|
active.ttl_seconds = Set(req.ttl_seconds);
|
||
|
|
active.expires_at = Set(expires_at);
|
||
|
|
active.update(&state.db).await?;
|
||
|
|
|
||
|
|
state
|
||
|
|
.cache
|
||
|
|
.refresh_resource(&state.db, &req.resource_uri)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
Ok(Json(json!({"id": link_id})))
|
||
|
|
}
|
||
|
|
|
||
|
|
async fn delete_link(
|
||
|
|
State(state): State<AppState>,
|
||
|
|
Path(link_id): Path<String>,
|
||
|
|
headers: axum::http::HeaderMap,
|
||
|
|
) -> AppResult<StatusCode> {
|
||
|
|
let auth_header = headers.get("authorization").and_then(|v| v.to_str().ok());
|
||
|
|
let (token, _) = authenticate_service(&state.db, auth_header).await?;
|
||
|
|
|
||
|
|
let link = links::Entity::find_by_id(&link_id)
|
||
|
|
.filter(links::Column::ServiceTokenId.eq(&token.id))
|
||
|
|
.one(&state.db)
|
||
|
|
.await?
|
||
|
|
.ok_or(AppError::NotFound)?;
|
||
|
|
|
||
|
|
let resource = resources::Entity::find_by_id(&link.resource_id)
|
||
|
|
.one(&state.db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
links::Entity::delete_by_id(&link_id)
|
||
|
|
.exec(&state.db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
// Refresh cache
|
||
|
|
if let Some(resource) = resource {
|
||
|
|
state
|
||
|
|
.cache
|
||
|
|
.refresh_resource(&state.db, &resource.resource_uri)
|
||
|
|
.await?;
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(StatusCode::NO_CONTENT)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn router() -> Router<AppState> {
|
||
|
|
Router::new()
|
||
|
|
.route("/api/v1/links", post(create_link).get(list_links))
|
||
|
|
.route(
|
||
|
|
"/api/v1/links/{lid}",
|
||
|
|
put(update_link).delete(delete_link),
|
||
|
|
)
|
||
|
|
.route("/api/v1/links/batch", post(batch_create_links))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Update handler/mod.rs**
|
||
|
|
|
||
|
|
```rust
|
||
|
|
pub mod domains;
|
||
|
|
mod health;
|
||
|
|
mod host_meta;
|
||
|
|
pub mod links;
|
||
|
|
pub mod tokens;
|
||
|
|
mod webfinger;
|
||
|
|
|
||
|
|
use axum::Router;
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
pub fn router(state: AppState) -> Router {
|
||
|
|
Router::new()
|
||
|
|
.merge(webfinger::router())
|
||
|
|
.merge(host_meta::router())
|
||
|
|
.merge(domains::router())
|
||
|
|
.merge(tokens::router())
|
||
|
|
.merge(links::router())
|
||
|
|
.merge(health::router())
|
||
|
|
.with_state(state)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run tests**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_links`
|
||
|
|
Expected: All tests PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Run all tests**
|
||
|
|
|
||
|
|
Run: `cargo test`
|
||
|
|
Expected: All tests PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/handler/links.rs src/handler/mod.rs tests/test_links.rs
|
||
|
|
git commit -m "feat: add link registration API with scope enforcement, upsert, and batch"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 11: TTL Reaper
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/reaper.rs`
|
||
|
|
- Create: `tests/test_reaper.rs`
|
||
|
|
- Modify: `src/lib.rs`, `src/main.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write failing test**
|
||
|
|
|
||
|
|
Create `tests/test_reaper.rs`:
|
||
|
|
```rust
|
||
|
|
mod common;
|
||
|
|
|
||
|
|
use std::time::Duration;
|
||
|
|
use webfingerd::reaper;
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_reaper_expires_links() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
|
||
|
|
// Insert a resource + link that expires immediately
|
||
|
|
use sea_orm::*;
|
||
|
|
use webfingerd::entity::{domains, links, resources, service_tokens};
|
||
|
|
use webfingerd::auth;
|
||
|
|
|
||
|
|
let now = chrono::Utc::now().naive_utc();
|
||
|
|
let past = now - chrono::Duration::seconds(60);
|
||
|
|
|
||
|
|
// Create domain
|
||
|
|
let domain = domains::ActiveModel {
|
||
|
|
id: Set("d1".into()),
|
||
|
|
domain: Set("example.com".into()),
|
||
|
|
owner_token_hash: Set(auth::hash_token("test").unwrap()),
|
||
|
|
registration_secret: Set(String::new()),
|
||
|
|
challenge_type: Set("dns-01".into()),
|
||
|
|
challenge_token: Set(None),
|
||
|
|
verified: Set(true),
|
||
|
|
created_at: Set(now),
|
||
|
|
verified_at: Set(Some(now)),
|
||
|
|
};
|
||
|
|
domain.insert(&state.db).await.unwrap();
|
||
|
|
|
||
|
|
// Create service token
|
||
|
|
let token = service_tokens::ActiveModel {
|
||
|
|
id: Set("t1".into()),
|
||
|
|
domain_id: Set("d1".into()),
|
||
|
|
name: Set("test".into()),
|
||
|
|
token_hash: Set(auth::hash_token("test").unwrap()),
|
||
|
|
allowed_rels: Set(r#"["self"]"#.into()),
|
||
|
|
resource_pattern: Set("acct:*@example.com".into()),
|
||
|
|
created_at: Set(now),
|
||
|
|
revoked_at: Set(None),
|
||
|
|
};
|
||
|
|
token.insert(&state.db).await.unwrap();
|
||
|
|
|
||
|
|
// Create resource
|
||
|
|
let resource = resources::ActiveModel {
|
||
|
|
id: Set("r1".into()),
|
||
|
|
domain_id: Set("d1".into()),
|
||
|
|
resource_uri: Set("acct:alice@example.com".into()),
|
||
|
|
aliases: Set(None),
|
||
|
|
properties: Set(None),
|
||
|
|
created_at: Set(now),
|
||
|
|
updated_at: Set(now),
|
||
|
|
};
|
||
|
|
resource.insert(&state.db).await.unwrap();
|
||
|
|
|
||
|
|
// Create expired link
|
||
|
|
let link = links::ActiveModel {
|
||
|
|
id: Set("l1".into()),
|
||
|
|
resource_id: Set("r1".into()),
|
||
|
|
service_token_id: Set("t1".into()),
|
||
|
|
domain_id: Set("d1".into()),
|
||
|
|
rel: Set("self".into()),
|
||
|
|
href: Set(Some("https://example.com/users/alice".into())),
|
||
|
|
link_type: Set(None),
|
||
|
|
titles: Set(None),
|
||
|
|
properties: Set(None),
|
||
|
|
template: Set(None),
|
||
|
|
ttl_seconds: Set(Some(1)),
|
||
|
|
created_at: Set(past),
|
||
|
|
expires_at: Set(Some(past + chrono::Duration::seconds(1))),
|
||
|
|
};
|
||
|
|
link.insert(&state.db).await.unwrap();
|
||
|
|
|
||
|
|
// Hydrate cache
|
||
|
|
state.cache.hydrate(&state.db).await.unwrap();
|
||
|
|
|
||
|
|
// Should NOT be in cache (already expired)
|
||
|
|
assert!(state.cache.get("acct:alice@example.com").is_none());
|
||
|
|
|
||
|
|
// Run reaper once
|
||
|
|
reaper::reap_once(&state.db, &state.cache).await.unwrap();
|
||
|
|
|
||
|
|
// Link should be deleted from DB
|
||
|
|
let remaining = links::Entity::find().all(&state.db).await.unwrap();
|
||
|
|
assert!(remaining.is_empty());
|
||
|
|
|
||
|
|
// Orphaned resource should also be cleaned up
|
||
|
|
let remaining_resources = resources::Entity::find().all(&state.db).await.unwrap();
|
||
|
|
assert!(remaining_resources.is_empty());
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_reaper`
|
||
|
|
Expected: FAIL.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement reaper.rs**
|
||
|
|
|
||
|
|
Create `src/reaper.rs`:
|
||
|
|
```rust
|
||
|
|
use sea_orm::*;
|
||
|
|
use std::time::Duration;
|
||
|
|
use tokio::time;
|
||
|
|
|
||
|
|
use crate::cache::Cache;
|
||
|
|
use crate::entity::{links, resources};
|
||
|
|
|
||
|
|
/// Run a single reap cycle: delete expired links, clean up orphaned resources.
|
||
|
|
pub async fn reap_once(db: &DatabaseConnection, cache: &Cache) -> Result<(), DbErr> {
|
||
|
|
let now = chrono::Utc::now().naive_utc();
|
||
|
|
|
||
|
|
// Find expired links and their resource URIs
|
||
|
|
let expired_links = links::Entity::find()
|
||
|
|
.filter(links::Column::ExpiresAt.is_not_null())
|
||
|
|
.filter(links::Column::ExpiresAt.lt(now))
|
||
|
|
.find_also_related(resources::Entity)
|
||
|
|
.all(db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
let affected_resource_ids: std::collections::HashSet<String> = expired_links
|
||
|
|
.iter()
|
||
|
|
.map(|(link, _)| link.resource_id.clone())
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
let affected_resource_uris: std::collections::HashMap<String, String> = expired_links
|
||
|
|
.iter()
|
||
|
|
.filter_map(|(link, resource)| {
|
||
|
|
resource
|
||
|
|
.as_ref()
|
||
|
|
.map(|r| (link.resource_id.clone(), r.resource_uri.clone()))
|
||
|
|
})
|
||
|
|
.collect();
|
||
|
|
|
||
|
|
if affected_resource_ids.is_empty() {
|
||
|
|
return Ok(());
|
||
|
|
}
|
||
|
|
|
||
|
|
// Delete expired links
|
||
|
|
let deleted = links::Entity::delete_many()
|
||
|
|
.filter(links::Column::ExpiresAt.is_not_null())
|
||
|
|
.filter(links::Column::ExpiresAt.lt(now))
|
||
|
|
.exec(db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
if deleted.rows_affected > 0 {
|
||
|
|
tracing::info!(count = deleted.rows_affected, "reaped expired links");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clean up orphaned resources (resources with no remaining links)
|
||
|
|
for resource_id in &affected_resource_ids {
|
||
|
|
let link_count = links::Entity::find()
|
||
|
|
.filter(links::Column::ResourceId.eq(resource_id.as_str()))
|
||
|
|
.count(db)
|
||
|
|
.await?;
|
||
|
|
|
||
|
|
if link_count == 0 {
|
||
|
|
resources::Entity::delete_by_id(resource_id)
|
||
|
|
.exec(db)
|
||
|
|
.await?;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Refresh cache for affected resources
|
||
|
|
for (_, uri) in &affected_resource_uris {
|
||
|
|
cache.refresh_resource(db, uri).await?;
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Spawn the background reaper task.
|
||
|
|
pub fn spawn_reaper(db: DatabaseConnection, cache: Cache, interval_secs: u64) {
|
||
|
|
tokio::spawn(async move {
|
||
|
|
let mut interval = time::interval(Duration::from_secs(interval_secs));
|
||
|
|
loop {
|
||
|
|
interval.tick().await;
|
||
|
|
if let Err(e) = reap_once(&db, &cache).await {
|
||
|
|
tracing::error!("reaper error: {e}");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Update lib.rs and main.rs**
|
||
|
|
|
||
|
|
Add to `src/lib.rs`:
|
||
|
|
```rust
|
||
|
|
pub mod reaper;
|
||
|
|
```
|
||
|
|
|
||
|
|
Add reaper spawn to `src/main.rs` after cache hydration:
|
||
|
|
```rust
|
||
|
|
// Spawn TTL reaper
|
||
|
|
webfingerd::reaper::spawn_reaper(
|
||
|
|
state.db.clone(),
|
||
|
|
state.cache.clone(),
|
||
|
|
settings.cache.reaper_interval_secs,
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run tests**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_reaper`
|
||
|
|
Expected: PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/reaper.rs src/lib.rs src/main.rs tests/test_reaper.rs
|
||
|
|
git commit -m "feat: add background TTL reaper with orphaned resource cleanup"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 12: Middleware (Rate Limiting, Request ID, CORS)
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/middleware/mod.rs`, `src/middleware/rate_limit.rs`, `src/middleware/request_id.rs`
|
||
|
|
- Create: `tests/test_rate_limit.rs`
|
||
|
|
- Modify: `src/lib.rs`, `src/handler/mod.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write failing rate limit test**
|
||
|
|
|
||
|
|
Create `tests/test_rate_limit.rs`:
|
||
|
|
```rust
|
||
|
|
mod common;
|
||
|
|
|
||
|
|
use axum_test::TestServer;
|
||
|
|
use webfingerd::handler;
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_public_rate_limiting() {
|
||
|
|
let mut settings = common::test_settings();
|
||
|
|
settings.rate_limit.public_rpm = 2; // Very low for testing
|
||
|
|
|
||
|
|
let state = common::test_state_with_settings(settings).await;
|
||
|
|
let app = handler::router(state);
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
// First two requests should succeed (even with 404)
|
||
|
|
server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:a@a.com")
|
||
|
|
.await;
|
||
|
|
server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:b@b.com")
|
||
|
|
.await;
|
||
|
|
|
||
|
|
// Third should be rate limited
|
||
|
|
let response = server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:c@c.com")
|
||
|
|
.await;
|
||
|
|
|
||
|
|
response.assert_status(axum::http::StatusCode::TOO_MANY_REQUESTS);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify test_state_with_settings exists in common/mod.rs**
|
||
|
|
|
||
|
|
`test_state_with_settings` was already added in Task 5. No changes needed.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_rate_limit`
|
||
|
|
Expected: FAIL.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Implement middleware**
|
||
|
|
|
||
|
|
Create `src/middleware/mod.rs`:
|
||
|
|
```rust
|
||
|
|
pub mod rate_limit;
|
||
|
|
pub mod request_id;
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `src/middleware/request_id.rs`:
|
||
|
|
```rust
|
||
|
|
use axum::http::{HeaderName, HeaderValue, Request};
|
||
|
|
use axum::middleware::Next;
|
||
|
|
use axum::response::Response;
|
||
|
|
use uuid::Uuid;
|
||
|
|
|
||
|
|
static X_REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id");
|
||
|
|
|
||
|
|
// Note: axum 0.8 Next is not generic over body type
|
||
|
|
pub async fn request_id(mut request: Request, next: Next) -> Response {
|
||
|
|
let id = Uuid::new_v4().to_string();
|
||
|
|
request
|
||
|
|
.headers_mut()
|
||
|
|
.insert(X_REQUEST_ID.clone(), HeaderValue::from_str(&id).unwrap());
|
||
|
|
|
||
|
|
let mut response = next.run(request).await;
|
||
|
|
response
|
||
|
|
.headers_mut()
|
||
|
|
.insert(X_REQUEST_ID.clone(), HeaderValue::from_str(&id).unwrap());
|
||
|
|
|
||
|
|
response
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `src/middleware/rate_limit.rs`:
|
||
|
|
```rust
|
||
|
|
use axum::extract::ConnectInfo;
|
||
|
|
use axum::http::{Request, StatusCode};
|
||
|
|
use axum::middleware::Next;
|
||
|
|
use axum::response::{IntoResponse, Response};
|
||
|
|
use dashmap::DashMap;
|
||
|
|
use governor::clock::DefaultClock;
|
||
|
|
use governor::state::{InMemoryState, NotKeyed};
|
||
|
|
use governor::{Quota, RateLimiter};
|
||
|
|
use std::net::{IpAddr, SocketAddr};
|
||
|
|
use std::num::NonZeroU32;
|
||
|
|
use std::sync::Arc;
|
||
|
|
|
||
|
|
/// Per-key rate limiter using DashMap for keyed limiting (per IP or per token).
|
||
|
|
#[derive(Clone)]
|
||
|
|
pub struct KeyedLimiter {
|
||
|
|
limiters: Arc<DashMap<String, Arc<RateLimiter<NotKeyed, InMemoryState, DefaultClock>>>>,
|
||
|
|
quota: Quota,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl KeyedLimiter {
|
||
|
|
pub fn new(rpm: u32) -> Self {
|
||
|
|
let quota = Quota::per_minute(NonZeroU32::new(rpm).expect("rpm must be > 0"));
|
||
|
|
Self {
|
||
|
|
limiters: Arc::new(DashMap::new()),
|
||
|
|
quota,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn check_key(&self, key: &str) -> bool {
|
||
|
|
let limiter = self.limiters
|
||
|
|
.entry(key.to_string())
|
||
|
|
.or_insert_with(|| Arc::new(RateLimiter::direct(self.quota)))
|
||
|
|
.clone();
|
||
|
|
limiter.check().is_ok()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Rate limit middleware for public endpoints (keyed by client IP).
|
||
|
|
// Note: axum 0.8 Next is not generic over body type
|
||
|
|
pub async fn rate_limit_by_ip(
|
||
|
|
limiter: KeyedLimiter,
|
||
|
|
request: Request,
|
||
|
|
next: Next,
|
||
|
|
) -> Response {
|
||
|
|
// Extract IP from x-forwarded-for or connection info
|
||
|
|
let ip = request
|
||
|
|
.headers()
|
||
|
|
.get("x-forwarded-for")
|
||
|
|
.and_then(|v| v.to_str().ok())
|
||
|
|
.and_then(|v| v.split(',').next())
|
||
|
|
.map(|s| s.trim().to_string())
|
||
|
|
.unwrap_or_else(|| "unknown".to_string());
|
||
|
|
|
||
|
|
if !limiter.check_key(&ip) {
|
||
|
|
return (
|
||
|
|
StatusCode::TOO_MANY_REQUESTS,
|
||
|
|
[("retry-after", "60")],
|
||
|
|
"rate limited",
|
||
|
|
)
|
||
|
|
.into_response();
|
||
|
|
}
|
||
|
|
|
||
|
|
next.run(request).await
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Rate limit middleware for API endpoints (keyed by Bearer token prefix).
|
||
|
|
pub async fn rate_limit_by_token(
|
||
|
|
limiter: KeyedLimiter,
|
||
|
|
request: Request,
|
||
|
|
next: Next,
|
||
|
|
) -> Response {
|
||
|
|
let key = request
|
||
|
|
.headers()
|
||
|
|
.get("authorization")
|
||
|
|
.and_then(|v| v.to_str().ok())
|
||
|
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||
|
|
.and_then(|t| t.split('.').next()) // Use token ID prefix as key
|
||
|
|
.unwrap_or("anonymous")
|
||
|
|
.to_string();
|
||
|
|
|
||
|
|
if !limiter.check_key(&key) {
|
||
|
|
return (
|
||
|
|
StatusCode::TOO_MANY_REQUESTS,
|
||
|
|
[("retry-after", "60")],
|
||
|
|
"rate limited",
|
||
|
|
)
|
||
|
|
.into_response();
|
||
|
|
}
|
||
|
|
|
||
|
|
next.run(request).await
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Wire middleware into handler/mod.rs**
|
||
|
|
|
||
|
|
Update `src/handler/mod.rs` to apply rate limiting and CORS:
|
||
|
|
```rust
|
||
|
|
pub mod domains;
|
||
|
|
mod health;
|
||
|
|
mod host_meta;
|
||
|
|
pub mod links;
|
||
|
|
pub mod tokens;
|
||
|
|
mod webfinger;
|
||
|
|
|
||
|
|
use axum::middleware as axum_mw;
|
||
|
|
use axum::Router;
|
||
|
|
use tower_http::cors::{Any, CorsLayer};
|
||
|
|
|
||
|
|
use crate::middleware::rate_limit::KeyedLimiter;
|
||
|
|
use crate::middleware::rate_limit;
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
pub fn router(state: AppState) -> Router {
|
||
|
|
let public_limiter = KeyedLimiter::new(state.settings.rate_limit.public_rpm);
|
||
|
|
let api_limiter = KeyedLimiter::new(state.settings.rate_limit.api_rpm);
|
||
|
|
|
||
|
|
let public_cors = CorsLayer::new()
|
||
|
|
.allow_origin(Any)
|
||
|
|
.allow_methods(Any)
|
||
|
|
.allow_headers(Any);
|
||
|
|
|
||
|
|
// Public endpoints: webfinger + host-meta with per-IP rate limit + CORS
|
||
|
|
let public_routes = Router::new()
|
||
|
|
.merge(webfinger::router())
|
||
|
|
.merge(host_meta::router())
|
||
|
|
.layer(public_cors)
|
||
|
|
.layer(axum_mw::from_fn(move |req, next| {
|
||
|
|
let limiter = public_limiter.clone();
|
||
|
|
rate_limit::rate_limit_by_ip(limiter, req, next)
|
||
|
|
}));
|
||
|
|
|
||
|
|
// API endpoints with per-token rate limit (no wildcard CORS)
|
||
|
|
let api_routes = Router::new()
|
||
|
|
.merge(domains::router())
|
||
|
|
.merge(tokens::router())
|
||
|
|
.merge(links::router())
|
||
|
|
.layer(axum_mw::from_fn(move |req, next| {
|
||
|
|
let limiter = api_limiter.clone();
|
||
|
|
rate_limit::rate_limit_by_token(limiter, req, next)
|
||
|
|
}));
|
||
|
|
|
||
|
|
Router::new()
|
||
|
|
.merge(public_routes)
|
||
|
|
.merge(api_routes)
|
||
|
|
.merge(health::router())
|
||
|
|
.with_state(state)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Update lib.rs**
|
||
|
|
|
||
|
|
Add `pub mod middleware;` to `src/lib.rs`.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Run tests**
|
||
|
|
|
||
|
|
Run: `cargo test`
|
||
|
|
Expected: All tests PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 8: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/middleware/ src/handler/mod.rs src/lib.rs tests/test_rate_limit.rs tests/common/mod.rs
|
||
|
|
git commit -m "feat: add rate limiting, request ID, and CORS middleware"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 13: Prometheus Metrics + Metrics Endpoint
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/handler/metrics.rs`
|
||
|
|
- Modify: `src/handler/mod.rs`, `src/handler/webfinger.rs`, `src/handler/health.rs`, `src/state.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Set up metrics recorder in state.rs**
|
||
|
|
|
||
|
|
Add to `src/state.rs`:
|
||
|
|
```rust
|
||
|
|
use metrics_exporter_prometheus::{PrometheusBuilder, PrometheusHandle};
|
||
|
|
|
||
|
|
#[derive(Clone)]
|
||
|
|
pub struct AppState {
|
||
|
|
pub db: DatabaseConnection,
|
||
|
|
pub cache: Cache,
|
||
|
|
pub settings: Arc<Settings>,
|
||
|
|
pub metrics_handle: PrometheusHandle,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create metrics endpoint**
|
||
|
|
|
||
|
|
Create `src/handler/metrics.rs`:
|
||
|
|
```rust
|
||
|
|
use axum::extract::State;
|
||
|
|
use axum::routing::get;
|
||
|
|
use axum::Router;
|
||
|
|
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
async fn metrics(State(state): State<AppState>) -> String {
|
||
|
|
state.metrics_handle.render()
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn router() -> Router<AppState> {
|
||
|
|
Router::new().route("/metrics", get(metrics))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Add metrics recording to webfinger handler**
|
||
|
|
|
||
|
|
Add to `src/handler/webfinger.rs` at the start of the `webfinger` function:
|
||
|
|
```rust
|
||
|
|
metrics::counter!("webfinger_queries_total", "domain" => resource_domain.clone(), "status" => "...").increment(1);
|
||
|
|
```
|
||
|
|
|
||
|
|
Instrument the handler to record `webfinger_queries_total` and `webfinger_query_duration_seconds`. Extract the domain from the resource parameter for labeling.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Update main.rs to initialize metrics**
|
||
|
|
|
||
|
|
In `src/main.rs`:
|
||
|
|
```rust
|
||
|
|
let metrics_handle = PrometheusBuilder::new()
|
||
|
|
.install_recorder()
|
||
|
|
.expect("failed to install metrics recorder");
|
||
|
|
```
|
||
|
|
|
||
|
|
Pass `metrics_handle` into `AppState`.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Wire metrics route in handler/mod.rs**
|
||
|
|
|
||
|
|
Add `mod metrics;` and merge `metrics::router()` into the router.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Update health.rs to check DB and cache**
|
||
|
|
|
||
|
|
```rust
|
||
|
|
async fn healthz(State(state): State<AppState>) -> StatusCode {
|
||
|
|
match state.db.execute(Statement::from_string(
|
||
|
|
sea_orm::DatabaseBackend::Sqlite,
|
||
|
|
"SELECT 1".to_string(),
|
||
|
|
)).await {
|
||
|
|
Ok(_) => StatusCode::OK,
|
||
|
|
Err(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 7: Run all tests, fix any failures**
|
||
|
|
|
||
|
|
Run: `cargo test`
|
||
|
|
Expected: All tests PASS (may need to update test_state to include metrics_handle).
|
||
|
|
|
||
|
|
- [ ] **Step 8: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/handler/metrics.rs src/handler/mod.rs src/handler/webfinger.rs src/handler/health.rs src/state.rs src/main.rs tests/common/mod.rs
|
||
|
|
git commit -m "feat: add Prometheus metrics endpoint and query instrumentation"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 14: Web UI
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `src/ui/mod.rs`, `src/ui/handlers.rs`, `src/ui/templates.rs`
|
||
|
|
- Create: `src/templates/layout.html`, `src/templates/login.html`, `src/templates/dashboard.html`
|
||
|
|
- Create: `src/templates/domain_detail.html`, `src/templates/token_management.html`
|
||
|
|
- Create: `src/templates/link_browser.html`
|
||
|
|
- Modify: `src/lib.rs`, `src/handler/mod.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create askama templates**
|
||
|
|
|
||
|
|
Create `src/templates/layout.html`:
|
||
|
|
```html
|
||
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
|
<title>{% block title %}webfingerd{% endblock %}</title>
|
||
|
|
<style>
|
||
|
|
:root { --bg: #fafafa; --fg: #222; --accent: #2563eb; --border: #ddd; --muted: #666; }
|
||
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
|
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); max-width: 960px; margin: 0 auto; padding: 1rem; }
|
||
|
|
header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; margin-bottom: 1.5rem; }
|
||
|
|
header h1 { font-size: 1.25rem; }
|
||
|
|
header a { color: var(--accent); text-decoration: none; }
|
||
|
|
a { color: var(--accent); }
|
||
|
|
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
|
||
|
|
th, td { padding: 0.5rem; text-align: left; border-bottom: 1px solid var(--border); }
|
||
|
|
th { font-weight: 600; color: var(--muted); font-size: 0.875rem; }
|
||
|
|
.btn { display: inline-block; padding: 0.4rem 0.8rem; background: var(--accent); color: #fff; border: none; border-radius: 4px; cursor: pointer; text-decoration: none; font-size: 0.875rem; }
|
||
|
|
.btn-danger { background: #dc2626; }
|
||
|
|
input, textarea { padding: 0.4rem; border: 1px solid var(--border); border-radius: 4px; width: 100%; margin-bottom: 0.5rem; }
|
||
|
|
label { display: block; font-weight: 600; margin-bottom: 0.25rem; font-size: 0.875rem; }
|
||
|
|
.card { background: #fff; border: 1px solid var(--border); border-radius: 6px; padding: 1rem; margin-bottom: 1rem; }
|
||
|
|
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 999px; font-size: 0.75rem; }
|
||
|
|
.badge-green { background: #dcfce7; color: #166534; }
|
||
|
|
.badge-yellow { background: #fef9c3; color: #854d0e; }
|
||
|
|
.flash { padding: 0.75rem; margin-bottom: 1rem; border-radius: 4px; }
|
||
|
|
.flash-error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; }
|
||
|
|
.flash-success { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<header>
|
||
|
|
<h1>webfingerd</h1>
|
||
|
|
{% block nav %}{% endblock %}
|
||
|
|
</header>
|
||
|
|
{% block content %}{% endblock %}
|
||
|
|
</body>
|
||
|
|
</html>
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `src/templates/login.html`:
|
||
|
|
```html
|
||
|
|
{% extends "layout.html" %}
|
||
|
|
{% block title %}Login - webfingerd{% endblock %}
|
||
|
|
{% block content %}
|
||
|
|
<div class="card" style="max-width: 400px; margin: 2rem auto;">
|
||
|
|
<h2 style="margin-bottom: 1rem;">Domain Owner Login</h2>
|
||
|
|
{% if let Some(error) = error %}
|
||
|
|
<div class="flash flash-error">{{ error }}</div>
|
||
|
|
{% endif %}
|
||
|
|
<form method="post" action="/ui/login">
|
||
|
|
<label for="token">Owner Token</label>
|
||
|
|
<input type="password" name="token" id="token" required placeholder="Paste your owner token">
|
||
|
|
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Login</button>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
{% endblock %}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `src/templates/dashboard.html`:
|
||
|
|
```html
|
||
|
|
{% extends "layout.html" %}
|
||
|
|
{% block title %}Dashboard - webfingerd{% endblock %}
|
||
|
|
{% block nav %}<a href="/ui/logout">Logout</a>{% endblock %}
|
||
|
|
{% block content %}
|
||
|
|
<h2>Your Domains</h2>
|
||
|
|
{% if domains.is_empty() %}
|
||
|
|
<p>No domains found for this token.</p>
|
||
|
|
{% else %}
|
||
|
|
<table>
|
||
|
|
<thead><tr><th>Domain</th><th>Status</th><th>Links</th><th></th></tr></thead>
|
||
|
|
<tbody>
|
||
|
|
{% for d in domains %}
|
||
|
|
<tr>
|
||
|
|
<td><a href="/ui/domains/{{ d.id }}">{{ d.domain }}</a></td>
|
||
|
|
<td>{% if d.verified %}<span class="badge badge-green">Verified</span>{% else %}<span class="badge badge-yellow">Pending</span>{% endif %}</td>
|
||
|
|
<td>{{ d.link_count }}</td>
|
||
|
|
<td><a href="/ui/domains/{{ d.id }}">Manage</a></td>
|
||
|
|
</tr>
|
||
|
|
{% endfor %}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
{% endif %}
|
||
|
|
{% endblock %}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `src/templates/domain_detail.html`, `src/templates/token_management.html`, `src/templates/link_browser.html` with similar patterns (forms for creating/revoking tokens, tables for browsing links).
|
||
|
|
|
||
|
|
- [ ] **Step 2: Implement UI module**
|
||
|
|
|
||
|
|
Create `src/ui/mod.rs`:
|
||
|
|
```rust
|
||
|
|
pub mod handlers;
|
||
|
|
pub mod templates;
|
||
|
|
|
||
|
|
use axum::Router;
|
||
|
|
use crate::state::AppState;
|
||
|
|
|
||
|
|
pub fn router() -> Router<AppState> {
|
||
|
|
handlers::router()
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `src/ui/templates.rs` with askama template structs:
|
||
|
|
```rust
|
||
|
|
use askama::Template;
|
||
|
|
|
||
|
|
#[derive(Template)]
|
||
|
|
#[template(path = "login.html")]
|
||
|
|
pub struct LoginTemplate {
|
||
|
|
pub error: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Template)]
|
||
|
|
#[template(path = "dashboard.html")]
|
||
|
|
pub struct DashboardTemplate {
|
||
|
|
pub domains: Vec<DomainSummary>,
|
||
|
|
}
|
||
|
|
|
||
|
|
pub struct DomainSummary {
|
||
|
|
pub id: String,
|
||
|
|
pub domain: String,
|
||
|
|
pub verified: bool,
|
||
|
|
pub link_count: u64,
|
||
|
|
}
|
||
|
|
|
||
|
|
// ... additional template structs for domain_detail, token_management, link_browser
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `src/ui/handlers.rs` with the route handlers for login, dashboard, domain detail, token management, and link browser pages.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Wire UI into handler/mod.rs**
|
||
|
|
|
||
|
|
Conditionally merge `ui::router()` based on `settings.ui.enabled`.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Verify compilation and basic manual testing**
|
||
|
|
|
||
|
|
Run: `cargo build`
|
||
|
|
Expected: Compiles. Manual smoke test by running the server and visiting `/ui/login`.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/ui/ src/templates/ src/handler/mod.rs src/lib.rs
|
||
|
|
git commit -m "feat: add server-rendered web UI for domain owner management"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 15: Structured Logging + main.rs Finalization
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `src/main.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Finalize main.rs**
|
||
|
|
|
||
|
|
Ensure `src/main.rs` has:
|
||
|
|
- Tracing with JSON output and env filter
|
||
|
|
- Database connection with WAL mode
|
||
|
|
- Migrations
|
||
|
|
- Cache hydration
|
||
|
|
- Metrics recorder installation
|
||
|
|
- Reaper spawn
|
||
|
|
- Full router assembly
|
||
|
|
- Graceful shutdown via `tokio::signal::ctrl_c`
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// Graceful shutdown
|
||
|
|
let listener = tokio::net::TcpListener::bind(&settings.server.listen)
|
||
|
|
.await
|
||
|
|
.expect("failed to bind");
|
||
|
|
tracing::info!(listen = %settings.server.listen, "webfingerd started");
|
||
|
|
|
||
|
|
axum::serve(listener, handler::router(state))
|
||
|
|
.with_graceful_shutdown(async {
|
||
|
|
tokio::signal::ctrl_c().await.ok();
|
||
|
|
tracing::info!("shutting down");
|
||
|
|
})
|
||
|
|
.await
|
||
|
|
.expect("server error");
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify full build and all tests**
|
||
|
|
|
||
|
|
Run: `cargo build && cargo test`
|
||
|
|
Expected: All pass.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/main.rs
|
||
|
|
git commit -m "feat: finalize main.rs with graceful shutdown and full wiring"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 16: Integration Test — Full Flow
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `tests/test_full_flow.rs`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write end-to-end integration test**
|
||
|
|
|
||
|
|
Create `tests/test_full_flow.rs`:
|
||
|
|
```rust
|
||
|
|
mod common;
|
||
|
|
|
||
|
|
use axum_test::TestServer;
|
||
|
|
use serde_json::json;
|
||
|
|
use webfingerd::handler;
|
||
|
|
|
||
|
|
#[tokio::test]
|
||
|
|
async fn test_full_webfinger_flow() {
|
||
|
|
let state = common::test_state().await;
|
||
|
|
let app = handler::router(state.clone());
|
||
|
|
let server = TestServer::new(app).unwrap();
|
||
|
|
|
||
|
|
// 1. Register domain
|
||
|
|
let create_resp = server
|
||
|
|
.post("/api/v1/domains")
|
||
|
|
.json(&json!({"domain": "social.alice.example", "challenge_type": "dns-01"}))
|
||
|
|
.await;
|
||
|
|
create_resp.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
let body: serde_json::Value = create_resp.json();
|
||
|
|
let domain_id = body["id"].as_str().unwrap().to_string();
|
||
|
|
let reg_secret = body["registration_secret"].as_str().unwrap().to_string();
|
||
|
|
|
||
|
|
// 2. Verify domain (MockChallengeVerifier always succeeds)
|
||
|
|
let verify_resp = server
|
||
|
|
.post(&format!("/api/v1/domains/{domain_id}/verify"))
|
||
|
|
.json(&json!({"registration_secret": reg_secret}))
|
||
|
|
.await;
|
||
|
|
let owner_token = verify_resp.json::<serde_json::Value>()["owner_token"]
|
||
|
|
.as_str().unwrap().to_string();
|
||
|
|
|
||
|
|
// 3. Create service token for ActivityPub
|
||
|
|
let token_resp = server
|
||
|
|
.post(&format!("/api/v1/domains/{domain_id}/tokens"))
|
||
|
|
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"name": "oxifed",
|
||
|
|
"allowed_rels": ["self", "http://webfinger.net/rel/profile-page"],
|
||
|
|
"resource_pattern": "acct:*@social.alice.example"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
token_resp.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
let ap_token = token_resp.json::<serde_json::Value>()["token"]
|
||
|
|
.as_str().unwrap().to_string();
|
||
|
|
|
||
|
|
// 4. Create service token for OIDC
|
||
|
|
let token_resp = server
|
||
|
|
.post(&format!("/api/v1/domains/{domain_id}/tokens"))
|
||
|
|
.add_header("Authorization", format!("Bearer {owner_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"name": "barycenter",
|
||
|
|
"allowed_rels": ["http://openid.net/specs/connect/1.0/issuer"],
|
||
|
|
"resource_pattern": "acct:*@social.alice.example"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
let oidc_token = token_resp.json::<serde_json::Value>()["token"]
|
||
|
|
.as_str().unwrap().to_string();
|
||
|
|
|
||
|
|
// 5. oxifed registers ActivityPub links
|
||
|
|
server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {ap_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@social.alice.example",
|
||
|
|
"rel": "self",
|
||
|
|
"href": "https://social.alice.example/users/alice",
|
||
|
|
"type": "application/activity+json",
|
||
|
|
"aliases": ["https://social.alice.example/@alice"]
|
||
|
|
}))
|
||
|
|
.await
|
||
|
|
.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
|
||
|
|
server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {ap_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@social.alice.example",
|
||
|
|
"rel": "http://webfinger.net/rel/profile-page",
|
||
|
|
"href": "https://social.alice.example/@alice",
|
||
|
|
"type": "text/html"
|
||
|
|
}))
|
||
|
|
.await
|
||
|
|
.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
|
||
|
|
// 6. barycenter registers OIDC issuer link
|
||
|
|
server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {oidc_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@social.alice.example",
|
||
|
|
"rel": "http://openid.net/specs/connect/1.0/issuer",
|
||
|
|
"href": "https://auth.alice.example"
|
||
|
|
}))
|
||
|
|
.await
|
||
|
|
.assert_status(axum::http::StatusCode::CREATED);
|
||
|
|
|
||
|
|
// 7. Query WebFinger — should return all three links
|
||
|
|
let wf_resp = server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:alice@social.alice.example")
|
||
|
|
.await;
|
||
|
|
wf_resp.assert_status_ok();
|
||
|
|
let jrd: serde_json::Value = wf_resp.json();
|
||
|
|
|
||
|
|
assert_eq!(jrd["subject"], "acct:alice@social.alice.example");
|
||
|
|
assert_eq!(jrd["aliases"][0], "https://social.alice.example/@alice");
|
||
|
|
|
||
|
|
let links = jrd["links"].as_array().unwrap();
|
||
|
|
assert_eq!(links.len(), 3);
|
||
|
|
|
||
|
|
// 8. Filter by rel
|
||
|
|
let wf_resp = server
|
||
|
|
.get("/.well-known/webfinger")
|
||
|
|
.add_query_param("resource", "acct:alice@social.alice.example")
|
||
|
|
.add_query_param("rel", "self")
|
||
|
|
.await;
|
||
|
|
let jrd: serde_json::Value = wf_resp.json();
|
||
|
|
let links = jrd["links"].as_array().unwrap();
|
||
|
|
assert_eq!(links.len(), 1);
|
||
|
|
assert_eq!(links[0]["rel"], "self");
|
||
|
|
// aliases should still be present despite rel filter
|
||
|
|
assert!(jrd["aliases"].is_array());
|
||
|
|
|
||
|
|
// 9. Verify scope isolation: oxifed can't register OIDC links
|
||
|
|
let bad_resp = server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {ap_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@social.alice.example",
|
||
|
|
"rel": "http://openid.net/specs/connect/1.0/issuer",
|
||
|
|
"href": "https://evil.com"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
bad_resp.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||
|
|
|
||
|
|
// 10. Verify scope isolation: barycenter can't register AP links
|
||
|
|
let bad_resp = server
|
||
|
|
.post("/api/v1/links")
|
||
|
|
.add_header("Authorization", format!("Bearer {oidc_token}"))
|
||
|
|
.json(&json!({
|
||
|
|
"resource_uri": "acct:alice@social.alice.example",
|
||
|
|
"rel": "self",
|
||
|
|
"href": "https://evil.com"
|
||
|
|
}))
|
||
|
|
.await;
|
||
|
|
bad_resp.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run the full flow test**
|
||
|
|
|
||
|
|
Run: `cargo test --test test_full_flow`
|
||
|
|
Expected: PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Run entire test suite**
|
||
|
|
|
||
|
|
Run: `cargo test`
|
||
|
|
Expected: All tests PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add tests/test_full_flow.rs
|
||
|
|
git commit -m "test: add full integration test covering multi-service WebFinger flow"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Summary
|
||
|
|
|
||
|
|
16 tasks covering:
|
||
|
|
|
||
|
|
1. **Project scaffold + config** — Cargo workspace, Settings, AppError
|
||
|
|
2. **Database migrations** — 4 tables with foreign keys and unique constraints
|
||
|
|
3. **SeaORM entities** — Type-safe ORM models for all tables
|
||
|
|
4. **AppState + cache + auth** — DashMap cache, argon2 auth helpers, DB bootstrap
|
||
|
|
5. **Test helpers** — In-memory DB setup for all tests
|
||
|
|
6. **WebFinger query endpoint** — RFC 7033 compliant with rel filtering + CORS
|
||
|
|
7. **host-meta endpoint** — RFC 6415 XRD with domain-aware routing
|
||
|
|
8. **Domain onboarding API** — Registration, DNS/HTTP challenges, verification, token rotation
|
||
|
|
9. **Service token API** — CRUD with pattern validation and revocation cascade
|
||
|
|
10. **Link registration API** — CRUD, upsert, batch (all-or-nothing), scope enforcement
|
||
|
|
11. **TTL reaper** — Background task for expiring links + orphaned resource cleanup
|
||
|
|
12. **Middleware** — Rate limiting, request IDs, CORS
|
||
|
|
13. **Metrics** — Prometheus endpoint + query instrumentation
|
||
|
|
14. **Web UI** — Server-rendered askama templates for domain management
|
||
|
|
15. **main.rs finalization** — Full wiring, graceful shutdown
|
||
|
|
16. **Integration test** — End-to-end multi-service WebFinger flow
|