From 217a3f1197c77b0e7ac49aa8c3696d454bdb913e Mon Sep 17 00:00:00 2001 From: lewis Date: Tue, 23 Dec 2025 21:50:56 +0200 Subject: [PATCH] Attempt at better byod did:web registration --- frontend/src/lib/api.ts | 1 + frontend/src/locales/en.json | 2 + frontend/src/locales/fi.json | 2 + frontend/src/locales/ja.json | 2 + frontend/src/locales/ko.json | 2 + frontend/src/locales/sv.json | 2 + frontend/src/locales/zh.json | 2 + frontend/src/routes/Comms.svelte | 59 +++++-- frontend/src/routes/Home.svelte | 6 +- frontend/src/routes/Login.svelte | 2 +- frontend/src/routes/Register.svelte | 18 +- frontend/src/routes/RegisterPasskey.svelte | 19 +- src/api/identity/account.rs | 53 ++++-- src/api/server/meta.rs | 18 +- src/auth/service.rs | 17 +- tests/did_web.rs | 193 +++++++++++++++++++++ 16 files changed, 347 insertions(+), 51 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 03f227b..a312695 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -266,6 +266,7 @@ export const api = { inviteCodeRequired: boolean links?: { privacyPolicy?: string; termsOfService?: string } version?: string + availableCommsChannels?: string[] }> { return xrpc('com.atproto.server.describeServer') }, diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 4130468..24cede1 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -96,6 +96,7 @@ "signalNumber": "Signal Phone Number", "signalNumberPlaceholder": "+1234567890", "signalNumberHint": "Include country code (e.g., +1 for US)", + "notConfigured": "not configured", "inviteCode": "Invite Code", "inviteCodePlaceholder": "Enter your invite code", "inviteCodeRequired": "required", @@ -388,6 +389,7 @@ "telegramVia": "Receive messages via Telegram", "signalVia": "Receive messages via Signal", "configureToEnable": "Configure below to enable", + "notConfiguredOnServer": "Not configured on this server", "emailManagedInSettings": "Your email is managed in Account Settings", "discordIdHint": "Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.", "telegramHint": "Your Telegram username without the @ symbol", diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json index f986bd1..4fe8f5d 100644 --- a/frontend/src/locales/fi.json +++ b/frontend/src/locales/fi.json @@ -96,6 +96,7 @@ "signalNumber": "Signal-puhelinnumero", "signalNumberPlaceholder": "+358401234567", "signalNumberHint": "Sisällytä maakoodi (esim. +358 Suomelle)", + "notConfigured": "ei määritetty", "inviteCode": "Kutsukoodi", "inviteCodePlaceholder": "Syötä kutsukoodisi", "inviteCodeRequired": "vaaditaan", @@ -388,6 +389,7 @@ "telegramVia": "Vastaanota viestejä Telegramissa", "signalVia": "Vastaanota viestejä Signalissa", "configureToEnable": "Määritä alla ottaaksesi käyttöön", + "notConfiguredOnServer": "Ei määritetty tällä palvelimella", "emailManagedInSettings": "Sähköpostisi hallinnoidaan Tilin asetuksissa", "discordIdHint": "Discord-käyttäjätunnuksesi (ei käyttäjänimi). Ota Kehittäjätila käyttöön Discordissa kopioidaksesi sen.", "telegramHint": "Telegram-käyttäjänimesi ilman @-merkkiä", diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index 88bc705..74c5f16 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -96,6 +96,7 @@ "signalNumber": "Signal 電話番号", "signalNumberPlaceholder": "+81XXXXXXXXXX", "signalNumberHint": "国番号を含めてください(例: 日本は +81)", + "notConfigured": "未設定", "inviteCode": "招待コード", "inviteCodePlaceholder": "招待コードを入力", "inviteCodeRequired": "必須", @@ -388,6 +389,7 @@ "telegramVia": "Telegram でメッセージを受信", "signalVia": "Signal でメッセージを受信", "configureToEnable": "有効にするには下記で設定", + "notConfiguredOnServer": "このサーバーでは設定されていません", "emailManagedInSettings": "メールはアカウント設定で管理されています", "discordIdHint": "Discord ユーザー ID(ユーザー名ではありません)。Discord で開発者モードを有効にしてコピーしてください。", "telegramHint": "@ 記号なしの Telegram ユーザー名", diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index a6a11dc..62b9aee 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -96,6 +96,7 @@ "signalNumber": "Signal 전화번호", "signalNumberPlaceholder": "+821012345678", "signalNumberHint": "국가 코드 포함 (예: 한국 +82)", + "notConfigured": "구성되지 않음", "inviteCode": "초대 코드", "inviteCodePlaceholder": "초대 코드 입력", "inviteCodeRequired": "필수", @@ -388,6 +389,7 @@ "telegramVia": "Telegram으로 메시지 받기", "signalVia": "Signal로 메시지 받기", "configureToEnable": "활성화하려면 아래에서 설정", + "notConfiguredOnServer": "이 서버에서 설정되지 않음", "emailManagedInSettings": "이메일은 계정 설정에서 관리됩니다", "discordIdHint": "Discord 사용자 ID (사용자 이름 아님). Discord에서 개발자 모드를 활성화하여 복사하세요.", "telegramHint": "@ 기호 없이 Telegram 사용자 이름", diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 84c97d2..8c4f349 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -96,6 +96,7 @@ "signalNumber": "Signal-telefonnummer", "signalNumberPlaceholder": "+46701234567", "signalNumberHint": "Inkludera landskod (t.ex. +46 för Sverige)", + "notConfigured": "ej konfigurerad", "inviteCode": "Inbjudningskod", "inviteCodePlaceholder": "Ange din inbjudningskod", "inviteCodeRequired": "krävs", @@ -388,6 +389,7 @@ "telegramVia": "Ta emot meddelanden via Telegram", "signalVia": "Ta emot meddelanden via Signal", "configureToEnable": "Konfigurera nedan för att aktivera", + "notConfiguredOnServer": "Inte konfigurerat på denna server", "emailManagedInSettings": "Din e-post hanteras i Kontoinställningar", "discordIdHint": "Ditt Discord användar-ID (inte användarnamn). Aktivera Utvecklarläge i Discord för att kopiera det.", "telegramHint": "Ditt Telegram-användarnamn utan @-symbolen", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 6f8c2d3..d4d0a20 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -96,6 +96,7 @@ "signalNumber": "Signal 电话号码", "signalNumberPlaceholder": "+1234567890", "signalNumberHint": "包含国家代码(例如中国为 +86)", + "notConfigured": "未配置", "inviteCode": "邀请码", "inviteCodePlaceholder": "输入您的邀请码", "inviteCodeRequired": "必填", @@ -388,6 +389,7 @@ "telegramVia": "通过 Telegram 接收消息", "signalVia": "通过 Signal 接收消息", "configureToEnable": "请先在下方配置", + "notConfiguredOnServer": "此服务器未配置", "emailManagedInSettings": "邮箱在账户设置中管理", "discordIdHint": "您的 Discord 数字用户 ID(非用户名)。在 Discord 中开启开发者模式即可复制。", "telegramHint": "您的 Telegram 用户名,不含 @ 符号", diff --git a/frontend/src/routes/Comms.svelte b/frontend/src/routes/Comms.svelte index 030fecd..3c0afe4 100644 --- a/frontend/src/routes/Comms.svelte +++ b/frontend/src/routes/Comms.svelte @@ -10,6 +10,7 @@ let error = $state(null) let success = $state(null) let preferredChannel = $state('email') + let availableCommsChannels = $state(['email']) let email = $state('') let discordId = $state('') let discordVerified = $state(false) @@ -47,7 +48,10 @@ loading = true error = null try { - const prefs = await api.getNotificationPrefs(auth.session.accessJwt) + const [prefs, serverInfo] = await Promise.all([ + api.getNotificationPrefs(auth.session.accessJwt), + api.describeServer() + ]) preferredChannel = prefs.preferredChannel email = prefs.email discordId = prefs.discordId ?? '' @@ -56,6 +60,7 @@ telegramVerified = prefs.telegramVerified signalNumber = prefs.signalNumber ?? '' signalVerified = prefs.signalVerified + availableCommsChannels = serverInfo.availableCommsChannels ?? ['email'] } catch (e) { error = e instanceof ApiError ? e.message : 'Failed to load notification preferences' } finally { @@ -135,7 +140,11 @@ default: return '' } } + function isChannelAvailableOnServer(channelId: string): boolean { + return availableCommsChannels.includes(channelId) + } function canSelectChannel(channelId: string): boolean { + if (!isChannelAvailableOnServer(channelId)) return false if (channelId === 'email') return true if (channelId === 'discord') return !!discordId if (channelId === 'telegram') return !!telegramUsername @@ -174,7 +183,7 @@

{#each channels as channelId} -
@@ -210,7 +221,7 @@

{$_('comms.emailManagedInSettings')}

-
+
- {#if discordId} + {#if !isChannelAvailableOnServer('discord')} + {$_('comms.notConfiguredOnServer')} + {:else if discordId} {#if discordVerified} {$_('comms.verified')} {:else} @@ -243,7 +256,7 @@
{/if}
-
+
- {#if telegramUsername} + {#if !isChannelAvailableOnServer('telegram')} + {$_('comms.notConfiguredOnServer')} + {:else if telegramUsername} {#if telegramVerified} {$_('comms.verified')} {:else} @@ -276,7 +291,7 @@
{/if}
-
+
- {#if signalNumber} + {#if !isChannelAvailableOnServer('signal')} + {$_('comms.notConfiguredOnServer')} + {:else if signalNumber} {#if signalVerified} {$_('comms.verified')} {:else} @@ -439,6 +456,11 @@ cursor: not-allowed; } + .channel-option.unavailable { + opacity: 0.5; + background: var(--bg-input-disabled); + } + .channel-option input[type="radio"] { flex-shrink: 0; width: 16px; @@ -469,6 +491,10 @@ font-style: italic; } + .channel-hint.server-unavailable { + color: var(--warning-text); + } + .channel-config { display: flex; flex-direction: column; @@ -481,6 +507,10 @@ gap: var(--space-1); } + .config-item.unavailable { + opacity: 0.6; + } + .config-item label { font-size: var(--text-sm); font-weight: var(--font-medium); @@ -518,6 +548,11 @@ color: var(--warning-text); } + .status.unavailable { + background: var(--bg-input-disabled); + color: var(--text-muted); + } + .config-hint { font-size: var(--text-xs); color: var(--text-secondary); diff --git a/frontend/src/routes/Home.svelte b/frontend/src/routes/Home.svelte index f714908..0a54d2f 100644 --- a/frontend/src/routes/Home.svelte +++ b/frontend/src/routes/Home.svelte @@ -153,7 +153,7 @@

Works with everything

-

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.

+

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.

Ready to try it?

@@ -170,8 +170,8 @@
- Open Source - Made with patience + Made by people who don't take themselves too seriously + Open Source: issues & PRs welcome
diff --git a/frontend/src/routes/Login.svelte b/frontend/src/routes/Login.svelte index 6c786b8..405fe49 100644 --- a/frontend/src/routes/Login.svelte +++ b/frontend/src/routes/Login.svelte @@ -144,7 +144,7 @@ {:else} diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte index 06055e8..11dcc12 100644 --- a/frontend/src/routes/Register.svelte +++ b/frontend/src/routes/Register.svelte @@ -22,6 +22,7 @@ let serverInfo = $state<{ availableUserDomains: string[] inviteCodeRequired: boolean + availableCommsChannels?: string[] } | null>(null) let loadingServerInfo = $state(true) let serverInfoLoaded = false @@ -47,6 +48,11 @@ let handleHasDot = $derived(handle.includes('.')) + function isChannelAvailable(channel: string): boolean { + const available = serverInfo?.availableCommsChannels ?? ['email'] + return available.includes(channel) + } + function validateForm(): string | null { if (!handle.trim()) return $_('register.validation.handleRequired') if (handle.includes('.')) return $_('register.validation.handleNoDots') @@ -262,9 +268,15 @@
diff --git a/frontend/src/routes/RegisterPasskey.svelte b/frontend/src/routes/RegisterPasskey.svelte index 7fc4d46..9a86071 100644 --- a/frontend/src/routes/RegisterPasskey.svelte +++ b/frontend/src/routes/RegisterPasskey.svelte @@ -19,7 +19,7 @@ let passkeyName = $state('') let submitting = $state(false) let error = $state(null) - let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean } | null>(null) + let serverInfo = $state<{ availableUserDomains: string[]; inviteCodeRequired: boolean; availableCommsChannels?: string[] } | null>(null) let loadingServerInfo = $state(true) let serverInfoLoaded = false @@ -289,6 +289,11 @@ } } + function isChannelAvailable(ch: string): boolean { + const available = serverInfo?.availableCommsChannels ?? ['email'] + return available.includes(ch) + } + function goToLogin() { navigate('/login') } @@ -363,9 +368,15 @@
{#if verificationChannel === 'email'} diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index bc6478c..b29c71c 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -122,7 +122,14 @@ pub async fn create_account( && input .did .as_ref() - .map(|d| d.starts_with("did:plc:")) + .map(|d| d.starts_with("did:plc:") || d.starts_with("did:web:")) + .unwrap_or(false); + + let is_did_web_byod = migration_auth.is_some() + && input + .did + .as_ref() + .map(|d| d.starts_with("did:web:")) .unwrap_or(false); if is_migration { @@ -138,7 +145,11 @@ pub async fn create_account( ) .into_response(); } - info!(did = %migration_did, "Processing account migration"); + if is_did_web_byod { + info!(did = %migration_did, "Processing did:web BYOD account creation"); + } else { + info!(did = %migration_did, "Processing account migration"); + } } } @@ -337,14 +348,16 @@ pub async fn create_account( ) .into_response(); } - if let Err(e) = - verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await - { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidDid", "message": e})), - ) - .into_response(); + if !is_did_web_byod { + if let Err(e) = + verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidDid", "message": e})), + ) + .into_response(); + } } info!(did = %d, "Creating external did:web account"); d.clone() @@ -355,15 +368,17 @@ pub async fn create_account( info!(did = %d, "Migration with existing did:plc"); d.clone() } else if d.starts_with("did:web:") { - if let Err(e) = - verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()) - .await - { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidDid", "message": e})), - ) - .into_response(); + if !is_did_web_byod { + if let Err(e) = + verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()) + .await + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidDid", "message": e})), + ) + .into_response(); + } } d.clone() } else if !d.trim().is_empty() { diff --git a/src/api/server/meta.rs b/src/api/server/meta.rs index da299bd..4245a79 100644 --- a/src/api/server/meta.rs +++ b/src/api/server/meta.rs @@ -2,6 +2,21 @@ use crate::state::AppState; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use serde_json::json; use tracing::error; + +fn get_available_comms_channels() -> Vec<&'static str> { + let mut channels = vec!["email"]; + if std::env::var("DISCORD_WEBHOOK_URL").is_ok() { + channels.push("discord"); + } + if std::env::var("TELEGRAM_BOT_TOKEN").is_ok() { + channels.push("telegram"); + } + if std::env::var("SIGNAL_CLI_PATH").is_ok() && std::env::var("SIGNAL_SENDER_NUMBER").is_ok() { + channels.push("signal"); + } + channels +} + pub async fn robots_txt() -> impl IntoResponse { ( StatusCode::OK, @@ -21,7 +36,8 @@ pub async fn describe_server() -> impl IntoResponse { "availableUserDomains": domains, "inviteCodeRequired": invite_code_required, "did": format!("did:web:{}", pds_hostname), - "version": env!("CARGO_PKG_VERSION") + "version": env!("CARGO_PKG_VERSION"), + "availableCommsChannels": get_available_comms_channels() })) } pub async fn health(State(state): State) -> impl IntoResponse { diff --git a/src/auth/service.rs b/src/auth/service.rs index 1b52020..b5fd571 100644 --- a/src/auth/service.rs +++ b/src/auth/service.rs @@ -229,12 +229,12 @@ impl ServiceTokenVerifier { .strip_prefix("did:web:") .ok_or_else(|| anyhow!("Invalid did:web format"))?; - let decoded_host = host.replace("%3A", ":"); - let (host_part, path_part) = if let Some(idx) = decoded_host.find('/') { - (&decoded_host[..idx], &decoded_host[idx..]) - } else { - (decoded_host.as_str(), "") - }; + let parts: Vec<&str> = host.split(':').collect(); + if parts.is_empty() { + return Err(anyhow!("Invalid did:web format - no host")); + } + + let host_part = parts[0].replace("%3A", ":"); let scheme = if host_part.starts_with("localhost") || host_part.starts_with("127.0.0.1") @@ -245,10 +245,11 @@ impl ServiceTokenVerifier { "https" }; - let url = if path_part.is_empty() { + let url = if parts.len() == 1 { format!("{}://{}/.well-known/did.json", scheme, host_part) } else { - format!("{}://{}{}/did.json", scheme, host_part, path_part) + let path = parts[1..].join("/"); + format!("{}://{}/{}/did.json", scheme, host_part, path) }; debug!("Resolving did:web {} via {}", did, url); diff --git a/tests/did_web.rs b/tests/did_web.rs index c1c2357..194578e 100644 --- a/tests/did_web.rs +++ b/tests/did_web.rs @@ -1,5 +1,8 @@ mod common; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; use common::*; +use k256::ecdsa::{SigningKey, signature::Signer}; use reqwest::StatusCode; use serde_json::{Value, json}; use wiremock::matchers::{method, path}; @@ -348,3 +351,193 @@ async fn test_external_did_web_requires_did_field() { body ); } + +fn signing_key_to_multibase(signing_key: &SigningKey) -> String { + let verifying_key = signing_key.verifying_key(); + let compressed = verifying_key.to_sec1_bytes(); + let mut multicodec = vec![0xe7, 0x01]; + multicodec.extend_from_slice(&compressed); + multibase::encode(multibase::Base::Base58Btc, &multicodec) +} + +fn create_service_jwt(signing_key: &SigningKey, did: &str, aud: &str) -> String { + let header = json!({"alg": "ES256K", "typ": "jwt"}); + let now = chrono::Utc::now().timestamp() as usize; + let claims = json!({ + "iss": did, + "sub": did, + "aud": aud, + "exp": now + 300, + "iat": now, + "lxm": "com.atproto.server.createAccount", + "jti": uuid::Uuid::new_v4().to_string() + }); + let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string()); + let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string()); + let message = format!("{}.{}", header_b64, claims_b64); + let signature: k256::ecdsa::Signature = signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + format!("{}.{}", message, sig_b64) +} + +#[tokio::test] +async fn test_did_web_byod_flow() { + let client = client(); + let mock_server = MockServer::start().await; + let mock_uri = mock_server.uri(); + let mock_addr = mock_uri.trim_start_matches("http://"); + let unique_id = uuid::Uuid::new_v4().to_string().replace("-", ""); + let did = format!("did:web:{}:byod:{}", mock_addr.replace(":", "%3A"), unique_id); + let handle = format!("byod_{}", uuid::Uuid::new_v4()); + let pds_endpoint = base_url().await.replace("http://", "https://"); + let pds_did = format!( + "did:web:{}", + pds_endpoint.trim_start_matches("https://") + ); + + let temp_key = SigningKey::random(&mut rand::thread_rng()); + let public_key_multibase = signing_key_to_multibase(&temp_key); + + let did_doc = json!({ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": did, + "verificationMethod": [{ + "id": format!("{}#atproto", did), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": public_key_multibase + }], + "service": [{ + "id": "#atproto_pds", + "type": "AtprotoPersonalDataServer", + "serviceEndpoint": pds_endpoint + }] + }); + Mock::given(method("GET")) + .and(path(format!("/byod/{}/did.json", unique_id))) + .respond_with(ResponseTemplate::new(200).set_body_json(&did_doc)) + .mount(&mock_server) + .await; + + let service_jwt = create_service_jwt(&temp_key, &did, &pds_did); + let payload = json!({ + "handle": handle, + "email": format!("{}@example.com", handle), + "password": "Testpass123!", + "did": did + }); + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.createAccount", + base_url().await + )) + .header("Authorization", format!("Bearer {}", service_jwt)) + .json(&payload) + .send() + .await + .expect("Failed to send request"); + if res.status() != StatusCode::OK { + let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"})); + panic!("createAccount BYOD failed: {:?}", body); + } + let body: Value = res.json().await.expect("Response was not JSON"); + let returned_did = body["did"].as_str().expect("No DID in response"); + assert_eq!(returned_did, did, "Returned DID should match requested DID"); + let access_jwt = body["accessJwt"] + .as_str() + .expect("No accessJwt in response"); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.server.checkAccountStatus", + base_url().await + )) + .bearer_auth(access_jwt) + .send() + .await + .expect("Failed to check account status"); + assert_eq!(res.status(), StatusCode::OK); + let status: Value = res.json().await.expect("Response was not JSON"); + assert_eq!( + status["activated"], false, + "BYOD account should be deactivated initially" + ); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.identity.getRecommendedDidCredentials", + base_url().await + )) + .bearer_auth(access_jwt) + .send() + .await + .expect("Failed to get recommended credentials"); + assert_eq!(res.status(), StatusCode::OK); + let creds: Value = res.json().await.expect("Response was not JSON"); + assert!( + creds["verificationMethods"]["atproto"].is_string(), + "Should return PDS signing key" + ); + let pds_signing_key = creds["verificationMethods"]["atproto"] + .as_str() + .expect("No atproto verification method"); + assert!( + pds_signing_key.starts_with("did:key:"), + "PDS signing key should be did:key format" + ); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.server.activateAccount", + base_url().await + )) + .bearer_auth(access_jwt) + .send() + .await + .expect("Failed to activate account"); + assert_eq!( + res.status(), + StatusCode::OK, + "activateAccount should succeed" + ); + + let res = client + .get(format!( + "{}/xrpc/com.atproto.server.checkAccountStatus", + base_url().await + )) + .bearer_auth(access_jwt) + .send() + .await + .expect("Failed to check account status"); + assert_eq!(res.status(), StatusCode::OK); + let status: Value = res.json().await.expect("Response was not JSON"); + assert_eq!( + status["activated"], true, + "Account should be activated after activateAccount call" + ); + + let res = client + .post(format!( + "{}/xrpc/com.atproto.repo.createRecord", + base_url().await + )) + .bearer_auth(access_jwt) + .json(&json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "record": { + "$type": "app.bsky.feed.post", + "text": "Hello from BYOD did:web!", + "createdAt": chrono::Utc::now().to_rfc3339() + } + })) + .send() + .await + .expect("Failed to create post"); + assert_eq!( + res.status(), + StatusCode::OK, + "Activated BYOD account should be able to create records" + ); +}