From 55b583b6e608d1d3a73b9e56788d95d83dfa112e Mon Sep 17 00:00:00 2001 From: Julia Lange Date: Fri, 29 Aug 2025 13:15:35 -0700 Subject: [PATCH] WIP implement core account creation logic --- entryway/Cargo.toml | 7 +- .../20250828184830_initial_schema.sql | 23 +++ entryway/src/database/error.rs | 13 ++ entryway/src/database/mod.rs | 5 + entryway/src/database/operations.rs | 82 ++++++++ entryway/src/main.rs | 36 ++++ entryway/src/xrpc/create_account.rs | 188 +++++++++--------- 7 files changed, 259 insertions(+), 95 deletions(-) create mode 100644 entryway/migrations/20250828184830_initial_schema.sql create mode 100644 entryway/src/database/error.rs create mode 100644 entryway/src/database/mod.rs create mode 100644 entryway/src/database/operations.rs diff --git a/entryway/Cargo.toml b/entryway/Cargo.toml index 47e68d8..e4b9f0f 100644 --- a/entryway/Cargo.toml +++ b/entryway/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -atproto.workspace = true +atproto = { workspace = true, features = ["sqlx-support"] } router.workspace = true http = "1.3.1" serde.workspace = true @@ -12,3 +12,8 @@ serde_json.workspace = true tokio.workspace = true tracing-subscriber.workspace = true tracing.workspace = true +async-trait.workspace = true +sqlx.workspace = true +thiserror.workspace = true +argon2 = "0.5" +time = { version = "0.3", features = ["formatting", "macros"] } diff --git a/entryway/migrations/20250828184830_initial_schema.sql b/entryway/migrations/20250828184830_initial_schema.sql new file mode 100644 index 0000000..a6b1ea8 --- /dev/null +++ b/entryway/migrations/20250828184830_initial_schema.sql @@ -0,0 +1,23 @@ +-- PDS Entryway Account Management Schema +-- Minimal schema for account creation and authentication + +-- Actor table - stores public identity information +CREATE TABLE actor ( + did VARCHAR PRIMARY KEY, + handle VARCHAR, + created_at VARCHAR NOT NULL +); + +-- Case-insensitive unique index on handle +CREATE UNIQUE INDEX actor_handle_lower_idx ON actor (LOWER(handle)); + +-- Account table - stores private authentication data +CREATE TABLE account ( + did VARCHAR PRIMARY KEY, + email VARCHAR NOT NULL, + password_scrypt VARCHAR NOT NULL, + email_confirmed_at VARCHAR +); + +-- Case-insensitive unique index on email +CREATE UNIQUE INDEX account_email_lower_idx ON account (LOWER(email)); diff --git a/entryway/src/database/error.rs b/entryway/src/database/error.rs new file mode 100644 index 0000000..855e280 --- /dev/null +++ b/entryway/src/database/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum DatabaseError { + #[error("Database connection error: {0}")] + Connection(#[from] sqlx::Error), + + #[error("Handle already taken: {0}")] + HandleTaken(String), + + #[error("Email already taken: {0}")] + EmailTaken(String), +} \ No newline at end of file diff --git a/entryway/src/database/mod.rs b/entryway/src/database/mod.rs new file mode 100644 index 0000000..fc13e9a --- /dev/null +++ b/entryway/src/database/mod.rs @@ -0,0 +1,5 @@ +pub mod error; +pub mod operations; + +pub use error::DatabaseError; +pub use operations::Database; \ No newline at end of file diff --git a/entryway/src/database/operations.rs b/entryway/src/database/operations.rs new file mode 100644 index 0000000..b5ec548 --- /dev/null +++ b/entryway/src/database/operations.rs @@ -0,0 +1,82 @@ +use sqlx::{Pool, Postgres}; +use atproto::types::{ + Handle + Did +}; +use crate::database::DatabaseError; + +pub struct Database { + pool: Pool, +} + +impl Database { + pub fn new(pool: Pool) -> Self { + Self { pool } + } + + // Account availability checking + pub async fn check_handle_available(&self, handle: &Handle) -> Result { + let count = sqlx::query_scalar!( + "SELECT COUNT(*) FROM actor WHERE LOWER(handle) = LOWER($1)", + handle + ) + .fetch_one(&self.pool) + .await?; + + Ok(count.unwrap_or(0) == 0) + } + + pub async fn check_email_available(&self, email: &str) -> Result { + let count = sqlx::query_scalar!( + "SELECT COUNT(*) FROM account WHERE LOWER(email) = LOWER($1)", + email + ) + .fetch_one(&self.pool) + .await?; + + Ok(count.unwrap_or(0) == 0) + } + + // Account creation + pub async fn create_account( + &self, + did: &Did, + handle: &Handle, + email: &str, + password_hash: &str, + created_at: &str, + ) -> Result<(), DatabaseError> { + // Use a transaction to ensure both actor and account records are created together + let mut tx = self.pool.begin().await?; + + // Insert into actor table + sqlx::query!( + r#" + INSERT INTO actor (did, handle, created_at) + VALUES ($1, $2, $3) + "#, + did, + handle, + created_at + ) + .execute(&mut *tx) + .await?; + + // Insert into account table + sqlx::query!( + r#" + INSERT INTO account (did, email, password_scrypt) + VALUES ($1, $2, $3) + "#, + did, + email, + password_hash + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) + } +} diff --git a/entryway/src/main.rs b/entryway/src/main.rs index 73358ae..98153a4 100644 --- a/entryway/src/main.rs +++ b/entryway/src/main.rs @@ -3,9 +3,15 @@ use router::{ xrpc::XrpcEndpoint, }; use atproto::types::Nsid; +use sqlx::{Pool, Postgres}; +use std::env; +use tracing::{event, Level}; mod xrpc; +mod database; + use xrpc::create_account; +use database::Database; struct Config { entryway_url: String, @@ -19,6 +25,36 @@ async fn main() { let subscriber = tracing_subscriber::FmtSubscriber::new(); let _ = tracing::subscriber::set_global_default(subscriber); + // Set up database connection + let database_url = env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://localhost/entryway_dev".to_string()); + + event!(Level::INFO, "Connecting to database: {}", database_url); + + let pool = match sqlx::postgres::PgPoolOptions::new() + .max_connections(5) + .connect(&database_url) + .await + { + Ok(pool) => pool, + Err(e) => { + event!(Level::ERROR, "Failed to connect to database: {}", e); + std::process::exit(1); + } + }; + + let database = Database::new(pool); + + // Run migrations + if let Err(e) = database.run_migrations().await { + event!(Level::ERROR, "Failed to run migrations: {}", e); + std::process::exit(1); + } + + event!(Level::INFO, "Database setup complete"); + + // TODO: Wire up database to XRPC handlers + // For now, keeping the existing router setup let mut router = Router::new(); let create_account_nsid: Nsid = "com.atproto.server.createAccount".parse::().expect("valid nsid"); router = router.add_endpoint(XrpcEndpoint::not_implemented()); diff --git a/entryway/src/xrpc/create_account.rs b/entryway/src/xrpc/create_account.rs index 66d5070..7f0a0d6 100644 --- a/entryway/src/xrpc/create_account.rs +++ b/entryway/src/xrpc/create_account.rs @@ -4,6 +4,9 @@ use http::status::StatusCode; use tracing::{event, instrument, Level}; use atproto::types::Handle; use std::str::FromStr; +use argon2::{Argon2, PasswordHasher, password_hash::{rand_core::OsRng, SaltString}}; +use time::OffsetDateTime; +use crate::database::{Database, DatabaseError}; #[derive(Deserialize, Debug)] pub struct CreateAccountInput { @@ -31,7 +34,8 @@ pub struct CreateAccountResponse { pub async fn create_account(data: ProcedureInput) -> Response { event!(Level::INFO, "Creating account for handle: {}", data.input.handle); - // TODO: Implement the following steps based on the TypeScript reference: + // TODO: Get database from context/config + // For now, this won't compile but shows the intended flow // 1. Input validation let validated_input = match validate_inputs(&data.input).await { @@ -40,59 +44,52 @@ pub async fn create_account(data: ProcedureInput) -> Respons }; // 2. Check handle and email availability - if let Err(err) = check_availability(&validated_input).await { - return err; - } + // if let Err(err) = check_availability(&database, &validated_input).await { + // return err; + // } - // 3. Generate DID and signing key - let (did, signing_key, plc_op) = match generate_identity(&validated_input).await { - Ok(identity) => identity, - Err(err) => return err, - }; + // 3. Generate DID (placeholder for now) + let did = generate_placeholder_did(&validated_input.handle).await; - // 4. Create actor store entry - if let Err(err) = create_actor_store(&did, &signing_key).await { - return err; - } - - // 5. Create repository - let repo_commit = match create_repository(&did).await { - Ok(commit) => commit, - Err(err) => return err, - }; - - // 6. Submit PLC operation (if needed) - if let Some(op) = plc_op { - if let Err(err) = submit_plc_operation(&did, &op).await { - return err; + // 4. Hash password if provided + let password_hash = if let Some(password) = &validated_input.password { + match hash_password(password) { + Ok(hash) => Some(hash), + Err(_) => { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalServerError", + "Failed to hash password" + ); + } } - } - - // 7. Create account and session - let credentials = match create_account_and_session(&validated_input, &did, &repo_commit).await { - Ok(creds) => creds, - Err(err) => return err, + } else { + None }; - // 8. Sequence events (identity, account, commit, sync) - if let Err(err) = sequence_events(&did, &validated_input.handle, &repo_commit).await { - return err; - } + // 5. Create account in database + let created_at = OffsetDateTime::now_utc().format(&time::format_description::well_known::Iso8601::DEFAULT) + .unwrap_or_else(|_| "unknown".to_string()); + + // if let Err(err) = create_account_in_db(&database, &did, &validated_input, password_hash.as_deref(), &created_at).await { + // return convert_db_error_to_response(err); + // } - // 9. Update repo root - if let Err(err) = update_repo_root(&did, &repo_commit).await { - return err; - } + // 6. Generate session tokens (placeholder for now) + let credentials = Credentials { + access_jwt: "placeholder_access_token".to_string(), + refresh_jwt: "placeholder_refresh_token".to_string(), + }; // Return success response let response = CreateAccountResponse { - handle: validated_input.handle, + handle: validated_input.handle.clone(), did: did.clone(), access_jwt: credentials.access_jwt, refresh_jwt: credentials.refresh_jwt, }; - event!(Level::INFO, "Account created successfully for DID: {}", did); + event!(Level::INFO, "Account created successfully for DID: {} with handle: {}", did, validated_input.handle); // TODO: Replace with proper JSON response encoding error(StatusCode::OK, "success", "Account created successfully") @@ -179,54 +176,73 @@ fn is_valid_email(email: &str) -> bool { } } -async fn check_availability(input: &ValidatedInput) -> Result<(), Response> { +async fn check_availability(database: &Database, input: &ValidatedInput) -> Result<(), Response> { // Check that handle and email are not already taken - todo!("Implement availability checking") + match database.check_handle_available(&input.handle).await { + Ok(false) => { + return Err(error( + StatusCode::BAD_REQUEST, + "InvalidRequest", + &format!("Handle already taken: {}", input.handle) + )); + } + Err(err) => { + event!(Level::ERROR, "Database error checking handle availability: {:?}", err); + return Err(error( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalServerError", + "Database error" + )); + } + _ => {} + } + + match database.check_email_available(&input.email).await { + Ok(false) => { + return Err(error( + StatusCode::BAD_REQUEST, + "InvalidRequest", + &format!("Email already taken: {}", input.email) + )); + } + Err(err) => { + event!(Level::ERROR, "Database error checking email availability: {:?}", err); + return Err(error( + StatusCode::INTERNAL_SERVER_ERROR, + "InternalServerError", + "Database error" + )); + } + _ => {} + } + + Ok(()) } -async fn generate_identity(input: &ValidatedInput) -> Result<(String, SigningKey, Option), Response> { - // Generate signing key - // Create DID and PLC operation if not provided - todo!("Implement identity generation") +async fn generate_placeholder_did(handle: &str) -> String { + // TODO: Replace with actual DID generation (did:plc) + // For now, generate a placeholder DID based on handle + format!("did:placeholder:{}", handle.replace(".", "-")) } -async fn create_actor_store(did: &str, signing_key: &SigningKey) -> Result<(), Response> { - // Create actor store entry for the new account - todo!("Implement actor store creation") +fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2.hash_password(password.as_bytes(), &salt)?; + Ok(password_hash.to_string()) } -async fn create_repository(did: &str) -> Result { - // Create empty repository for the account - todo!("Implement repository creation") -} - -async fn submit_plc_operation(did: &str, plc_op: &PlcOp) -> Result<(), Response> { - // Submit PLC operation to register/update DID - todo!("Implement PLC operation submission") -} - -async fn create_account_and_session( - input: &ValidatedInput, +async fn create_account_in_db( + database: &Database, did: &str, - repo_commit: &RepoCommit, -) -> Result { - // Create account record and initial session - // Generate JWT tokens - todo!("Implement account and session creation") + input: &ValidatedInput, + password_hash: Option<&str>, + created_at: &str, +) -> Result<(), DatabaseError> { + let hash = password_hash.unwrap_or(""); // Empty hash if no password + database.create_account(did, &input.handle, &input.email, hash, created_at).await } -async fn sequence_events(did: &str, handle: &str, repo_commit: &RepoCommit) -> Result<(), Response> { - // Sequence identity, account, commit, and sync events - todo!("Implement event sequencing") -} - -async fn update_repo_root(did: &str, repo_commit: &RepoCommit) -> Result<(), Response> { - // Update repository root reference - todo!("Implement repo root update") -} - -// TODO: Define these types based on our implementation needs - #[derive(Debug)] struct ValidatedInput { handle: String, @@ -235,22 +251,6 @@ struct ValidatedInput { invite_code: Option, } -#[derive(Debug)] -struct SigningKey { - // TODO: Define signing key structure -} - -#[derive(Debug)] -struct PlcOp { - // TODO: Define PLC operation structure -} - -#[derive(Debug)] -struct RepoCommit { - cid: String, - rev: String, -} - #[derive(Debug)] struct Credentials { access_jwt: String,