···106106# INVITE_CODE_REQUIRED=false
107107# Comma-separated list of available user domains
108108# AVAILABLE_USER_DOMAINS=example.com
109109+# Enable self-hosted did:web identities (default: true)
110110+# Hosting did:web requires a long-term commitment to serve DID documents.
111111+# Set to false if you don't want to offer this option.
112112+# ENABLE_SELF_HOSTED_DID_WEB=true
109113# =============================================================================
110114# Server Metadata (returned by describeServer)
111115# =============================================================================
···8787 "didPlcHint": "Portable identity managed by PLC Directory",
8888 "didWeb": "did:web",
8989 "didWebHint": "Identity hosted on this PDS (read warning below)",
9090+ "didWebDisabledHint": "Not available on this PDS - use did:plc or bring your own did:web",
9091 "didWebBYOD": "did:web (BYOD)",
9192 "didWebBYODHint": "Bring your own domain",
9293 "didWebWarningTitle": "Important: Understand the trade-offs",
···175176 "navDelegation": "Delegation",
176177 "navDelegationDesc": "Manage account controllers and delegated accounts",
177178 "navAdmin": "Admin Panel",
178178- "navAdminDesc": "Server stats and admin operations"
179179+ "navAdminDesc": "Server stats and admin operations",
180180+ "navDidDocument": "DID Document",
181181+ "navDidDocumentDesc": "Manage your DID document for external migrations",
182182+ "migrated": "Migrated",
183183+ "migratedTitle": "Account Migrated",
184184+ "migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.",
185185+ "navMigrateAgain": "Migrate Again",
186186+ "navMigrateAgainDesc": "Move to another PDS and update your DID document"
187187+ },
188188+ "didEditor": {
189189+ "title": "DID Document Editor",
190190+ "preview": "Current DID Document",
191191+ "verificationMethods": "Verification Methods",
192192+ "verificationMethodsDesc": "Signing keys that can act on behalf of your DID. When you migrate to a new PDS, add their signing key here.",
193193+ "addKey": "Add Key",
194194+ "removeKey": "Remove",
195195+ "keyId": "Key ID",
196196+ "keyIdPlaceholder": "#atproto",
197197+ "publicKey": "Public Key (Multibase)",
198198+ "publicKeyPlaceholder": "zQ3sh...",
199199+ "noKeys": "No verification methods configured. Using the local PDS key.",
200200+ "alsoKnownAs": "Also Known As",
201201+ "alsoKnownAsDesc": "Handles that point to your DID. Update this when your handle changes on a new PDS.",
202202+ "addHandle": "Add Handle",
203203+ "removeHandle": "Remove",
204204+ "handle": "Handle",
205205+ "handlePlaceholder": "at://handle.newpds.com",
206206+ "noHandles": "No handles configured. Using the local handle.",
207207+ "serviceEndpoint": "Service Endpoint",
208208+ "serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.",
209209+ "currentPds": "Current PDS URL",
210210+ "save": "Save Changes",
211211+ "saving": "Saving...",
212212+ "success": "DID document updated successfully",
213213+ "saveFailed": "Failed to save DID document",
214214+ "loadFailed": "Failed to load DID document",
215215+ "invalidMultibase": "Public key must be a valid multibase string starting with 'z'",
216216+ "invalidHandle": "Handle must be an at:// URI (e.g., at://handle.example.com)",
217217+ "helpTitle": "What is this?",
218218+ "helpText": "When you migrate to another PDS, that PDS generates new signing keys. Update your DID document here so it points to your new keys and location. This enables multi-hop migrations (PDS 1 → PDS 2 → PDS 3)."
179219 },
180220 "settings": {
181221 "title": "Account Settings",
···792832 "didPlcHint": "Portable identity managed by PLC Directory",
793833 "didWeb": "did:web",
794834 "didWebHint": "Identity hosted on this PDS (read warning below)",
835835+ "didWebDisabledHint": "Not available on this PDS - use did:plc or bring your own did:web",
795836 "didWebBYOD": "did:web (BYOD)",
796837 "didWebBYODHint": "Bring your own domain",
797838 "didWebWarningTitle": "Important: Understand the trade-offs",
+42-1
frontend/src/locales/fi.json
···8787 "didPlcHint": "Siirrettävä identiteetti, jota hallinnoi PLC Directory",
8888 "didWeb": "did:web",
8989 "didWebHint": "Identiteetti isännöidään tällä PDS:llä (lue alla oleva varoitus)",
9090+ "didWebDisabledHint": "Ei saatavilla tällä PDS:llä - käytä did:plc:tä tai tuo oma did:web",
9091 "didWebBYOD": "did:web (oma verkkotunnus)",
9192 "didWebBYODHint": "Käytä omaa verkkotunnustasi",
9293 "didWebWarningTitle": "Tärkeää: Ymmärrä kompromissit",
···175176 "navDelegation": "Delegointi",
176177 "navDelegationDesc": "Hallitse tilin ohjaajia ja delegoituja tilejä",
177178 "navAdmin": "Ylläpitopaneeli",
178178- "navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot"
179179+ "navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot",
180180+ "navDidDocument": "DID-dokumentti",
181181+ "navDidDocumentDesc": "Hallitse DID-dokumenttiasi ulkoisia siirtoja varten",
182182+ "migrated": "Siirretty",
183183+ "migratedTitle": "Tili siirretty",
184184+ "migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isännöidään edelleen täällä, ja voit päivittää sen tulevia siirtoja varten.",
185185+ "navMigrateAgain": "Siirrä uudelleen",
186186+ "navMigrateAgainDesc": "Siirrä toiseen PDS:ään ja päivitä DID-dokumenttisi"
187187+ },
188188+ "didEditor": {
189189+ "title": "DID-dokumentin muokkain",
190190+ "preview": "Nykyinen DID-dokumentti",
191191+ "verificationMethods": "Vahvistusmenetelmät",
192192+ "verificationMethodsDesc": "Allekirjoitusavaimet, jotka voivat toimia DID:si puolesta. Kun siirryt uuteen PDS:ään, lisää niiden allekirjoitusavain tähän.",
193193+ "addKey": "Lisää avain",
194194+ "removeKey": "Poista",
195195+ "keyId": "Avaimen tunnus",
196196+ "keyIdPlaceholder": "#atproto",
197197+ "publicKey": "Julkinen avain (Multibase)",
198198+ "publicKeyPlaceholder": "zQ3sh...",
199199+ "noKeys": "Ei vahvistusmenetelmiä määritetty. Käytetään paikallista PDS-avainta.",
200200+ "alsoKnownAs": "Tunnetaan myös nimellä",
201201+ "alsoKnownAsDesc": "Kahvat, jotka osoittavat DID:iisi. Päivitä tämä, kun kahvasi muuttuu uudessa PDS:ssä.",
202202+ "addHandle": "Lisää kahva",
203203+ "removeHandle": "Poista",
204204+ "handle": "Kahva",
205205+ "handlePlaceholder": "at://kahva.uusipds.com",
206206+ "noHandles": "Ei kahvoja määritetty. Käytetään paikallista kahvaa.",
207207+ "serviceEndpoint": "Palvelupäätepiste",
208208+ "serviceEndpointDesc": "PDS, joka tällä hetkellä isännöi tilitietojasi. Päivitä tämä siirron yhteydessä.",
209209+ "currentPds": "Nykyinen PDS-URL",
210210+ "save": "Tallenna muutokset",
211211+ "saving": "Tallennetaan...",
212212+ "success": "DID-dokumentti päivitetty onnistuneesti",
213213+ "saveFailed": "DID-dokumentin tallennus epäonnistui",
214214+ "loadFailed": "DID-dokumentin lataus epäonnistui",
215215+ "invalidMultibase": "Julkisen avaimen on oltava kelvollinen multibase-merkkijono, joka alkaa 'z':llä",
216216+ "invalidHandle": "Kahvan on oltava at://-URI (esim. at://kahva.esimerkki.com)",
217217+ "helpTitle": "Mikä tämä on?",
218218+ "helpText": "Kun siirryt toiseen PDS:ään, se luo uudet allekirjoitusavaimet. Päivitä DID-dokumenttisi tässä osoittamaan uusiin avaimiin ja sijaintiin. Tämä mahdollistaa monivaiheiset siirrot (PDS 1 → PDS 2 → PDS 3)."
179219 },
180220 "settings": {
181221 "title": "Tilin asetukset",
···817857 "didPlcHint": "Siirrettävä identiteetti, jota hallinnoi PLC Directory",
818858 "didWeb": "did:web",
819859 "didWebHint": "Tällä PDS:llä isännöity identiteetti (lue varoitus alla)",
860860+ "didWebDisabledHint": "Ei saatavilla tällä PDS:llä - käytä did:plc:tä tai tuo oma did:web",
820861 "didWebBYOD": "did:web (BYOD)",
821862 "didWebBYODHint": "Tuo oma verkkotunnuksesi",
822863 "didWebWarningTitle": "Tärkeää: Ymmärrä kompromissit",
···8787 "didPlcHint": "PLC 디렉토리에서 관리하는 이동 가능한 ID",
8888 "didWeb": "did:web",
8989 "didWebHint": "이 PDS에서 호스팅되는 ID (아래 경고 참조)",
9090+ "didWebDisabledHint": "이 PDS에서 사용할 수 없음 - did:plc를 사용하거나 자체 did:web을 가져오세요",
9091 "didWebBYOD": "did:web (자체 도메인)",
9192 "didWebBYODHint": "자체 도메인 사용",
9293 "didWebWarningTitle": "중요: 장단점을 이해하세요",
···175176 "navDelegation": "위임",
176177 "navDelegationDesc": "계정 컨트롤러 및 위임된 계정 관리",
177178 "navAdmin": "관리 패널",
178178- "navAdminDesc": "서버 통계 및 관리 작업"
179179+ "navAdminDesc": "서버 통계 및 관리 작업",
180180+ "navDidDocument": "DID 문서",
181181+ "navDidDocumentDesc": "DID 문서 및 키 관리",
182182+ "migrated": "마이그레이션됨",
183183+ "migratedTitle": "계정 마이그레이션됨",
184184+ "migratedMessage": "계정이 {pds}로 마이그레이션되었습니다. DID 문서는 여전히 여기에서 호스팅됩니다.",
185185+ "navMigrateAgain": "다시 마이그레이션",
186186+ "navMigrateAgainDesc": "다른 PDS로 이동하고 DID 문서 업데이트"
187187+ },
188188+ "didEditor": {
189189+ "title": "DID 문서 편집기",
190190+ "preview": "현재 DID 문서",
191191+ "verificationMethods": "검증 방법 (서명 키)",
192192+ "addKey": "키 추가",
193193+ "removeKey": "삭제",
194194+ "keyId": "키 ID",
195195+ "keyIdPlaceholder": "#atproto",
196196+ "publicKey": "공개 키 (Multibase)",
197197+ "publicKeyPlaceholder": "zQ3sh...",
198198+ "alsoKnownAs": "다른 이름 (핸들)",
199199+ "addHandle": "핸들 추가",
200200+ "handlePlaceholder": "at://handle.pds.com",
201201+ "serviceEndpoint": "서비스 엔드포인트 (현재 PDS)",
202202+ "save": "변경사항 저장",
203203+ "saving": "저장 중...",
204204+ "success": "DID 문서가 업데이트되었습니다",
205205+ "helpTitle": "이것은 무엇인가요?",
206206+ "helpText": "다른 PDS로 마이그레이션하면 해당 PDS가 새 서명 키를 생성합니다. 여기에서 DID 문서를 업데이트하여 새 키와 위치를 가리키도록 하세요."
179207 },
180208 "settings": {
181209 "title": "계정 설정",
···817845 "didPlcHint": "PLC Directory에서 관리하는 이동 가능한 아이덴티티",
818846 "didWeb": "did:web",
819847 "didWebHint": "이 PDS에서 호스팅되는 아이덴티티 (아래 경고 읽기)",
848848+ "didWebDisabledHint": "이 PDS에서 사용할 수 없음 - did:plc를 사용하거나 자체 did:web을 가져오세요",
820849 "didWebBYOD": "did:web (BYOD)",
821850 "didWebBYODHint": "자체 도메인 사용",
822851 "didWebWarningTitle": "중요: 장단점 이해하기",
+30-1
frontend/src/locales/sv.json
···8787 "didPlcHint": "Portabel identitet hanterad av PLC Directory",
8888 "didWeb": "did:web",
8989 "didWebHint": "Identitet lagrad på denna PDS (läs varningen nedan)",
9090+ "didWebDisabledHint": "Inte tillgänglig på denna PDS - använd did:plc eller ta med din egen did:web",
9091 "didWebBYOD": "did:web (egen domän)",
9192 "didWebBYODHint": "Använd din egen domän",
9293 "didWebWarningTitle": "Viktigt: Förstå avvägningarna",
···175176 "navDelegation": "Delegering",
176177 "navDelegationDesc": "Hantera kontokontrollanter och delegerade konton",
177178 "navAdmin": "Adminpanel",
178178- "navAdminDesc": "Serverstatistik och administratörsoperationer"
179179+ "navAdminDesc": "Serverstatistik och administratörsoperationer",
180180+ "navDidDocument": "DID-dokument",
181181+ "navDidDocumentDesc": "Hantera ditt DID-dokument och nycklar",
182182+ "migrated": "Flyttad",
183183+ "migratedTitle": "Konto flyttat",
184184+ "migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande här.",
185185+ "navMigrateAgain": "Flytta igen",
186186+ "navMigrateAgainDesc": "Flytta till en annan PDS och uppdatera ditt DID-dokument"
187187+ },
188188+ "didEditor": {
189189+ "title": "DID-dokumentredigerare",
190190+ "preview": "Nuvarande DID-dokument",
191191+ "verificationMethods": "Verifieringsmetoder (signeringsnycklar)",
192192+ "addKey": "Lägg till nyckel",
193193+ "removeKey": "Ta bort",
194194+ "keyId": "Nyckel-ID",
195195+ "keyIdPlaceholder": "#atproto",
196196+ "publicKey": "Publik nyckel (Multibase)",
197197+ "publicKeyPlaceholder": "zQ3sh...",
198198+ "alsoKnownAs": "Även känd som (användarnamn)",
199199+ "addHandle": "Lägg till användarnamn",
200200+ "handlePlaceholder": "at://handle.pds.com",
201201+ "serviceEndpoint": "Tjänstslutpunkt (nuvarande PDS)",
202202+ "save": "Spara ändringar",
203203+ "saving": "Sparar...",
204204+ "success": "DID-dokumentet har uppdaterats",
205205+ "helpTitle": "Vad är detta?",
206206+ "helpText": "När du flyttar till en annan PDS genererar den PDS nya signeringsnycklar. Uppdatera ditt DID-dokument här så att det pekar på dina nya nycklar och plats."
179207 },
180208 "settings": {
181209 "title": "Kontoinställningar",
···817845 "didPlcHint": "Portabel identitet som hanteras av PLC Directory",
818846 "didWeb": "did:web",
819847 "didWebHint": "Identitet som lagras på denna PDS (läs varningen nedan)",
848848+ "didWebDisabledHint": "Inte tillgänglig på denna PDS - använd did:plc eller ta med din egen did:web",
820849 "didWebBYOD": "did:web (BYOD)",
821850 "didWebBYODHint": "Ta med din egen domän",
822851 "didWebWarningTitle": "Viktigt: Förstå kompromisserna",
···237237 }
238238 }
239239}
240240+241241+#[derive(Debug, Clone, Serialize, Deserialize)]
242242+#[serde(rename_all = "camelCase")]
243243+pub struct VerificationMethod {
244244+ pub id: String,
245245+ #[serde(rename = "type")]
246246+ pub method_type: String,
247247+ pub public_key_multibase: String,
248248+}
249249+250250+#[derive(Deserialize)]
251251+#[serde(rename_all = "camelCase")]
252252+pub struct UpdateDidDocumentInput {
253253+ pub verification_methods: Option<Vec<VerificationMethod>>,
254254+ pub also_known_as: Option<Vec<String>>,
255255+ pub service_endpoint: Option<String>,
256256+}
257257+258258+#[derive(Serialize)]
259259+#[serde(rename_all = "camelCase")]
260260+pub struct UpdateDidDocumentOutput {
261261+ pub success: bool,
262262+ pub did_document: serde_json::Value,
263263+}
264264+265265+pub async fn update_did_document(
266266+ State(state): State<AppState>,
267267+ headers: axum::http::HeaderMap,
268268+ Json(input): Json<UpdateDidDocumentInput>,
269269+) -> Response {
270270+ let extracted = match crate::auth::extract_auth_token_from_header(
271271+ headers.get("Authorization").and_then(|h| h.to_str().ok()),
272272+ ) {
273273+ Some(t) => t,
274274+ None => return ApiError::AuthenticationRequired.into_response(),
275275+ };
276276+ let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
277277+ let http_uri = format!(
278278+ "https://{}/xrpc/com.tranquil.account.updateDidDocument",
279279+ std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
280280+ );
281281+ let auth_user = match crate::auth::validate_token_with_dpop(
282282+ &state.db,
283283+ &extracted.token,
284284+ extracted.is_dpop,
285285+ dpop_proof,
286286+ "POST",
287287+ &http_uri,
288288+ true,
289289+ )
290290+ .await
291291+ {
292292+ Ok(user) => user,
293293+ Err(e) => return ApiError::from(e).into_response(),
294294+ };
295295+296296+ if !auth_user.did.starts_with("did:web:") {
297297+ return (
298298+ StatusCode::BAD_REQUEST,
299299+ Json(json!({
300300+ "error": "InvalidRequest",
301301+ "message": "DID document updates are only available for did:web accounts"
302302+ })),
303303+ )
304304+ .into_response();
305305+ }
306306+307307+ let user = match sqlx::query!(
308308+ "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1",
309309+ auth_user.did
310310+ )
311311+ .fetch_optional(&state.db)
312312+ .await
313313+ {
314314+ Ok(Some(row)) => row,
315315+ Ok(None) => return ApiError::AccountNotFound.into_response(),
316316+ Err(e) => {
317317+ tracing::error!("DB error getting user: {:?}", e);
318318+ return ApiError::InternalError.into_response();
319319+ }
320320+ };
321321+322322+ if user.migrated_to_pds.is_none() {
323323+ return (
324324+ StatusCode::BAD_REQUEST,
325325+ Json(json!({
326326+ "error": "InvalidRequest",
327327+ "message": "DID document updates are only available for migrated accounts. Use the migration flow to migrate first."
328328+ })),
329329+ )
330330+ .into_response();
331331+ }
332332+333333+ if let Some(ref methods) = input.verification_methods {
334334+ if methods.is_empty() {
335335+ return ApiError::InvalidRequest(
336336+ "verification_methods cannot be empty".into(),
337337+ )
338338+ .into_response();
339339+ }
340340+ for method in methods {
341341+ if method.id.is_empty() {
342342+ return ApiError::InvalidRequest("verification method id is required".into())
343343+ .into_response();
344344+ }
345345+ if method.method_type != "Multikey" {
346346+ return ApiError::InvalidRequest(
347347+ "verification method type must be 'Multikey'".into(),
348348+ )
349349+ .into_response();
350350+ }
351351+ if !method.public_key_multibase.starts_with('z') {
352352+ return ApiError::InvalidRequest(
353353+ "publicKeyMultibase must start with 'z' (base58btc)".into(),
354354+ )
355355+ .into_response();
356356+ }
357357+ if method.public_key_multibase.len() < 40 {
358358+ return ApiError::InvalidRequest(
359359+ "publicKeyMultibase appears too short for a valid key".into(),
360360+ )
361361+ .into_response();
362362+ }
363363+ }
364364+ }
365365+366366+ if let Some(ref handles) = input.also_known_as {
367367+ for handle in handles {
368368+ if !handle.starts_with("at://") {
369369+ return ApiError::InvalidRequest(
370370+ "alsoKnownAs entries must be at:// URIs".into(),
371371+ )
372372+ .into_response();
373373+ }
374374+ }
375375+ }
376376+377377+ if let Some(ref endpoint) = input.service_endpoint {
378378+ let endpoint = endpoint.trim();
379379+ if !endpoint.starts_with("https://") {
380380+ return ApiError::InvalidRequest(
381381+ "serviceEndpoint must start with https://".into(),
382382+ )
383383+ .into_response();
384384+ }
385385+ }
386386+387387+ let verification_methods_json = input
388388+ .verification_methods
389389+ .as_ref()
390390+ .map(|v| serde_json::to_value(v).unwrap_or_default());
391391+392392+ let also_known_as: Option<Vec<String>> = input.also_known_as.clone();
393393+394394+ let now = Utc::now();
395395+396396+ let upsert_result = sqlx::query!(
397397+ r#"
398398+ INSERT INTO did_web_overrides (user_id, verification_methods, also_known_as, updated_at)
399399+ VALUES ($1, COALESCE($2, '[]'::jsonb), COALESCE($3, '{}'::text[]), $4)
400400+ ON CONFLICT (user_id) DO UPDATE SET
401401+ verification_methods = CASE WHEN $2 IS NOT NULL THEN $2 ELSE did_web_overrides.verification_methods END,
402402+ also_known_as = CASE WHEN $3 IS NOT NULL THEN $3 ELSE did_web_overrides.also_known_as END,
403403+ updated_at = $4
404404+ "#,
405405+ user.id,
406406+ verification_methods_json,
407407+ also_known_as.as_deref(),
408408+ now
409409+ )
410410+ .execute(&state.db)
411411+ .await;
412412+413413+ if let Err(e) = upsert_result {
414414+ tracing::error!("DB error upserting did_web_overrides: {:?}", e);
415415+ return ApiError::InternalError.into_response();
416416+ }
417417+418418+ if let Some(ref endpoint) = input.service_endpoint {
419419+ let endpoint_clean = endpoint.trim().trim_end_matches('/');
420420+ let update_result = sqlx::query!(
421421+ "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
422422+ endpoint_clean,
423423+ now,
424424+ auth_user.did
425425+ )
426426+ .execute(&state.db)
427427+ .await;
428428+429429+ if let Err(e) = update_result {
430430+ tracing::error!("DB error updating service endpoint: {:?}", e);
431431+ return ApiError::InternalError.into_response();
432432+ }
433433+ }
434434+435435+ let did_doc = build_did_document(&state.db, &auth_user.did).await;
436436+437437+ tracing::info!("Updated DID document for {}", auth_user.did);
438438+439439+ (
440440+ StatusCode::OK,
441441+ Json(UpdateDidDocumentOutput {
442442+ success: true,
443443+ did_document: did_doc,
444444+ }),
445445+ )
446446+ .into_response()
447447+}
448448+449449+pub async fn get_did_document(
450450+ State(state): State<AppState>,
451451+ headers: axum::http::HeaderMap,
452452+) -> Response {
453453+ let extracted = match crate::auth::extract_auth_token_from_header(
454454+ headers.get("Authorization").and_then(|h| h.to_str().ok()),
455455+ ) {
456456+ Some(t) => t,
457457+ None => return ApiError::AuthenticationRequired.into_response(),
458458+ };
459459+ let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
460460+ let http_uri = format!(
461461+ "https://{}/xrpc/com.tranquil.account.getDidDocument",
462462+ std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
463463+ );
464464+ let auth_user = match crate::auth::validate_token_with_dpop(
465465+ &state.db,
466466+ &extracted.token,
467467+ extracted.is_dpop,
468468+ dpop_proof,
469469+ "GET",
470470+ &http_uri,
471471+ true,
472472+ )
473473+ .await
474474+ {
475475+ Ok(user) => user,
476476+ Err(e) => return ApiError::from(e).into_response(),
477477+ };
478478+479479+ if !auth_user.did.starts_with("did:web:") {
480480+ return (
481481+ StatusCode::BAD_REQUEST,
482482+ Json(json!({
483483+ "error": "InvalidRequest",
484484+ "message": "This endpoint is only available for did:web accounts"
485485+ })),
486486+ )
487487+ .into_response();
488488+ }
489489+490490+ let did_doc = build_did_document(&state.db, &auth_user.did).await;
491491+492492+ (StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response()
493493+}
494494+495495+async fn build_did_document(db: &sqlx::PgPool, did: &str) -> serde_json::Value {
496496+ let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
497497+498498+ let user = match sqlx::query!(
499499+ "SELECT id, handle, migrated_to_pds FROM users WHERE did = $1",
500500+ did
501501+ )
502502+ .fetch_optional(db)
503503+ .await
504504+ {
505505+ Ok(Some(row)) => row,
506506+ _ => {
507507+ return json!({
508508+ "error": "User not found"
509509+ });
510510+ }
511511+ };
512512+513513+ let overrides = sqlx::query!(
514514+ "SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
515515+ user.id
516516+ )
517517+ .fetch_optional(db)
518518+ .await
519519+ .ok()
520520+ .flatten();
521521+522522+ let service_endpoint = user
523523+ .migrated_to_pds
524524+ .unwrap_or_else(|| format!("https://{}", hostname));
525525+526526+ if let Some(ref ovr) = overrides {
527527+ if let Ok(parsed) = serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone()) {
528528+ if !parsed.is_empty() {
529529+ let also_known_as = if !ovr.also_known_as.is_empty() {
530530+ ovr.also_known_as.clone()
531531+ } else {
532532+ vec![format!("at://{}", user.handle)]
533533+ };
534534+ return json!({
535535+ "@context": [
536536+ "https://www.w3.org/ns/did/v1",
537537+ "https://w3id.org/security/multikey/v1",
538538+ "https://w3id.org/security/suites/secp256k1-2019/v1"
539539+ ],
540540+ "id": did,
541541+ "alsoKnownAs": also_known_as,
542542+ "verificationMethod": parsed.iter().map(|m| json!({
543543+ "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
544544+ "type": m.method_type,
545545+ "controller": did,
546546+ "publicKeyMultibase": m.public_key_multibase
547547+ })).collect::<Vec<_>>(),
548548+ "service": [{
549549+ "id": "#atproto_pds",
550550+ "type": "AtprotoPersonalDataServer",
551551+ "serviceEndpoint": service_endpoint
552552+ }]
553553+ });
554554+ }
555555+ }
556556+ }
557557+558558+ let key_row = sqlx::query!(
559559+ "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
560560+ user.id
561561+ )
562562+ .fetch_optional(db)
563563+ .await;
564564+565565+ let public_key_multibase = match key_row {
566566+ Ok(Some(row)) => {
567567+ match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
568568+ Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes)
569569+ .unwrap_or_else(|_| "error".to_string()),
570570+ Err(_) => "error".to_string(),
571571+ }
572572+ }
573573+ _ => "error".to_string(),
574574+ };
575575+576576+ let also_known_as = if let Some(ref ovr) = overrides {
577577+ if !ovr.also_known_as.is_empty() {
578578+ ovr.also_known_as.clone()
579579+ } else {
580580+ vec![format!("at://{}", user.handle)]
581581+ }
582582+ } else {
583583+ vec![format!("at://{}", user.handle)]
584584+ };
585585+586586+ json!({
587587+ "@context": [
588588+ "https://www.w3.org/ns/did/v1",
589589+ "https://w3id.org/security/multikey/v1",
590590+ "https://w3id.org/security/suites/secp256k1-2019/v1"
591591+ ],
592592+ "id": did,
593593+ "alsoKnownAs": also_known_as,
594594+ "verificationMethod": [{
595595+ "id": format!("{}#atproto", did),
596596+ "type": "Multikey",
597597+ "controller": did,
598598+ "publicKeyMultibase": public_key_multibase
599599+ }],
600600+ "service": [{
601601+ "id": "#atproto_pds",
602602+ "type": "AtprotoPersonalDataServer",
603603+ "serviceEndpoint": service_endpoint
604604+ }]
605605+ })
606606+}
+2-1
src/api/server/mod.rs
···2727pub use logo::get_logo;
2828pub use meta::{describe_server, health, robots_txt};
2929pub use migration::{
3030- clear_migration_forwarding, get_migration_status, update_migration_forwarding,
3030+ clear_migration_forwarding, get_did_document, get_migration_status, update_did_document,
3131+ update_migration_forwarding,
3132};
3233pub use passkey_account::{
3334 complete_passkey_setup, create_passkey_account, recover_passkey_account,
+11-2
src/api/server/session.rs
···104104 r#"SELECT
105105 u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,
106106 u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,
107107- u.allow_legacy_login,
107107+ u.allow_legacy_login, u.migrated_to_pds,
108108 u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel",
109109 k.key_bytes, k.encryption_version,
110110 (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled
···276276 }
277277 }
278278 let handle = full_handle(&row.handle, &pds_hostname);
279279+ let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
279280 let is_active = row.deactivated_at.is_none() && !is_takendown;
280281 let status = if is_takendown {
281282 Some("takendown".to_string())
283283+ } else if is_migrated {
284284+ Some("migrated".to_string())
282285 } else if row.deactivated_at.is_some() {
283286 Some("deactivated".to_string())
284287 } else {
···312315 r#"SELECT
313316 handle, email, email_verified, is_admin, deactivated_at, takedown_ref, preferred_locale,
314317 preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel",
315315- discord_verified, telegram_verified, signal_verified
318318+ discord_verified, telegram_verified, signal_verified, migrated_to_pds, migrated_at
316319 FROM users WHERE did = $1"#,
317320 auth_user.did
318321 )
···331334 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
332335 let handle = full_handle(&row.handle, &pds_hostname);
333336 let is_takendown = row.takedown_ref.is_some();
337337+ let is_migrated =
338338+ row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
334339 let is_active = row.deactivated_at.is_none() && !is_takendown;
335340 let email_value = if can_read_email {
336341 row.email.clone()
···353358 }
354359 if is_takendown {
355360 response["status"] = json!("takendown");
361361+ } else if is_migrated {
362362+ response["status"] = json!("migrated");
363363+ response["migratedToPds"] = json!(row.migrated_to_pds);
364364+ response["migratedAt"] = json!(row.migrated_at);
356365 } else if row.deactivated_at.is_some() {
357366 response["status"] = json!("deactivated");
358367 }
+4-3
src/auth/extractor.rs
···1111 validate_bearer_token_cached_allow_deactivated, validate_token_with_dpop,
1212};
1313use crate::state::AppState;
1414+use crate::util::build_full_url;
14151516pub struct BearerAuth(pub AuthenticatedUser);
1617···164165 if extracted.is_dpop {
165166 let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
166167 let method = parts.method.as_str();
167167- let uri = parts.uri.to_string();
168168+ let uri = build_full_url(&parts.uri.to_string());
168169169170 match validate_token_with_dpop(
170171 &state.db,
···217218 if extracted.is_dpop {
218219 let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
219220 let method = parts.method.as_str();
220220- let uri = parts.uri.to_string();
221221+ let uri = build_full_url(&parts.uri.to_string());
221222222223 match validate_token_with_dpop(
223224 &state.db,
···274275 let user = if extracted.is_dpop {
275276 let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
276277 let method = parts.method.as_str();
277277- let uri = parts.uri.to_string();
278278+ let uri = build_full_url(&parts.uri.to_string());
278279279280 match validate_token_with_dpop(
280281 &state.db,