···11+// pattern: Imperative Shell
22+//
33+// POST /v1/dids — DID creation and account promotion
44+//
55+// Inputs:
66+// - Authorization: Bearer <pending_session_token>
77+// - JSON body: { "signingKey": "did:key:z...", "rotationKey": "did:key:z..." }
88+//
99+// Processing steps:
1010+// 1. require_pending_session → PendingSessionInfo { account_id, device_id }
1111+// 2. SELECT handle, pending_did FROM pending_accounts WHERE id = account_id
1212+// 3. SELECT private_key_encrypted FROM relay_signing_keys WHERE id = signing_key
1313+// 4. decrypt_private_key(encrypted, master_key)
1414+// 5. build_did_plc_genesis_op(rotation_key, signing_key, private_key, handle, public_url)
1515+// 6. If pending_did IS NULL: UPDATE pending_accounts SET pending_did = did (pre-store resilience)
1616+// 7. If pending_did IS NOT NULL (retry): skip step 8
1717+// 8. POST {plc_directory_url}/{did} with signed_op_json
1818+// 9. Atomic transaction:
1919+// INSERT accounts (did, email, password_hash=NULL)
2020+// INSERT did_documents (did, document)
2121+// INSERT handles (handle, did)
2222+// DELETE pending_sessions WHERE account_id = ?
2323+// DELETE pending_accounts WHERE id = ?
2424+// 10. Return { "did": "did:plc:...", "status": "active" }
2525+//
2626+// Outputs (success): 200 { "did": "did:plc:...", "status": "active" }
2727+// Outputs (error): 401 UNAUTHORIZED, 404 NOT_FOUND, 409 DID_ALREADY_EXISTS,
2828+// 502 PLC_DIRECTORY_ERROR, 500 INTERNAL_ERROR
2929+3030+use axum::{extract::State, http::HeaderMap, Json};
3131+use serde::{Deserialize, Serialize};
3232+3333+use crate::app::AppState;
3434+use crate::routes::auth::require_pending_session;
3535+use common::{ApiError, ErrorCode};
3636+3737+#[derive(Deserialize)]
3838+#[serde(rename_all = "camelCase")]
3939+pub struct CreateDidRequest {
4040+ pub signing_key: String,
4141+ pub rotation_key: String,
4242+}
4343+4444+#[derive(Serialize)]
4545+pub struct CreateDidResponse {
4646+ pub did: String,
4747+ pub status: &'static str,
4848+}
4949+5050+pub async fn create_did_handler(
5151+ State(state): State<AppState>,
5252+ headers: HeaderMap,
5353+ Json(payload): Json<CreateDidRequest>,
5454+) -> Result<Json<CreateDidResponse>, ApiError> {
5555+ // Step 1: Authenticate via pending_session Bearer token.
5656+ let session = require_pending_session(&headers, &state.db).await?;
5757+5858+ // Step 2: Load pending account details.
5959+ let (handle, pending_did, email): (String, Option<String>, String) = sqlx::query_as(
6060+ "SELECT handle, pending_did, email FROM pending_accounts WHERE id = ?",
6161+ )
6262+ .bind(&session.account_id)
6363+ .fetch_optional(&state.db)
6464+ .await
6565+ .map_err(|e| {
6666+ tracing::error!(error = %e, "failed to query pending account");
6767+ ApiError::new(ErrorCode::InternalError, "failed to load account")
6868+ })?
6969+ .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "account not found"))?;
7070+7171+ // Step 3: Look up signing key in relay_signing_keys.
7272+ let (private_key_encrypted,): (String,) = sqlx::query_as(
7373+ "SELECT private_key_encrypted FROM relay_signing_keys WHERE id = ?",
7474+ )
7575+ .bind(&payload.signing_key)
7676+ .fetch_optional(&state.db)
7777+ .await
7878+ .map_err(|e| {
7979+ tracing::error!(error = %e, "failed to query relay signing key");
8080+ ApiError::new(ErrorCode::InternalError, "key lookup failed")
8181+ })?
8282+ .ok_or_else(|| {
8383+ ApiError::new(ErrorCode::NotFound, "signing key not found in relay_signing_keys")
8484+ })?;
8585+8686+ // Step 4: Decrypt the private key using the master key from config.
8787+ let master_key: &[u8; 32] = state
8888+ .config
8989+ .signing_key_master_key
9090+ .as_ref()
9191+ .map(|s| &*s.0)
9292+ .ok_or_else(|| {
9393+ ApiError::new(ErrorCode::InternalError, "signing key master key not configured")
9494+ })?;
9595+9696+ let private_key_bytes = crypto::decrypt_private_key(&private_key_encrypted, master_key)
9797+ .map_err(|e| {
9898+ tracing::error!(error = %e, "failed to decrypt signing key");
9999+ ApiError::new(ErrorCode::InternalError, "failed to decrypt signing key")
100100+ })?;
101101+102102+ // Step 5: Build the genesis operation and derive the DID.
103103+ let rotation_key = crypto::DidKeyUri(payload.rotation_key.clone());
104104+ let signing_key_uri = crypto::DidKeyUri(payload.signing_key.clone());
105105+106106+ let genesis = crypto::build_did_plc_genesis_op(
107107+ &rotation_key,
108108+ &signing_key_uri,
109109+ &private_key_bytes,
110110+ &handle,
111111+ &state.config.public_url,
112112+ )
113113+ .map_err(|e| {
114114+ tracing::error!(error = %e, "failed to build genesis op");
115115+ ApiError::new(ErrorCode::InternalError, "failed to build genesis operation")
116116+ })?;
117117+118118+ let did = genesis.did.clone();
119119+ let signed_op_json = genesis.signed_op_json;
120120+121121+ // Step 6: Pre-store the DID for retry resilience.
122122+ // If pending_did is already set, we are on a retry path — skip the plc.directory call.
123123+ let skip_plc_directory = if let Some(pre_stored_did) = &pending_did {
124124+ // Retry: use the pre-stored DID (should match — same deterministic inputs).
125125+ tracing::info!(did = %pre_stored_did, "retry detected: pending_did already set, skipping plc.directory");
126126+ true
127127+ } else {
128128+ // First attempt: write the DID before calling plc.directory.
129129+ sqlx::query(
130130+ "UPDATE pending_accounts SET pending_did = ? WHERE id = ?",
131131+ )
132132+ .bind(&did)
133133+ .bind(&session.account_id)
134134+ .execute(&state.db)
135135+ .await
136136+ .map_err(|e| {
137137+ tracing::error!(error = %e, "failed to pre-store pending_did");
138138+ ApiError::new(ErrorCode::InternalError, "failed to store pending DID")
139139+ })?;
140140+ false
141141+ };
142142+143143+ // Step 7: Check if the account is already fully promoted (idempotency guard for AC2.10).
144144+ let already_promoted: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM accounts WHERE did = ?)")
145145+ .bind(&did)
146146+ .fetch_one(&state.db)
147147+ .await
148148+ .map_err(|e| {
149149+ tracing::error!(error = %e, "failed to check accounts existence");
150150+ ApiError::new(ErrorCode::InternalError, "database error")
151151+ })?;
152152+153153+ if already_promoted {
154154+ return Err(ApiError::new(ErrorCode::DidAlreadyExists, "DID is already fully promoted"));
155155+ }
156156+157157+ // Step 8: POST the genesis operation to plc.directory (skipped on retry).
158158+ if !skip_plc_directory {
159159+ let plc_url = format!("{}/{}", state.config.plc_directory_url, did);
160160+ let response = state
161161+ .http_client
162162+ .post(&plc_url)
163163+ .body(signed_op_json.clone())
164164+ .header("Content-Type", "application/json")
165165+ .send()
166166+ .await
167167+ .map_err(|e| {
168168+ tracing::error!(error = %e, plc_url = %plc_url, "failed to contact plc.directory");
169169+ ApiError::new(ErrorCode::PlcDirectoryError, "failed to contact plc.directory")
170170+ })?;
171171+172172+ if !response.status().is_success() {
173173+ let status = response.status();
174174+ tracing::error!(status = %status, "plc.directory rejected genesis operation");
175175+ return Err(ApiError::new(
176176+ ErrorCode::PlcDirectoryError,
177177+ format!("plc.directory returned {status}"),
178178+ ));
179179+ }
180180+ }
181181+182182+ // Step 9: Build the DID document for local storage.
183183+ let did_document = build_did_document(&did, &handle, &payload.signing_key, &state.config.public_url);
184184+185185+ // Step 10: Atomically promote the account.
186186+ let mut tx = state
187187+ .db
188188+ .begin()
189189+ .await
190190+ .inspect_err(|e| tracing::error!(error = %e, "failed to begin promotion transaction"))
191191+ .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to begin transaction"))?;
192192+193193+ sqlx::query(
194194+ "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \
195195+ VALUES (?, ?, NULL, datetime('now'), datetime('now'))",
196196+ )
197197+ .bind(&did)
198198+ .bind(&email)
199199+ .execute(&mut *tx)
200200+ .await
201201+ .inspect_err(|e| tracing::error!(error = %e, "failed to insert account"))
202202+ .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to create account"))?;
203203+204204+ sqlx::query(
205205+ "INSERT INTO did_documents (did, document, created_at, updated_at) \
206206+ VALUES (?, ?, datetime('now'), datetime('now'))",
207207+ )
208208+ .bind(&did)
209209+ .bind(&did_document)
210210+ .execute(&mut *tx)
211211+ .await
212212+ .inspect_err(|e| tracing::error!(error = %e, "failed to insert did_document"))
213213+ .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to store DID document"))?;
214214+215215+ sqlx::query(
216216+ "INSERT INTO handles (handle, did, created_at) VALUES (?, ?, datetime('now'))",
217217+ )
218218+ .bind(&handle)
219219+ .bind(&did)
220220+ .execute(&mut *tx)
221221+ .await
222222+ .inspect_err(|e| tracing::error!(error = %e, "failed to insert handle"))
223223+ .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register handle"))?;
224224+225225+ sqlx::query("DELETE FROM pending_sessions WHERE account_id = ?")
226226+ .bind(&session.account_id)
227227+ .execute(&mut *tx)
228228+ .await
229229+ .inspect_err(|e| tracing::error!(error = %e, "failed to delete pending sessions"))
230230+ .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up sessions"))?;
231231+232232+ sqlx::query("DELETE FROM devices WHERE account_id = ?")
233233+ .bind(&session.account_id)
234234+ .execute(&mut *tx)
235235+ .await
236236+ .inspect_err(|e| tracing::error!(error = %e, "failed to delete devices"))
237237+ .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up devices"))?;
238238+239239+ sqlx::query("DELETE FROM pending_accounts WHERE id = ?")
240240+ .bind(&session.account_id)
241241+ .execute(&mut *tx)
242242+ .await
243243+ .inspect_err(|e| tracing::error!(error = %e, "failed to delete pending account"))
244244+ .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up account"))?;
245245+246246+ tx.commit()
247247+ .await
248248+ .inspect_err(|e| tracing::error!(error = %e, "failed to commit promotion transaction"))
249249+ .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to commit transaction"))?;
250250+251251+ Ok(Json(CreateDidResponse { did, status: "active" }))
252252+}
253253+254254+/// Construct a minimal DID Core document from known fields.
255255+///
256256+/// No I/O — pure construction from parameters.
257257+fn build_did_document(
258258+ did: &str,
259259+ handle: &str,
260260+ signing_key_did: &str,
261261+ service_endpoint: &str,
262262+) -> String {
263263+ // Extract the multibase-encoded public key from the did:key URI.
264264+ // did:key:zAbcDef... → publicKeyMultibase = "zAbcDef..."
265265+ let public_key_multibase = signing_key_did
266266+ .strip_prefix("did:key:")
267267+ .unwrap_or(signing_key_did);
268268+269269+ serde_json::json!({
270270+ "@context": [
271271+ "https://www.w3.org/ns/did/v1"
272272+ ],
273273+ "id": did,
274274+ "alsoKnownAs": [format!("at://{handle}")],
275275+ "verificationMethod": [{
276276+ "id": format!("{did}#atproto"),
277277+ "type": "Multikey",
278278+ "controller": did,
279279+ "publicKeyMultibase": public_key_multibase
280280+ }],
281281+ "service": [{
282282+ "id": "#atproto_pds",
283283+ "type": "AtprotoPersonalDataServer",
284284+ "serviceEndpoint": service_endpoint
285285+ }]
286286+ })
287287+ .to_string()
288288+}
289289+290290+// ── Tests ────────────────────────────────────────────────────────────────────
291291+292292+#[cfg(test)]
293293+mod tests {
294294+ use super::*;
295295+ use crate::app::test_state_with_plc_url;
296296+ use axum::{
297297+ body::Body,
298298+ http::{Request, StatusCode},
299299+ };
300300+ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
301301+ use rand_core::{OsRng, RngCore};
302302+ use sha2::{Digest, Sha256};
303303+ use tower::ServiceExt; // for `.oneshot()`
304304+ use uuid::Uuid;
305305+ use wiremock::{Mock, MockServer, ResponseTemplate, matchers::{method, path_regex}};
306306+307307+ // ── Test setup helpers ────────────────────────────────────────────────────
308308+309309+ /// A test master key: 32 bytes of 0x01.
310310+ const TEST_MASTER_KEY: [u8; 32] = [0x01u8; 32];
311311+312312+ /// All data needed to call POST /v1/dids in a test.
313313+ struct TestSetup {
314314+ session_token: String,
315315+ signing_key_id: String,
316316+ rotation_key_id: String,
317317+ account_id: String,
318318+ /// The handle stored in `pending_accounts`. Needed for AC2.10 to re-create
319319+ /// a second pending account that derives the same DID (same keys + same handle).
320320+ handle: String,
321321+ }
322322+323323+ /// Insert all prerequisite rows for a DID-creation test.
324324+ ///
325325+ /// Inserts: relay_signing_key, pending_account (with claim code), device, pending_session.
326326+ ///
327327+ /// Pre-step: Read `crates/relay/src/routes/test_utils.rs` to see if helpers already
328328+ /// exist for inserting claim codes, pending accounts, or pending sessions. Use them here
329329+ /// if available. If not, use the raw SQL below.
330330+ async fn insert_test_data(db: &sqlx::SqlitePool) -> TestSetup {
331331+ use crypto::{encrypt_private_key, generate_p256_keypair};
332332+333333+ // Generate signing and rotation keypairs.
334334+ let signing_kp = generate_p256_keypair().expect("signing keypair");
335335+ let rotation_kp = generate_p256_keypair().expect("rotation keypair");
336336+337337+ // Encrypt the signing private key with the test master key.
338338+ let encrypted =
339339+ encrypt_private_key(&signing_kp.private_key_bytes, &TEST_MASTER_KEY)
340340+ .expect("encrypt key");
341341+342342+ // Insert relay_signing_key.
343343+ sqlx::query(
344344+ "INSERT INTO relay_signing_keys \
345345+ (id, algorithm, public_key, private_key_encrypted, created_at) \
346346+ VALUES (?, 'p256', ?, ?, datetime('now'))",
347347+ )
348348+ .bind(&signing_kp.key_id.0)
349349+ .bind(&signing_kp.public_key)
350350+ .bind(&encrypted)
351351+ .execute(db)
352352+ .await
353353+ .expect("insert relay_signing_key");
354354+355355+ // Insert a claim_code row (required FK for pending_accounts).
356356+ let claim_code = format!("TEST-{}", Uuid::new_v4());
357357+ sqlx::query(
358358+ "INSERT INTO claim_codes (code, expires_at, created_at) \
359359+ VALUES (?, datetime('now', '+1 hour'), datetime('now'))",
360360+ )
361361+ .bind(&claim_code)
362362+ .execute(db)
363363+ .await
364364+ .expect("insert claim_code");
365365+366366+ // Insert pending_account.
367367+ let account_id = Uuid::new_v4().to_string();
368368+ let handle = format!("alice{}.example.com", &account_id[..8]);
369369+ sqlx::query(
370370+ "INSERT INTO pending_accounts \
371371+ (id, email, handle, tier, claim_code, created_at) \
372372+ VALUES (?, ?, ?, 'free', ?, datetime('now'))",
373373+ )
374374+ .bind(&account_id)
375375+ .bind(format!("alice{}@example.com", &account_id[..8]))
376376+ .bind(&handle)
377377+ .bind(&claim_code)
378378+ .execute(db)
379379+ .await
380380+ .expect("insert pending_account");
381381+382382+ // Insert a device (required FK for pending_sessions).
383383+ let device_id = Uuid::new_v4().to_string();
384384+ sqlx::query(
385385+ "INSERT INTO devices \
386386+ (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \
387387+ VALUES (?, ?, 'ios', 'test_pubkey', 'test_device_hash', datetime('now'), datetime('now'))",
388388+ )
389389+ .bind(&device_id)
390390+ .bind(&account_id)
391391+ .execute(db)
392392+ .await
393393+ .expect("insert device");
394394+395395+ // Generate pending session token.
396396+ let mut token_bytes = [0u8; 32];
397397+ OsRng.fill_bytes(&mut token_bytes);
398398+ let session_token = URL_SAFE_NO_PAD.encode(token_bytes);
399399+ let token_hash: String = Sha256::digest(token_bytes)
400400+ .iter()
401401+ .map(|b| format!("{b:02x}"))
402402+ .collect();
403403+404404+ // Insert pending_session.
405405+ sqlx::query(
406406+ "INSERT INTO pending_sessions \
407407+ (id, account_id, device_id, token_hash, created_at, expires_at) \
408408+ VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+1 hour'))",
409409+ )
410410+ .bind(Uuid::new_v4().to_string())
411411+ .bind(&account_id)
412412+ .bind(&device_id)
413413+ .bind(&token_hash)
414414+ .execute(db)
415415+ .await
416416+ .expect("insert pending_session");
417417+418418+ TestSetup {
419419+ session_token,
420420+ signing_key_id: signing_kp.key_id.0,
421421+ rotation_key_id: rotation_kp.key_id.0,
422422+ account_id,
423423+ handle,
424424+ }
425425+ }
426426+427427+ /// Create an AppState with TEST_MASTER_KEY set and plc_directory_url pointing to the mock.
428428+ async fn test_state_for_did(plc_url: String) -> AppState {
429429+ use common::Sensitive;
430430+ use std::sync::Arc;
431431+ use zeroize::Zeroizing;
432432+433433+ let base = test_state_with_plc_url(plc_url).await;
434434+ let mut config = (*base.config).clone();
435435+ config.signing_key_master_key = Some(Sensitive(Zeroizing::new(TEST_MASTER_KEY)));
436436+ AppState {
437437+ config: Arc::new(config),
438438+ db: base.db,
439439+ http_client: base.http_client,
440440+ }
441441+ }
442442+443443+ /// Build a POST /v1/dids request with the given session token and body.
444444+ fn create_did_request(
445445+ session_token: &str,
446446+ signing_key: &str,
447447+ rotation_key: &str,
448448+ ) -> Request<Body> {
449449+ let body = serde_json::json!({
450450+ "signingKey": signing_key,
451451+ "rotationKey": rotation_key,
452452+ });
453453+ Request::builder()
454454+ .method("POST")
455455+ .uri("/v1/dids")
456456+ .header("Authorization", format!("Bearer {session_token}"))
457457+ .header("Content-Type", "application/json")
458458+ .body(Body::from(body.to_string()))
459459+ .unwrap()
460460+ }
461461+462462+ // ── AC2.1: Valid request returns 200 with { did, status: "active" } ───────
463463+464464+ /// MM-89.AC2.1, AC2.2, AC2.3, AC2.4, AC2.5: Happy path — full promotion
465465+ #[tokio::test]
466466+ async fn happy_path_promotes_account_and_returns_did() {
467467+ let mock_server = MockServer::start().await;
468468+ Mock::given(method("POST"))
469469+ .and(path_regex(r"^/did:plc:[a-z2-7]+$"))
470470+ .respond_with(ResponseTemplate::new(200))
471471+ .expect(1)
472472+ .named("plc.directory genesis op")
473473+ .mount(&mock_server)
474474+ .await;
475475+476476+ let state = test_state_for_did(mock_server.uri()).await;
477477+ let db = state.db.clone();
478478+ let setup = insert_test_data(&db).await;
479479+480480+ let app = crate::app::app(state);
481481+ let response = app
482482+ .oneshot(create_did_request(
483483+ &setup.session_token,
484484+ &setup.signing_key_id,
485485+ &setup.rotation_key_id,
486486+ ))
487487+ .await
488488+ .unwrap();
489489+490490+ // AC2.1: 200 OK with did + status
491491+ assert_eq!(response.status(), StatusCode::OK);
492492+ let body: serde_json::Value =
493493+ serde_json::from_slice(&axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap();
494494+ let did = body["did"].as_str().expect("did field");
495495+ assert!(did.starts_with("did:plc:"), "did should start with did:plc:");
496496+ assert_eq!(body["status"], "active");
497497+498498+ // AC2.2: accounts row with null password_hash
499499+ let (stored_email, stored_hash): (String, Option<String>) =
500500+ sqlx::query_as("SELECT email, password_hash FROM accounts WHERE did = ?")
501501+ .bind(did)
502502+ .fetch_one(&db)
503503+ .await
504504+ .expect("accounts row should exist");
505505+ assert!(stored_hash.is_none(), "password_hash should be NULL");
506506+ assert!(stored_email.contains("alice"), "email should be set");
507507+508508+ // AC2.3: did_documents row with non-empty document
509509+ let (doc,): (String,) =
510510+ sqlx::query_as("SELECT document FROM did_documents WHERE did = ?")
511511+ .bind(did)
512512+ .fetch_one(&db)
513513+ .await
514514+ .expect("did_documents row should exist");
515515+ assert!(!doc.is_empty(), "did_document should be non-empty");
516516+517517+ // AC2.4: handles row
518518+ let (handle_did,): (String,) =
519519+ sqlx::query_as("SELECT did FROM handles WHERE did = ?")
520520+ .bind(did)
521521+ .fetch_one(&db)
522522+ .await
523523+ .expect("handles row should exist");
524524+ assert_eq!(handle_did, did);
525525+526526+ // AC2.5: pending_accounts and pending_sessions deleted
527527+ let pending_count: i64 =
528528+ sqlx::query_scalar("SELECT COUNT(*) FROM pending_accounts WHERE id = ?")
529529+ .bind(&setup.account_id)
530530+ .fetch_one(&db)
531531+ .await
532532+ .unwrap();
533533+ assert_eq!(pending_count, 0, "pending_account should be deleted");
534534+535535+ let session_count: i64 =
536536+ sqlx::query_scalar("SELECT COUNT(*) FROM pending_sessions WHERE account_id = ?")
537537+ .bind(&setup.account_id)
538538+ .fetch_one(&db)
539539+ .await
540540+ .unwrap();
541541+ assert_eq!(session_count, 0, "pending_sessions should be deleted");
542542+ }
543543+544544+ /// MM-89.AC2.6: Retry path — pending_did pre-set, plc.directory NOT called
545545+ #[tokio::test]
546546+ async fn retry_with_pending_did_skips_plc_directory() {
547547+ let mock_server = MockServer::start().await;
548548+ // Expect zero calls to plc.directory on a retry.
549549+ // MockServer auto-verifies .expect(0) on drop — if plc.directory is called,
550550+ // the mock panics and the test fails.
551551+ Mock::given(method("POST"))
552552+ .and(path_regex(r"^/did:plc:.*$"))
553553+ .respond_with(ResponseTemplate::new(200))
554554+ .expect(0) // Must NOT be called
555555+ .named("plc.directory (should not be called on retry)")
556556+ .mount(&mock_server)
557557+ .await;
558558+559559+ let state = test_state_for_did(mock_server.uri()).await;
560560+ let db = state.db.clone();
561561+ let setup = insert_test_data(&db).await;
562562+563563+ // Simulate a partial-failure retry: set pending_did to any non-null value.
564564+ // The handler checks `pending_did.is_some()` as a boolean flag to skip
565565+ // plc.directory. It does NOT use the stored value — it always re-derives
566566+ // the DID from the crypto function (deterministic from key + handle inputs).
567567+ // So any syntactically valid DID string works here.
568568+ let any_did = "did:plc:abcdefghijklmnopqrstuvwx";
569569+ sqlx::query("UPDATE pending_accounts SET pending_did = ? WHERE id = ?")
570570+ .bind(any_did)
571571+ .bind(&setup.account_id)
572572+ .execute(&db)
573573+ .await
574574+ .expect("pre-store pending_did");
575575+576576+ let app = crate::app::app(state);
577577+ let response = app
578578+ .oneshot(create_did_request(
579579+ &setup.session_token,
580580+ &setup.signing_key_id,
581581+ &setup.rotation_key_id,
582582+ ))
583583+ .await
584584+ .unwrap();
585585+586586+ // The route skips plc.directory (enforced by .expect(0) above) and proceeds
587587+ // to promote the account using the crypto-derived DID. Returns 200.
588588+ assert_eq!(
589589+ response.status(),
590590+ StatusCode::OK,
591591+ "retry should succeed with 200"
592592+ );
593593+ }
594594+595595+ /// MM-89.AC2.7: Missing Authorization header returns 401
596596+ #[tokio::test]
597597+ async fn missing_auth_header_returns_401() {
598598+ let state = test_state_with_plc_url("https://plc.directory".to_string()).await;
599599+ let app = crate::app::app(state);
600600+601601+ let request = Request::builder()
602602+ .method("POST")
603603+ .uri("/v1/dids")
604604+ .header("Content-Type", "application/json")
605605+ .body(Body::from(r#"{"signingKey":"did:key:z...","rotationKey":"did:key:z..."}"#))
606606+ .unwrap();
607607+608608+ let response = app.oneshot(request).await.unwrap();
609609+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
610610+ }
611611+612612+ /// MM-89.AC2.8: Expired session token returns 401
613613+ #[tokio::test]
614614+ async fn expired_session_returns_401() {
615615+ let state = test_state_for_did("https://plc.directory".to_string()).await;
616616+ let db = state.db.clone();
617617+ let setup = insert_test_data(&db).await;
618618+619619+ // Manually expire the session.
620620+ sqlx::query("UPDATE pending_sessions SET expires_at = datetime('now', '-1 hour') WHERE account_id = ?")
621621+ .bind(&setup.account_id)
622622+ .execute(&db)
623623+ .await
624624+ .expect("expire session");
625625+626626+ let app = crate::app::app(state);
627627+ let response = app
628628+ .oneshot(create_did_request(
629629+ &setup.session_token,
630630+ &setup.signing_key_id,
631631+ &setup.rotation_key_id,
632632+ ))
633633+ .await
634634+ .unwrap();
635635+636636+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
637637+ }
638638+639639+ /// MM-89.AC2.9: signingKey not in relay_signing_keys returns 404
640640+ #[tokio::test]
641641+ async fn unknown_signing_key_returns_404() {
642642+ let state = test_state_for_did("https://plc.directory".to_string()).await;
643643+ let db = state.db.clone();
644644+ let setup = insert_test_data(&db).await;
645645+646646+ let app = crate::app::app(state);
647647+ let response = app
648648+ .oneshot(create_did_request(
649649+ &setup.session_token,
650650+ "did:key:zNONEXISTENT", // Not in relay_signing_keys
651651+ &setup.rotation_key_id,
652652+ ))
653653+ .await
654654+ .unwrap();
655655+656656+ assert_eq!(response.status(), StatusCode::NOT_FOUND);
657657+ }
658658+659659+ /// MM-89.AC2.10: Account already promoted returns 409 DID_ALREADY_EXISTS
660660+ ///
661661+ /// The DID is deterministic from (rotation_key, signing_key, handle, service_endpoint).
662662+ /// To reliably trigger 409, we:
663663+ /// 1. First call promotes setup's account (deletes pending_accounts + pending_sessions).
664664+ /// 2. Create a NEW pending account+session using the SAME signing key, rotation key,
665665+ /// and handle as setup. Same inputs → same crypto-derived DID.
666666+ /// 3. Second call: handler derives the same DID, finds the existing `accounts` row,
667667+ /// returns 409 DID_ALREADY_EXISTS.
668668+ #[tokio::test]
669669+ async fn already_promoted_account_returns_409() {
670670+ let mock_server = MockServer::start().await;
671671+ Mock::given(method("POST"))
672672+ .and(path_regex(r"^/did:plc:.*$"))
673673+ .respond_with(ResponseTemplate::new(200))
674674+ .expect(1) // Only first call should hit plc.directory
675675+ .mount(&mock_server)
676676+ .await;
677677+678678+ let state = test_state_for_did(mock_server.uri()).await;
679679+ let db = state.db.clone();
680680+ let setup = insert_test_data(&db).await;
681681+ let signing_kp = crypto::generate_p256_keypair().expect("signing keypair");
682682+ let encrypted =
683683+ crypto::encrypt_private_key(&signing_kp.private_key_bytes, &TEST_MASTER_KEY)
684684+ .expect("encrypt key");
685685+ sqlx::query(
686686+ "INSERT INTO relay_signing_keys \
687687+ (id, algorithm, public_key, private_key_encrypted, created_at) \
688688+ VALUES (?, 'p256', ?, ?, datetime('now'))",
689689+ )
690690+ .bind(&signing_kp.key_id.0)
691691+ .bind(&signing_kp.public_key)
692692+ .bind(&encrypted)
693693+ .execute(&db)
694694+ .await
695695+ .expect("insert second signing key");
696696+697697+ // First call: promotes setup's account (deletes pending_accounts + pending_sessions).
698698+ let app1 = crate::app::app(state);
699699+ let resp1 = app1
700700+ .oneshot(create_did_request(
701701+ &setup.session_token,
702702+ &setup.signing_key_id,
703703+ &setup.rotation_key_id,
704704+ ))
705705+ .await
706706+ .unwrap();
707707+ assert_eq!(resp1.status(), StatusCode::OK, "first call should succeed");
708708+709709+ // setup's pending_accounts row is now deleted. Create a NEW pending account
710710+ // with the SAME handle and signing key. Since pending_accounts.handle has no
711711+ // unique constraint, we can reuse setup.handle here.
712712+ let claim_code2 = format!("TEST-{}", Uuid::new_v4());
713713+ sqlx::query(
714714+ "INSERT INTO claim_codes (code, expires_at, created_at) \
715715+ VALUES (?, datetime('now', '+1 hour'), datetime('now'))",
716716+ )
717717+ .bind(&claim_code2)
718718+ .execute(&db)
719719+ .await
720720+ .expect("claim_code2");
721721+722722+ let account_id2 = Uuid::new_v4().to_string();
723723+ sqlx::query(
724724+ "INSERT INTO pending_accounts \
725725+ (id, email, handle, tier, claim_code, created_at) \
726726+ VALUES (?, ?, ?, 'free', ?, datetime('now'))",
727727+ )
728728+ .bind(&account_id2)
729729+ .bind(format!("retry{}@example.com", &account_id2[..8]))
730730+ .bind(&setup.handle) // same handle → same DID with same signing/rotation keys
731731+ .bind(&claim_code2)
732732+ .execute(&db)
733733+ .await
734734+ .expect("pending_account2");
735735+736736+ let device_id2 = Uuid::new_v4().to_string();
737737+ sqlx::query(
738738+ "INSERT INTO devices \
739739+ (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \
740740+ VALUES (?, ?, 'ios', 'retry_pubkey', 'retry_device_hash', datetime('now'), datetime('now'))",
741741+ )
742742+ .bind(&device_id2)
743743+ .bind(&account_id2)
744744+ .execute(&db)
745745+ .await
746746+ .expect("device2");
747747+748748+ let mut token_bytes2 = [0u8; 32];
749749+ OsRng.fill_bytes(&mut token_bytes2);
750750+ let session_token2 = URL_SAFE_NO_PAD.encode(token_bytes2);
751751+ let token_hash2: String = Sha256::digest(token_bytes2)
752752+ .iter()
753753+ .map(|b| format!("{b:02x}"))
754754+ .collect();
755755+ sqlx::query(
756756+ "INSERT INTO pending_sessions \
757757+ (id, account_id, device_id, token_hash, created_at, expires_at) \
758758+ VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+1 hour'))",
759759+ )
760760+ .bind(Uuid::new_v4().to_string())
761761+ .bind(&account_id2)
762762+ .bind(&device_id2)
763763+ .bind(&token_hash2)
764764+ .execute(&db)
765765+ .await
766766+ .expect("session2");
767767+768768+ // Second call: same signing_key + rotation_key + handle → same DID.
769769+ // accounts table already has this DID → handler returns 409.
770770+ let state2 = test_state_for_did(mock_server.uri()).await;
771771+ let app2 = crate::app::app(AppState {
772772+ config: state2.config,
773773+ db: db.clone(),
774774+ http_client: state2.http_client,
775775+ });
776776+ let resp2 = app2
777777+ .oneshot(create_did_request(
778778+ &session_token2,
779779+ &setup.signing_key_id, // same signing key
780780+ &setup.rotation_key_id, // same rotation key
781781+ ))
782782+ .await
783783+ .unwrap();
784784+ assert_eq!(resp2.status(), StatusCode::CONFLICT, "should return 409 DID_ALREADY_EXISTS");
785785+ }
786786+787787+ /// MM-89.AC2.11: plc.directory returns non-2xx → 502 PLC_DIRECTORY_ERROR
788788+ #[tokio::test]
789789+ async fn plc_directory_error_returns_502() {
790790+ let mock_server = MockServer::start().await;
791791+ Mock::given(method("POST"))
792792+ .and(path_regex(r"^/did:plc:.*$"))
793793+ .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
794794+ .expect(1)
795795+ .mount(&mock_server)
796796+ .await;
797797+798798+ let state = test_state_for_did(mock_server.uri()).await;
799799+ let db = state.db.clone();
800800+ let setup = insert_test_data(&db).await;
801801+802802+ let app = crate::app::app(state);
803803+ let response = app
804804+ .oneshot(create_did_request(
805805+ &setup.session_token,
806806+ &setup.signing_key_id,
807807+ &setup.rotation_key_id,
808808+ ))
809809+ .await
810810+ .unwrap();
811811+812812+ assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
813813+ }
814814+}
+1
crates/relay/src/routes/mod.rs
···11pub(crate) mod auth;
22pub mod claim_codes;
33pub mod create_account;
44+pub mod create_did;
45pub mod create_mobile_account;
56pub mod create_signing_key;
67pub mod describe_server;