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