diff --git a/docs/superpowers/plans/2026-04-03-webfingerd-implementation.md b/docs/superpowers/plans/2026-04-03-webfingerd-implementation.md new file mode 100644 index 0000000..8471648 --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-webfingerd-implementation.md @@ -0,0 +1,4662 @@ +# 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 { + 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 = Result; +``` + +- [ ] **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> { + 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, + pub verified: bool, + pub created_at: chrono::NaiveDateTime, + pub verified_at: Option, +} + +#[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 for Entity { + fn to() -> RelationDef { + Relation::Resources.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ServiceTokens.def() + } +} + +impl Related 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, + pub properties: Option, + 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 for Entity { + fn to() -> RelationDef { + Relation::Domain.def() + } +} + +impl Related 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, +} + +#[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 for Entity { + fn to() -> RelationDef { + Relation::Domain.def() + } +} + +impl Related 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, + #[sea_orm(column_name = "type")] + pub link_type: Option, + pub titles: Option, + pub properties: Option, + pub template: Option, + pub ttl_seconds: Option, + pub created_at: chrono::NaiveDateTime, + pub expires_at: Option, +} + +#[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 for Entity { + fn to() -> RelationDef { + Relation::Resource.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ServiceToken.def() + } +} + +impl Related 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, + pub link_type: Option, + pub titles: Option, + pub properties: Option, + pub template: Option, +} + +#[derive(Debug, Clone)] +pub struct CachedResource { + pub subject: String, + pub aliases: Option>, + pub properties: Option, + pub links: Vec, +} + +#[derive(Debug, Clone)] +pub struct Cache { + inner: Arc>, +} + +impl Cache { + pub fn new() -> Self { + Self { + inner: Arc::new(DashMap::new()), + } + } + + pub fn get(&self, resource_uri: &str) -> Option { + 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 { + 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, + pub challenge_verifier: Arc, +} +``` + +- [ ] **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, Vec) { + 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, + uri: Uri, +) -> AppResult { + 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 = 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::(titles) { + obj.insert("titles".into(), v); + } + } + if let Some(props) = &link.properties { + if let Ok(v) = serde_json::from_str::(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 { + 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 { + 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, + Host(hostname): Host, +) -> AppResult { + // 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#" + + +"# + ); + + Ok(( + [(header::CONTENT_TYPE, "application/xrd+xml; charset=utf-8")], + xrd, + ) + .into_response()) +} + +pub fn router() -> Router { + 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::()["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::()["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::()["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::()["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; + + async fn verify_http( + &self, + domain: &str, + expected_token: &str, + config: &ChallengeConfig, + ) -> Result; +} + +/// 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 { + 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 { + 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 { + Ok(true) + } + async fn verify_http(&self, _: &str, _: &str, _: &ChallengeConfig) -> Result { + Ok(true) + } +} +``` + +Add `async-trait = "0.1"` to `[dependencies]` in Cargo.toml. + +The `ChallengeVerifier` trait is stored in `AppState` as `Arc`. +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, + Json(req): Json, +) -> AppResult<(StatusCode, Json)> { + 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, + Path(id): Path, + Json(req): Json, +) -> AppResult> { + 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 { + 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, + Path(id): Path, + headers: axum::http::HeaderMap, +) -> AppResult> { + 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, + Path(id): Path, + headers: axum::http::HeaderMap, +) -> AppResult> { + 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, + Path(id): Path, + headers: axum::http::HeaderMap, +) -> AppResult { + 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 = 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 { + 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::()["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::(); + 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, + resource_pattern: String, +} + +async fn create_token( + State(state): State, + Path(domain_id): Path, + headers: axum::http::HeaderMap, + Json(req): Json, +) -> AppResult<(StatusCode, Json)> { + 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, + Path(domain_id): Path, + headers: axum::http::HeaderMap, +) -> AppResult> { + 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 = tokens + .into_iter() + .map(|t| { + json!({ + "id": t.id, + "name": t.name, + "allowed_rels": serde_json::from_str::(&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, + Path((domain_id, token_id)): Path<(String, String)>, + headers: axum::http::HeaderMap, +) -> AppResult { + 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 = affected_links + .iter() + .filter_map(|(_, resource)| resource.as_ref().map(|r| r.resource_uri.clone())) + .collect::>() + .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 { + 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::()["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::()["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::()["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 = + 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 { + 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, + #[serde(rename = "type")] + link_type: Option, + titles: Option, + properties: Option, + template: Option, + ttl_seconds: Option, + aliases: Option>, +} + +/// 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, + headers: axum::http::HeaderMap, + Json(req): Json, +) -> AppResult<(StatusCode, Json)> { + 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, +} + +async fn batch_create_links( + State(state): State, + headers: axum::http::HeaderMap, + Json(req): Json, +) -> AppResult<(StatusCode, Json)> { + 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, +} + +async fn list_links( + State(state): State, + headers: axum::http::HeaderMap, + Query(query): Query, +) -> AppResult> { + 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 = 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, + Path(link_id): Path, + headers: axum::http::HeaderMap, + Json(req): Json, +) -> AppResult> { + 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, + Path(link_id): Path, + headers: axum::http::HeaderMap, +) -> AppResult { + 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 { + 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 = expired_links + .iter() + .map(|(link, _)| link.resource_id.clone()) + .collect(); + + let affected_resource_uris: std::collections::HashMap = 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>>>, + 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, + 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) -> String { + state.metrics_handle.render() +} + +pub fn router() -> Router { + 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) -> 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 + + + + + + {% block title %}webfingerd{% endblock %} + + + +
+

webfingerd

+ {% block nav %}{% endblock %} +
+ {% block content %}{% endblock %} + + +``` + +Create `src/templates/login.html`: +```html +{% extends "layout.html" %} +{% block title %}Login - webfingerd{% endblock %} +{% block content %} +
+

Domain Owner Login

+ {% if let Some(error) = error %} +
{{ error }}
+ {% endif %} +
+ + + +
+
+{% endblock %} +``` + +Create `src/templates/dashboard.html`: +```html +{% extends "layout.html" %} +{% block title %}Dashboard - webfingerd{% endblock %} +{% block nav %}Logout{% endblock %} +{% block content %} +

Your Domains

+{% if domains.is_empty() %} +

No domains found for this token.

+{% else %} + + + + {% for d in domains %} + + + + + + + {% endfor %} + +
DomainStatusLinks
{{ d.domain }}{% if d.verified %}Verified{% else %}Pending{% endif %}{{ d.link_count }}Manage
+{% 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 { + 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, +} + +#[derive(Template)] +#[template(path = "dashboard.html")] +pub struct DashboardTemplate { + pub domains: Vec, +} + +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::()["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::()["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::()["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