Attempt at better byod did:web registration

This commit is contained in:
lewis
2025-12-23 21:50:56 +02:00
parent 11fc081971
commit 217a3f1197
16 changed files with 347 additions and 51 deletions
+1
View File
@@ -266,6 +266,7 @@ export const api = {
inviteCodeRequired: boolean
links?: { privacyPolicy?: string; termsOfService?: string }
version?: string
availableCommsChannels?: string[]
}> {
return xrpc('com.atproto.server.describeServer')
},
+2
View File
@@ -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",
+2
View File
@@ -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ä",
+2
View File
@@ -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 ユーザー名",
+2
View File
@@ -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 사용자 이름",
+2
View File
@@ -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",
+2
View File
@@ -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 用户名,不含 @ 符号",
+47 -12
View File
@@ -10,6 +10,7 @@
let error = $state<string | null>(null)
let success = $state<string | null>(null)
let preferredChannel = $state('email')
let availableCommsChannels = $state<string[]>(['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 @@
</p>
<div class="channel-options">
{#each channels as channelId}
<label class="channel-option" class:disabled={!canSelectChannel(channelId)}>
<label class="channel-option" class:disabled={!canSelectChannel(channelId)} class:unavailable={!isChannelAvailableOnServer(channelId)}>
<input
type="radio"
name="preferredChannel"
@@ -185,7 +194,9 @@
<div class="channel-info">
<span class="channel-name">{getChannelName(channelId)}</span>
<span class="channel-description">{getChannelDescription(channelId)}</span>
{#if channelId !== 'email' && !canSelectChannel(channelId)}
{#if !isChannelAvailableOnServer(channelId)}
<span class="channel-hint server-unavailable">{$_('comms.notConfiguredOnServer')}</span>
{:else if channelId !== 'email' && !canSelectChannel(channelId)}
<span class="channel-hint">{$_('comms.configureToEnable')}</span>
{/if}
</div>
@@ -210,7 +221,7 @@
</div>
<p class="config-hint">{$_('comms.emailManagedInSettings')}</p>
</div>
<div class="config-item">
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('discord')}>
<label for="discord">{$_('register.discordId')}</label>
<div class="config-input">
<input
@@ -218,9 +229,11 @@
type="text"
bind:value={discordId}
placeholder={$_('register.discordIdPlaceholder')}
disabled={saving}
disabled={saving || !isChannelAvailableOnServer('discord')}
/>
{#if discordId}
{#if !isChannelAvailableOnServer('discord')}
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
{:else if discordId}
{#if discordVerified}
<span class="status verified">{$_('comms.verified')}</span>
{:else}
@@ -243,7 +256,7 @@
</div>
{/if}
</div>
<div class="config-item">
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('telegram')}>
<label for="telegram">{$_('register.telegramUsername')}</label>
<div class="config-input">
<input
@@ -251,9 +264,11 @@
type="text"
bind:value={telegramUsername}
placeholder={$_('register.telegramUsernamePlaceholder')}
disabled={saving}
disabled={saving || !isChannelAvailableOnServer('telegram')}
/>
{#if telegramUsername}
{#if !isChannelAvailableOnServer('telegram')}
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
{:else if telegramUsername}
{#if telegramVerified}
<span class="status verified">{$_('comms.verified')}</span>
{:else}
@@ -276,7 +291,7 @@
</div>
{/if}
</div>
<div class="config-item">
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('signal')}>
<label for="signal">{$_('register.signalNumber')}</label>
<div class="config-input">
<input
@@ -284,9 +299,11 @@
type="tel"
bind:value={signalNumber}
placeholder={$_('register.signalNumberPlaceholder')}
disabled={saving}
disabled={saving || !isChannelAvailableOnServer('signal')}
/>
{#if signalNumber}
{#if !isChannelAvailableOnServer('signal')}
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
{:else if signalNumber}
{#if signalVerified}
<span class="status verified">{$_('comms.verified')}</span>
{: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);
+3 -3
View File
@@ -153,7 +153,7 @@
<h2>Works with everything</h2>
<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>
<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>
<h2>Ready to try it?</h2>
@@ -170,8 +170,8 @@
</section>
<footer class="site-footer">
<span>Open Source</span>
<span>Made with patience</span>
<span>Made by people who don't take themselves too seriously</span>
<span>Open Source: issues & PRs welcome</span>
</footer>
</div>
+1 -1
View File
@@ -144,7 +144,7 @@
</button>
<p class="link-text">
{$_('login.noAccount')} <a href="#/register">{$_('login.createAcount')}</a>
{$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a>
</p>
{:else}
+15 -3
View File
@@ -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 @@
<label for="verification-channel">{$_('register.verificationMethod')}</label>
<select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
<option value="email">{$_('register.email')}</option>
<option value="discord">{$_('register.discord')}</option>
<option value="telegram">{$_('register.telegram')}</option>
<option value="signal">{$_('register.signal')}</option>
<option value="discord" disabled={!isChannelAvailable('discord')}>
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="signal" disabled={!isChannelAvailable('signal')}>
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
</option>
</select>
</div>
+15 -4
View File
@@ -19,7 +19,7 @@
let passkeyName = $state('')
let submitting = $state(false)
let error = $state<string | null>(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 @@
<label for="verification-channel">Verification Method</label>
<select id="verification-channel" bind:value={verificationChannel} disabled={submitting}>
<option value="email">Email</option>
<option value="discord">Discord</option>
<option value="telegram">Telegram</option>
<option value="signal">Signal</option>
<option value="discord" disabled={!isChannelAvailable('discord')}>
Discord{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
Telegram{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
</option>
<option value="signal" disabled={!isChannelAvailable('signal')}>
Signal{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
</option>
</select>
</div>
{#if verificationChannel === 'email'}
+34 -19
View File
@@ -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() {
+17 -1
View File
@@ -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<AppState>) -> impl IntoResponse {
+9 -8
View File
@@ -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);
+193
View File
@@ -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"
);
}