Replace username/password authentication with FIDO2/WebAuthn passkey-based auth using webauthn-rs. Sessions and bearer tokens are unchanged — only the way they are created changes. - Add webauthn-rs, uuid, open, url deps; remove argon2, rpassword - Add passkey_credentials and registration_tokens tables (migrations 17-18) - Add domain entities, typed IDs, and repository traits for passkeys/tokens - Add SQL repository implementations for passkeys and registration tokens - Add ChallengeStore for in-memory WebAuthn ceremony state - Add WebAuthn route handlers (register/auth start+finish ceremonies) - Add CLI browser handoff for token creation (opens browser, local callback) - Replace login form with "Sign in with Passkey" button - Add registration page for first-user bootstrap via one-time token - Replace BREWLOG_ADMIN_USERNAME/PASSWORD with BREWLOG_RP_ID/RP_ORIGIN - Change default BREWLOG_URL from 127.0.0.1 to localhost (WebAuthn requires it)
144 lines
5.1 KiB
Rust
144 lines
5.1 KiB
Rust
use anyhow::Result;
|
|
use brewlog::application::{ServerConfig, serve};
|
|
use brewlog::infrastructure::backup::BackupData;
|
|
use brewlog::infrastructure::client::BrewlogClient;
|
|
use brewlog::presentation::cli::{
|
|
Cli, Commands, ServeCommand, bags, brews, cafes, cups, gear, roasters, roasts, tokens,
|
|
};
|
|
use clap::Parser;
|
|
use tracing::{Subscriber, subscriber::set_global_default};
|
|
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
|
|
use tracing_log::LogTracer;
|
|
use tracing_subscriber::fmt::MakeWriter;
|
|
use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt};
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
// Load .env file if present (before clap parses env vars)
|
|
let _ = dotenvy::dotenv();
|
|
|
|
let subscriber = get_subscriber("brewlog".into(), "info".into(), std::io::stdout);
|
|
init_subscriber(subscriber);
|
|
|
|
let cli = Cli::parse();
|
|
|
|
match cli.command {
|
|
Commands::Serve(cmd) => run_server(cmd).await,
|
|
Commands::Roaster { command } => {
|
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
|
roasters::run(&client, command).await
|
|
}
|
|
Commands::Roast { command } => {
|
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
|
roasts::run(&client, command).await
|
|
}
|
|
Commands::Bag { command } => {
|
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
|
bags::run(&client, command).await
|
|
}
|
|
Commands::Gear { command } => {
|
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
|
gear::run(&client, command).await
|
|
}
|
|
Commands::Brew { command } => {
|
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
|
brews::run(&client, command).await
|
|
}
|
|
Commands::Cafe { command } => {
|
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
|
cafes::run(&client, command).await
|
|
}
|
|
Commands::Cup { command } => {
|
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
|
cups::run(&client, command).await
|
|
}
|
|
Commands::Token { command } => {
|
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
|
tokens::run(&client, command).await
|
|
}
|
|
Commands::Backup(_cmd) => {
|
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
|
let data = client.backup().export().await?;
|
|
let json = serde_json::to_string_pretty(&data)?;
|
|
println!("{json}");
|
|
Ok(())
|
|
}
|
|
Commands::Restore(cmd) => {
|
|
let contents = std::fs::read_to_string(&cmd.file)?;
|
|
let data: BackupData = serde_json::from_str(&contents)?;
|
|
let client = BrewlogClient::from_base_url(&cli.api_url)?;
|
|
client.backup().restore(&data).await?;
|
|
eprintln!("Restore complete.");
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_server(command: ServeCommand) -> Result<()> {
|
|
let rp_id = command.rp_id.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"BREWLOG_RP_ID is required. Set this to the domain where the app is hosted \
|
|
(e.g. 'brewlog.example.com' or 'localhost')."
|
|
)
|
|
})?;
|
|
|
|
let rp_origin = command.rp_origin.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"BREWLOG_RP_ORIGIN is required. Set this to the full origin URL \
|
|
(e.g. 'https://brewlog.example.com' or 'http://localhost:3000')."
|
|
)
|
|
})?;
|
|
|
|
let openrouter_api_key = command.openrouter_api_key.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"BREWLOG_OPENROUTER_API_KEY is required. Set this environment variable \
|
|
to your OpenRouter API key for AI-powered extraction features."
|
|
)
|
|
})?;
|
|
|
|
let foursquare_api_key = command.foursquare_api_key.ok_or_else(|| {
|
|
anyhow::anyhow!(
|
|
"BREWLOG_FOURSQUARE_API_KEY is required. Set this environment variable \
|
|
to your Foursquare API key for nearby cafe search."
|
|
)
|
|
})?;
|
|
|
|
let config = ServerConfig {
|
|
bind_address: command.bind_address,
|
|
database_url: command.database_url,
|
|
rp_id,
|
|
rp_origin,
|
|
openrouter_api_key,
|
|
openrouter_model: command.openrouter_model,
|
|
foursquare_api_key,
|
|
};
|
|
|
|
serve(config).await
|
|
}
|
|
|
|
pub fn get_subscriber<Sink>(
|
|
name: String,
|
|
env_filter: String,
|
|
sink: Sink,
|
|
) -> impl Subscriber + Send + Sync
|
|
where
|
|
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
|
|
{
|
|
let env_filter =
|
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter));
|
|
let formatting_layer = BunyanFormattingLayer::new(name, sink);
|
|
|
|
Registry::default()
|
|
.with(env_filter)
|
|
.with(JsonStorageLayer)
|
|
.with(formatting_layer)
|
|
}
|
|
|
|
/// Register a subscriber as global default to process span data.
|
|
///
|
|
/// This should only be called once!
|
|
#[allow(clippy::expect_used)] // Startup: panicking is appropriate if logging cannot be initialized
|
|
pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
|
|
LogTracer::init().expect("Failed to set logger");
|
|
set_global_default(subscriber).expect("Failed to set subscriber");
|
|
}
|