An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(relay): implement POST /v1/devices (MM-87)

Device registration via claim code: validates and redeems a single-use claim
code, stores the device public key, generates an opaque device_token (stored
as SHA-256 hash, returned once), and enforces platform validation.

V006 migration rebuilds the devices table to reference pending_accounts.id
instead of accounts.did (registration precedes DID assignment), adding
platform, public_key, and device_token_hash columns. sessions, oauth_tokens,
and refresh_tokens are also rebuilt to maintain correct FK targets after the
cascading rename.

authored by malpercio.dev and committed by

Tangled e92d67c6 24cb2724

+800 -12
+2
Cargo.lock
··· 1802 1802 dependencies = [ 1803 1803 "anyhow", 1804 1804 "axum", 1805 + "base64 0.21.7", 1805 1806 "clap", 1806 1807 "common", 1807 1808 "crypto", ··· 1811 1812 "rand_core", 1812 1813 "serde", 1813 1814 "serde_json", 1815 + "sha2", 1814 1816 "sqlx", 1815 1817 "subtle", 1816 1818 "tempfile",
+1
Cargo.toml
··· 59 59 multibase = "0.9" 60 60 rand_core = { version = "0.6", features = ["getrandom"] } 61 61 base64 = "0.21" 62 + sha2 = "0.10" 62 63 zeroize = "1" 63 64 subtle = "2" 64 65 uuid = { version = "1", features = ["v4"] }
+19
bruno/register_device.bru
··· 1 + meta { 2 + name: Register Device 3 + type: http 4 + seq: 6 5 + } 6 + 7 + post { 8 + url: {{baseUrl}}/v1/devices 9 + body: json 10 + auth: none 11 + } 12 + 13 + body:json { 14 + { 15 + "claimCode": "ABC123", 16 + "devicePublicKey": "dGVzdC1rZXk=", 17 + "platform": "ios" 18 + } 19 + }
+2
crates/relay/Cargo.toml
··· 27 27 serde = { workspace = true } 28 28 sqlx = { workspace = true } 29 29 crypto = { workspace = true } 30 + base64 = { workspace = true } 30 31 rand_core = { workspace = true } 32 + sha2 = { workspace = true } 31 33 subtle = { workspace = true } 32 34 uuid = { workspace = true } 33 35 zeroize = { workspace = true }
+2
crates/relay/src/app.rs
··· 16 16 use crate::routes::create_signing_key::create_signing_key; 17 17 use crate::routes::describe_server::describe_server; 18 18 use crate::routes::health::health; 19 + use crate::routes::register_device::register_device; 19 20 20 21 /// Wraps an `axum::http::HeaderMap` as an OTel text-map [`Extractor`] so that 21 22 /// the W3C `traceparent` and `tracestate` headers can be read by the global propagator. ··· 90 91 .route("/xrpc/:method", get(xrpc_handler).post(xrpc_handler)) 91 92 .route("/v1/accounts", post(create_account)) 92 93 .route("/v1/accounts/claim-codes", post(claim_codes)) 94 + .route("/v1/devices", post(register_device)) 93 95 .route("/v1/relay/keys", post(create_signing_key)) 94 96 .layer(CorsLayer::permissive()) 95 97 .layer(TraceLayer::new_for_http().make_span_with(OtelMakeSpan))
+4 -1
crates/relay/src/db/CLAUDE.md
··· 1 1 # Database Module 2 2 3 - Last verified: 2026-03-11 3 + Last verified: 2026-03-13 4 4 5 5 ## Purpose 6 6 Owns SQLite connection lifecycle and schema migration for the relay's server-level database. ··· 33 33 - `migrations/V001__init.sql` - server_metadata table (WITHOUT ROWID) 34 34 - `migrations/V002__auth_identity.sql` - 12 Wave 2 tables: accounts, handles, did_documents, signing_keys, devices, claim_codes, sessions, refresh_tokens, oauth_clients, oauth_authorization_codes, oauth_tokens, oauth_par_requests 35 35 - `migrations/V003__relay_signing_keys.sql` - relay_signing_keys table (WITHOUT ROWID, keyed by did:key URI) for operator-level relay signing keys (not tied to a specific account DID) 36 + - `migrations/V004__claim_codes_invite.sql` - Rebuilds claim_codes: removes DID FK, adds redeemed_at; status derived not stored 37 + - `migrations/V005__pending_accounts.sql` - pending_accounts table: pre-provisioned account slots (id, email, handle, tier, claim_code) 38 + - `migrations/V006__devices_v2.sql` - Rebuilds devices: replaces did FK (accounts) with account_id FK (pending_accounts); adds platform, public_key, device_token_hash; also rebuilds sessions, oauth_tokens, refresh_tokens (cascade due to FK references)
+92
crates/relay/src/db/migrations/V006__devices_v2.sql
··· 1 + -- V006: Rebuild devices table to support device registration via claim code 2 + -- 3 + -- The V002 devices table required a NOT NULL DID FK to accounts, which prevents 4 + -- registration before DID assignment. The new schema references pending_accounts.id 5 + -- and adds platform, public_key, and device_token_hash for challenge-response auth. 6 + -- 7 + -- Cascade: sessions and oauth_tokens FK to devices; refresh_tokens FKs to sessions. 8 + -- SQLite 3.26+ auto-updates FK references in child tables when a parent is renamed, 9 + -- so renaming devices → devices_v1 also updates sessions and oauth_tokens to reference 10 + -- devices_v1, and renaming sessions → sessions_v1 updates refresh_tokens similarly. 11 + -- All four tables are therefore recreated here. All are empty at this migration (pre-launch). 12 + -- 13 + -- IMPORTANT index naming: SQLite indexes follow the table when it is renamed — they 14 + -- retain their original names on the renamed table. Dropping the old tables (which 15 + -- also drops their indexes) must happen BEFORE creating the new tables, otherwise 16 + -- CREATE INDEX fails with "already exists". Drop order: children before parents. 17 + 18 + -- Step 1: Rename all affected tables (most-derived first so FK auto-updates cascade 19 + -- in the right direction as parent tables are renamed after their children). 20 + ALTER TABLE refresh_tokens RENAME TO refresh_tokens_v1; 21 + ALTER TABLE oauth_tokens RENAME TO oauth_tokens_v1; 22 + ALTER TABLE sessions RENAME TO sessions_v1; 23 + ALTER TABLE devices RENAME TO devices_v1; 24 + 25 + -- Step 2: Drop old tables in children-before-parents order. Each DROP also removes 26 + -- the table's indexes (idx_refresh_tokens_did, idx_oauth_tokens_did, idx_sessions_did, 27 + -- idx_devices_did), clearing the way for the new tables to use the same index names. 28 + -- FK enforcement: at DROP time SQLite only checks for child rows in the table being 29 + -- dropped, not the table's own outbound FKs. All tables are empty (pre-launch). 30 + DROP TABLE refresh_tokens_v1; 31 + DROP TABLE oauth_tokens_v1; 32 + DROP TABLE sessions_v1; 33 + DROP TABLE devices_v1; 34 + 35 + -- Step 3: Create new devices with updated schema. 36 + -- account_id references pending_accounts.id (the pre-DID account slot). 37 + -- public_key is stored as provided by the device (used for future challenge-response auth). 38 + -- device_token_hash is SHA-256(device_token); the plaintext token is returned once at 39 + -- registration and never stored. 40 + CREATE TABLE devices ( 41 + id TEXT NOT NULL, 42 + account_id TEXT NOT NULL REFERENCES pending_accounts (id), 43 + platform TEXT NOT NULL, -- ios | android | macos | linux | windows 44 + public_key TEXT NOT NULL, -- device public key for challenge-response auth 45 + device_token_hash TEXT NOT NULL, -- SHA-256(device_token) as hex; token returned once 46 + device_name TEXT, -- set by the device after registration 47 + created_at TEXT NOT NULL, 48 + last_seen_at TEXT NOT NULL, 49 + PRIMARY KEY (id) 50 + ); 51 + 52 + -- Device listing by account (e.g., show all devices for a user). 53 + CREATE INDEX idx_devices_account_id ON devices (account_id); 54 + 55 + -- Step 4: Recreate sessions, oauth_tokens, and refresh_tokens with FKs pointing to the 56 + -- new devices/sessions tables. Schemas are identical to V002 except for the FK targets. 57 + CREATE TABLE sessions ( 58 + id TEXT NOT NULL, 59 + did TEXT NOT NULL REFERENCES accounts (did), 60 + device_id TEXT NOT NULL REFERENCES devices (id), 61 + created_at TEXT NOT NULL, 62 + expires_at TEXT NOT NULL, 63 + PRIMARY KEY (id) 64 + ); 65 + 66 + CREATE INDEX idx_sessions_did ON sessions (did); 67 + 68 + CREATE TABLE oauth_tokens ( 69 + id TEXT NOT NULL, 70 + client_id TEXT NOT NULL REFERENCES oauth_clients (client_id), 71 + did TEXT NOT NULL REFERENCES accounts (did), 72 + device_id TEXT REFERENCES devices (id), 73 + scope TEXT NOT NULL, 74 + expires_at TEXT NOT NULL, 75 + created_at TEXT NOT NULL, 76 + PRIMARY KEY (id) 77 + ); 78 + 79 + CREATE INDEX idx_oauth_tokens_did ON oauth_tokens (did); 80 + 81 + CREATE TABLE refresh_tokens ( 82 + jti TEXT NOT NULL, 83 + did TEXT NOT NULL REFERENCES accounts (did), 84 + session_id TEXT NOT NULL REFERENCES sessions (id), 85 + next_jti TEXT, 86 + expires_at TEXT NOT NULL, 87 + app_password_name TEXT, 88 + created_at TEXT NOT NULL, 89 + PRIMARY KEY (jti) 90 + ); 91 + 92 + CREATE INDEX idx_refresh_tokens_did ON refresh_tokens (did);
+35 -11
crates/relay/src/db/mod.rs
··· 48 48 version: 5, 49 49 sql: include_str!("migrations/V005__pending_accounts.sql"), 50 50 }, 51 + Migration { 52 + version: 6, 53 + sql: include_str!("migrations/V006__devices_v2.sql"), 54 + }, 51 55 ]; 52 56 53 57 /// Open a WAL-mode SQLite connection pool with a maximum of 1 connection. ··· 522 526 .await 523 527 .unwrap(); 524 528 529 + // V006: devices now references pending_accounts.id instead of accounts.did. 530 + // Set up a claim_code and pending_account so the device FK can be satisfied. 525 531 sqlx::query( 526 - "INSERT INTO devices (id, did, device_name, user_agent, created_at, last_seen_at) 527 - VALUES ('dev1', 'did:plc:aaa', 'My Phone', 'Mozilla/5.0', '2024-01-01T00:00:00', '2024-01-01T00:00:00')", 532 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 533 + VALUES ('CHAIN1', datetime('now', '+24 hours'), datetime('now'))", 534 + ) 535 + .execute(&pool) 536 + .await 537 + .unwrap(); 538 + 539 + sqlx::query( 540 + "INSERT INTO pending_accounts (id, email, handle, tier, claim_code, created_at) \ 541 + VALUES ('acct1', 'a@example.com', 'a.example.com', 'free', 'CHAIN1', datetime('now'))", 542 + ) 543 + .execute(&pool) 544 + .await 545 + .unwrap(); 546 + 547 + sqlx::query( 548 + "INSERT INTO devices (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) 549 + VALUES ('dev1', 'acct1', 'ios', 'pubkey123', 'deadbeef', '2024-01-01T00:00:00', '2024-01-01T00:00:00')", 528 550 ) 529 551 .execute(&pool) 530 552 .await ··· 697 719 ); 698 720 } 699 721 700 - /// EXPLAIN QUERY PLAN must show idx_devices_did for a WHERE did = ? query. 722 + /// EXPLAIN QUERY PLAN must show idx_devices_account_id for a WHERE account_id = ? query. 723 + /// (V006 replaced the did FK with account_id; the index is now idx_devices_account_id.) 701 724 #[tokio::test] 702 - async fn v002_index_devices_did_used() { 725 + async fn v006_index_devices_account_id_used() { 703 726 let pool = in_memory_pool().await; 704 727 run_migrations(&pool).await.unwrap(); 705 728 706 - let plan: Vec<(i64, i64, i64, String)> = 707 - sqlx::query_as("EXPLAIN QUERY PLAN SELECT * FROM devices WHERE did = 'did:plc:aaa'") 708 - .fetch_all(&pool) 709 - .await 710 - .unwrap(); 729 + let plan: Vec<(i64, i64, i64, String)> = sqlx::query_as( 730 + "EXPLAIN QUERY PLAN SELECT * FROM devices WHERE account_id = 'acct1'", 731 + ) 732 + .fetch_all(&pool) 733 + .await 734 + .unwrap(); 711 735 712 736 let detail = plan 713 737 .iter() ··· 715 739 .collect::<Vec<_>>() 716 740 .join("\n"); 717 741 assert!( 718 - detail.contains("idx_devices_did"), 719 - "devices WHERE did query must use idx_devices_did; got: {detail}" 742 + detail.contains("idx_devices_account_id"), 743 + "devices WHERE account_id query must use idx_devices_account_id; got: {detail}" 720 744 ); 721 745 } 722 746
+1
crates/relay/src/routes/mod.rs
··· 4 4 pub mod create_signing_key; 5 5 pub mod describe_server; 6 6 pub mod health; 7 + pub mod register_device; 7 8 8 9 mod code_gen; 9 10
+642
crates/relay/src/routes/register_device.rs
··· 1 + // pattern: Imperative Shell 2 + // 3 + // Gathers: JSON request body (claim_code, device_public_key, platform), DB pool 4 + // Processes: platform validation → public key non-empty check → atomic claim-code 5 + // redemption + device registration (single transaction): 6 + // UPDATE claim_codes WHERE code = ? AND unredeemed AND unexpired 7 + // SELECT pending_accounts.id WHERE claim_code = ? 8 + // INSERT INTO devices (...) 9 + // Returns: JSON { device_id, device_token, account_id } on success; ApiError on all failure paths 10 + 11 + use axum::{extract::State, http::StatusCode, response::Json}; 12 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 13 + use rand_core::{OsRng, RngCore}; 14 + use serde::{Deserialize, Serialize}; 15 + use sha2::{Digest, Sha256}; 16 + use uuid::Uuid; 17 + 18 + use common::{ApiError, ErrorCode}; 19 + 20 + use crate::app::AppState; 21 + 22 + #[derive(Deserialize)] 23 + #[serde(rename_all = "camelCase")] 24 + pub struct RegisterDeviceRequest { 25 + claim_code: String, 26 + device_public_key: String, 27 + platform: String, 28 + } 29 + 30 + #[derive(Serialize)] 31 + #[serde(rename_all = "camelCase")] 32 + pub struct RegisterDeviceResponse { 33 + device_id: String, 34 + device_token: String, 35 + account_id: String, 36 + } 37 + 38 + pub async fn register_device( 39 + State(state): State<AppState>, 40 + Json(payload): Json<RegisterDeviceRequest>, 41 + ) -> Result<(StatusCode, Json<RegisterDeviceResponse>), ApiError> { 42 + // --- Validate platform --- 43 + if !is_valid_platform(&payload.platform) { 44 + return Err(ApiError::new( 45 + ErrorCode::InvalidClaim, 46 + "platform must be one of: ios, android, macos, linux, windows", 47 + )); 48 + } 49 + 50 + // --- Validate device_public_key is non-empty --- 51 + if payload.device_public_key.is_empty() { 52 + return Err(ApiError::new( 53 + ErrorCode::InvalidClaim, 54 + "devicePublicKey must not be empty", 55 + )); 56 + } 57 + 58 + // --- Generate device credentials --- 59 + // 32 random bytes → base64url (no padding) for the wire; SHA-256 hex for the DB. 60 + // The plaintext token is returned once and never stored; future auth uses the hash. 61 + let device_id = Uuid::new_v4().to_string(); 62 + let mut token_bytes = [0u8; 32]; 63 + OsRng.fill_bytes(&mut token_bytes); 64 + let device_token = URL_SAFE_NO_PAD.encode(token_bytes); 65 + let device_token_hash: String = Sha256::digest(token_bytes) 66 + .iter() 67 + .map(|b| format!("{b:02x}")) 68 + .collect(); 69 + 70 + // --- Atomically redeem claim code and register device --- 71 + let account_id = redeem_and_register( 72 + &state.db, 73 + &payload.claim_code, 74 + &device_id, 75 + &payload.platform, 76 + &payload.device_public_key, 77 + &device_token_hash, 78 + ) 79 + .await?; 80 + 81 + Ok(( 82 + StatusCode::CREATED, 83 + Json(RegisterDeviceResponse { 84 + device_id, 85 + device_token, 86 + account_id, 87 + }), 88 + )) 89 + } 90 + 91 + fn is_valid_platform(platform: &str) -> bool { 92 + matches!(platform, "ios" | "android" | "macos" | "linux" | "windows") 93 + } 94 + 95 + /// Atomically redeem a claim code and register the device in a single transaction. 96 + /// 97 + /// The UPDATE runs with a WHERE guard (`redeemed_at IS NULL AND expires_at > now`) so a 98 + /// zero `rows_affected` unambiguously means the code is invalid, expired, or already 99 + /// redeemed — no race window, and no second SELECT is needed for the guard. 100 + /// 101 + /// Returns the `account_id` (pending_accounts.id) on success. 102 + async fn redeem_and_register( 103 + db: &sqlx::SqlitePool, 104 + claim_code: &str, 105 + device_id: &str, 106 + platform: &str, 107 + public_key: &str, 108 + device_token_hash: &str, 109 + ) -> Result<String, ApiError> { 110 + let mut tx = db.begin().await.inspect_err(|e| { 111 + tracing::error!(error = %e, "failed to begin device registration transaction"); 112 + }).map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register device"))?; 113 + 114 + // Attempt to mark the claim code redeemed. The WHERE guard rejects invalid, expired, 115 + // or previously-redeemed codes atomically — no separate SELECT needed. 116 + let result = sqlx::query( 117 + "UPDATE claim_codes \ 118 + SET redeemed_at = datetime('now') \ 119 + WHERE code = ? AND redeemed_at IS NULL AND expires_at > datetime('now')", 120 + ) 121 + .bind(claim_code) 122 + .execute(&mut *tx) 123 + .await 124 + .inspect_err(|e| { 125 + tracing::error!(error = %e, "failed to redeem claim code"); 126 + }) 127 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register device"))?; 128 + 129 + if result.rows_affected() == 0 { 130 + return Err(ApiError::new( 131 + ErrorCode::InvalidClaim, 132 + "claim code is invalid, expired, or already redeemed", 133 + )); 134 + } 135 + 136 + // Resolve the pending account bound to this claim code. 137 + let (account_id,): (String,) = sqlx::query_as( 138 + "SELECT id FROM pending_accounts WHERE claim_code = ?", 139 + ) 140 + .bind(claim_code) 141 + .fetch_one(&mut *tx) 142 + .await 143 + .inspect_err(|e| { 144 + tracing::error!(error = %e, "failed to fetch pending account for claim code"); 145 + }) 146 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register device"))?; 147 + 148 + sqlx::query( 149 + "INSERT INTO devices \ 150 + (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \ 151 + VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))", 152 + ) 153 + .bind(device_id) 154 + .bind(&account_id) 155 + .bind(platform) 156 + .bind(public_key) 157 + .bind(device_token_hash) 158 + .execute(&mut *tx) 159 + .await 160 + .inspect_err(|e| { 161 + tracing::error!(error = %e, "failed to insert device record"); 162 + }) 163 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register device"))?; 164 + 165 + tx.commit().await.inspect_err(|e| { 166 + tracing::error!(error = %e, "failed to commit device registration transaction"); 167 + }).map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register device"))?; 168 + 169 + Ok(account_id) 170 + } 171 + 172 + #[cfg(test)] 173 + mod tests { 174 + use axum::{ 175 + body::Body, 176 + http::{Request, StatusCode}, 177 + }; 178 + use tower::ServiceExt; 179 + 180 + use crate::app::{app, test_state}; 181 + 182 + // ── Helpers ─────────────────────────────────────────────────────────────── 183 + 184 + fn post_register_device(body: &str) -> Request<Body> { 185 + Request::builder() 186 + .method("POST") 187 + .uri("/v1/devices") 188 + .header("Content-Type", "application/json") 189 + .body(Body::from(body.to_string())) 190 + .unwrap() 191 + } 192 + 193 + /// Seed a pending account with a valid (unredeemed, unexpired) claim code. 194 + /// Returns (account_id, claim_code). 195 + async fn seed_pending_account(db: &sqlx::SqlitePool) -> (String, String) { 196 + let account_id = uuid::Uuid::new_v4().to_string(); 197 + let claim_code = "SEED01"; 198 + 199 + sqlx::query( 200 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 201 + VALUES (?, datetime('now', '+24 hours'), datetime('now'))", 202 + ) 203 + .bind(claim_code) 204 + .execute(db) 205 + .await 206 + .unwrap(); 207 + 208 + sqlx::query( 209 + "INSERT INTO pending_accounts (id, email, handle, tier, claim_code, created_at) \ 210 + VALUES (?, 'alice@example.com', 'alice.example.com', 'free', ?, datetime('now'))", 211 + ) 212 + .bind(&account_id) 213 + .bind(claim_code) 214 + .execute(db) 215 + .await 216 + .unwrap(); 217 + 218 + (account_id, claim_code.to_string()) 219 + } 220 + 221 + // ── Happy path ──────────────────────────────────────────────────────────── 222 + 223 + #[tokio::test] 224 + async fn returns_201_with_correct_shape() { 225 + // MM-87.AC1: valid claim code registers device and returns credentials 226 + let state = test_state().await; 227 + let (_, claim_code) = seed_pending_account(&state.db).await; 228 + 229 + let body = format!( 230 + r#"{{"claimCode":"{claim_code}","devicePublicKey":"dGVzdC1rZXk=","platform":"ios"}}"# 231 + ); 232 + let response = app(state) 233 + .oneshot(post_register_device(&body)) 234 + .await 235 + .unwrap(); 236 + 237 + assert_eq!(response.status(), StatusCode::CREATED); 238 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 239 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 240 + 241 + assert!(json["deviceId"].as_str().is_some(), "deviceId must be present"); 242 + assert!(json["deviceToken"].as_str().is_some(), "deviceToken must be present"); 243 + assert!(json["accountId"].as_str().is_some(), "accountId must be present"); 244 + } 245 + 246 + #[tokio::test] 247 + async fn device_id_is_uuid() { 248 + let state = test_state().await; 249 + let (_, claim_code) = seed_pending_account(&state.db).await; 250 + 251 + let body = format!( 252 + r#"{{"claimCode":"{claim_code}","devicePublicKey":"dGVzdC1rZXk=","platform":"android"}}"# 253 + ); 254 + let response = app(state) 255 + .oneshot(post_register_device(&body)) 256 + .await 257 + .unwrap(); 258 + 259 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 260 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 261 + let device_id = json["deviceId"].as_str().unwrap(); 262 + 263 + uuid::Uuid::parse_str(device_id).expect("deviceId must be a valid UUID"); 264 + } 265 + 266 + #[tokio::test] 267 + async fn device_token_is_base64url() { 268 + let state = test_state().await; 269 + let (_, claim_code) = seed_pending_account(&state.db).await; 270 + 271 + let body = format!( 272 + r#"{{"claimCode":"{claim_code}","devicePublicKey":"dGVzdC1rZXk=","platform":"macos"}}"# 273 + ); 274 + let response = app(state) 275 + .oneshot(post_register_device(&body)) 276 + .await 277 + .unwrap(); 278 + 279 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 280 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 281 + let token = json["deviceToken"].as_str().unwrap(); 282 + 283 + // URL_SAFE_NO_PAD base64: only [A-Za-z0-9_-], no '=' padding 284 + assert!( 285 + token.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), 286 + "deviceToken must be base64url without padding; got: {token}" 287 + ); 288 + // 32 bytes encoded as base64url (no pad) → 43 chars 289 + assert_eq!(token.len(), 43, "deviceToken must be 43 chars (base64url of 32 bytes)"); 290 + } 291 + 292 + #[tokio::test] 293 + async fn account_id_matches_pending_account() { 294 + // MM-87.AC1: returned account_id matches the pending account bound to the claim code 295 + let state = test_state().await; 296 + let (expected_account_id, claim_code) = seed_pending_account(&state.db).await; 297 + 298 + let body = format!( 299 + r#"{{"claimCode":"{claim_code}","devicePublicKey":"dGVzdC1rZXk=","platform":"linux"}}"# 300 + ); 301 + let response = app(state) 302 + .oneshot(post_register_device(&body)) 303 + .await 304 + .unwrap(); 305 + 306 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 307 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 308 + 309 + assert_eq!(json["accountId"].as_str().unwrap(), expected_account_id); 310 + } 311 + 312 + #[tokio::test] 313 + async fn device_persisted_in_db() { 314 + // MM-87.AC3: device appears in account's device list after registration 315 + let state = test_state().await; 316 + let db = state.db.clone(); 317 + let (account_id, claim_code) = seed_pending_account(&state.db).await; 318 + 319 + let body = format!( 320 + r#"{{"claimCode":"{claim_code}","devicePublicKey":"dGVzdC1rZXk=","platform":"windows"}}"# 321 + ); 322 + let response = app(state) 323 + .oneshot(post_register_device(&body)) 324 + .await 325 + .unwrap(); 326 + 327 + assert_eq!(response.status(), StatusCode::CREATED); 328 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 329 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 330 + let device_id = json["deviceId"].as_str().unwrap(); 331 + 332 + let row: (String, String, String, String) = sqlx::query_as( 333 + "SELECT account_id, platform, public_key, device_token_hash FROM devices WHERE id = ?", 334 + ) 335 + .bind(device_id) 336 + .fetch_one(&db) 337 + .await 338 + .expect("device row must exist in DB"); 339 + 340 + assert_eq!(row.0, account_id, "account_id"); 341 + assert_eq!(row.1, "windows", "platform"); 342 + assert_eq!(row.2, "dGVzdC1rZXk=", "public_key"); 343 + // token hash must be 64-char hex (SHA-256) 344 + assert_eq!(row.3.len(), 64, "device_token_hash must be 64-char hex"); 345 + assert!( 346 + row.3.chars().all(|c| c.is_ascii_hexdigit()), 347 + "device_token_hash must be lowercase hex" 348 + ); 349 + } 350 + 351 + #[tokio::test] 352 + async fn token_hash_is_sha256_of_token() { 353 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 354 + use sha2::{Digest, Sha256}; 355 + 356 + let state = test_state().await; 357 + let db = state.db.clone(); 358 + let (_, claim_code) = seed_pending_account(&state.db).await; 359 + 360 + let body = format!( 361 + r#"{{"claimCode":"{claim_code}","devicePublicKey":"dGVzdC1rZXk=","platform":"ios"}}"# 362 + ); 363 + let response = app(state) 364 + .oneshot(post_register_device(&body)) 365 + .await 366 + .unwrap(); 367 + 368 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 369 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 370 + let device_token = json["deviceToken"].as_str().unwrap(); 371 + let device_id = json["deviceId"].as_str().unwrap(); 372 + 373 + let token_bytes = URL_SAFE_NO_PAD.decode(device_token).unwrap(); 374 + let expected_hash: String = Sha256::digest(&token_bytes) 375 + .iter() 376 + .map(|b| format!("{b:02x}")) 377 + .collect(); 378 + 379 + let stored_hash: (String,) = 380 + sqlx::query_as("SELECT device_token_hash FROM devices WHERE id = ?") 381 + .bind(device_id) 382 + .fetch_one(&db) 383 + .await 384 + .unwrap(); 385 + 386 + assert_eq!(stored_hash.0, expected_hash); 387 + } 388 + 389 + #[tokio::test] 390 + async fn claim_code_marked_redeemed_after_registration() { 391 + // MM-87 requirement: claim code is single-use; marked redeemed on success 392 + let state = test_state().await; 393 + let db = state.db.clone(); 394 + let (_, claim_code) = seed_pending_account(&state.db).await; 395 + 396 + let body = format!( 397 + r#"{{"claimCode":"{claim_code}","devicePublicKey":"dGVzdC1rZXk=","platform":"ios"}}"# 398 + ); 399 + app(state) 400 + .oneshot(post_register_device(&body)) 401 + .await 402 + .unwrap(); 403 + 404 + let redeemed_at: Option<String> = 405 + sqlx::query_scalar("SELECT redeemed_at FROM claim_codes WHERE code = ?") 406 + .bind(&claim_code) 407 + .fetch_one(&db) 408 + .await 409 + .unwrap(); 410 + 411 + assert!(redeemed_at.is_some(), "claim code must have redeemed_at set"); 412 + } 413 + 414 + // ── Invalid / expired / redeemed claim codes ────────────────────────────── 415 + 416 + #[tokio::test] 417 + async fn invalid_claim_code_returns_400() { 418 + // MM-87.AC2: invalid code returns error 419 + let response = app(test_state().await) 420 + .oneshot(post_register_device( 421 + r#"{"claimCode":"ZZZZZZ","devicePublicKey":"dGVzdC1rZXk=","platform":"ios"}"#, 422 + )) 423 + .await 424 + .unwrap(); 425 + 426 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 427 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 428 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 429 + assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 430 + } 431 + 432 + #[tokio::test] 433 + async fn expired_claim_code_returns_400() { 434 + // MM-87.AC2: expired code returns error 435 + let state = test_state().await; 436 + let account_id = uuid::Uuid::new_v4().to_string(); 437 + let claim_code = "EXPIRD"; 438 + 439 + sqlx::query( 440 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 441 + VALUES (?, datetime('now', '-1 hour'), datetime('now', '-2 hours'))", 442 + ) 443 + .bind(claim_code) 444 + .execute(&state.db) 445 + .await 446 + .unwrap(); 447 + 448 + sqlx::query( 449 + "INSERT INTO pending_accounts (id, email, handle, tier, claim_code, created_at) \ 450 + VALUES (?, 'expired@example.com', 'expired.example.com', 'free', ?, datetime('now'))", 451 + ) 452 + .bind(&account_id) 453 + .bind(claim_code) 454 + .execute(&state.db) 455 + .await 456 + .unwrap(); 457 + 458 + let body = format!( 459 + r#"{{"claimCode":"{claim_code}","devicePublicKey":"dGVzdC1rZXk=","platform":"ios"}}"# 460 + ); 461 + let response = app(state) 462 + .oneshot(post_register_device(&body)) 463 + .await 464 + .unwrap(); 465 + 466 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 467 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 468 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 469 + assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 470 + } 471 + 472 + #[tokio::test] 473 + async fn already_redeemed_claim_code_returns_400() { 474 + // MM-87 requirement: claim code is single-use; second use returns error 475 + let state = test_state().await; 476 + let (_, claim_code) = seed_pending_account(&state.db).await; 477 + 478 + let body = format!( 479 + r#"{{"claimCode":"{claim_code}","devicePublicKey":"dGVzdC1rZXk=","platform":"ios"}}"# 480 + ); 481 + let application = app(state); 482 + 483 + // First registration succeeds. 484 + let first = application 485 + .clone() 486 + .oneshot(post_register_device(&body)) 487 + .await 488 + .unwrap(); 489 + assert_eq!(first.status(), StatusCode::CREATED); 490 + 491 + // Second registration with the same code fails. 492 + let second = application 493 + .oneshot(post_register_device(&body)) 494 + .await 495 + .unwrap(); 496 + assert_eq!(second.status(), StatusCode::BAD_REQUEST); 497 + let body = axum::body::to_bytes(second.into_body(), 4096).await.unwrap(); 498 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 499 + assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 500 + } 501 + 502 + // ── Platform validation ─────────────────────────────────────────────────── 503 + 504 + #[tokio::test] 505 + async fn all_valid_platforms_accepted() { 506 + // MM-87 requirement: platform validation (ios, android, macos, linux, windows) 507 + for platform in ["ios", "android", "macos", "linux", "windows"] { 508 + let state = test_state().await; 509 + let (_, claim_code) = seed_pending_account(&state.db).await; 510 + 511 + let body = format!( 512 + r#"{{"claimCode":"{claim_code}","devicePublicKey":"dGVzdC1rZXk=","platform":"{platform}"}}"# 513 + ); 514 + let response = app(state) 515 + .oneshot(post_register_device(&body)) 516 + .await 517 + .unwrap(); 518 + 519 + assert_eq!( 520 + response.status(), 521 + StatusCode::CREATED, 522 + "platform {platform:?} must be accepted" 523 + ); 524 + } 525 + } 526 + 527 + #[tokio::test] 528 + async fn invalid_platform_returns_400() { 529 + let response = app(test_state().await) 530 + .oneshot(post_register_device( 531 + r#"{"claimCode":"ABC123","devicePublicKey":"dGVzdC1rZXk=","platform":"plan9"}"#, 532 + )) 533 + .await 534 + .unwrap(); 535 + 536 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 537 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 538 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 539 + assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 540 + } 541 + 542 + #[tokio::test] 543 + async fn platform_is_case_sensitive() { 544 + let response = app(test_state().await) 545 + .oneshot(post_register_device( 546 + r#"{"claimCode":"ABC123","devicePublicKey":"dGVzdC1rZXk=","platform":"iOS"}"#, 547 + )) 548 + .await 549 + .unwrap(); 550 + 551 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 552 + } 553 + 554 + // ── Empty public key ────────────────────────────────────────────────────── 555 + 556 + #[tokio::test] 557 + async fn empty_public_key_returns_400() { 558 + let response = app(test_state().await) 559 + .oneshot(post_register_device( 560 + r#"{"claimCode":"ABC123","devicePublicKey":"","platform":"ios"}"#, 561 + )) 562 + .await 563 + .unwrap(); 564 + 565 + assert_eq!(response.status(), StatusCode::BAD_REQUEST); 566 + let body = axum::body::to_bytes(response.into_body(), 4096).await.unwrap(); 567 + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 568 + assert_eq!(json["error"]["code"], "INVALID_CLAIM"); 569 + } 570 + 571 + // ── Missing required fields ─────────────────────────────────────────────── 572 + 573 + #[tokio::test] 574 + async fn missing_claim_code_returns_422() { 575 + let response = app(test_state().await) 576 + .oneshot(post_register_device( 577 + r#"{"devicePublicKey":"dGVzdC1rZXk=","platform":"ios"}"#, 578 + )) 579 + .await 580 + .unwrap(); 581 + 582 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 583 + } 584 + 585 + #[tokio::test] 586 + async fn missing_device_public_key_returns_422() { 587 + let response = app(test_state().await) 588 + .oneshot(post_register_device( 589 + r#"{"claimCode":"ABC123","platform":"ios"}"#, 590 + )) 591 + .await 592 + .unwrap(); 593 + 594 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 595 + } 596 + 597 + #[tokio::test] 598 + async fn missing_platform_returns_422() { 599 + let response = app(test_state().await) 600 + .oneshot(post_register_device( 601 + r#"{"claimCode":"ABC123","devicePublicKey":"dGVzdC1rZXk="}"#, 602 + )) 603 + .await 604 + .unwrap(); 605 + 606 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); 607 + } 608 + 609 + // ── DB failure ──────────────────────────────────────────────────────────── 610 + 611 + #[tokio::test] 612 + async fn closed_db_pool_returns_500() { 613 + let state = test_state().await; 614 + state.db.close().await; 615 + 616 + let response = app(state) 617 + .oneshot(post_register_device( 618 + r#"{"claimCode":"ABC123","devicePublicKey":"dGVzdC1rZXk=","platform":"ios"}"#, 619 + )) 620 + .await 621 + .unwrap(); 622 + 623 + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); 624 + } 625 + 626 + // ── Pure unit tests ─────────────────────────────────────────────────────── 627 + 628 + #[test] 629 + fn is_valid_platform_accepts_known_platforms() { 630 + for p in ["ios", "android", "macos", "linux", "windows"] { 631 + assert!(super::is_valid_platform(p), "{p} must be valid"); 632 + } 633 + } 634 + 635 + #[test] 636 + fn is_valid_platform_rejects_unknown() { 637 + assert!(!super::is_valid_platform("plan9")); 638 + assert!(!super::is_valid_platform("")); 639 + assert!(!super::is_valid_platform("iOS")); // case-sensitive 640 + assert!(!super::is_valid_platform("Windows")); // case-sensitive 641 + } 642 + }