this repo has no description

Better handles

lewis afa83692 2cddf065

+8 -8
TODO.md
··· 12 ### Passkeys and 2FA 13 Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth. 14 15 - - [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name) 16 - - [ ] user_totp table (did, secret_encrypted, verified, created_at, last_used) 17 - - [ ] WebAuthn registration challenge generation and attestation verification 18 - - [ ] TOTP secret generation with QR code setup flow 19 - - [ ] Backup codes (hashed, one-time use) with recovery flow 20 - - [ ] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative) 21 - [ ] Passkey-only account creation (no password) 22 - - [ ] Settings UI for managing passkeys, TOTP, backup codes 23 - [ ] Trusted devices option (remember this browser) 24 - - [ ] Rate limit 2FA attempts 25 - [ ] Re-auth for sensitive actions (email change, adding new auth methods) 26 27 ### Delegated accounts
··· 12 ### Passkeys and 2FA 13 Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth. 14 15 + - [x] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name) 16 + - [x] user_totp table (did, secret_encrypted, verified, created_at, last_used) 17 + - [x] WebAuthn registration challenge generation and attestation verification 18 + - [x] TOTP secret generation with QR code setup flow 19 + - [x] Backup codes (hashed, one-time use) with recovery flow 20 + - [x] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative) 21 - [ ] Passkey-only account creation (no password) 22 + - [x] Settings UI for managing passkeys, TOTP, backup codes 23 - [ ] Trusted devices option (remember this browser) 24 + - [x] Rate limit 2FA attempts 25 - [ ] Re-auth for sensitive actions (email change, adding new auth methods) 26 27 ### Delegated accounts
+6 -4
frontend/src/routes/OAuthLogin.svelte
··· 341 /> 342 </div> 343 344 - {#if securityStatusChecked && passkeySupported} 345 <button 346 type="button" 347 class="passkey-btn" 348 - class:passkey-unavailable={!hasPasskeys} 349 onclick={handlePasskeyLogin} 350 - disabled={submitting || !hasPasskeys || !username} 351 - title={hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'} 352 > 353 <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 354 <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> ··· 358 <span class="passkey-text"> 359 {#if submitting} 360 Authenticating... 361 {:else if hasPasskeys} 362 Sign in with passkey 363 {:else}
··· 341 /> 342 </div> 343 344 + {#if passkeySupported && username.length >= 3} 345 <button 346 type="button" 347 class="passkey-btn" 348 + class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked} 349 onclick={handlePasskeyLogin} 350 + disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked} 351 + title={checkingSecurityStatus ? 'Checking passkey status...' : hasPasskeys ? 'Sign in with your passkey' : 'No passkeys registered for this account'} 352 > 353 <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 354 <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> ··· 358 <span class="passkey-text"> 359 {#if submitting} 360 Authenticating... 361 + {:else if checkingSecurityStatus || !securityStatusChecked} 362 + Checking passkey... 363 {:else if hasPasskeys} 364 Sign in with passkey 365 {:else}
+9 -1
frontend/src/routes/Register.svelte
··· 50 } 51 } 52 53 function validateForm(): string | null { 54 if (!handle.trim()) return 'Handle is required' 55 if (!password) return 'Password is required' 56 if (password.length < 8) return 'Password must be at least 8 characters' 57 if (password !== confirmPassword) return 'Passwords do not match' ··· 152 disabled={submitting} 153 required 154 /> 155 - {#if fullHandle()} 156 <p class="hint">Your full handle will be: @{fullHandle()}</p> 157 {/if} 158 </div> ··· 389 font-size: 0.75rem; 390 color: var(--text-secondary); 391 margin: 0.25rem 0 0 0; 392 } 393 .verification-section { 394 border: 1px solid var(--border-color-light);
··· 50 } 51 } 52 53 + let handleHasDot = $derived(handle.includes('.')) 54 + 55 function validateForm(): string | null { 56 if (!handle.trim()) return 'Handle is required' 57 + if (handle.includes('.')) return 'Handle cannot contain dots. You can set up a custom domain handle after creating your account.' 58 if (!password) return 'Password is required' 59 if (password.length < 8) return 'Password must be at least 8 characters' 60 if (password !== confirmPassword) return 'Passwords do not match' ··· 155 disabled={submitting} 156 required 157 /> 158 + {#if handleHasDot} 159 + <p class="hint warning">Custom domain handles can be set up after account creation in Settings.</p> 160 + {:else if fullHandle()} 161 <p class="hint">Your full handle will be: @{fullHandle()}</p> 162 {/if} 163 </div> ··· 394 font-size: 0.75rem; 395 color: var(--text-secondary); 396 margin: 0.25rem 0 0 0; 397 + } 398 + .hint.warning { 399 + color: var(--warning-text, #856404); 400 } 401 .verification-section { 402 border: 1px solid var(--border-color-light);
+90 -19
migrations/20251211_initial_schema.sql
··· 1 - CREATE TYPE notification_channel AS ENUM ('email', 'discord', 'telegram', 'signal'); 2 - CREATE TYPE notification_status AS ENUM ('pending', 'processing', 'sent', 'failed'); 3 - CREATE TYPE notification_type AS ENUM ( 4 'welcome', 5 'email_verification', 6 'password_reset', ··· 8 'account_deletion', 9 'admin_email', 10 'plc_operation', 11 - 'two_factor_code' 12 ); 13 CREATE TABLE IF NOT EXISTS users ( 14 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), ··· 21 deactivated_at TIMESTAMPTZ, 22 invites_disabled BOOLEAN DEFAULT FALSE, 23 takedown_ref TEXT, 24 - preferred_notification_channel notification_channel NOT NULL DEFAULT 'email', 25 password_reset_code TEXT, 26 password_reset_code_expires_at TIMESTAMPTZ, 27 - email_pending_verification TEXT, 28 - email_confirmation_code TEXT, 29 - email_confirmation_code_expires_at TIMESTAMPTZ, 30 - email_confirmed BOOLEAN NOT NULL DEFAULT FALSE, 31 two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE, 32 discord_id TEXT, 33 discord_verified BOOLEAN NOT NULL DEFAULT FALSE, 34 telegram_username TEXT, 35 telegram_verified BOOLEAN NOT NULL DEFAULT FALSE, 36 signal_number TEXT, 37 - signal_verified BOOLEAN NOT NULL DEFAULT FALSE 38 ); 39 CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL; 40 - CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL; 41 CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL; 42 CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL; 43 CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL; 44 CREATE TABLE IF NOT EXISTS invite_codes ( 45 code TEXT PRIMARY KEY, 46 available_uses INT NOT NULL DEFAULT 1, ··· 48 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 49 disabled BOOLEAN DEFAULT FALSE 50 ); 51 CREATE TABLE IF NOT EXISTS invite_code_uses ( 52 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 53 code TEXT NOT NULL REFERENCES invite_codes(code), ··· 86 UNIQUE(repo_id, collection, rkey) 87 ); 88 CREATE INDEX idx_records_repo_rev ON records(repo_rev); 89 CREATE TABLE IF NOT EXISTS blobs ( 90 cid TEXT PRIMARY KEY, 91 mime_type TEXT NOT NULL, ··· 95 takedown_ref TEXT, 96 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 97 ); 98 CREATE TABLE IF NOT EXISTS app_passwords ( 99 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 100 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, ··· 104 privileged BOOLEAN NOT NULL DEFAULT FALSE, 105 UNIQUE(user_id, name) 106 ); 107 CREATE TABLE reports ( 108 id BIGINT PRIMARY KEY, 109 reason_type TEXT NOT NULL, ··· 118 expires_at TIMESTAMPTZ NOT NULL, 119 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 120 ); 121 - CREATE TABLE IF NOT EXISTS notification_queue ( 122 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 123 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 124 - channel notification_channel NOT NULL DEFAULT 'email', 125 - notification_type notification_type NOT NULL, 126 - status notification_status NOT NULL DEFAULT 'pending', 127 recipient TEXT NOT NULL, 128 subject TEXT, 129 body TEXT NOT NULL, ··· 136 scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(), 137 processed_at TIMESTAMPTZ 138 ); 139 - CREATE INDEX idx_notification_queue_status_scheduled 140 - ON notification_queue(status, scheduled_for) 141 WHERE status = 'pending'; 142 - CREATE INDEX idx_notification_queue_user_id ON notification_queue(user_id); 143 CREATE TABLE IF NOT EXISTS reserved_signing_keys ( 144 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 145 did TEXT, ··· 160 prev_cid TEXT, 161 ops JSONB, 162 blobs TEXT[], 163 - blocks_cids TEXT[] 164 ); 165 CREATE INDEX idx_repo_seq_seq ON repo_seq(seq); 166 CREATE INDEX idx_repo_seq_did ON repo_seq(did); 167 CREATE TABLE IF NOT EXISTS session_tokens ( 168 id SERIAL PRIMARY KEY, 169 did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, ··· 275 ); 276 CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri); 277 CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
··· 1 + CREATE TYPE comms_channel AS ENUM ('email', 'discord', 'telegram', 'signal'); 2 + CREATE TYPE comms_status AS ENUM ('pending', 'processing', 'sent', 'failed'); 3 + CREATE TYPE comms_type AS ENUM ( 4 'welcome', 5 'email_verification', 6 'password_reset', ··· 8 'account_deletion', 9 'admin_email', 10 'plc_operation', 11 + 'two_factor_code', 12 + 'channel_verification' 13 ); 14 CREATE TABLE IF NOT EXISTS users ( 15 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), ··· 22 deactivated_at TIMESTAMPTZ, 23 invites_disabled BOOLEAN DEFAULT FALSE, 24 takedown_ref TEXT, 25 + preferred_comms_channel comms_channel NOT NULL DEFAULT 'email', 26 password_reset_code TEXT, 27 password_reset_code_expires_at TIMESTAMPTZ, 28 + email_verified BOOLEAN NOT NULL DEFAULT FALSE, 29 two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE, 30 discord_id TEXT, 31 discord_verified BOOLEAN NOT NULL DEFAULT FALSE, 32 telegram_username TEXT, 33 telegram_verified BOOLEAN NOT NULL DEFAULT FALSE, 34 signal_number TEXT, 35 + signal_verified BOOLEAN NOT NULL DEFAULT FALSE, 36 + is_admin BOOLEAN NOT NULL DEFAULT FALSE, 37 + migrated_to_pds TEXT, 38 + migrated_at TIMESTAMPTZ 39 ); 40 CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL; 41 CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL; 42 CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL; 43 CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL; 44 + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email) WHERE email IS NOT NULL; 45 CREATE TABLE IF NOT EXISTS invite_codes ( 46 code TEXT PRIMARY KEY, 47 available_uses INT NOT NULL DEFAULT 1, ··· 49 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 50 disabled BOOLEAN DEFAULT FALSE 51 ); 52 + CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by ON invite_codes(created_by_user); 53 CREATE TABLE IF NOT EXISTS invite_code_uses ( 54 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 55 code TEXT NOT NULL REFERENCES invite_codes(code), ··· 88 UNIQUE(repo_id, collection, rkey) 89 ); 90 CREATE INDEX idx_records_repo_rev ON records(repo_rev); 91 + CREATE INDEX IF NOT EXISTS idx_records_repo_collection ON records(repo_id, collection); 92 + CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created ON records(repo_id, collection, created_at DESC); 93 CREATE TABLE IF NOT EXISTS blobs ( 94 cid TEXT PRIMARY KEY, 95 mime_type TEXT NOT NULL, ··· 99 takedown_ref TEXT, 100 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 101 ); 102 + CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user ON blobs(created_by_user, created_at DESC); 103 CREATE TABLE IF NOT EXISTS app_passwords ( 104 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 105 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, ··· 109 privileged BOOLEAN NOT NULL DEFAULT FALSE, 110 UNIQUE(user_id, name) 111 ); 112 + CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id ON app_passwords(user_id); 113 CREATE TABLE reports ( 114 id BIGINT PRIMARY KEY, 115 reason_type TEXT NOT NULL, ··· 124 expires_at TIMESTAMPTZ NOT NULL, 125 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 126 ); 127 + CREATE TABLE IF NOT EXISTS comms_queue ( 128 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 129 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 130 + channel comms_channel NOT NULL DEFAULT 'email', 131 + comms_type comms_type NOT NULL, 132 + status comms_status NOT NULL DEFAULT 'pending', 133 recipient TEXT NOT NULL, 134 subject TEXT, 135 body TEXT NOT NULL, ··· 142 scheduled_for TIMESTAMPTZ NOT NULL DEFAULT NOW(), 143 processed_at TIMESTAMPTZ 144 ); 145 + CREATE INDEX idx_comms_queue_status_scheduled 146 + ON comms_queue(status, scheduled_for) 147 WHERE status = 'pending'; 148 + CREATE INDEX idx_comms_queue_user_id ON comms_queue(user_id); 149 CREATE TABLE IF NOT EXISTS reserved_signing_keys ( 150 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 151 did TEXT, ··· 166 prev_cid TEXT, 167 ops JSONB, 168 blobs TEXT[], 169 + blocks_cids TEXT[], 170 + prev_data_cid TEXT, 171 + handle TEXT, 172 + active BOOLEAN, 173 + status TEXT 174 ); 175 CREATE INDEX idx_repo_seq_seq ON repo_seq(seq); 176 CREATE INDEX idx_repo_seq_did ON repo_seq(did); 177 + CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq ON repo_seq(did, seq DESC); 178 CREATE TABLE IF NOT EXISTS session_tokens ( 179 id SERIAL PRIMARY KEY, 180 did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, ··· 286 ); 287 CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri); 288 CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at); 289 + CREATE TABLE IF NOT EXISTS channel_verifications ( 290 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 291 + channel comms_channel NOT NULL, 292 + code TEXT NOT NULL, 293 + pending_identifier TEXT, 294 + expires_at TIMESTAMPTZ NOT NULL, 295 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 296 + PRIMARY KEY (user_id, channel) 297 + ); 298 + CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at); 299 + CREATE TABLE oauth_scope_preference ( 300 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 301 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 302 + client_id TEXT NOT NULL, 303 + scope TEXT NOT NULL, 304 + granted BOOLEAN NOT NULL DEFAULT TRUE, 305 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 306 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 307 + UNIQUE(did, client_id, scope) 308 + ); 309 + CREATE INDEX idx_oauth_scope_pref_lookup ON oauth_scope_preference(did, client_id); 310 + CREATE TABLE user_totp ( 311 + did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE, 312 + secret_encrypted BYTEA NOT NULL, 313 + encryption_version INTEGER NOT NULL DEFAULT 1, 314 + verified BOOLEAN NOT NULL DEFAULT FALSE, 315 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 316 + last_used TIMESTAMPTZ 317 + ); 318 + CREATE TABLE backup_codes ( 319 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 320 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 321 + code_hash TEXT NOT NULL, 322 + used_at TIMESTAMPTZ, 323 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 324 + ); 325 + CREATE INDEX idx_backup_codes_did ON backup_codes(did); 326 + CREATE TABLE passkeys ( 327 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 328 + did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 329 + credential_id BYTEA NOT NULL UNIQUE, 330 + public_key BYTEA NOT NULL, 331 + sign_count INTEGER NOT NULL DEFAULT 0, 332 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 333 + last_used TIMESTAMPTZ, 334 + friendly_name TEXT, 335 + aaguid BYTEA, 336 + transports TEXT[] 337 + ); 338 + CREATE INDEX idx_passkeys_did ON passkeys(did); 339 + CREATE TABLE webauthn_challenges ( 340 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 341 + did TEXT NOT NULL, 342 + challenge BYTEA NOT NULL, 343 + challenge_type TEXT NOT NULL, 344 + state_json TEXT NOT NULL, 345 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 346 + expires_at TIMESTAMPTZ NOT NULL 347 + ); 348 + CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
-15
migrations/20251213_performance_indexes.sql
··· 1 - CREATE INDEX IF NOT EXISTS idx_records_repo_collection 2 - ON records(repo_id, collection); 3 - CREATE INDEX IF NOT EXISTS idx_records_repo_collection_created 4 - ON records(repo_id, collection, created_at DESC); 5 - CREATE INDEX IF NOT EXISTS idx_users_email 6 - ON users(email) 7 - WHERE email IS NOT NULL; 8 - CREATE INDEX IF NOT EXISTS idx_blobs_created_by_user 9 - ON blobs(created_by_user, created_at DESC); 10 - CREATE INDEX IF NOT EXISTS idx_repo_seq_did_seq 11 - ON repo_seq(did, seq DESC); 12 - CREATE INDEX IF NOT EXISTS idx_app_passwords_user_id 13 - ON app_passwords(user_id); 14 - CREATE INDEX IF NOT EXISTS idx_invite_codes_created_by 15 - ON invite_codes(created_by_user);
···
-1
migrations/20251214_add_prev_data_cid.sql
··· 1 - ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS prev_data_cid TEXT;
···
-3
migrations/20251215_add_identity_account_fields.sql
··· 1 - ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS handle TEXT; 2 - ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS active BOOLEAN; 3 - ALTER TABLE repo_seq ADD COLUMN IF NOT EXISTS status TEXT;
···
-12
migrations/20251216_add_channel_verification.sql
··· 1 - ALTER TYPE notification_type ADD VALUE IF NOT EXISTS 'channel_verification'; 2 - 3 - CREATE TABLE IF NOT EXISTS channel_verifications ( 4 - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 5 - channel notification_channel NOT NULL, 6 - code TEXT NOT NULL, 7 - expires_at TIMESTAMPTZ NOT NULL, 8 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 9 - PRIMARY KEY (user_id, channel) 10 - ); 11 - 12 - CREATE INDEX IF NOT EXISTS idx_channel_verifications_expires ON channel_verifications(expires_at);
···
-11
migrations/20251217_migrate_email_to_channel_verifications.sql
··· 1 - ALTER TABLE channel_verifications ADD COLUMN pending_identifier TEXT; 2 - 3 - INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) 4 - SELECT id, 'email', email_confirmation_code, email_pending_verification, email_confirmation_code_expires_at 5 - FROM users 6 - WHERE email_confirmation_code IS NOT NULL AND email_confirmation_code_expires_at IS NOT NULL; 7 - 8 - ALTER TABLE users 9 - DROP COLUMN email_confirmation_code, 10 - DROP COLUMN email_confirmation_code_expires_at, 11 - DROP COLUMN email_pending_verification;
···
-1
migrations/20251218_add_is_admin.sql
··· 1 - ALTER TABLE users ADD COLUMN is_admin BOOLEAN NOT NULL DEFAULT FALSE;
···
-6
migrations/20251219_rename_email_confirmed.sql
··· 1 - DO $$ 2 - BEGIN 3 - IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'email_confirmed') THEN 4 - ALTER TABLE users RENAME COLUMN email_confirmed TO email_verified; 5 - END IF; 6 - END $$;
···
-27
migrations/20251220_rename_notifications_to_comms.sql
··· 1 - DO $$ 2 - BEGIN 3 - IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_channel') THEN 4 - ALTER TYPE notification_channel RENAME TO comms_channel; 5 - END IF; 6 - IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_status') THEN 7 - ALTER TYPE notification_status RENAME TO comms_status; 8 - END IF; 9 - IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'notification_type') THEN 10 - ALTER TYPE notification_type RENAME TO comms_type; 11 - END IF; 12 - IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'notification_queue') THEN 13 - ALTER TABLE notification_queue RENAME TO comms_queue; 14 - END IF; 15 - IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'comms_queue' AND column_name = 'notification_type') THEN 16 - ALTER TABLE comms_queue RENAME COLUMN notification_type TO comms_type; 17 - END IF; 18 - IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_status_scheduled') THEN 19 - ALTER INDEX idx_notification_queue_status_scheduled RENAME TO idx_comms_queue_status_scheduled; 20 - END IF; 21 - IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'idx_notification_queue_user_id') THEN 22 - ALTER INDEX idx_notification_queue_user_id RENAME TO idx_comms_queue_user_id; 23 - END IF; 24 - IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'preferred_notification_channel') THEN 25 - ALTER TABLE users RENAME COLUMN preferred_notification_channel TO preferred_comms_channel; 26 - END IF; 27 - END $$;
···
-12
migrations/20251221_oauth_scope_preferences.sql
··· 1 - CREATE TABLE oauth_scope_preference ( 2 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 3 - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 4 - client_id TEXT NOT NULL, 5 - scope TEXT NOT NULL, 6 - granted BOOLEAN NOT NULL DEFAULT TRUE, 7 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 8 - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 9 - UNIQUE(did, client_id, scope) 10 - ); 11 - 12 - CREATE INDEX idx_oauth_scope_pref_lookup ON oauth_scope_preference(did, client_id);
···
-2
migrations/20251222_add_did_web_migration_tracking.sql
··· 1 - ALTER TABLE users ADD COLUMN migrated_to_pds TEXT; 2 - ALTER TABLE users ADD COLUMN migrated_at TIMESTAMPTZ;
···
-42
migrations/20251223_add_passkeys_totp.sql
··· 1 - CREATE TABLE user_totp ( 2 - did TEXT PRIMARY KEY REFERENCES users(did) ON DELETE CASCADE, 3 - secret_encrypted BYTEA NOT NULL, 4 - encryption_version INTEGER NOT NULL DEFAULT 1, 5 - verified BOOLEAN NOT NULL DEFAULT FALSE, 6 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 7 - last_used TIMESTAMPTZ 8 - ); 9 - 10 - CREATE TABLE backup_codes ( 11 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 12 - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 13 - code_hash TEXT NOT NULL, 14 - used_at TIMESTAMPTZ, 15 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 16 - ); 17 - CREATE INDEX idx_backup_codes_did ON backup_codes(did); 18 - 19 - CREATE TABLE passkeys ( 20 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 21 - did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE, 22 - credential_id BYTEA NOT NULL UNIQUE, 23 - public_key BYTEA NOT NULL, 24 - sign_count INTEGER NOT NULL DEFAULT 0, 25 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 26 - last_used TIMESTAMPTZ, 27 - friendly_name TEXT, 28 - aaguid BYTEA, 29 - transports TEXT[] 30 - ); 31 - CREATE INDEX idx_passkeys_did ON passkeys(did); 32 - 33 - CREATE TABLE webauthn_challenges ( 34 - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 35 - did TEXT NOT NULL, 36 - challenge BYTEA NOT NULL, 37 - challenge_type TEXT NOT NULL, 38 - state_json TEXT NOT NULL, 39 - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 40 - expires_at TIMESTAMPTZ NOT NULL 41 - ); 42 - CREATE INDEX idx_webauthn_challenges_did ON webauthn_challenges(did);
···
+9 -3
src/api/admin/account/update.rs
··· 67 Json(input): Json<UpdateAccountHandleInput>, 68 ) -> Response { 69 let did = input.did.trim(); 70 - let handle = input.handle.trim(); 71 - if did.is_empty() || handle.is_empty() { 72 return ( 73 StatusCode::BAD_REQUEST, 74 Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})), 75 ) 76 .into_response(); 77 } 78 - if !handle 79 .chars() 80 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') 81 { ··· 87 ) 88 .into_response(); 89 } 90 let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) 91 .fetch_optional(&state.db) 92 .await
··· 67 Json(input): Json<UpdateAccountHandleInput>, 68 ) -> Response { 69 let did = input.did.trim(); 70 + let input_handle = input.handle.trim(); 71 + if did.is_empty() || input_handle.is_empty() { 72 return ( 73 StatusCode::BAD_REQUEST, 74 Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})), 75 ) 76 .into_response(); 77 } 78 + if !input_handle 79 .chars() 80 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') 81 { ··· 87 ) 88 .into_response(); 89 } 90 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 91 + let handle = if !input_handle.contains('.') { 92 + format!("{}.{}", input_handle, hostname) 93 + } else { 94 + input_handle.to_string() 95 + }; 96 let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) 97 .fetch_optional(&state.db) 98 .await
+45 -24
src/api/identity/account.rs
··· 139 info!(did = %migration_did, "Processing account migration"); 140 } 141 142 - if input.handle.contains('!') || input.handle.contains('@') { 143 - return ( 144 - StatusCode::BAD_REQUEST, 145 - Json( 146 - json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}), 147 - ), 148 - ) 149 - .into_response(); 150 - } 151 let email: Option<String> = input 152 .email 153 .as_ref() ··· 212 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 213 let pds_endpoint = format!("https://{}", hostname); 214 let suffix = format!(".{}", hostname); 215 - let short_handle = if input.handle.ends_with(&suffix) { 216 - input.handle.strip_suffix(&suffix).unwrap_or(&input.handle) 217 } else { 218 - &input.handle 219 }; 220 - let full_handle = format!("{}.{}", short_handle, hostname); 221 let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) = 222 if let Some(signing_key_did) = &input.signing_key { 223 let reserved = sqlx::query!( ··· 298 ) 299 .into_response(); 300 } 301 - if let Err(e) = verify_did_web(d, &hostname, &input.handle).await { 302 return ( 303 StatusCode::BAD_REQUEST, 304 Json(json!({"error": "InvalidDid", "message": e})), ··· 314 info!(did = %d, "Migration with existing did:plc"); 315 d.clone() 316 } else if d.starts_with("did:web:") { 317 - if let Err(e) = verify_did_web(d, &hostname, &input.handle).await { 318 return ( 319 StatusCode::BAD_REQUEST, 320 Json(json!({"error": "InvalidDid", "message": e})), ··· 334 let genesis_result = match create_genesis_operation( 335 &signing_key, 336 &rotation_key, 337 - &full_handle, 338 &pds_endpoint, 339 ) { 340 Ok(r) => r, ··· 371 let genesis_result = match create_genesis_operation( 372 &signing_key, 373 &rotation_key, 374 - &full_handle, 375 &pds_endpoint, 376 ) { 377 Ok(r) => r, ··· 424 .unwrap_or(None); 425 if let Some((account_id, old_handle, deactivated_at)) = existing_account { 426 if deactivated_at.is_some() { 427 - info!(did = %did, old_handle = %old_handle, new_handle = %short_handle, "Preparing existing account for inbound migration"); 428 let update_result: Result<_, sqlx::Error> = 429 sqlx::query("UPDATE users SET handle = $1 WHERE id = $2") 430 - .bind(short_handle) 431 .bind(account_id) 432 .execute(&mut *tx) 433 .await; ··· 536 return ( 537 StatusCode::OK, 538 Json(CreateAccountOutput { 539 - handle: full_handle.clone(), 540 did, 541 access_jwt: Some(access_meta.token), 542 refresh_jwt: Some(refresh_meta.token), ··· 556 } 557 let exists_result: Option<(i32,)> = 558 sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL") 559 - .bind(short_handle) 560 .fetch_optional(&mut *tx) 561 .await 562 .unwrap_or(None); ··· 660 is_admin, deactivated_at, email_verified 661 ) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9, $10, $11) RETURNING id"#, 662 ) 663 - .bind(short_handle) 664 .bind(&email) 665 .bind(&did) 666 .bind(&password_hash) ··· 898 } 899 if !is_migration { 900 if let Err(e) = 901 - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)) 902 .await 903 { 904 warn!("Failed to sequence identity event for {}: {}", did, e); ··· 991 ( 992 StatusCode::OK, 993 Json(CreateAccountOutput { 994 - handle: full_handle.clone(), 995 did, 996 access_jwt, 997 refresh_jwt,
··· 139 info!(did = %migration_did, "Processing account migration"); 140 } 141 142 + let hostname_for_validation = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 143 + let pds_suffix = format!(".{}", hostname_for_validation); 144 + 145 + let validated_short_handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) { 146 + let handle_to_validate = if input.handle.ends_with(&pds_suffix) { 147 + input.handle.strip_suffix(&pds_suffix).unwrap_or(&input.handle) 148 + } else { 149 + &input.handle 150 + }; 151 + match crate::api::validation::validate_short_handle(handle_to_validate) { 152 + Ok(h) => h, 153 + Err(e) => { 154 + return ( 155 + StatusCode::BAD_REQUEST, 156 + Json(json!({"error": "InvalidHandle", "message": e.to_string()})), 157 + ) 158 + .into_response(); 159 + } 160 + } 161 + } else { 162 + if input.handle.contains(' ') || input.handle.contains('\t') { 163 + return ( 164 + StatusCode::BAD_REQUEST, 165 + Json(json!({"error": "InvalidHandle", "message": "Handle cannot contain spaces"})), 166 + ) 167 + .into_response(); 168 + } 169 + input.handle.to_lowercase() 170 + }; 171 let email: Option<String> = input 172 .email 173 .as_ref() ··· 232 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 233 let pds_endpoint = format!("https://{}", hostname); 234 let suffix = format!(".{}", hostname); 235 + let handle = if input.handle.ends_with(&suffix) { 236 + format!("{}.{}", validated_short_handle, hostname) 237 + } else if input.handle.contains('.') { 238 + validated_short_handle.clone() 239 } else { 240 + format!("{}.{}", validated_short_handle, hostname) 241 }; 242 let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) = 243 if let Some(signing_key_did) = &input.signing_key { 244 let reserved = sqlx::query!( ··· 319 ) 320 .into_response(); 321 } 322 + if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await { 323 return ( 324 StatusCode::BAD_REQUEST, 325 Json(json!({"error": "InvalidDid", "message": e})), ··· 335 info!(did = %d, "Migration with existing did:plc"); 336 d.clone() 337 } else if d.starts_with("did:web:") { 338 + if let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await { 339 return ( 340 StatusCode::BAD_REQUEST, 341 Json(json!({"error": "InvalidDid", "message": e})), ··· 355 let genesis_result = match create_genesis_operation( 356 &signing_key, 357 &rotation_key, 358 + &handle, 359 &pds_endpoint, 360 ) { 361 Ok(r) => r, ··· 392 let genesis_result = match create_genesis_operation( 393 &signing_key, 394 &rotation_key, 395 + &handle, 396 &pds_endpoint, 397 ) { 398 Ok(r) => r, ··· 445 .unwrap_or(None); 446 if let Some((account_id, old_handle, deactivated_at)) = existing_account { 447 if deactivated_at.is_some() { 448 + info!(did = %did, old_handle = %old_handle, new_handle = %handle, "Preparing existing account for inbound migration"); 449 let update_result: Result<_, sqlx::Error> = 450 sqlx::query("UPDATE users SET handle = $1 WHERE id = $2") 451 + .bind(&handle) 452 .bind(account_id) 453 .execute(&mut *tx) 454 .await; ··· 557 return ( 558 StatusCode::OK, 559 Json(CreateAccountOutput { 560 + handle: handle.clone(), 561 did, 562 access_jwt: Some(access_meta.token), 563 refresh_jwt: Some(refresh_meta.token), ··· 577 } 578 let exists_result: Option<(i32,)> = 579 sqlx::query_as("SELECT 1 FROM users WHERE handle = $1 AND deactivated_at IS NULL") 580 + .bind(&handle) 581 .fetch_optional(&mut *tx) 582 .await 583 .unwrap_or(None); ··· 681 is_admin, deactivated_at, email_verified 682 ) VALUES ($1, $2, $3, $4, $5::comms_channel, $6, $7, $8, $9, $10, $11) RETURNING id"#, 683 ) 684 + .bind(&handle) 685 .bind(&email) 686 .bind(&did) 687 .bind(&password_hash) ··· 919 } 920 if !is_migration { 921 if let Err(e) = 922 + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)) 923 .await 924 { 925 warn!("Failed to sequence identity event for {}: {}", did, e); ··· 1012 ( 1013 StatusCode::OK, 1014 Json(CreateAccountOutput { 1015 + handle: handle.clone(), 1016 did, 1017 access_jwt, 1018 refresh_jwt,
+64 -56
src/api/identity/did.rs
··· 36 if let Some(did) = state.cache.get(&cache_key).await { 37 return (StatusCode::OK, Json(json!({ "did": did }))).into_response(); 38 } 39 - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 40 - let suffix = format!(".{}", hostname); 41 - let short_handle = if handle.ends_with(&suffix) { 42 - handle.strip_suffix(&suffix).unwrap_or(handle) 43 - } else { 44 - handle 45 - }; 46 - let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", short_handle) 47 .fetch_optional(&state.db) 48 .await; 49 match user { ··· 139 } 140 141 async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response { 142 let user = sqlx::query!( 143 "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1", 144 - handle 145 ) 146 .fetch_optional(&state.db) 147 .await; ··· 212 .into_response(); 213 } 214 }; 215 - let full_handle = if handle.contains('.') { 216 - handle.to_string() 217 - } else { 218 - format!("{}.{}", handle, hostname) 219 - }; 220 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 221 Json(json!({ 222 "@context": [ ··· 225 "https://w3id.org/security/suites/secp256k1-2019/v1" 226 ], 227 "id": did, 228 - "alsoKnownAs": [format!("at://{}", full_handle)], 229 "verificationMethod": [{ 230 "id": format!("{}#atproto", did), 231 "type": "Multikey", ··· 243 244 pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response { 245 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 246 let user = sqlx::query!( 247 "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1", 248 - handle 249 ) 250 .fetch_optional(&state.db) 251 .await; ··· 318 .into_response(); 319 } 320 }; 321 - let full_handle = if handle.contains('.') { 322 - handle.clone() 323 - } else { 324 - format!("{}.{}", handle, hostname) 325 - }; 326 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 327 Json(json!({ 328 "@context": [ ··· 331 "https://w3id.org/security/suites/secp256k1-2019/v1" 332 ], 333 "id": did, 334 - "alsoKnownAs": [format!("at://{}", full_handle)], 335 "verificationMethod": [{ 336 "id": format!("{}#atproto", did), 337 "type": "Multikey", ··· 347 .into_response() 348 } 349 350 - pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> { 351 let subdomain_host = format!("{}.{}", handle, hostname); 352 let encoded_subdomain = subdomain_host.replace(':', "%3A"); 353 let expected_subdomain_did = format!("did:web:{}", encoded_subdomain); ··· 371 )); 372 } 373 } 374 let parts: Vec<&str> = did.split(':').collect(); 375 if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" { 376 return Err("Invalid did:web format".into()); ··· 411 let has_valid_service = services 412 .iter() 413 .any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint); 414 - if has_valid_service { 415 - Ok(()) 416 - } else { 417 - Err(format!( 418 "DID document does not list this PDS ({}) as AtprotoPersonalDataServer", 419 pds_endpoint 420 - )) 421 } 422 } 423 424 #[derive(serde::Serialize)] ··· 492 }; 493 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 494 let pds_endpoint = format!("https://{}", hostname); 495 - let full_handle = if user.handle.contains('.') { 496 - user.handle.clone() 497 - } else { 498 - format!("{}.{}", user.handle, hostname) 499 - }; 500 let signing_key = match k256::ecdsa::SigningKey::from_slice(&key_bytes) { 501 Ok(k) => k, 502 Err(_) => return ApiError::InternalError.into_response(), ··· 511 StatusCode::OK, 512 Json(GetRecommendedDidCredentialsOutput { 513 rotation_keys, 514 - also_known_as: vec![format!("at://{}", full_handle)], 515 verification_methods: VerificationMethods { atproto: did_key }, 516 services: Services { 517 atproto_pds: AtprotoPds { ··· 577 .into_response(); 578 } 579 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 580 let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname); 581 - let (handle_to_store, full_handle) = if is_service_domain { 582 - let suffix = format!(".{}", hostname); 583 - let short_handle = if new_handle.ends_with(&suffix) { 584 new_handle.strip_suffix(&suffix).unwrap_or(new_handle) 585 } else { 586 new_handle 587 }; 588 - ( 589 - short_handle.to_string(), 590 - format!("{}.{}", short_handle, hostname), 591 - ) 592 } else { 593 match crate::handle::verify_handle_ownership(new_handle, &did).await { 594 Ok(()) => {} ··· 625 .into_response(); 626 } 627 } 628 - (new_handle.to_string(), new_handle.to_string()) 629 }; 630 let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id) 631 .fetch_optional(&state.db) ··· 634 .flatten(); 635 let existing = sqlx::query!( 636 "SELECT id FROM users WHERE handle = $1 AND id != $2", 637 - handle_to_store, 638 user_id 639 ) 640 .fetch_optional(&state.db) ··· 648 } 649 let result = sqlx::query!( 650 "UPDATE users SET handle = $1 WHERE id = $2", 651 - handle_to_store, 652 user_id 653 ) 654 .execute(&state.db) ··· 660 } 661 let _ = state 662 .cache 663 - .delete(&format!("handle:{}", handle_to_store)) 664 .await; 665 - let _ = state.cache.delete(&format!("handle:{}", full_handle)).await; 666 if let Err(e) = 667 - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)) 668 .await 669 { 670 warn!("Failed to sequence identity event for handle update: {}", e); 671 } 672 - if let Err(e) = update_plc_handle(&state, &did, &full_handle).await { 673 warn!("Failed to update PLC handle: {}", e); 674 } 675 (StatusCode::OK, Json(json!({}))).into_response() ··· 723 Some(h) => h, 724 None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(), 725 }; 726 - let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 727 - let suffix = format!(".{}", hostname); 728 let handle = host.split(':').next().unwrap_or(host); 729 - let short_handle = if handle.ends_with(&suffix) { 730 - handle.strip_suffix(&suffix).unwrap_or(handle) 731 - } else { 732 - return (StatusCode::NOT_FOUND, "Handle not found").into_response(); 733 - }; 734 - let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", short_handle) 735 .fetch_optional(&state.db) 736 .await; 737 match user {
··· 36 if let Some(did) = state.cache.get(&cache_key).await { 37 return (StatusCode::OK, Json(json!({ "did": did }))).into_response(); 38 } 39 + let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle) 40 .fetch_optional(&state.db) 41 .await; 42 match user { ··· 132 } 133 134 async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response { 135 + let full_handle = format!("{}.{}", handle, hostname); 136 let user = sqlx::query!( 137 "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1", 138 + full_handle 139 ) 140 .fetch_optional(&state.db) 141 .await; ··· 206 .into_response(); 207 } 208 }; 209 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 210 Json(json!({ 211 "@context": [ ··· 214 "https://w3id.org/security/suites/secp256k1-2019/v1" 215 ], 216 "id": did, 217 + "alsoKnownAs": [format!("at://{}", handle)], 218 "verificationMethod": [{ 219 "id": format!("{}#atproto", did), 220 "type": "Multikey", ··· 232 233 pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response { 234 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 235 + let full_handle = format!("{}.{}", handle, hostname); 236 let user = sqlx::query!( 237 "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1", 238 + full_handle 239 ) 240 .fetch_optional(&state.db) 241 .await; ··· 308 .into_response(); 309 } 310 }; 311 let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname)); 312 Json(json!({ 313 "@context": [ ··· 316 "https://w3id.org/security/suites/secp256k1-2019/v1" 317 ], 318 "id": did, 319 + "alsoKnownAs": [format!("at://{}", handle)], 320 "verificationMethod": [{ 321 "id": format!("{}#atproto", did), 322 "type": "Multikey", ··· 332 .into_response() 333 } 334 335 + pub async fn verify_did_web( 336 + did: &str, 337 + hostname: &str, 338 + handle: &str, 339 + expected_signing_key: Option<&str>, 340 + ) -> Result<(), String> { 341 let subdomain_host = format!("{}.{}", handle, hostname); 342 let encoded_subdomain = subdomain_host.replace(':', "%3A"); 343 let expected_subdomain_did = format!("did:web:{}", encoded_subdomain); ··· 361 )); 362 } 363 } 364 + let expected_signing_key = expected_signing_key.ok_or_else(|| { 365 + "External did:web requires a pre-reserved signing key. Call com.atproto.server.reserveSigningKey first, configure your DID document with the returned key, then provide the signingKey in createAccount.".to_string() 366 + })?; 367 let parts: Vec<&str> = did.split(':').collect(); 368 if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" { 369 return Err("Invalid did:web format".into()); ··· 404 let has_valid_service = services 405 .iter() 406 .any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint); 407 + if !has_valid_service { 408 + return Err(format!( 409 "DID document does not list this PDS ({}) as AtprotoPersonalDataServer", 410 pds_endpoint 411 + )); 412 } 413 + let verification_methods = doc["verificationMethod"] 414 + .as_array() 415 + .ok_or("No verificationMethod found in DID doc")?; 416 + let expected_multibase = expected_signing_key 417 + .strip_prefix("did:key:") 418 + .ok_or("Invalid signing key format")?; 419 + let has_matching_key = verification_methods.iter().any(|vm| { 420 + vm["publicKeyMultibase"] 421 + .as_str() 422 + .map(|pk| pk == expected_multibase) 423 + .unwrap_or(false) 424 + }); 425 + if !has_matching_key { 426 + return Err(format!( 427 + "DID document verification key does not match reserved signing key. Expected publicKeyMultibase: {}", 428 + expected_multibase 429 + )); 430 + } 431 + Ok(()) 432 } 433 434 #[derive(serde::Serialize)] ··· 502 }; 503 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 504 let pds_endpoint = format!("https://{}", hostname); 505 let signing_key = match k256::ecdsa::SigningKey::from_slice(&key_bytes) { 506 Ok(k) => k, 507 Err(_) => return ApiError::InternalError.into_response(), ··· 516 StatusCode::OK, 517 Json(GetRecommendedDidCredentialsOutput { 518 rotation_keys, 519 + also_known_as: vec![format!("at://{}", user.handle)], 520 verification_methods: VerificationMethods { atproto: did_key }, 521 services: Services { 522 atproto_pds: AtprotoPds { ··· 582 .into_response(); 583 } 584 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 585 + let suffix = format!(".{}", hostname); 586 let is_service_domain = crate::handle::is_service_domain_handle(new_handle, &hostname); 587 + let handle = if is_service_domain { 588 + let short_part = if new_handle.ends_with(&suffix) { 589 new_handle.strip_suffix(&suffix).unwrap_or(new_handle) 590 } else { 591 new_handle 592 }; 593 + if short_part.contains('.') { 594 + return ( 595 + StatusCode::BAD_REQUEST, 596 + Json(json!({ 597 + "error": "InvalidHandle", 598 + "message": "Nested subdomains are not allowed. Use a simple handle without dots." 599 + })), 600 + ) 601 + .into_response(); 602 + } 603 + if new_handle.ends_with(&suffix) { 604 + new_handle.to_string() 605 + } else { 606 + format!("{}.{}", new_handle, hostname) 607 + } 608 } else { 609 match crate::handle::verify_handle_ownership(new_handle, &did).await { 610 Ok(()) => {} ··· 641 .into_response(); 642 } 643 } 644 + new_handle.to_string() 645 }; 646 let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE id = $1", user_id) 647 .fetch_optional(&state.db) ··· 650 .flatten(); 651 let existing = sqlx::query!( 652 "SELECT id FROM users WHERE handle = $1 AND id != $2", 653 + handle, 654 user_id 655 ) 656 .fetch_optional(&state.db) ··· 664 } 665 let result = sqlx::query!( 666 "UPDATE users SET handle = $1 WHERE id = $2", 667 + handle, 668 user_id 669 ) 670 .execute(&state.db) ··· 676 } 677 let _ = state 678 .cache 679 + .delete(&format!("handle:{}", handle)) 680 .await; 681 if let Err(e) = 682 + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)) 683 .await 684 { 685 warn!("Failed to sequence identity event for handle update: {}", e); 686 } 687 + if let Err(e) = update_plc_handle(&state, &did, &handle).await { 688 warn!("Failed to update PLC handle: {}", e); 689 } 690 (StatusCode::OK, Json(json!({}))).into_response() ··· 738 Some(h) => h, 739 None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(), 740 }; 741 let handle = host.split(':').next().unwrap_or(host); 742 + let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle) 743 .fetch_optional(&state.db) 744 .await; 745 match user {
+2 -2
src/api/repo/blob.rs
··· 16 use std::str::FromStr; 17 use tracing::{debug, error}; 18 19 - const MAX_BLOB_SIZE: usize = 1_000_000; 20 - const MAX_VIDEO_BLOB_SIZE: usize = 100_000_000; 21 22 pub async fn upload_blob( 23 State(state): State<AppState>,
··· 16 use std::str::FromStr; 17 use tracing::{debug, error}; 18 19 + const MAX_BLOB_SIZE: usize = 10_000_000_000; 20 + const MAX_VIDEO_BLOB_SIZE: usize = 10_000_000_000; 21 22 pub async fn upload_blob( 23 State(state): State<AppState>,
-24
src/api/repo/import.rs
··· 318 records.len(), 319 did 320 ); 321 - if is_migration { 322 - if let Err(e) = 323 - sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did) 324 - .execute(&state.db) 325 - .await 326 - { 327 - error!("Failed to reactivate account after import: {:?}", e); 328 - } 329 - let _ = state.cache.delete(&format!("handle:{}", user.handle)).await; 330 - if let Err(e) = crate::api::repo::record::sequence_identity_event( 331 - &state, 332 - did, 333 - Some(&user.handle), 334 - ) 335 - .await 336 - { 337 - warn!("Failed to sequence identity event after import: {:?}", e); 338 - } 339 - if let Err(e) = 340 - crate::api::repo::record::sequence_account_event(&state, did, true, None).await 341 - { 342 - warn!("Failed to sequence account event after import: {:?}", e); 343 - } 344 - } 345 if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await { 346 warn!("Failed to sequence import event: {:?}", e); 347 }
··· 318 records.len(), 319 did 320 ); 321 if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await { 322 warn!("Failed to sequence import event: {:?}", e); 323 }
+7 -1
src/api/repo/meta.rs
··· 17 State(state): State<AppState>, 18 Query(input): Query<DescribeRepoInput>, 19 ) -> Response { 20 let user_row = if input.repo.starts_with("did:") { 21 sqlx::query!( 22 "SELECT id, handle, did FROM users WHERE did = $1", ··· 26 .await 27 .map(|opt| opt.map(|r| (r.id, r.handle, r.did))) 28 } else { 29 sqlx::query!( 30 "SELECT id, handle, did FROM users WHERE handle = $1", 31 - input.repo 32 ) 33 .fetch_optional(&state.db) 34 .await
··· 17 State(state): State<AppState>, 18 Query(input): Query<DescribeRepoInput>, 19 ) -> Response { 20 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 21 let user_row = if input.repo.starts_with("did:") { 22 sqlx::query!( 23 "SELECT id, handle, did FROM users WHERE did = $1", ··· 27 .await 28 .map(|opt| opt.map(|r| (r.id, r.handle, r.did))) 29 } else { 30 + let handle = if !input.repo.contains('.') { 31 + format!("{}.{}", input.repo, hostname) 32 + } else { 33 + input.repo.clone() 34 + }; 35 sqlx::query!( 36 "SELECT id, handle, did FROM users WHERE handle = $1", 37 + handle 38 ) 39 .fetch_optional(&state.db) 40 .await
+8 -10
src/api/repo/record/read.rs
··· 34 .await 35 .map(|opt| opt.map(|r| r.id)) 36 } else { 37 - let suffix = format!(".{}", hostname); 38 - let short_handle = if input.repo.ends_with(&suffix) { 39 - input.repo.strip_suffix(&suffix).unwrap_or(&input.repo) 40 } else { 41 - &input.repo 42 }; 43 - sqlx::query!("SELECT id FROM users WHERE handle = $1", short_handle) 44 .fetch_optional(&state.db) 45 .await 46 .map(|opt| opt.map(|r| r.id)) ··· 212 .await 213 .map(|opt| opt.map(|r| r.id)) 214 } else { 215 - let suffix = format!(".{}", hostname); 216 - let short_handle = if input.repo.ends_with(&suffix) { 217 - input.repo.strip_suffix(&suffix).unwrap_or(&input.repo) 218 } else { 219 - &input.repo 220 }; 221 - sqlx::query!("SELECT id FROM users WHERE handle = $1", short_handle) 222 .fetch_optional(&state.db) 223 .await 224 .map(|opt| opt.map(|r| r.id))
··· 34 .await 35 .map(|opt| opt.map(|r| r.id)) 36 } else { 37 + let handle = if !input.repo.contains('.') { 38 + format!("{}.{}", input.repo, hostname) 39 } else { 40 + input.repo.clone() 41 }; 42 + sqlx::query!("SELECT id FROM users WHERE handle = $1", handle) 43 .fetch_optional(&state.db) 44 .await 45 .map(|opt| opt.map(|r| r.id)) ··· 211 .await 212 .map(|opt| opt.map(|r| r.id)) 213 } else { 214 + let handle = if !input.repo.contains('.') { 215 + format!("{}.{}", input.repo, hostname) 216 } else { 217 + input.repo.clone() 218 }; 219 + sqlx::query!("SELECT id FROM users WHERE handle = $1", handle) 220 .fetch_optional(&state.db) 221 .await 222 .map(|opt| opt.map(|r| r.id))
+10 -12
src/api/server/session.rs
··· 29 } 30 31 fn normalize_handle(identifier: &str, pds_hostname: &str) -> String { 32 - let suffix = format!(".{}", pds_hostname); 33 - if identifier.ends_with(&suffix) { 34 - identifier[..identifier.len() - suffix.len()].to_string() 35 } else { 36 - identifier.to_string() 37 } 38 } 39 40 - fn full_handle(stored_handle: &str, pds_hostname: &str) -> String { 41 - let suffix = format!(".{}", pds_hostname); 42 - if stored_handle.ends_with(&suffix) || stored_handle.ends_with(pds_hostname) { 43 - stored_handle.to_string() 44 - } else { 45 - format!("{}.{}", stored_handle, pds_hostname) 46 - } 47 } 48 49 #[derive(Deserialize)] ··· 66 headers: HeaderMap, 67 Json(input): Json<CreateSessionInput>, 68 ) -> Response { 69 - info!("create_session called"); 70 let client_ip = extract_client_ip(&headers); 71 if !state 72 .check_rate_limit(RateLimitKind::Login, &client_ip) ··· 84 } 85 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 86 let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname); 87 let row = match sqlx::query!( 88 r#"SELECT 89 u.id, u.did, u.handle, u.password_hash,
··· 29 } 30 31 fn normalize_handle(identifier: &str, pds_hostname: &str) -> String { 32 + let identifier = identifier.trim(); 33 + if identifier.contains('@') || identifier.starts_with("did:") { 34 + identifier.to_string() 35 + } else if !identifier.contains('.') { 36 + format!("{}.{}", identifier.to_lowercase(), pds_hostname) 37 } else { 38 + identifier.to_lowercase() 39 } 40 } 41 42 + fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String { 43 + stored_handle.to_string() 44 } 45 46 #[derive(Deserialize)] ··· 63 headers: HeaderMap, 64 Json(input): Json<CreateSessionInput>, 65 ) -> Response { 66 + info!("create_session called with identifier: {}", input.identifier); 67 let client_ip = extract_client_ip(&headers); 68 if !state 69 .check_rate_limit(RateLimitKind::Login, &client_ip) ··· 81 } 82 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 83 let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname); 84 + info!("Normalized identifier: {} -> {}", input.identifier, normalized_identifier); 85 let row = match sqlx::query!( 86 r#"SELECT 87 u.id, u.did, u.handle, u.password_hash,
+100
src/api/validation.rs
··· 4 pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63; 5 const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-"; 6 7 pub fn is_valid_email(email: &str) -> bool { 8 let email = email.trim(); 9 if email.is_empty() || email.len() > MAX_EMAIL_LENGTH { ··· 54 #[cfg(test)] 55 mod tests { 56 use super::*; 57 #[test] 58 fn test_valid_emails() { 59 assert!(is_valid_email("user@example.com"));
··· 4 pub const MAX_DOMAIN_LABEL_LENGTH: usize = 63; 5 const EMAIL_LOCAL_SPECIAL_CHARS: &str = ".!#$%&'*+/=?^_`{|}~-"; 6 7 + pub const MIN_HANDLE_LENGTH: usize = 3; 8 + pub const MAX_HANDLE_LENGTH: usize = 253; 9 + 10 + #[derive(Debug, PartialEq)] 11 + pub enum HandleValidationError { 12 + Empty, 13 + TooShort, 14 + TooLong, 15 + InvalidCharacters, 16 + StartsWithInvalidChar, 17 + EndsWithInvalidChar, 18 + ContainsSpaces, 19 + } 20 + 21 + impl std::fmt::Display for HandleValidationError { 22 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 23 + match self { 24 + Self::Empty => write!(f, "Handle cannot be empty"), 25 + Self::TooShort => write!(f, "Handle must be at least {} characters", MIN_HANDLE_LENGTH), 26 + Self::TooLong => write!(f, "Handle exceeds maximum length of {} characters", MAX_HANDLE_LENGTH), 27 + Self::InvalidCharacters => write!(f, "Handle contains invalid characters. Only alphanumeric, hyphens, and underscores are allowed"), 28 + Self::StartsWithInvalidChar => write!(f, "Handle cannot start with a hyphen or underscore"), 29 + Self::EndsWithInvalidChar => write!(f, "Handle cannot end with a hyphen or underscore"), 30 + Self::ContainsSpaces => write!(f, "Handle cannot contain spaces"), 31 + } 32 + } 33 + } 34 + 35 + pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationError> { 36 + let handle = handle.trim(); 37 + 38 + if handle.is_empty() { 39 + return Err(HandleValidationError::Empty); 40 + } 41 + 42 + if handle.contains(' ') || handle.contains('\t') || handle.contains('\n') { 43 + return Err(HandleValidationError::ContainsSpaces); 44 + } 45 + 46 + if handle.len() < MIN_HANDLE_LENGTH { 47 + return Err(HandleValidationError::TooShort); 48 + } 49 + 50 + if handle.len() > MAX_HANDLE_LENGTH { 51 + return Err(HandleValidationError::TooLong); 52 + } 53 + 54 + let first_char = handle.chars().next().unwrap(); 55 + if first_char == '-' || first_char == '_' { 56 + return Err(HandleValidationError::StartsWithInvalidChar); 57 + } 58 + 59 + let last_char = handle.chars().last().unwrap(); 60 + if last_char == '-' || last_char == '_' { 61 + return Err(HandleValidationError::EndsWithInvalidChar); 62 + } 63 + 64 + for c in handle.chars() { 65 + if !c.is_ascii_alphanumeric() && c != '-' && c != '_' { 66 + return Err(HandleValidationError::InvalidCharacters); 67 + } 68 + } 69 + 70 + Ok(handle.to_lowercase()) 71 + } 72 + 73 pub fn is_valid_email(email: &str) -> bool { 74 let email = email.trim(); 75 if email.is_empty() || email.len() > MAX_EMAIL_LENGTH { ··· 120 #[cfg(test)] 121 mod tests { 122 use super::*; 123 + 124 + #[test] 125 + fn test_valid_handles() { 126 + assert_eq!(validate_short_handle("alice"), Ok("alice".to_string())); 127 + assert_eq!(validate_short_handle("bob123"), Ok("bob123".to_string())); 128 + assert_eq!(validate_short_handle("user-name"), Ok("user-name".to_string())); 129 + assert_eq!(validate_short_handle("user_name"), Ok("user_name".to_string())); 130 + assert_eq!(validate_short_handle("UPPERCASE"), Ok("uppercase".to_string())); 131 + assert_eq!(validate_short_handle("MixedCase123"), Ok("mixedcase123".to_string())); 132 + assert_eq!(validate_short_handle("abc"), Ok("abc".to_string())); 133 + } 134 + 135 + #[test] 136 + fn test_invalid_handles() { 137 + assert_eq!(validate_short_handle(""), Err(HandleValidationError::Empty)); 138 + assert_eq!(validate_short_handle(" "), Err(HandleValidationError::Empty)); 139 + assert_eq!(validate_short_handle("ab"), Err(HandleValidationError::TooShort)); 140 + assert_eq!(validate_short_handle("a"), Err(HandleValidationError::TooShort)); 141 + assert_eq!(validate_short_handle("test spaces"), Err(HandleValidationError::ContainsSpaces)); 142 + assert_eq!(validate_short_handle("test\ttab"), Err(HandleValidationError::ContainsSpaces)); 143 + assert_eq!(validate_short_handle("-starts"), Err(HandleValidationError::StartsWithInvalidChar)); 144 + assert_eq!(validate_short_handle("_starts"), Err(HandleValidationError::StartsWithInvalidChar)); 145 + assert_eq!(validate_short_handle("ends-"), Err(HandleValidationError::EndsWithInvalidChar)); 146 + assert_eq!(validate_short_handle("ends_"), Err(HandleValidationError::EndsWithInvalidChar)); 147 + assert_eq!(validate_short_handle("test@user"), Err(HandleValidationError::InvalidCharacters)); 148 + assert_eq!(validate_short_handle("test!user"), Err(HandleValidationError::InvalidCharacters)); 149 + assert_eq!(validate_short_handle("test.user"), Err(HandleValidationError::InvalidCharacters)); 150 + } 151 + 152 + #[test] 153 + fn test_handle_trimming() { 154 + assert_eq!(validate_short_handle(" alice "), Ok("alice".to_string())); 155 + } 156 + 157 #[test] 158 fn test_valid_emails() { 159 assert!(is_valid_email("user@example.com"));
+15 -17
src/oauth/endpoints/authorize.rs
··· 426 let normalized_username = normalized_username 427 .strip_prefix('@') 428 .unwrap_or(normalized_username); 429 - let normalized_username = if let Some(bare_handle) = 430 - normalized_username.strip_suffix(&format!(".{}", pds_hostname)) 431 - { 432 - bare_handle.to_string() 433 } else { 434 normalized_username.to_string() 435 }; ··· 1585 Query(query): Query<CheckPasskeysQuery>, 1586 ) -> Response { 1587 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1588 - let normalized_identifier = query.identifier.trim(); 1589 - let normalized_identifier = normalized_identifier 1590 - .strip_prefix('@') 1591 - .unwrap_or(normalized_identifier); 1592 - let normalized_identifier = if let Some(bare_handle) = 1593 - normalized_identifier.strip_suffix(&format!(".{}", pds_hostname)) 1594 - { 1595 - bare_handle.to_string() 1596 } else { 1597 - normalized_identifier.to_string() 1598 }; 1599 1600 let user = sqlx::query!( ··· 1695 let normalized_username = normalized_username 1696 .strip_prefix('@') 1697 .unwrap_or(normalized_username); 1698 - let normalized_username = if let Some(bare_handle) = 1699 - normalized_username.strip_suffix(&format!(".{}", pds_hostname)) 1700 - { 1701 - bare_handle.to_string() 1702 } else { 1703 normalized_username.to_string() 1704 };
··· 426 let normalized_username = normalized_username 427 .strip_prefix('@') 428 .unwrap_or(normalized_username); 429 + let normalized_username = if normalized_username.contains('@') { 430 + normalized_username.to_string() 431 + } else if !normalized_username.contains('.') { 432 + format!("{}.{}", normalized_username, pds_hostname) 433 } else { 434 normalized_username.to_string() 435 }; ··· 1585 Query(query): Query<CheckPasskeysQuery>, 1586 ) -> Response { 1587 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 1588 + let identifier = query.identifier.trim(); 1589 + let identifier = identifier.strip_prefix('@').unwrap_or(identifier); 1590 + let normalized_identifier = if identifier.contains('@') || identifier.starts_with("did:") { 1591 + identifier.to_string() 1592 + } else if !identifier.contains('.') { 1593 + format!("{}.{}", identifier.to_lowercase(), pds_hostname) 1594 } else { 1595 + identifier.to_lowercase() 1596 }; 1597 1598 let user = sqlx::query!( ··· 1693 let normalized_username = normalized_username 1694 .strip_prefix('@') 1695 .unwrap_or(normalized_username); 1696 + let normalized_username = if normalized_username.contains('@') { 1697 + normalized_username.to_string() 1698 + } else if !normalized_username.contains('.') { 1699 + format!("{}.{}", normalized_username, pds_hostname) 1700 } else { 1701 normalized_username.to_string() 1702 };
+30 -29
tests/email_update.rs
··· 17 base_url: &str, 18 handle: &str, 19 email: &str, 20 - ) -> String { 21 let res = client 22 .post(format!( 23 "{}/xrpc/com.atproto.server.createAccount", ··· 33 .expect("Failed to create account"); 34 assert_eq!(res.status(), StatusCode::OK); 35 let body: Value = res.json().await.expect("Invalid JSON"); 36 - let did = body["did"].as_str().expect("No did"); 37 - common::verify_new_account(client, did).await 38 } 39 40 #[tokio::test] ··· 44 let pool = get_pool().await; 45 let handle = format!("emailup_{}", uuid::Uuid::new_v4()); 46 let email = format!("{}@example.com", handle); 47 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 48 let new_email = format!("new_{}@example.com", handle); 49 let res = client 50 .post(format!( ··· 61 assert_eq!(body["tokenRequired"], true); 62 63 let verification = sqlx::query!( 64 - "SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 65 - handle 66 ) 67 .fetch_one(&pool) 68 .await ··· 84 .await 85 .expect("Failed to confirm email"); 86 assert_eq!(res.status(), StatusCode::OK); 87 - let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle) 88 .fetch_one(&pool) 89 .await 90 .expect("User not found"); 91 assert_eq!(user.email, Some(new_email)); 92 93 let verification = sqlx::query!( 94 - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 95 - handle 96 ) 97 .fetch_optional(&pool) 98 .await ··· 106 let base_url = common::base_url().await; 107 let handle1 = format!("emailup_taken1_{}", uuid::Uuid::new_v4()); 108 let email1 = format!("{}@example.com", handle1); 109 - let _ = create_verified_account(&client, &base_url, &handle1, &email1).await; 110 let handle2 = format!("emailup_taken2_{}", uuid::Uuid::new_v4()); 111 let email2 = format!("{}@example.com", handle2); 112 - let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await; 113 let res = client 114 .post(format!( 115 "{}/xrpc/com.atproto.server.requestEmailUpdate", ··· 131 let base_url = common::base_url().await; 132 let handle = format!("emailup_inv_{}", uuid::Uuid::new_v4()); 133 let email = format!("{}@example.com", handle); 134 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 135 let new_email = format!("new_{}@example.com", handle); 136 let res = client 137 .post(format!( ··· 166 let pool = get_pool().await; 167 let handle = format!("emailup_wrong_{}", uuid::Uuid::new_v4()); 168 let email = format!("{}@example.com", handle); 169 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 170 let new_email = format!("new_{}@example.com", handle); 171 let res = client 172 .post(format!( ··· 180 .expect("Failed to request email update"); 181 assert_eq!(res.status(), StatusCode::OK); 182 let verification = sqlx::query!( 183 - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 184 - handle 185 ) 186 .fetch_one(&pool) 187 .await ··· 209 let pool = get_pool().await; 210 let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4()); 211 let email = format!("{}@example.com", handle); 212 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 213 let new_email = format!("direct_{}@example.com", handle); 214 let res = client 215 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) ··· 219 .await 220 .expect("Failed to update email"); 221 assert_eq!(res.status(), StatusCode::OK); 222 - let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle) 223 .fetch_one(&pool) 224 .await 225 .expect("User not found"); ··· 232 let base_url = common::base_url().await; 233 let handle = format!("emailup_same_{}", uuid::Uuid::new_v4()); 234 let email = format!("{}@example.com", handle); 235 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 236 let res = client 237 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 238 .bearer_auth(&access_jwt) ··· 253 let base_url = common::base_url().await; 254 let handle = format!("emailup_token_{}", uuid::Uuid::new_v4()); 255 let email = format!("{}@example.com", handle); 256 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 257 let new_email = format!("pending_{}@example.com", handle); 258 let res = client 259 .post(format!( ··· 285 let pool = get_pool().await; 286 let handle = format!("emailup_valid_{}", uuid::Uuid::new_v4()); 287 let email = format!("{}@example.com", handle); 288 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 289 let new_email = format!("valid_{}@example.com", handle); 290 let res = client 291 .post(format!( ··· 299 .expect("Failed to request email update"); 300 assert_eq!(res.status(), StatusCode::OK); 301 let verification = sqlx::query!( 302 - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 303 - handle 304 ) 305 .fetch_one(&pool) 306 .await ··· 317 .await 318 .expect("Failed to update email"); 319 assert_eq!(res.status(), StatusCode::OK); 320 - let user = sqlx::query!("SELECT email FROM users WHERE handle = $1", handle) 321 .fetch_one(&pool) 322 .await 323 .expect("User not found"); 324 assert_eq!(user.email, Some(new_email)); 325 let verification = sqlx::query!( 326 - "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE handle = $1) AND channel = 'email'", 327 - handle 328 ) 329 .fetch_optional(&pool) 330 .await ··· 338 let base_url = common::base_url().await; 339 let handle = format!("emailup_badtok_{}", uuid::Uuid::new_v4()); 340 let email = format!("{}@example.com", handle); 341 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 342 let new_email = format!("badtok_{}@example.com", handle); 343 let res = client 344 .post(format!( ··· 372 let base_url = common::base_url().await; 373 let handle1 = format!("emailup_dup1_{}", uuid::Uuid::new_v4()); 374 let email1 = format!("{}@example.com", handle1); 375 - let _ = create_verified_account(&client, &base_url, &handle1, &email1).await; 376 let handle2 = format!("emailup_dup2_{}", uuid::Uuid::new_v4()); 377 let email2 = format!("{}@example.com", handle2); 378 - let access_jwt2 = create_verified_account(&client, &base_url, &handle2, &email2).await; 379 let res = client 380 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 381 .bearer_auth(&access_jwt2) ··· 412 let base_url = common::base_url().await; 413 let handle = format!("emailup_fmt_{}", uuid::Uuid::new_v4()); 414 let email = format!("{}@example.com", handle); 415 - let access_jwt = create_verified_account(&client, &base_url, &handle, &email).await; 416 let res = client 417 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 418 .bearer_auth(&access_jwt)
··· 17 base_url: &str, 18 handle: &str, 19 email: &str, 20 + ) -> (String, String) { 21 let res = client 22 .post(format!( 23 "{}/xrpc/com.atproto.server.createAccount", ··· 33 .expect("Failed to create account"); 34 assert_eq!(res.status(), StatusCode::OK); 35 let body: Value = res.json().await.expect("Invalid JSON"); 36 + let did = body["did"].as_str().expect("No did").to_string(); 37 + let jwt = common::verify_new_account(client, &did).await; 38 + (jwt, did) 39 } 40 41 #[tokio::test] ··· 45 let pool = get_pool().await; 46 let handle = format!("emailup_{}", uuid::Uuid::new_v4()); 47 let email = format!("{}@example.com", handle); 48 + let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 49 let new_email = format!("new_{}@example.com", handle); 50 let res = client 51 .post(format!( ··· 62 assert_eq!(body["tokenRequired"], true); 63 64 let verification = sqlx::query!( 65 + "SELECT pending_identifier, code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 66 + did 67 ) 68 .fetch_one(&pool) 69 .await ··· 85 .await 86 .expect("Failed to confirm email"); 87 assert_eq!(res.status(), StatusCode::OK); 88 + let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) 89 .fetch_one(&pool) 90 .await 91 .expect("User not found"); 92 assert_eq!(user.email, Some(new_email)); 93 94 let verification = sqlx::query!( 95 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 96 + did 97 ) 98 .fetch_optional(&pool) 99 .await ··· 107 let base_url = common::base_url().await; 108 let handle1 = format!("emailup_taken1_{}", uuid::Uuid::new_v4()); 109 let email1 = format!("{}@example.com", handle1); 110 + let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; 111 let handle2 = format!("emailup_taken2_{}", uuid::Uuid::new_v4()); 112 let email2 = format!("{}@example.com", handle2); 113 + let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await; 114 let res = client 115 .post(format!( 116 "{}/xrpc/com.atproto.server.requestEmailUpdate", ··· 132 let base_url = common::base_url().await; 133 let handle = format!("emailup_inv_{}", uuid::Uuid::new_v4()); 134 let email = format!("{}@example.com", handle); 135 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 136 let new_email = format!("new_{}@example.com", handle); 137 let res = client 138 .post(format!( ··· 167 let pool = get_pool().await; 168 let handle = format!("emailup_wrong_{}", uuid::Uuid::new_v4()); 169 let email = format!("{}@example.com", handle); 170 + let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 171 let new_email = format!("new_{}@example.com", handle); 172 let res = client 173 .post(format!( ··· 181 .expect("Failed to request email update"); 182 assert_eq!(res.status(), StatusCode::OK); 183 let verification = sqlx::query!( 184 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 185 + did 186 ) 187 .fetch_one(&pool) 188 .await ··· 210 let pool = get_pool().await; 211 let handle = format!("emailup_direct_{}", uuid::Uuid::new_v4()); 212 let email = format!("{}@example.com", handle); 213 + let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 214 let new_email = format!("direct_{}@example.com", handle); 215 let res = client 216 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) ··· 220 .await 221 .expect("Failed to update email"); 222 assert_eq!(res.status(), StatusCode::OK); 223 + let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) 224 .fetch_one(&pool) 225 .await 226 .expect("User not found"); ··· 233 let base_url = common::base_url().await; 234 let handle = format!("emailup_same_{}", uuid::Uuid::new_v4()); 235 let email = format!("{}@example.com", handle); 236 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 237 let res = client 238 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 239 .bearer_auth(&access_jwt) ··· 254 let base_url = common::base_url().await; 255 let handle = format!("emailup_token_{}", uuid::Uuid::new_v4()); 256 let email = format!("{}@example.com", handle); 257 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 258 let new_email = format!("pending_{}@example.com", handle); 259 let res = client 260 .post(format!( ··· 286 let pool = get_pool().await; 287 let handle = format!("emailup_valid_{}", uuid::Uuid::new_v4()); 288 let email = format!("{}@example.com", handle); 289 + let (access_jwt, did) = create_verified_account(&client, &base_url, &handle, &email).await; 290 let new_email = format!("valid_{}@example.com", handle); 291 let res = client 292 .post(format!( ··· 300 .expect("Failed to request email update"); 301 assert_eq!(res.status(), StatusCode::OK); 302 let verification = sqlx::query!( 303 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 304 + did 305 ) 306 .fetch_one(&pool) 307 .await ··· 318 .await 319 .expect("Failed to update email"); 320 assert_eq!(res.status(), StatusCode::OK); 321 + let user = sqlx::query!("SELECT email FROM users WHERE did = $1", did) 322 .fetch_one(&pool) 323 .await 324 .expect("User not found"); 325 assert_eq!(user.email, Some(new_email)); 326 let verification = sqlx::query!( 327 + "SELECT code FROM channel_verifications WHERE user_id = (SELECT id FROM users WHERE did = $1) AND channel = 'email'", 328 + did 329 ) 330 .fetch_optional(&pool) 331 .await ··· 339 let base_url = common::base_url().await; 340 let handle = format!("emailup_badtok_{}", uuid::Uuid::new_v4()); 341 let email = format!("{}@example.com", handle); 342 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 343 let new_email = format!("badtok_{}@example.com", handle); 344 let res = client 345 .post(format!( ··· 373 let base_url = common::base_url().await; 374 let handle1 = format!("emailup_dup1_{}", uuid::Uuid::new_v4()); 375 let email1 = format!("{}@example.com", handle1); 376 + let (_, _) = create_verified_account(&client, &base_url, &handle1, &email1).await; 377 let handle2 = format!("emailup_dup2_{}", uuid::Uuid::new_v4()); 378 let email2 = format!("{}@example.com", handle2); 379 + let (access_jwt2, _) = create_verified_account(&client, &base_url, &handle2, &email2).await; 380 let res = client 381 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 382 .bearer_auth(&access_jwt2) ··· 413 let base_url = common::base_url().await; 414 let handle = format!("emailup_fmt_{}", uuid::Uuid::new_v4()); 415 let email = format!("{}@example.com", handle); 416 + let (access_jwt, _) = create_verified_account(&client, &base_url, &handle, &email).await; 417 let res = client 418 .post(format!("{}/xrpc/com.atproto.server.updateEmail", base_url)) 419 .bearer_auth(&access_jwt)
+5 -4
tests/identity.rs
··· 8 #[tokio::test] 9 async fn test_resolve_handle_success() { 10 let client = client(); 11 - let handle = format!("resolvetest_{}", uuid::Uuid::new_v4()); 12 let payload = json!({ 13 - "handle": handle, 14 - "email": format!("{}@example.com", handle), 15 "password": "password" 16 }); 17 let res = client ··· 26 assert_eq!(res.status(), StatusCode::OK); 27 let body: Value = res.json().await.expect("Invalid JSON"); 28 let did = body["did"].as_str().expect("No DID").to_string(); 29 - let params = [("handle", handle.as_str())]; 30 let res = client 31 .get(format!( 32 "{}/xrpc/com.atproto.identity.resolveHandle",
··· 8 #[tokio::test] 9 async fn test_resolve_handle_success() { 10 let client = client(); 11 + let short_handle = format!("resolvetest_{}", uuid::Uuid::new_v4()); 12 let payload = json!({ 13 + "handle": short_handle, 14 + "email": format!("{}@example.com", short_handle), 15 "password": "password" 16 }); 17 let res = client ··· 26 assert_eq!(res.status(), StatusCode::OK); 27 let body: Value = res.json().await.expect("Invalid JSON"); 28 let did = body["did"].as_str().expect("No DID").to_string(); 29 + let full_handle = body["handle"].as_str().expect("No handle in response").to_string(); 30 + let params = [("handle", full_handle.as_str())]; 31 let res = client 32 .get(format!( 33 "{}/xrpc/com.atproto.identity.resolveHandle",