+34
.sqlx/query-9bd55935253b57b1b7e2d2bf69509e571af810234fa61368f58dd72e1d111cc5.json
+34
.sqlx/query-9bd55935253b57b1b7e2d2bf69509e571af810234fa61368f58dd72e1d111cc5.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "migrated_to_pds",
19
+
"type_info": "Text"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Text"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
false,
30
+
true
31
+
]
32
+
},
33
+
"hash": "9bd55935253b57b1b7e2d2bf69509e571af810234fa61368f58dd72e1d111cc5"
34
+
}
-28
.sqlx/query-b2c53e6a278c4549c99a5b98cc7ca77fc1e9cd39a591c1d8ec1ca41adfffa3a6.json
-28
.sqlx/query-b2c53e6a278c4549c99a5b98cc7ca77fc1e9cd39a591c1d8ec1ca41adfffa3a6.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT id, did FROM users WHERE handle = $1",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "id",
9
-
"type_info": "Uuid"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "did",
14
-
"type_info": "Text"
15
-
}
16
-
],
17
-
"parameters": {
18
-
"Left": [
19
-
"Text"
20
-
]
21
-
},
22
-
"nullable": [
23
-
false,
24
-
false
25
-
]
26
-
},
27
-
"hash": "b2c53e6a278c4549c99a5b98cc7ca77fc1e9cd39a591c1d8ec1ca41adfffa3a6"
28
-
}
+10
Cargo.lock
+10
Cargo.lock
···
930
930
]
931
931
932
932
[[package]]
933
+
name = "bs58"
934
+
version = "0.5.1"
935
+
source = "registry+https://github.com/rust-lang/crates.io-index"
936
+
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
937
+
dependencies = [
938
+
"tinyvec",
939
+
]
940
+
941
+
[[package]]
933
942
name = "btree-range-map"
934
943
version = "0.7.2"
935
944
source = "registry+https://github.com/rust-lang/crates.io-index"
···
6175
6184
"base32",
6176
6185
"base64 0.22.1",
6177
6186
"bcrypt",
6187
+
"bs58",
6178
6188
"bytes",
6179
6189
"chrono",
6180
6190
"cid",
+1
Cargo.toml
+1
Cargo.toml
···
55
55
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
56
56
metrics = "0.24"
57
57
metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] }
58
+
bs58 = "0.5.1"
58
59
[features]
59
60
external-infra = []
60
61
[dev-dependencies]
+30
-14
TODO.md
+30
-14
TODO.md
···
9
9
- [ ] Unique "brand" style both unauthed and authed
10
10
- [ ] Better documentation on how to sub out the entire frontend for whatever the users want
11
11
12
+
### Passkeys and 2FA
13
+
Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth.
14
+
15
+
- [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
16
+
- [ ] user_totp table (did, secret_encrypted, verified, created_at, last_used)
17
+
- [ ] WebAuthn registration challenge generation and attestation verification
18
+
- [ ] TOTP secret generation with QR code setup flow
19
+
- [ ] Backup codes (hashed, one-time use) with recovery flow
20
+
- [ ] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative)
21
+
- [ ] Passkey-only account creation (no password)
22
+
- [ ] Settings UI for managing passkeys, TOTP, backup codes
23
+
- [ ] Trusted devices option (remember this browser)
24
+
- [ ] Rate limit 2FA attempts
25
+
- [ ] Re-auth for sensitive actions (email change, adding new auth methods)
26
+
12
27
### Delegated accounts
13
28
Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model.
14
29
···
26
41
- [ ] Log all actions with both actor DID and controller DID
27
42
- [ ] Audit log view for delegated account owners
28
43
29
-
### Passkeys and 2FA
30
-
Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth.
44
+
### Migration tool
45
+
Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states.
31
46
32
-
- [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
33
-
- [ ] user_totp table (did, secret_encrypted, verified, created_at, last_used)
34
-
- [ ] WebAuthn registration challenge generation and attestation verification
35
-
- [ ] TOTP secret generation with QR code setup flow
36
-
- [ ] Backup codes (hashed, one-time use) with recovery flow
37
-
- [ ] OAuth authorize flow: password → 2FA (if enabled) → passkey (as alternative)
38
-
- [ ] Passkey-only account creation (no password)
39
-
- [ ] Settings UI for managing passkeys, TOTP, backup codes
40
-
- [ ] Trusted devices option (remember this browser)
41
-
- [ ] Rate limit 2FA attempts
42
-
- [ ] Re-auth for sensitive actions (email change, adding new auth methods)
47
+
- [ ] Add `migratingTo` parameter to `deactivateAccount` endpoint
48
+
- [ ] For self-hosted did:web users: set `migrated_to_pds`, update DID doc serviceEndpoint
49
+
- [ ] "Migrated" account state for self-hosted did:web: can authenticate but no repo operations
50
+
- [ ] Migrated did:web user UI: minimal dashboard with "update forwarding PDS" setting, or full migration wizard to handle PDS 2 -> PDS 3 moves automatically
51
+
- [ ] Outbound UI wizard: new PDS URL -> export repo -> guide account creation -> complete migration
52
+
- [ ] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow
53
+
- [ ] Support `createAccount` with existing DID + service auth token
54
+
- [ ] Progress tracking with resume capability
55
+
- [ ] Scheduled automatic backups (CAR export)
56
+
- [ ] One-click restore from backup
43
57
44
58
### Plugin system
45
59
Extensible architecture allowing third-party plugins to add functionality, like minecraft mods or browser extensions.
···
74
88
75
89
## Completed
76
90
77
-
Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, did:web, full admin API, moderation reports.
91
+
Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, full admin API, moderation reports.
92
+
93
+
did:web support: Self-hosted did:web (subdomain format `did:web:handle.pds.com`), external/BYOD did:web, DID document serving via `/.well-known/did.json`, migration tracking for did:web users who leave (serviceEndpoint redirect), clear registration warnings about did:web trade-offs vs did:plc.
78
94
79
95
OAuth 2.1: Authorization server metadata, JWKS, PAR, authorize endpoint with login UI, token endpoint (auth code + refresh), revocation, introspection, DPoP, PKCE S256, client metadata validation, private_key_jwt verification.
80
96
+6
frontend/src/lib/api.ts
+6
frontend/src/lib/api.ts
···
71
71
72
72
export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
73
73
74
+
export type DidType = 'plc' | 'web' | 'web-external'
75
+
74
76
export interface CreateAccountParams {
75
77
handle: string
76
78
email: string
77
79
password: string
78
80
inviteCode?: string
81
+
didType?: DidType
82
+
did?: string
79
83
verificationChannel?: VerificationChannel
80
84
discordId?: string
81
85
telegramUsername?: string
···
109
113
email: params.email,
110
114
password: params.password,
111
115
inviteCode: params.inviteCode,
116
+
didType: params.didType,
117
+
did: params.did,
112
118
verificationChannel: params.verificationChannel,
113
119
discordId: params.discordId,
114
120
telegramUsername: params.telegramUsername,
+141
-1
frontend/src/routes/Register.svelte
+141
-1
frontend/src/routes/Register.svelte
···
1
1
<script lang="ts">
2
2
import { register, getAuthState } from '../lib/auth.svelte'
3
3
import { navigate } from '../lib/router.svelte'
4
-
import { api, ApiError, type VerificationChannel } from '../lib/api'
4
+
import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api'
5
5
6
6
const STORAGE_KEY = 'tranquil_pds_pending_verification'
7
7
···
14
14
let discordId = $state('')
15
15
let telegramUsername = $state('')
16
16
let signalNumber = $state('')
17
+
let didType = $state<DidType>('plc')
18
+
let externalDid = $state('')
17
19
let submitting = $state(false)
18
20
let error = $state<string | null>(null)
19
21
let serverInfo = $state<{
···
56
58
if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
57
59
return 'Invite code is required'
58
60
}
61
+
if (didType === 'web-external') {
62
+
if (!externalDid.trim()) return 'External did:web is required'
63
+
if (!externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
64
+
}
59
65
switch (verificationChannel) {
60
66
case 'email':
61
67
if (!email.trim()) return 'Email is required for email verification'
···
88
94
email: email.trim(),
89
95
password,
90
96
inviteCode: inviteCode.trim() || undefined,
97
+
didType,
98
+
did: didType === 'web-external' ? externalDid.trim() : undefined,
91
99
verificationChannel,
92
100
discordId: discordId.trim() || undefined,
93
101
telegramUsername: telegramUsername.trim() || undefined,
···
171
179
required
172
180
/>
173
181
</div>
182
+
<fieldset class="identity-section">
183
+
<legend>Identity Type</legend>
184
+
<p class="section-hint">Choose how your decentralized identity will be managed.</p>
185
+
<div class="radio-group">
186
+
<label class="radio-label">
187
+
<input
188
+
type="radio"
189
+
name="didType"
190
+
value="plc"
191
+
bind:group={didType}
192
+
disabled={submitting}
193
+
/>
194
+
<span class="radio-content">
195
+
<strong>did:plc</strong> (Recommended)
196
+
<span class="radio-hint">Portable identity managed by PLC Directory</span>
197
+
</span>
198
+
</label>
199
+
<label class="radio-label">
200
+
<input
201
+
type="radio"
202
+
name="didType"
203
+
value="web"
204
+
bind:group={didType}
205
+
disabled={submitting}
206
+
/>
207
+
<span class="radio-content">
208
+
<strong>did:web</strong>
209
+
<span class="radio-hint">Identity hosted on this PDS (read warning below)</span>
210
+
</span>
211
+
</label>
212
+
<label class="radio-label">
213
+
<input
214
+
type="radio"
215
+
name="didType"
216
+
value="web-external"
217
+
bind:group={didType}
218
+
disabled={submitting}
219
+
/>
220
+
<span class="radio-content">
221
+
<strong>did:web (BYOD)</strong>
222
+
<span class="radio-hint">Bring your own domain</span>
223
+
</span>
224
+
</label>
225
+
</div>
226
+
{#if didType === 'web'}
227
+
<div class="did-web-warning">
228
+
<strong>Important: Understand the trade-offs</strong>
229
+
<ul>
230
+
<li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>. Even if you migrate to another PDS later, this server must continue hosting your DID document.</li>
231
+
<li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys. If this PDS goes offline permanently, your identity cannot be recovered.</li>
232
+
<li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document pointing to your new PDS. Your identity will remain functional.</li>
233
+
<li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li>
234
+
</ul>
235
+
</div>
236
+
{/if}
237
+
{#if didType === 'web-external'}
238
+
<div class="field">
239
+
<label for="external-did">Your did:web</label>
240
+
<input
241
+
id="external-did"
242
+
type="text"
243
+
bind:value={externalDid}
244
+
placeholder="did:web:yourdomain.com"
245
+
disabled={submitting}
246
+
required
247
+
/>
248
+
<p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p>
249
+
</div>
250
+
{/if}
251
+
</fieldset>
174
252
<fieldset class="verification-section">
175
253
<legend>Contact Method</legend>
176
254
<p class="section-hint">Choose how you'd like to verify your account and receive notifications. You only need one.</p>
···
323
401
padding: 0 0.5rem;
324
402
color: var(--text-primary);
325
403
}
404
+
.identity-section {
405
+
border: 1px solid var(--border-color-light);
406
+
border-radius: 6px;
407
+
padding: 1rem;
408
+
margin: 0.5rem 0;
409
+
}
410
+
.identity-section legend {
411
+
font-weight: 600;
412
+
padding: 0 0.5rem;
413
+
color: var(--text-primary);
414
+
}
415
+
.radio-group {
416
+
display: flex;
417
+
flex-direction: column;
418
+
gap: 0.75rem;
419
+
}
420
+
.radio-label {
421
+
display: flex;
422
+
align-items: flex-start;
423
+
gap: 0.5rem;
424
+
cursor: pointer;
425
+
}
426
+
.radio-label input[type="radio"] {
427
+
margin-top: 0.25rem;
428
+
}
429
+
.radio-content {
430
+
display: flex;
431
+
flex-direction: column;
432
+
gap: 0.125rem;
433
+
}
434
+
.radio-hint {
435
+
font-size: 0.75rem;
436
+
color: var(--text-secondary);
437
+
}
326
438
.section-hint {
327
439
font-size: 0.8rem;
328
440
color: var(--text-secondary);
329
441
margin: 0 0 1rem 0;
442
+
}
443
+
.did-web-warning {
444
+
margin-top: 1rem;
445
+
padding: 1rem;
446
+
background: var(--warning-bg, #fff3cd);
447
+
border: 1px solid var(--warning-border, #ffc107);
448
+
border-radius: 6px;
449
+
font-size: 0.875rem;
450
+
}
451
+
.did-web-warning strong {
452
+
color: var(--warning-text, #856404);
453
+
}
454
+
.did-web-warning ul {
455
+
margin: 0.75rem 0 0 0;
456
+
padding-left: 1.25rem;
457
+
}
458
+
.did-web-warning li {
459
+
margin-bottom: 0.5rem;
460
+
line-height: 1.4;
461
+
}
462
+
.did-web-warning li:last-child {
463
+
margin-bottom: 0;
464
+
}
465
+
.did-web-warning code {
466
+
background: rgba(0, 0, 0, 0.1);
467
+
padding: 0.125rem 0.25rem;
468
+
border-radius: 3px;
469
+
font-size: 0.8rem;
330
470
}
331
471
button {
332
472
padding: 0.75rem;
+2
migrations/20251222_add_did_web_migration_tracking.sql
+2
migrations/20251222_add_did_web_migration_tracking.sql
+115
-71
src/api/identity/account.rs
+115
-71
src/api/identity/account.rs
···
41
41
pub password: String,
42
42
pub invite_code: Option<String>,
43
43
pub did: Option<String>,
44
+
pub did_type: Option<String>,
44
45
pub signing_key: Option<String>,
45
46
pub verification_channel: Option<String>,
46
47
pub discord_id: Option<String>,
···
268
269
.into_response();
269
270
}
270
271
};
271
-
let did = if let Some(d) = &input.did {
272
-
if d.trim().is_empty() {
273
-
let rotation_key = std::env::var("PLC_ROTATION_KEY")
274
-
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
275
-
let genesis_result = match create_genesis_operation(
276
-
&signing_key,
277
-
&rotation_key,
278
-
&full_handle,
279
-
&pds_endpoint,
280
-
) {
281
-
Ok(r) => r,
282
-
Err(e) => {
283
-
error!("Error creating PLC genesis operation: {:?}", e);
272
+
let did_type = input.did_type.as_deref().unwrap_or("plc");
273
+
let did = match did_type {
274
+
"web" => {
275
+
let subdomain_host = format!("{}.{}", input.handle, hostname);
276
+
let encoded_subdomain = subdomain_host.replace(':', "%3A");
277
+
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
278
+
info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
279
+
self_hosted_did
280
+
}
281
+
"web-external" => {
282
+
let d = match &input.did {
283
+
Some(d) if !d.trim().is_empty() => d,
284
+
_ => {
284
285
return (
285
-
StatusCode::INTERNAL_SERVER_ERROR,
286
-
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
286
+
StatusCode::BAD_REQUEST,
287
+
Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})),
287
288
)
288
289
.into_response();
289
290
}
290
291
};
291
-
let plc_client = PlcClient::new(None);
292
-
if let Err(e) = plc_client
293
-
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
294
-
.await
295
-
{
296
-
error!("Failed to submit PLC genesis operation: {:?}", e);
292
+
if !d.starts_with("did:web:") {
297
293
return (
298
-
StatusCode::BAD_GATEWAY,
299
-
Json(json!({
300
-
"error": "UpstreamError",
301
-
"message": format!("Failed to register DID with PLC directory: {}", e)
302
-
})),
294
+
StatusCode::BAD_REQUEST,
295
+
Json(
296
+
json!({"error": "InvalidDid", "message": "External DID must be a did:web"}),
297
+
),
303
298
)
304
299
.into_response();
305
300
}
306
-
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
307
-
genesis_result.did
308
-
} else if d.starts_with("did:web:") {
309
301
if let Err(e) = verify_did_web(d, &hostname, &input.handle).await {
310
302
return (
311
303
StatusCode::BAD_REQUEST,
···
313
305
)
314
306
.into_response();
315
307
}
316
-
d.clone()
317
-
} else if d.starts_with("did:plc:") && is_migration {
308
+
info!(did = %d, "Creating external did:web account");
318
309
d.clone()
319
-
} else {
320
-
return (
321
-
StatusCode::BAD_REQUEST,
322
-
Json(json!({"error": "InvalidDid", "message": "Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth."})),
323
-
)
324
-
.into_response();
325
310
}
326
-
} else {
327
-
let rotation_key = std::env::var("PLC_ROTATION_KEY")
328
-
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
329
-
let genesis_result = match create_genesis_operation(
330
-
&signing_key,
331
-
&rotation_key,
332
-
&full_handle,
333
-
&pds_endpoint,
334
-
) {
335
-
Ok(r) => r,
336
-
Err(e) => {
337
-
error!("Error creating PLC genesis operation: {:?}", e);
338
-
return (
339
-
StatusCode::INTERNAL_SERVER_ERROR,
340
-
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
341
-
)
342
-
.into_response();
311
+
_ => {
312
+
if let Some(d) = &input.did {
313
+
if d.starts_with("did:plc:") && is_migration {
314
+
info!(did = %d, "Migration with existing did:plc");
315
+
d.clone()
316
+
} else if d.starts_with("did:web:") {
317
+
if let Err(e) = verify_did_web(d, &hostname, &input.handle).await {
318
+
return (
319
+
StatusCode::BAD_REQUEST,
320
+
Json(json!({"error": "InvalidDid", "message": e})),
321
+
)
322
+
.into_response();
323
+
}
324
+
d.clone()
325
+
} else if !d.trim().is_empty() {
326
+
return (
327
+
StatusCode::BAD_REQUEST,
328
+
Json(json!({"error": "InvalidDid", "message": "Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth."})),
329
+
)
330
+
.into_response();
331
+
} else {
332
+
let rotation_key = std::env::var("PLC_ROTATION_KEY")
333
+
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
334
+
let genesis_result = match create_genesis_operation(
335
+
&signing_key,
336
+
&rotation_key,
337
+
&full_handle,
338
+
&pds_endpoint,
339
+
) {
340
+
Ok(r) => r,
341
+
Err(e) => {
342
+
error!("Error creating PLC genesis operation: {:?}", e);
343
+
return (
344
+
StatusCode::INTERNAL_SERVER_ERROR,
345
+
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
346
+
)
347
+
.into_response();
348
+
}
349
+
};
350
+
let plc_client = PlcClient::new(None);
351
+
if let Err(e) = plc_client
352
+
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
353
+
.await
354
+
{
355
+
error!("Failed to submit PLC genesis operation: {:?}", e);
356
+
return (
357
+
StatusCode::BAD_GATEWAY,
358
+
Json(json!({
359
+
"error": "UpstreamError",
360
+
"message": format!("Failed to register DID with PLC directory: {}", e)
361
+
})),
362
+
)
363
+
.into_response();
364
+
}
365
+
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
366
+
genesis_result.did
367
+
}
368
+
} else {
369
+
let rotation_key = std::env::var("PLC_ROTATION_KEY")
370
+
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
371
+
let genesis_result = match create_genesis_operation(
372
+
&signing_key,
373
+
&rotation_key,
374
+
&full_handle,
375
+
&pds_endpoint,
376
+
) {
377
+
Ok(r) => r,
378
+
Err(e) => {
379
+
error!("Error creating PLC genesis operation: {:?}", e);
380
+
return (
381
+
StatusCode::INTERNAL_SERVER_ERROR,
382
+
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
383
+
)
384
+
.into_response();
385
+
}
386
+
};
387
+
let plc_client = PlcClient::new(None);
388
+
if let Err(e) = plc_client
389
+
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
390
+
.await
391
+
{
392
+
error!("Failed to submit PLC genesis operation: {:?}", e);
393
+
return (
394
+
StatusCode::BAD_GATEWAY,
395
+
Json(json!({
396
+
"error": "UpstreamError",
397
+
"message": format!("Failed to register DID with PLC directory: {}", e)
398
+
})),
399
+
)
400
+
.into_response();
401
+
}
402
+
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
403
+
genesis_result.did
343
404
}
344
-
};
345
-
let plc_client = PlcClient::new(None);
346
-
if let Err(e) = plc_client
347
-
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
348
-
.await
349
-
{
350
-
error!("Failed to submit PLC genesis operation: {:?}", e);
351
-
return (
352
-
StatusCode::BAD_GATEWAY,
353
-
Json(json!({
354
-
"error": "UpstreamError",
355
-
"message": format!("Failed to register DID with PLC directory: {}", e)
356
-
})),
357
-
)
358
-
.into_response();
359
405
}
360
-
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
361
-
genesis_result.did
362
406
};
363
407
let mut tx = match state.db.begin().await {
364
408
Ok(tx) => tx,
+231
-68
src/api/identity/did.rs
+231
-68
src/api/identity/did.rs
···
95
95
}))
96
96
}
97
97
98
-
pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse {
98
+
pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, &'static str> {
99
+
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?;
100
+
let public_key = secret_key.public_key();
101
+
let compressed = public_key.to_encoded_point(true);
102
+
let compressed_bytes = compressed.as_bytes();
103
+
let mut multicodec_key = vec![0xe7, 0x01];
104
+
multicodec_key.extend_from_slice(compressed_bytes);
105
+
Ok(format!("z{}", bs58::encode(&multicodec_key).into_string()))
106
+
}
107
+
108
+
pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
99
109
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
100
-
// Kinda for local dev, encode hostname if it contains port
110
+
let host_header = headers
111
+
.get("host")
112
+
.and_then(|h| h.to_str().ok())
113
+
.unwrap_or(&hostname);
114
+
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
115
+
let hostname_without_port = hostname.split(':').next().unwrap_or(&hostname);
116
+
if host_without_port != hostname_without_port
117
+
&& host_without_port.ends_with(&format!(".{}", hostname_without_port))
118
+
{
119
+
let handle = host_without_port
120
+
.strip_suffix(&format!(".{}", hostname_without_port))
121
+
.unwrap_or(host_without_port);
122
+
return serve_subdomain_did_doc(&state, handle, &hostname).await;
123
+
}
101
124
let did = if hostname.contains(':') {
102
125
format!("did:web:{}", hostname.replace(':', "%3A"))
103
126
} else {
···
112
135
"serviceEndpoint": format!("https://{}", hostname)
113
136
}]
114
137
}))
138
+
.into_response()
139
+
}
140
+
141
+
async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response {
142
+
let user = sqlx::query!(
143
+
"SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
144
+
handle
145
+
)
146
+
.fetch_optional(&state.db)
147
+
.await;
148
+
let (user_id, did, migrated_to_pds) = match user {
149
+
Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds),
150
+
Ok(None) => {
151
+
return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response();
152
+
}
153
+
Err(e) => {
154
+
error!("DB Error: {:?}", e);
155
+
return (
156
+
StatusCode::INTERNAL_SERVER_ERROR,
157
+
Json(json!({"error": "InternalError"})),
158
+
)
159
+
.into_response();
160
+
}
161
+
};
162
+
if !did.starts_with("did:web:") {
163
+
return (
164
+
StatusCode::NOT_FOUND,
165
+
Json(json!({"error": "NotFound", "message": "User is not did:web"})),
166
+
)
167
+
.into_response();
168
+
}
169
+
let subdomain_host = format!("{}.{}", handle, hostname);
170
+
let encoded_subdomain = subdomain_host.replace(':', "%3A");
171
+
let expected_self_hosted = format!("did:web:{}", encoded_subdomain);
172
+
if did != expected_self_hosted {
173
+
return (
174
+
StatusCode::NOT_FOUND,
175
+
Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})),
176
+
)
177
+
.into_response();
178
+
}
179
+
let key_row = sqlx::query!(
180
+
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
181
+
user_id
182
+
)
183
+
.fetch_optional(&state.db)
184
+
.await;
185
+
let key_bytes: Vec<u8> = match key_row {
186
+
Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
187
+
Ok(k) => k,
188
+
Err(_) => {
189
+
return (
190
+
StatusCode::INTERNAL_SERVER_ERROR,
191
+
Json(json!({"error": "InternalError"})),
192
+
)
193
+
.into_response();
194
+
}
195
+
},
196
+
_ => {
197
+
return (
198
+
StatusCode::INTERNAL_SERVER_ERROR,
199
+
Json(json!({"error": "InternalError"})),
200
+
)
201
+
.into_response();
202
+
}
203
+
};
204
+
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
205
+
Ok(pk) => pk,
206
+
Err(e) => {
207
+
tracing::error!("Failed to generate public key multibase: {}", e);
208
+
return (
209
+
StatusCode::INTERNAL_SERVER_ERROR,
210
+
Json(json!({"error": "InternalError"})),
211
+
)
212
+
.into_response();
213
+
}
214
+
};
215
+
let full_handle = if handle.contains('.') {
216
+
handle.to_string()
217
+
} else {
218
+
format!("{}.{}", handle, hostname)
219
+
};
220
+
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
221
+
Json(json!({
222
+
"@context": [
223
+
"https://www.w3.org/ns/did/v1",
224
+
"https://w3id.org/security/multikey/v1",
225
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
226
+
],
227
+
"id": did,
228
+
"alsoKnownAs": [format!("at://{}", full_handle)],
229
+
"verificationMethod": [{
230
+
"id": format!("{}#atproto", did),
231
+
"type": "Multikey",
232
+
"controller": did,
233
+
"publicKeyMultibase": public_key_multibase
234
+
}],
235
+
"service": [{
236
+
"id": "#atproto_pds",
237
+
"type": "AtprotoPersonalDataServer",
238
+
"serviceEndpoint": service_endpoint
239
+
}]
240
+
}))
241
+
.into_response()
115
242
}
116
243
117
244
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
118
245
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
119
-
let user = sqlx::query!("SELECT id, did FROM users WHERE handle = $1", handle)
120
-
.fetch_optional(&state.db)
121
-
.await;
122
-
let (user_id, did) = match user {
123
-
Ok(Some(row)) => (row.id, row.did),
246
+
let user = sqlx::query!(
247
+
"SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
248
+
handle
249
+
)
250
+
.fetch_optional(&state.db)
251
+
.await;
252
+
let (user_id, did, migrated_to_pds) = match user {
253
+
Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds),
124
254
Ok(None) => {
125
255
return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response();
126
256
}
···
140
270
)
141
271
.into_response();
142
272
}
273
+
let encoded_hostname = hostname.replace(':', "%3A");
274
+
let old_path_format = format!("did:web:{}:u:{}", encoded_hostname, handle);
275
+
let subdomain_host = format!("{}.{}", handle, hostname);
276
+
let encoded_subdomain = subdomain_host.replace(':', "%3A");
277
+
let new_subdomain_format = format!("did:web:{}", encoded_subdomain);
278
+
if did != old_path_format && did != new_subdomain_format {
279
+
return (
280
+
StatusCode::NOT_FOUND,
281
+
Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})),
282
+
)
283
+
.into_response();
284
+
}
143
285
let key_row = sqlx::query!(
144
286
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
145
287
user_id
···
165
307
.into_response();
166
308
}
167
309
};
168
-
let jwk = match get_jwk(&key_bytes) {
169
-
Ok(j) => j,
310
+
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
311
+
Ok(pk) => pk,
170
312
Err(e) => {
171
-
tracing::error!("Failed to generate JWK: {}", e);
313
+
tracing::error!("Failed to generate public key multibase: {}", e);
172
314
return (
173
315
StatusCode::INTERNAL_SERVER_ERROR,
174
316
Json(json!({"error": "InternalError"})),
···
176
318
.into_response();
177
319
}
178
320
};
321
+
let full_handle = if handle.contains('.') {
322
+
handle.clone()
323
+
} else {
324
+
format!("{}.{}", handle, hostname)
325
+
};
326
+
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
179
327
Json(json!({
180
-
"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"],
328
+
"@context": [
329
+
"https://www.w3.org/ns/did/v1",
330
+
"https://w3id.org/security/multikey/v1",
331
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
332
+
],
181
333
"id": did,
182
-
"alsoKnownAs": [format!("at://{}", handle)],
334
+
"alsoKnownAs": [format!("at://{}", full_handle)],
183
335
"verificationMethod": [{
184
336
"id": format!("{}#atproto", did),
185
-
"type": "JsonWebKey2020",
337
+
"type": "Multikey",
186
338
"controller": did,
187
-
"publicKeyJwk": jwk
339
+
"publicKeyMultibase": public_key_multibase
188
340
}],
189
341
"service": [{
190
342
"id": "#atproto_pds",
191
343
"type": "AtprotoPersonalDataServer",
192
-
"serviceEndpoint": format!("https://{}", hostname)
344
+
"serviceEndpoint": service_endpoint
193
345
}]
194
-
})).into_response()
346
+
}))
347
+
.into_response()
195
348
}
196
349
197
350
pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> {
351
+
let subdomain_host = format!("{}.{}", handle, hostname);
352
+
let encoded_subdomain = subdomain_host.replace(':', "%3A");
353
+
let expected_subdomain_did = format!("did:web:{}", encoded_subdomain);
354
+
if did == expected_subdomain_did {
355
+
return Ok(());
356
+
}
198
357
let expected_prefix = if hostname.contains(':') {
199
358
format!("did:web:{}", hostname.replace(':', "%3A"))
200
359
} else {
···
204
363
let suffix = &did[expected_prefix.len()..];
205
364
let expected_suffix = format!(":u:{}", handle);
206
365
if suffix == expected_suffix {
207
-
Ok(())
366
+
return Ok(());
208
367
} else {
209
-
Err(format!(
368
+
return Err(format!(
210
369
"Invalid DID path for this PDS. Expected {}",
211
370
expected_suffix
212
-
))
371
+
));
213
372
}
373
+
}
374
+
let parts: Vec<&str> = did.split(':').collect();
375
+
if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
376
+
return Err("Invalid did:web format".into());
377
+
}
378
+
let domain_segment = parts[2];
379
+
let domain = domain_segment.replace("%3A", ":");
380
+
let scheme = if domain.starts_with("localhost") || domain.starts_with("127.0.0.1") {
381
+
"http"
214
382
} else {
215
-
let parts: Vec<&str> = did.split(':').collect();
216
-
if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
217
-
return Err("Invalid did:web format".into());
218
-
}
219
-
let domain_segment = parts[2];
220
-
let domain = domain_segment.replace("%3A", ":");
221
-
let scheme = if domain.starts_with("localhost") || domain.starts_with("127.0.0.1") {
222
-
"http"
223
-
} else {
224
-
"https"
225
-
};
226
-
let url = if parts.len() == 3 {
227
-
format!("{}://{}/.well-known/did.json", scheme, domain)
228
-
} else {
229
-
let path = parts[3..].join("/");
230
-
format!("{}://{}/{}/did.json", scheme, domain, path)
231
-
};
232
-
let client = reqwest::Client::builder()
233
-
.timeout(std::time::Duration::from_secs(5))
234
-
.build()
235
-
.map_err(|e| format!("Failed to create client: {}", e))?;
236
-
let resp = client
237
-
.get(&url)
238
-
.send()
239
-
.await
240
-
.map_err(|e| format!("Failed to fetch DID doc: {}", e))?;
241
-
if !resp.status().is_success() {
242
-
return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status()));
243
-
}
244
-
let doc: serde_json::Value = resp
245
-
.json()
246
-
.await
247
-
.map_err(|e| format!("Failed to parse DID doc: {}", e))?;
248
-
let services = doc["service"]
249
-
.as_array()
250
-
.ok_or("No services found in DID doc")?;
251
-
let pds_endpoint = format!("https://{}", hostname);
252
-
let has_valid_service = services.iter().any(|s| {
253
-
s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint
254
-
});
255
-
if has_valid_service {
256
-
Ok(())
257
-
} else {
258
-
Err(format!(
259
-
"DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
260
-
pds_endpoint
261
-
))
262
-
}
383
+
"https"
384
+
};
385
+
let url = if parts.len() == 3 {
386
+
format!("{}://{}/.well-known/did.json", scheme, domain)
387
+
} else {
388
+
let path = parts[3..].join("/");
389
+
format!("{}://{}/{}/did.json", scheme, domain, path)
390
+
};
391
+
let client = reqwest::Client::builder()
392
+
.timeout(std::time::Duration::from_secs(5))
393
+
.build()
394
+
.map_err(|e| format!("Failed to create client: {}", e))?;
395
+
let resp = client
396
+
.get(&url)
397
+
.send()
398
+
.await
399
+
.map_err(|e| format!("Failed to fetch DID doc: {}", e))?;
400
+
if !resp.status().is_success() {
401
+
return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status()));
402
+
}
403
+
let doc: serde_json::Value = resp
404
+
.json()
405
+
.await
406
+
.map_err(|e| format!("Failed to parse DID doc: {}", e))?;
407
+
let services = doc["service"]
408
+
.as_array()
409
+
.ok_or("No services found in DID doc")?;
410
+
let pds_endpoint = format!("https://{}", hostname);
411
+
let has_valid_service = services
412
+
.iter()
413
+
.any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint);
414
+
if has_valid_service {
415
+
Ok(())
416
+
} else {
417
+
Err(format!(
418
+
"DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
419
+
pds_endpoint
420
+
))
263
421
}
264
422
}
265
423
···
344
502
Err(_) => return ApiError::InternalError.into_response(),
345
503
};
346
504
let did_key = signing_key_to_did_key(&signing_key);
505
+
let rotation_keys = if auth_user.did.starts_with("did:web:") {
506
+
vec![]
507
+
} else {
508
+
vec![did_key.clone()]
509
+
};
347
510
(
348
511
StatusCode::OK,
349
512
Json(GetRecommendedDidCredentialsOutput {
350
-
rotation_keys: vec![did_key.clone()],
513
+
rotation_keys,
351
514
also_known_as: vec![format!("at://{}", full_handle)],
352
515
verification_methods: VerificationMethods { atproto: did_key },
353
516
services: Services {
+6
src/api/identity/plc/sign.rs
+6
src/api/identity/plc/sign.rs
···
63
63
return e;
64
64
}
65
65
let did = &auth_user.did;
66
+
if did.starts_with("did:web:") {
67
+
return ApiError::InvalidRequest(
68
+
"PLC operations are only valid for did:plc identities".into(),
69
+
)
70
+
.into_response();
71
+
}
66
72
let token = match &input.token {
67
73
Some(t) => t,
68
74
None => {
+6
src/api/identity/plc/submit.rs
+6
src/api/identity/plc/submit.rs
···
42
42
return e;
43
43
}
44
44
let did = &auth_user.did;
45
+
if did.starts_with("did:web:") {
46
+
return ApiError::InvalidRequest(
47
+
"PLC operations are only valid for did:plc identities".into(),
48
+
)
49
+
.into_response();
50
+
}
45
51
if let Err(e) = validate_plc_operation(&input.operation) {
46
52
return ApiError::InvalidRequest(format!("Invalid operation: {}", e)).into_response();
47
53
}
+324
tests/did_web.rs
+324
tests/did_web.rs
···
1
+
mod common;
2
+
use common::*;
3
+
use reqwest::StatusCode;
4
+
use serde_json::{Value, json};
5
+
use wiremock::matchers::{method, path};
6
+
use wiremock::{Mock, MockServer, ResponseTemplate};
7
+
8
+
#[tokio::test]
9
+
async fn test_create_self_hosted_did_web() {
10
+
let client = client();
11
+
let handle = format!("selfweb_{}", uuid::Uuid::new_v4());
12
+
let payload = json!({
13
+
"handle": handle,
14
+
"email": format!("{}@example.com", handle),
15
+
"password": "password",
16
+
"didType": "web"
17
+
});
18
+
let res = client
19
+
.post(format!(
20
+
"{}/xrpc/com.atproto.server.createAccount",
21
+
base_url().await
22
+
))
23
+
.json(&payload)
24
+
.send()
25
+
.await
26
+
.expect("Failed to send request");
27
+
if res.status() != StatusCode::OK {
28
+
let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"}));
29
+
panic!("createAccount failed: {:?}", body);
30
+
}
31
+
let body: Value = res.json().await.expect("Response was not JSON");
32
+
let did = body["did"].as_str().expect("No DID in response");
33
+
assert!(
34
+
did.starts_with("did:web:"),
35
+
"DID should start with did:web:, got: {}",
36
+
did
37
+
);
38
+
assert!(
39
+
did.contains(&handle),
40
+
"DID should contain handle {}, got: {}",
41
+
handle,
42
+
did
43
+
);
44
+
assert!(
45
+
!did.contains(":u:"),
46
+
"Self-hosted did:web should use subdomain format (no :u:), got: {}",
47
+
did
48
+
);
49
+
let jwt = verify_new_account(&client, did).await;
50
+
let res = client
51
+
.get(format!("{}/u/{}/did.json", base_url().await, handle))
52
+
.send()
53
+
.await
54
+
.expect("Failed to fetch DID doc via path");
55
+
assert_eq!(
56
+
res.status(),
57
+
StatusCode::OK,
58
+
"Self-hosted did:web should have DID doc served by PDS (via path for backwards compat)"
59
+
);
60
+
let doc: Value = res.json().await.expect("DID doc was not JSON");
61
+
assert_eq!(doc["id"], did);
62
+
assert!(
63
+
doc["verificationMethod"][0]["publicKeyMultibase"].is_string(),
64
+
"DID doc should have publicKeyMultibase"
65
+
);
66
+
let res = client
67
+
.post(format!(
68
+
"{}/xrpc/com.atproto.repo.createRecord",
69
+
base_url().await
70
+
))
71
+
.bearer_auth(&jwt)
72
+
.json(&json!({
73
+
"repo": did,
74
+
"collection": "app.bsky.feed.post",
75
+
"record": {
76
+
"$type": "app.bsky.feed.post",
77
+
"text": "Hello from did:web!",
78
+
"createdAt": chrono::Utc::now().to_rfc3339()
79
+
}
80
+
}))
81
+
.send()
82
+
.await
83
+
.expect("Failed to create post");
84
+
assert_eq!(
85
+
res.status(),
86
+
StatusCode::OK,
87
+
"Self-hosted did:web account should be able to create records"
88
+
);
89
+
}
90
+
91
+
#[tokio::test]
92
+
async fn test_external_did_web_no_local_doc() {
93
+
let client = client();
94
+
let mock_server = MockServer::start().await;
95
+
let mock_uri = mock_server.uri();
96
+
let mock_addr = mock_uri.trim_start_matches("http://");
97
+
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
98
+
let handle = format!("extweb_{}", uuid::Uuid::new_v4());
99
+
let pds_endpoint = base_url().await.replace("http://", "https://");
100
+
let did_doc = json!({
101
+
"@context": ["https://www.w3.org/ns/did/v1"],
102
+
"id": did,
103
+
"service": [{
104
+
"id": "#atproto_pds",
105
+
"type": "AtprotoPersonalDataServer",
106
+
"serviceEndpoint": pds_endpoint
107
+
}]
108
+
});
109
+
Mock::given(method("GET"))
110
+
.and(path("/.well-known/did.json"))
111
+
.respond_with(ResponseTemplate::new(200).set_body_json(did_doc))
112
+
.mount(&mock_server)
113
+
.await;
114
+
let payload = json!({
115
+
"handle": handle,
116
+
"email": format!("{}@example.com", handle),
117
+
"password": "password",
118
+
"didType": "web-external",
119
+
"did": did
120
+
});
121
+
let res = client
122
+
.post(format!(
123
+
"{}/xrpc/com.atproto.server.createAccount",
124
+
base_url().await
125
+
))
126
+
.json(&payload)
127
+
.send()
128
+
.await
129
+
.expect("Failed to send request");
130
+
if res.status() != StatusCode::OK {
131
+
let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"}));
132
+
panic!("createAccount failed: {:?}", body);
133
+
}
134
+
let res = client
135
+
.get(format!("{}/u/{}/did.json", base_url().await, handle))
136
+
.send()
137
+
.await
138
+
.expect("Failed to fetch DID doc");
139
+
assert_eq!(
140
+
res.status(),
141
+
StatusCode::NOT_FOUND,
142
+
"External did:web should NOT have DID doc served by PDS"
143
+
);
144
+
let body: Value = res.json().await.expect("Response was not JSON");
145
+
assert!(
146
+
body["message"].as_str().unwrap_or("").contains("External"),
147
+
"Error message should indicate external did:web"
148
+
);
149
+
}
150
+
151
+
#[tokio::test]
152
+
async fn test_plc_operations_blocked_for_did_web() {
153
+
let client = client();
154
+
let handle = format!("plcblock_{}", uuid::Uuid::new_v4());
155
+
let payload = json!({
156
+
"handle": handle,
157
+
"email": format!("{}@example.com", handle),
158
+
"password": "password",
159
+
"didType": "web"
160
+
});
161
+
let res = client
162
+
.post(format!(
163
+
"{}/xrpc/com.atproto.server.createAccount",
164
+
base_url().await
165
+
))
166
+
.json(&payload)
167
+
.send()
168
+
.await
169
+
.expect("Failed to send request");
170
+
assert_eq!(res.status(), StatusCode::OK);
171
+
let body: Value = res.json().await.expect("Response was not JSON");
172
+
let did = body["did"].as_str().expect("No DID").to_string();
173
+
let jwt = verify_new_account(&client, &did).await;
174
+
let res = client
175
+
.post(format!(
176
+
"{}/xrpc/com.atproto.identity.signPlcOperation",
177
+
base_url().await
178
+
))
179
+
.bearer_auth(&jwt)
180
+
.json(&json!({
181
+
"token": "fake-token"
182
+
}))
183
+
.send()
184
+
.await
185
+
.expect("Failed to send request");
186
+
assert_eq!(
187
+
res.status(),
188
+
StatusCode::BAD_REQUEST,
189
+
"signPlcOperation should be blocked for did:web users"
190
+
);
191
+
let body: Value = res.json().await.expect("Response was not JSON");
192
+
assert!(
193
+
body["message"].as_str().unwrap_or("").contains("did:plc"),
194
+
"Error should mention did:plc: {:?}",
195
+
body
196
+
);
197
+
let res = client
198
+
.post(format!(
199
+
"{}/xrpc/com.atproto.identity.submitPlcOperation",
200
+
base_url().await
201
+
))
202
+
.bearer_auth(&jwt)
203
+
.json(&json!({
204
+
"operation": {}
205
+
}))
206
+
.send()
207
+
.await
208
+
.expect("Failed to send request");
209
+
assert_eq!(
210
+
res.status(),
211
+
StatusCode::BAD_REQUEST,
212
+
"submitPlcOperation should be blocked for did:web users"
213
+
);
214
+
}
215
+
216
+
#[tokio::test]
217
+
async fn test_get_recommended_did_credentials_no_rotation_keys_for_did_web() {
218
+
let client = client();
219
+
let handle = format!("creds_{}", uuid::Uuid::new_v4());
220
+
let payload = json!({
221
+
"handle": handle,
222
+
"email": format!("{}@example.com", handle),
223
+
"password": "password",
224
+
"didType": "web"
225
+
});
226
+
let res = client
227
+
.post(format!(
228
+
"{}/xrpc/com.atproto.server.createAccount",
229
+
base_url().await
230
+
))
231
+
.json(&payload)
232
+
.send()
233
+
.await
234
+
.expect("Failed to send request");
235
+
assert_eq!(res.status(), StatusCode::OK);
236
+
let body: Value = res.json().await.expect("Response was not JSON");
237
+
let did = body["did"].as_str().expect("No DID").to_string();
238
+
let jwt = verify_new_account(&client, &did).await;
239
+
let res = client
240
+
.get(format!(
241
+
"{}/xrpc/com.atproto.identity.getRecommendedDidCredentials",
242
+
base_url().await
243
+
))
244
+
.bearer_auth(&jwt)
245
+
.send()
246
+
.await
247
+
.expect("Failed to send request");
248
+
assert_eq!(res.status(), StatusCode::OK);
249
+
let body: Value = res.json().await.expect("Response was not JSON");
250
+
let rotation_keys = body["rotationKeys"]
251
+
.as_array()
252
+
.expect("rotationKeys should be an array");
253
+
assert!(
254
+
rotation_keys.is_empty(),
255
+
"did:web should have no rotation keys, got: {:?}",
256
+
rotation_keys
257
+
);
258
+
assert!(
259
+
body["verificationMethods"].is_object(),
260
+
"verificationMethods should be present"
261
+
);
262
+
assert!(body["services"].is_object(), "services should be present");
263
+
}
264
+
265
+
#[tokio::test]
266
+
async fn test_did_plc_still_works_with_did_type_param() {
267
+
let client = client();
268
+
let handle = format!("plctype_{}", uuid::Uuid::new_v4());
269
+
let payload = json!({
270
+
"handle": handle,
271
+
"email": format!("{}@example.com", handle),
272
+
"password": "password",
273
+
"didType": "plc"
274
+
});
275
+
let res = client
276
+
.post(format!(
277
+
"{}/xrpc/com.atproto.server.createAccount",
278
+
base_url().await
279
+
))
280
+
.json(&payload)
281
+
.send()
282
+
.await
283
+
.expect("Failed to send request");
284
+
assert_eq!(res.status(), StatusCode::OK);
285
+
let body: Value = res.json().await.expect("Response was not JSON");
286
+
let did = body["did"].as_str().expect("No DID").to_string();
287
+
assert!(
288
+
did.starts_with("did:plc:"),
289
+
"DID with didType=plc should be did:plc:, got: {}",
290
+
did
291
+
);
292
+
}
293
+
294
+
#[tokio::test]
295
+
async fn test_external_did_web_requires_did_field() {
296
+
let client = client();
297
+
let handle = format!("nodid_{}", uuid::Uuid::new_v4());
298
+
let payload = json!({
299
+
"handle": handle,
300
+
"email": format!("{}@example.com", handle),
301
+
"password": "password",
302
+
"didType": "web-external"
303
+
});
304
+
let res = client
305
+
.post(format!(
306
+
"{}/xrpc/com.atproto.server.createAccount",
307
+
base_url().await
308
+
))
309
+
.json(&payload)
310
+
.send()
311
+
.await
312
+
.expect("Failed to send request");
313
+
assert_eq!(
314
+
res.status(),
315
+
StatusCode::BAD_REQUEST,
316
+
"web-external without did should fail"
317
+
);
318
+
let body: Value = res.json().await.expect("Response was not JSON");
319
+
assert!(
320
+
body["message"].as_str().unwrap_or("").contains("did"),
321
+
"Error should mention did field is required: {:?}",
322
+
body
323
+
);
324
+
}
+5
-6
tests/identity.rs
+5
-6
tests/identity.rs
···
143
143
.send()
144
144
.await
145
145
.expect("Failed to fetch DID doc");
146
-
assert_eq!(res.status(), StatusCode::OK);
147
-
let doc: Value = res.json().await.expect("DID doc was not JSON");
148
-
assert_eq!(doc["id"], did);
149
-
assert_eq!(doc["alsoKnownAs"][0], format!("at://{}", handle));
150
-
assert_eq!(doc["verificationMethod"][0]["controller"], did);
151
-
assert!(doc["verificationMethod"][0]["publicKeyJwk"].is_object());
146
+
assert_eq!(
147
+
res.status(),
148
+
StatusCode::NOT_FOUND,
149
+
"External did:web should not have DID doc served by PDS (user hosts their own)"
150
+
);
152
151
}
153
152
154
153
#[tokio::test]