Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

Attempt at better byod did:web registration

+347 -51
+1
frontend/src/lib/api.ts
··· 266 266 inviteCodeRequired: boolean 267 267 links?: { privacyPolicy?: string; termsOfService?: string } 268 268 version?: string 269 + availableCommsChannels?: string[] 269 270 }> { 270 271 return xrpc('com.atproto.server.describeServer') 271 272 },
+2
frontend/src/locales/en.json
··· 96 96 "signalNumber": "Signal Phone Number", 97 97 "signalNumberPlaceholder": "+1234567890", 98 98 "signalNumberHint": "Include country code (e.g., +1 for US)", 99 + "notConfigured": "not configured", 99 100 "inviteCode": "Invite Code", 100 101 "inviteCodePlaceholder": "Enter your invite code", 101 102 "inviteCodeRequired": "required", ··· 388 389 "telegramVia": "Receive messages via Telegram", 389 390 "signalVia": "Receive messages via Signal", 390 391 "configureToEnable": "Configure below to enable", 392 + "notConfiguredOnServer": "Not configured on this server", 391 393 "emailManagedInSettings": "Your email is managed in Account Settings", 392 394 "discordIdHint": "Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.", 393 395 "telegramHint": "Your Telegram username without the @ symbol",
+2
frontend/src/locales/fi.json
··· 96 96 "signalNumber": "Signal-puhelinnumero", 97 97 "signalNumberPlaceholder": "+358401234567", 98 98 "signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)", 99 + "notConfigured": "ei määritetty", 99 100 "inviteCode": "Kutsukoodi", 100 101 "inviteCodePlaceholder": "Syötä kutsukoodisi", 101 102 "inviteCodeRequired": "vaaditaan", ··· 388 389 "telegramVia": "Vastaanota viestejä Telegramissa", 389 390 "signalVia": "Vastaanota viestejä Signalissa", 390 391 "configureToEnable": "Määritä alla ottaaksesi käyttöön", 392 + "notConfiguredOnServer": "Ei määritetty tällä palvelimella", 391 393 "emailManagedInSettings": "Sähköpostisi hallinnoidaan Tilin asetuksissa", 392 394 "discordIdHint": "Discord-käyttäjätunnuksesi (ei käyttäjänimi). Ota Kehittäjätila käyttöön Discordissa kopioidaksesi sen.", 393 395 "telegramHint": "Telegram-käyttäjänimesi ilman @-merkkiä",
+2
frontend/src/locales/ja.json
··· 96 96 "signalNumber": "Signal 電話番号", 97 97 "signalNumberPlaceholder": "+81XXXXXXXXXX", 98 98 "signalNumberHint": "国番号を含めてください(例: 日本は +81)", 99 + "notConfigured": "未設定", 99 100 "inviteCode": "招待コード", 100 101 "inviteCodePlaceholder": "招待コードを入力", 101 102 "inviteCodeRequired": "必須", ··· 388 389 "telegramVia": "Telegram でメッセージを受信", 389 390 "signalVia": "Signal でメッセージを受信", 390 391 "configureToEnable": "有効にするには下記で設定", 392 + "notConfiguredOnServer": "このサーバーでは設定されていません", 391 393 "emailManagedInSettings": "メールはアカウント設定で管理されています", 392 394 "discordIdHint": "Discord ユーザー ID(ユーザー名ではありません)。Discord で開発者モードを有効にしてコピーしてください。", 393 395 "telegramHint": "@ 記号なしの Telegram ユーザー名",
+2
frontend/src/locales/ko.json
··· 96 96 "signalNumber": "Signal 전화번호", 97 97 "signalNumberPlaceholder": "+821012345678", 98 98 "signalNumberHint": "국가 코드 포함 (예: 한국 +82)", 99 + "notConfigured": "구성되지 않음", 99 100 "inviteCode": "초대 코드", 100 101 "inviteCodePlaceholder": "초대 코드 입력", 101 102 "inviteCodeRequired": "필수", ··· 388 389 "telegramVia": "Telegram으로 메시지 받기", 389 390 "signalVia": "Signal로 메시지 받기", 390 391 "configureToEnable": "활성화하려면 아래에서 설정", 392 + "notConfiguredOnServer": "이 서버에서 설정되지 않음", 391 393 "emailManagedInSettings": "이메일은 계정 설정에서 관리됩니다", 392 394 "discordIdHint": "Discord 사용자 ID (사용자 이름 아님). Discord에서 개발자 모드를 활성화하여 복사하세요.", 393 395 "telegramHint": "@ 기호 없이 Telegram 사용자 이름",
+2
frontend/src/locales/sv.json
··· 96 96 "signalNumber": "Signal-telefonnummer", 97 97 "signalNumberPlaceholder": "+46701234567", 98 98 "signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)", 99 + "notConfigured": "ej konfigurerad", 99 100 "inviteCode": "Inbjudningskod", 100 101 "inviteCodePlaceholder": "Ange din inbjudningskod", 101 102 "inviteCodeRequired": "krävs", ··· 388 389 "telegramVia": "Ta emot meddelanden via Telegram", 389 390 "signalVia": "Ta emot meddelanden via Signal", 390 391 "configureToEnable": "Konfigurera nedan för att aktivera", 392 + "notConfiguredOnServer": "Inte konfigurerat på denna server", 391 393 "emailManagedInSettings": "Din e-post hanteras i Kontoinställningar", 392 394 "discordIdHint": "Ditt Discord användar-ID (inte användarnamn). Aktivera Utvecklarläge i Discord för att kopiera det.", 393 395 "telegramHint": "Ditt Telegram-användarnamn utan @-symbolen",
+2
frontend/src/locales/zh.json
··· 96 96 "signalNumber": "Signal 电话号码", 97 97 "signalNumberPlaceholder": "+1234567890", 98 98 "signalNumberHint": "包含国家代码(例如中国为 +86)", 99 + "notConfigured": "未配置", 99 100 "inviteCode": "邀请码", 100 101 "inviteCodePlaceholder": "输入您的邀请码", 101 102 "inviteCodeRequired": "必填", ··· 388 389 "telegramVia": "通过 Telegram 接收消息", 389 390 "signalVia": "通过 Signal 接收消息", 390 391 "configureToEnable": "请先在下方配置", 392 + "notConfiguredOnServer": "此服务器未配置", 391 393 "emailManagedInSettings": "邮箱在账户设置中管理", 392 394 "discordIdHint": "您的 Discord 数字用户 ID(非用户名)。在 Discord 中开启开发者模式即可复制。", 393 395 "telegramHint": "您的 Telegram 用户名,不含 @ 符号",
+47 -12
frontend/src/routes/Comms.svelte
··· 10 10 let error = $state<string | null>(null) 11 11 let success = $state<string | null>(null) 12 12 let preferredChannel = $state('email') 13 + let availableCommsChannels = $state<string[]>(['email']) 13 14 let email = $state('') 14 15 let discordId = $state('') 15 16 let discordVerified = $state(false) ··· 47 48 loading = true 48 49 error = null 49 50 try { 50 - const prefs = await api.getNotificationPrefs(auth.session.accessJwt) 51 + const [prefs, serverInfo] = await Promise.all([ 52 + api.getNotificationPrefs(auth.session.accessJwt), 53 + api.describeServer() 54 + ]) 51 55 preferredChannel = prefs.preferredChannel 52 56 email = prefs.email 53 57 discordId = prefs.discordId ?? '' ··· 56 60 telegramVerified = prefs.telegramVerified 57 61 signalNumber = prefs.signalNumber ?? '' 58 62 signalVerified = prefs.signalVerified 63 + availableCommsChannels = serverInfo.availableCommsChannels ?? ['email'] 59 64 } catch (e) { 60 65 error = e instanceof ApiError ? e.message : 'Failed to load notification preferences' 61 66 } finally { ··· 135 140 default: return '' 136 141 } 137 142 } 143 + function isChannelAvailableOnServer(channelId: string): boolean { 144 + return availableCommsChannels.includes(channelId) 145 + } 138 146 function canSelectChannel(channelId: string): boolean { 147 + if (!isChannelAvailableOnServer(channelId)) return false 139 148 if (channelId === 'email') return true 140 149 if (channelId === 'discord') return !!discordId 141 150 if (channelId === 'telegram') return !!telegramUsername ··· 174 183 </p> 175 184 <div class="channel-options"> 176 185 {#each channels as channelId} 177 - <label class="channel-option" class:disabled={!canSelectChannel(channelId)}> 186 + <label class="channel-option" class:disabled={!canSelectChannel(channelId)} class:unavailable={!isChannelAvailableOnServer(channelId)}> 178 187 <input 179 188 type="radio" 180 189 name="preferredChannel" ··· 185 194 <div class="channel-info"> 186 195 <span class="channel-name">{getChannelName(channelId)}</span> 187 196 <span class="channel-description">{getChannelDescription(channelId)}</span> 188 - {#if channelId !== 'email' && !canSelectChannel(channelId)} 197 + {#if !isChannelAvailableOnServer(channelId)} 198 + <span class="channel-hint server-unavailable">{$_('comms.notConfiguredOnServer')}</span> 199 + {:else if channelId !== 'email' && !canSelectChannel(channelId)} 189 200 <span class="channel-hint">{$_('comms.configureToEnable')}</span> 190 201 {/if} 191 202 </div> ··· 210 221 </div> 211 222 <p class="config-hint">{$_('comms.emailManagedInSettings')}</p> 212 223 </div> 213 - <div class="config-item"> 224 + <div class="config-item" class:unavailable={!isChannelAvailableOnServer('discord')}> 214 225 <label for="discord">{$_('register.discordId')}</label> 215 226 <div class="config-input"> 216 227 <input ··· 218 229 type="text" 219 230 bind:value={discordId} 220 231 placeholder={$_('register.discordIdPlaceholder')} 221 - disabled={saving} 232 + disabled={saving || !isChannelAvailableOnServer('discord')} 222 233 /> 223 - {#if discordId} 234 + {#if !isChannelAvailableOnServer('discord')} 235 + <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 236 + {:else if discordId} 224 237 {#if discordVerified} 225 238 <span class="status verified">{$_('comms.verified')}</span> 226 239 {:else} ··· 243 256 </div> 244 257 {/if} 245 258 </div> 246 - <div class="config-item"> 259 + <div class="config-item" class:unavailable={!isChannelAvailableOnServer('telegram')}> 247 260 <label for="telegram">{$_('register.telegramUsername')}</label> 248 261 <div class="config-input"> 249 262 <input ··· 251 264 type="text" 252 265 bind:value={telegramUsername} 253 266 placeholder={$_('register.telegramUsernamePlaceholder')} 254 - disabled={saving} 267 + disabled={saving || !isChannelAvailableOnServer('telegram')} 255 268 /> 256 - {#if telegramUsername} 269 + {#if !isChannelAvailableOnServer('telegram')} 270 + <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 271 + {:else if telegramUsername} 257 272 {#if telegramVerified} 258 273 <span class="status verified">{$_('comms.verified')}</span> 259 274 {:else} ··· 276 291 </div> 277 292 {/if} 278 293 </div> 279 - <div class="config-item"> 294 + <div class="config-item" class:unavailable={!isChannelAvailableOnServer('signal')}> 280 295 <label for="signal">{$_('register.signalNumber')}</label> 281 296 <div class="config-input"> 282 297 <input ··· 284 299 type="tel" 285 300 bind:value={signalNumber} 286 301 placeholder={$_('register.signalNumberPlaceholder')} 287 - disabled={saving} 302 + disabled={saving || !isChannelAvailableOnServer('signal')} 288 303 /> 289 - {#if signalNumber} 304 + {#if !isChannelAvailableOnServer('signal')} 305 + <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 306 + {:else if signalNumber} 290 307 {#if signalVerified} 291 308 <span class="status verified">{$_('comms.verified')}</span> 292 309 {:else} ··· 439 456 cursor: not-allowed; 440 457 } 441 458 459 + .channel-option.unavailable { 460 + opacity: 0.5; 461 + background: var(--bg-input-disabled); 462 + } 463 + 442 464 .channel-option input[type="radio"] { 443 465 flex-shrink: 0; 444 466 width: 16px; ··· 467 489 font-size: var(--text-xs); 468 490 color: var(--text-muted); 469 491 font-style: italic; 492 + } 493 + 494 + .channel-hint.server-unavailable { 495 + color: var(--warning-text); 470 496 } 471 497 472 498 .channel-config { ··· 481 507 gap: var(--space-1); 482 508 } 483 509 510 + .config-item.unavailable { 511 + opacity: 0.6; 512 + } 513 + 484 514 .config-item label { 485 515 font-size: var(--text-sm); 486 516 font-weight: var(--font-medium); ··· 516 546 .status.unverified { 517 547 background: var(--warning-bg); 518 548 color: var(--warning-text); 549 + } 550 + 551 + .status.unavailable { 552 + background: var(--bg-input-disabled); 553 + color: var(--text-muted); 519 554 } 520 555 521 556 .config-hint {
+3 -3
frontend/src/routes/Home.svelte
··· 153 153 154 154 <h2>Works with everything</h2> 155 155 156 - <p>Use any ATProto app you already like. Tranquil PDS speaks the same language as Bluesky's servers, so all your favorite clients, tools, and bots just work.</p> 156 + <p>Use any ATProto app you already like. Tranquil PDS speaks the same language as Bluesky's servers, so all your favorite clients and tools just work.</p> 157 157 158 158 <h2>Ready to try it?</h2> 159 159 ··· 170 170 </section> 171 171 172 172 <footer class="site-footer"> 173 - <span>Open Source</span> 174 - <span>Made with patience</span> 173 + <span>Made by people who don't take themselves too seriously</span> 174 + <span>Open Source: issues & PRs welcome</span> 175 175 </footer> 176 176 </div> 177 177
+1 -1
frontend/src/routes/Login.svelte
··· 144 144 </button> 145 145 146 146 <p class="link-text"> 147 - {$_('login.noAccount')} <a href="#/register">{$_('login.createAcount')}</a> 147 + {$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a> 148 148 </p> 149 149 150 150 {:else}
+15 -3
frontend/src/routes/Register.svelte
··· 22 22 let serverInfo = $state<{ 23 23 availableUserDomains: string[] 24 24 inviteCodeRequired: boolean 25 + availableCommsChannels?: string[] 25 26 } | null>(null) 26 27 let loadingServerInfo = $state(true) 27 28 let serverInfoLoaded = false ··· 46 47 } 47 48 48 49 let handleHasDot = $derived(handle.includes('.')) 50 + 51 + function isChannelAvailable(channel: string): boolean { 52 + const available = serverInfo?.availableCommsChannels ?? ['email'] 53 + return available.includes(channel) 54 + } 49 55 50 56 function validateForm(): string | null { 51 57 if (!handle.trim()) return $_('register.validation.handleRequired') ··· 262 268 <label for="verification-channel">{$_('register.verificationMethod')}</label> 263 269 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 264 270 <option value="email">{$_('register.email')}</option> 265 - <option value="discord">{$_('register.discord')}</option> 266 - <option value="telegram">{$_('register.telegram')}</option> 267 - <option value="signal">{$_('register.signal')}</option> 271 + <option value="discord" disabled={!isChannelAvailable('discord')}> 272 + {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 273 + </option> 274 + <option value="telegram" disabled={!isChannelAvailable('telegram')}> 275 + {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 276 + </option> 277 + <option value="signal" disabled={!isChannelAvailable('signal')}> 278 + {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 279 + </option> 268 280 </select> 269 281 </div> 270 282
+15 -4
frontend/src/routes/RegisterPasskey.svelte
··· 19 19 let passkeyName = $state('') 20 20 let submitting = $state(false) 21 21 let error = $state<string | null>(null) 22 - let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean } | null>(null) 22 + let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean; availableCommsChannels?: string[] } | null>(null) 23 23 let loadingServerInfo = $state(true) 24 24 let serverInfoLoaded = false 25 25 ··· 289 289 } 290 290 } 291 291 292 + function isChannelAvailable(ch: string): boolean { 293 + const available = serverInfo?.availableCommsChannels ?? ['email'] 294 + return available.includes(ch) 295 + } 296 + 292 297 function goToLogin() { 293 298 navigate('/login') 294 299 } ··· 363 368 <label for="verification-channel">Verification Method</label> 364 369 <select id="verification-channel" bind:value={verificationChannel} disabled={submitting}> 365 370 <option value="email">Email</option> 366 - <option value="discord">Discord</option> 367 - <option value="telegram">Telegram</option> 368 - <option value="signal">Signal</option> 371 + <option value="discord" disabled={!isChannelAvailable('discord')}> 372 + Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 373 + </option> 374 + <option value="telegram" disabled={!isChannelAvailable('telegram')}> 375 + Telegram{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 376 + </option> 377 + <option value="signal" disabled={!isChannelAvailable('signal')}> 378 + Signal{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 379 + </option> 369 380 </select> 370 381 </div> 371 382 {#if verificationChannel === 'email'}
+34 -19
src/api/identity/account.rs
··· 122 122 && input 123 123 .did 124 124 .as_ref() 125 - .map(|d| d.starts_with("did:plc:")) 125 + .map(|d| d.starts_with("did:plc:") || d.starts_with("did:web:")) 126 + .unwrap_or(false); 127 + 128 + let is_did_web_byod = migration_auth.is_some() 129 + && input 130 + .did 131 + .as_ref() 132 + .map(|d| d.starts_with("did:web:")) 126 133 .unwrap_or(false); 127 134 128 135 if is_migration { ··· 138 145 ) 139 146 .into_response(); 140 147 } 141 - info!(did = %migration_did, "Processing account migration"); 148 + if is_did_web_byod { 149 + info!(did = %migration_did, "Processing did:web BYOD account creation"); 150 + } else { 151 + info!(did = %migration_did, "Processing account migration"); 152 + } 142 153 } 143 154 } 144 155 ··· 337 348 ) 338 349 .into_response(); 339 350 } 340 - if let Err(e) = 341 - verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await 342 - { 343 - return ( 344 - StatusCode::BAD_REQUEST, 345 - Json(json!({"error": "InvalidDid", "message": e})), 346 - ) 347 - .into_response(); 351 + if !is_did_web_byod { 352 + if let Err(e) = 353 + verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await 354 + { 355 + return ( 356 + StatusCode::BAD_REQUEST, 357 + Json(json!({"error": "InvalidDid", "message": e})), 358 + ) 359 + .into_response(); 360 + } 348 361 } 349 362 info!(did = %d, "Creating external did:web account"); 350 363 d.clone() ··· 355 368 info!(did = %d, "Migration with existing did:plc"); 356 369 d.clone() 357 370 } else if d.starts_with("did:web:") { 358 - if let Err(e) = 359 - verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()) 360 - .await 361 - { 362 - return ( 363 - StatusCode::BAD_REQUEST, 364 - Json(json!({"error": "InvalidDid", "message": e})), 365 - ) 366 - .into_response(); 371 + if !is_did_web_byod { 372 + if let Err(e) = 373 + verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()) 374 + .await 375 + { 376 + return ( 377 + StatusCode::BAD_REQUEST, 378 + Json(json!({"error": "InvalidDid", "message": e})), 379 + ) 380 + .into_response(); 381 + } 367 382 } 368 383 d.clone() 369 384 } else if !d.trim().is_empty() {
+17 -1
src/api/server/meta.rs
··· 2 2 use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; 3 3 use serde_json::json; 4 4 use tracing::error; 5 + 6 + fn get_available_comms_channels() -> Vec<&'static str> { 7 + let mut channels = vec!["email"]; 8 + if std::env::var("DISCORD_WEBHOOK_URL").is_ok() { 9 + channels.push("discord"); 10 + } 11 + if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() { 12 + channels.push("telegram"); 13 + } 14 + if std::env::var("SIGNAL_CLI_PATH").is_ok() && std::env::var("SIGNAL_SENDER_NUMBER").is_ok() { 15 + channels.push("signal"); 16 + } 17 + channels 18 + } 19 + 5 20 pub async fn robots_txt() -> impl IntoResponse { 6 21 ( 7 22 StatusCode::OK, ··· 21 36 "availableUserDomains": domains, 22 37 "inviteCodeRequired": invite_code_required, 23 38 "did": format!("did:web:{}", pds_hostname), 24 - "version": env!("CARGO_PKG_VERSION") 39 + "version": env!("CARGO_PKG_VERSION"), 40 + "availableCommsChannels": get_available_comms_channels() 25 41 })) 26 42 } 27 43 pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
+9 -8
src/auth/service.rs
··· 229 229 .strip_prefix("did:web:") 230 230 .ok_or_else(|| anyhow!("Invalid did:web format"))?; 231 231 232 - let decoded_host = host.replace("%3A", ":"); 233 - let (host_part, path_part) = if let Some(idx) = decoded_host.find('/') { 234 - (&decoded_host[..idx], &decoded_host[idx..]) 235 - } else { 236 - (decoded_host.as_str(), "") 237 - }; 232 + let parts: Vec<&str> = host.split(':').collect(); 233 + if parts.is_empty() { 234 + return Err(anyhow!("Invalid did:web format - no host")); 235 + } 236 + 237 + let host_part = parts[0].replace("%3A", ":"); 238 238 239 239 let scheme = if host_part.starts_with("localhost") 240 240 || host_part.starts_with("127.0.0.1") ··· 245 245 "https" 246 246 }; 247 247 248 - let url = if path_part.is_empty() { 248 + let url = if parts.len() == 1 { 249 249 format!("{}://{}/.well-known/did.json", scheme, host_part) 250 250 } else { 251 - format!("{}://{}{}/did.json", scheme, host_part, path_part) 251 + let path = parts[1..].join("/"); 252 + format!("{}://{}/{}/did.json", scheme, host_part, path) 252 253 }; 253 254 254 255 debug!("Resolving did:web {} via {}", did, url);
+193
tests/did_web.rs
··· 1 1 mod common; 2 + use base64::engine::general_purpose::URL_SAFE_NO_PAD; 3 + use base64::Engine; 2 4 use common::*; 5 + use k256::ecdsa::{SigningKey, signature::Signer}; 3 6 use reqwest::StatusCode; 4 7 use serde_json::{Value, json}; 5 8 use wiremock::matchers::{method, path}; ··· 348 351 body 349 352 ); 350 353 } 354 + 355 + fn signing_key_to_multibase(signing_key: &SigningKey) -> String { 356 + let verifying_key = signing_key.verifying_key(); 357 + let compressed = verifying_key.to_sec1_bytes(); 358 + let mut multicodec = vec![0xe7, 0x01]; 359 + multicodec.extend_from_slice(&compressed); 360 + multibase::encode(multibase::Base::Base58Btc, &multicodec) 361 + } 362 + 363 + fn create_service_jwt(signing_key: &SigningKey, did: &str, aud: &str) -> String { 364 + let header = json!({"alg": "ES256K", "typ": "jwt"}); 365 + let now = chrono::Utc::now().timestamp() as usize; 366 + let claims = json!({ 367 + "iss": did, 368 + "sub": did, 369 + "aud": aud, 370 + "exp": now + 300, 371 + "iat": now, 372 + "lxm": "com.atproto.server.createAccount", 373 + "jti": uuid::Uuid::new_v4().to_string() 374 + }); 375 + let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string()); 376 + let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string()); 377 + let message = format!("{}.{}", header_b64, claims_b64); 378 + let signature: k256::ecdsa::Signature = signing_key.sign(message.as_bytes()); 379 + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); 380 + format!("{}.{}", message, sig_b64) 381 + } 382 + 383 + #[tokio::test] 384 + async fn test_did_web_byod_flow() { 385 + let client = client(); 386 + let mock_server = MockServer::start().await; 387 + let mock_uri = mock_server.uri(); 388 + let mock_addr = mock_uri.trim_start_matches("http://"); 389 + let unique_id = uuid::Uuid::new_v4().to_string().replace("-", ""); 390 + let did = format!("did:web:{}:byod:{}", mock_addr.replace(":", "%3A"), unique_id); 391 + let handle = format!("byod_{}", uuid::Uuid::new_v4()); 392 + let pds_endpoint = base_url().await.replace("http://", "https://"); 393 + let pds_did = format!( 394 + "did:web:{}", 395 + pds_endpoint.trim_start_matches("https://") 396 + ); 397 + 398 + let temp_key = SigningKey::random(&mut rand::thread_rng()); 399 + let public_key_multibase = signing_key_to_multibase(&temp_key); 400 + 401 + let did_doc = json!({ 402 + "@context": ["https://www.w3.org/ns/did/v1"], 403 + "id": did, 404 + "verificationMethod": [{ 405 + "id": format!("{}#atproto", did), 406 + "type": "Multikey", 407 + "controller": did, 408 + "publicKeyMultibase": public_key_multibase 409 + }], 410 + "service": [{ 411 + "id": "#atproto_pds", 412 + "type": "AtprotoPersonalDataServer", 413 + "serviceEndpoint": pds_endpoint 414 + }] 415 + }); 416 + Mock::given(method("GET")) 417 + .and(path(format!("/byod/{}/did.json", unique_id))) 418 + .respond_with(ResponseTemplate::new(200).set_body_json(&did_doc)) 419 + .mount(&mock_server) 420 + .await; 421 + 422 + let service_jwt = create_service_jwt(&temp_key, &did, &pds_did); 423 + let payload = json!({ 424 + "handle": handle, 425 + "email": format!("{}@example.com", handle), 426 + "password": "Testpass123!", 427 + "did": did 428 + }); 429 + let res = client 430 + .post(format!( 431 + "{}/xrpc/com.atproto.server.createAccount", 432 + base_url().await 433 + )) 434 + .header("Authorization", format!("Bearer {}", service_jwt)) 435 + .json(&payload) 436 + .send() 437 + .await 438 + .expect("Failed to send request"); 439 + if res.status() != StatusCode::OK { 440 + let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"})); 441 + panic!("createAccount BYOD failed: {:?}", body); 442 + } 443 + let body: Value = res.json().await.expect("Response was not JSON"); 444 + let returned_did = body["did"].as_str().expect("No DID in response"); 445 + assert_eq!(returned_did, did, "Returned DID should match requested DID"); 446 + let access_jwt = body["accessJwt"] 447 + .as_str() 448 + .expect("No accessJwt in response"); 449 + 450 + let res = client 451 + .get(format!( 452 + "{}/xrpc/com.atproto.server.checkAccountStatus", 453 + base_url().await 454 + )) 455 + .bearer_auth(access_jwt) 456 + .send() 457 + .await 458 + .expect("Failed to check account status"); 459 + assert_eq!(res.status(), StatusCode::OK); 460 + let status: Value = res.json().await.expect("Response was not JSON"); 461 + assert_eq!( 462 + status["activated"], false, 463 + "BYOD account should be deactivated initially" 464 + ); 465 + 466 + let res = client 467 + .get(format!( 468 + "{}/xrpc/com.atproto.identity.getRecommendedDidCredentials", 469 + base_url().await 470 + )) 471 + .bearer_auth(access_jwt) 472 + .send() 473 + .await 474 + .expect("Failed to get recommended credentials"); 475 + assert_eq!(res.status(), StatusCode::OK); 476 + let creds: Value = res.json().await.expect("Response was not JSON"); 477 + assert!( 478 + creds["verificationMethods"]["atproto"].is_string(), 479 + "Should return PDS signing key" 480 + ); 481 + let pds_signing_key = creds["verificationMethods"]["atproto"] 482 + .as_str() 483 + .expect("No atproto verification method"); 484 + assert!( 485 + pds_signing_key.starts_with("did:key:"), 486 + "PDS signing key should be did:key format" 487 + ); 488 + 489 + let res = client 490 + .post(format!( 491 + "{}/xrpc/com.atproto.server.activateAccount", 492 + base_url().await 493 + )) 494 + .bearer_auth(access_jwt) 495 + .send() 496 + .await 497 + .expect("Failed to activate account"); 498 + assert_eq!( 499 + res.status(), 500 + StatusCode::OK, 501 + "activateAccount should succeed" 502 + ); 503 + 504 + let res = client 505 + .get(format!( 506 + "{}/xrpc/com.atproto.server.checkAccountStatus", 507 + base_url().await 508 + )) 509 + .bearer_auth(access_jwt) 510 + .send() 511 + .await 512 + .expect("Failed to check account status"); 513 + assert_eq!(res.status(), StatusCode::OK); 514 + let status: Value = res.json().await.expect("Response was not JSON"); 515 + assert_eq!( 516 + status["activated"], true, 517 + "Account should be activated after activateAccount call" 518 + ); 519 + 520 + let res = client 521 + .post(format!( 522 + "{}/xrpc/com.atproto.repo.createRecord", 523 + base_url().await 524 + )) 525 + .bearer_auth(access_jwt) 526 + .json(&json!({ 527 + "repo": did, 528 + "collection": "app.bsky.feed.post", 529 + "record": { 530 + "$type": "app.bsky.feed.post", 531 + "text": "Hello from BYOD did:web!", 532 + "createdAt": chrono::Utc::now().to_rfc3339() 533 + } 534 + })) 535 + .send() 536 + .await 537 + .expect("Failed to create post"); 538 + assert_eq!( 539 + res.status(), 540 + StatusCode::OK, 541 + "Activated BYOD account should be able to create records" 542 + ); 543 + }