mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-06 18:26:56 +00:00
Attempt at better byod did:web registration
This commit is contained in:
@@ -266,6 +266,7 @@ export const api = {
|
||||
inviteCodeRequired: boolean
|
||||
links?: { privacyPolicy?: string; termsOfService?: string }
|
||||
version?: string
|
||||
availableCommsChannels?: string[]
|
||||
}> {
|
||||
return xrpc('com.atproto.server.describeServer')
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ä",
|
||||
|
||||
@@ -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 ユーザー名",
|
||||
|
||||
@@ -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 사용자 이름",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 用户名,不含 @ 符号",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user