WIP implement core account creation logic
This commit is contained in:
parent
b522c062c0
commit
55b583b6e6
7 changed files with 259 additions and 95 deletions
|
|
@ -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"] }
|
||||
|
|
|
|||
23
entryway/migrations/20250828184830_initial_schema.sql
Normal file
23
entryway/migrations/20250828184830_initial_schema.sql
Normal file
|
|
@ -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));
|
||||
13
entryway/src/database/error.rs
Normal file
13
entryway/src/database/error.rs
Normal file
|
|
@ -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),
|
||||
}
|
||||
5
entryway/src/database/mod.rs
Normal file
5
entryway/src/database/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod error;
|
||||
pub mod operations;
|
||||
|
||||
pub use error::DatabaseError;
|
||||
pub use operations::Database;
|
||||
82
entryway/src/database/operations.rs
Normal file
82
entryway/src/database/operations.rs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
use sqlx::{Pool, Postgres};
|
||||
use atproto::types::{
|
||||
Handle
|
||||
Did
|
||||
};
|
||||
use crate::database::DatabaseError;
|
||||
|
||||
pub struct Database {
|
||||
pool: Pool<Postgres>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn new(pool: Pool<Postgres>) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
// Account availability checking
|
||||
pub async fn check_handle_available(&self, handle: &Handle) -> Result<bool, DatabaseError> {
|
||||
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<bool, DatabaseError> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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::<Nsid>().expect("valid nsid");
|
||||
router = router.add_endpoint(XrpcEndpoint::not_implemented());
|
||||
|
|
|
|||
|
|
@ -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<CreateAccountInput>) -> 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<CreateAccountInput>) -> 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<PlcOp>), 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<String, argon2::password_hash::Error> {
|
||||
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<RepoCommit, Response> {
|
||||
// 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<Credentials, Response> {
|
||||
// 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<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue