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}
-
+
{getChannelName(channelId)}
{getChannelDescription(channelId)}
- {#if channelId !== 'email' && !canSelectChannel(channelId)}
+ {#if !isChannelAvailableOnServer(channelId)}
+ {$_('comms.notConfiguredOnServer')}
+ {:else if channelId !== 'email' && !canSelectChannel(channelId)}
{$_('comms.configureToEnable')}
{/if}
@@ -210,7 +221,7 @@
{$_('comms.emailManagedInSettings')}
-
+
-
+
-
+
{$_('register.signalNumber')}
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 @@
- {$_('login.noAccount')} {$_('login.createAcount')}
+ {$_('login.noAccount')} {$_('login.createAccount')}
{: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 @@
{$_('register.verificationMethod')}
{$_('register.email')}
- {$_('register.discord')}
- {$_('register.telegram')}
- {$_('register.signal')}
+
+ {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
+
+
+ {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
+
+
+ {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
+
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 @@
Verification Method
Email
- Discord
- Telegram
- Signal
+
+ Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
+
+
+ Telegram{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
+
+
+ Signal{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
+
{#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"
+ );
+}