this repo has no description
1use crate::api::ApiError;
2use crate::state::AppState;
3use axum::{
4 Json,
5 extract::State,
6 http::StatusCode,
7 response::{IntoResponse, Response},
8};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_json::json;
12
13#[derive(Serialize)]
14#[serde(rename_all = "camelCase")]
15pub struct GetMigrationStatusOutput {
16 pub did: String,
17 pub did_type: String,
18 pub migrated: bool,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub migrated_to_pds: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub migrated_at: Option<DateTime<Utc>>,
23}
24
25pub async fn get_migration_status(
26 State(state): State<AppState>,
27 headers: axum::http::HeaderMap,
28) -> Response {
29 let extracted = match crate::auth::extract_auth_token_from_header(
30 headers.get("Authorization").and_then(|h| h.to_str().ok()),
31 ) {
32 Some(t) => t,
33 None => return ApiError::AuthenticationRequired.into_response(),
34 };
35 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
36 let http_uri = format!(
37 "https://{}/xrpc/com.tranquil.account.getMigrationStatus",
38 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
39 );
40 let auth_user = match crate::auth::validate_token_with_dpop(
41 &state.db,
42 &extracted.token,
43 extracted.is_dpop,
44 dpop_proof,
45 "GET",
46 &http_uri,
47 true,
48 )
49 .await
50 {
51 Ok(user) => user,
52 Err(e) => return ApiError::from(e).into_response(),
53 };
54 let user = match sqlx::query!(
55 "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1",
56 auth_user.did
57 )
58 .fetch_optional(&state.db)
59 .await
60 {
61 Ok(Some(row)) => row,
62 Ok(None) => return ApiError::AccountNotFound.into_response(),
63 Err(e) => {
64 tracing::error!("DB error getting migration status: {:?}", e);
65 return ApiError::InternalError.into_response();
66 }
67 };
68 let did_type = if user.did.starts_with("did:plc:") {
69 "plc"
70 } else if user.did.starts_with("did:web:") {
71 "web"
72 } else {
73 "unknown"
74 };
75 let migrated = user.migrated_to_pds.is_some();
76 (
77 StatusCode::OK,
78 Json(GetMigrationStatusOutput {
79 did: user.did,
80 did_type: did_type.to_string(),
81 migrated,
82 migrated_to_pds: user.migrated_to_pds,
83 migrated_at: user.migrated_at,
84 }),
85 )
86 .into_response()
87}
88
89#[derive(Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct UpdateMigrationForwardingInput {
92 pub pds_url: String,
93}
94
95#[derive(Serialize)]
96#[serde(rename_all = "camelCase")]
97pub struct UpdateMigrationForwardingOutput {
98 pub success: bool,
99 pub migrated_to_pds: String,
100 pub migrated_at: DateTime<Utc>,
101}
102
103pub async fn update_migration_forwarding(
104 State(state): State<AppState>,
105 headers: axum::http::HeaderMap,
106 Json(input): Json<UpdateMigrationForwardingInput>,
107) -> Response {
108 let extracted = match crate::auth::extract_auth_token_from_header(
109 headers.get("Authorization").and_then(|h| h.to_str().ok()),
110 ) {
111 Some(t) => t,
112 None => return ApiError::AuthenticationRequired.into_response(),
113 };
114 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
115 let http_uri = format!(
116 "https://{}/xrpc/com.tranquil.account.updateMigrationForwarding",
117 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
118 );
119 let auth_user = match crate::auth::validate_token_with_dpop(
120 &state.db,
121 &extracted.token,
122 extracted.is_dpop,
123 dpop_proof,
124 "POST",
125 &http_uri,
126 true,
127 )
128 .await
129 {
130 Ok(user) => user,
131 Err(e) => return ApiError::from(e).into_response(),
132 };
133 if !auth_user.did.starts_with("did:web:") {
134 return (
135 StatusCode::BAD_REQUEST,
136 Json(json!({
137 "error": "InvalidRequest",
138 "message": "Migration forwarding is only available for did:web accounts. did:plc accounts use PLC directory for identity updates."
139 })),
140 )
141 .into_response();
142 }
143 let pds_url = input.pds_url.trim();
144 if pds_url.is_empty() {
145 return ApiError::InvalidRequest("pds_url is required".into()).into_response();
146 }
147 if !pds_url.starts_with("https://") {
148 return ApiError::InvalidRequest("pds_url must start with https://".into()).into_response();
149 }
150 let pds_url_clean = pds_url.trim_end_matches('/');
151 let now = Utc::now();
152 let result = sqlx::query!(
153 "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
154 pds_url_clean,
155 now,
156 auth_user.did
157 )
158 .execute(&state.db)
159 .await;
160 match result {
161 Ok(_) => {
162 tracing::info!(
163 "Updated migration forwarding for {} to {}",
164 auth_user.did,
165 pds_url_clean
166 );
167 (
168 StatusCode::OK,
169 Json(UpdateMigrationForwardingOutput {
170 success: true,
171 migrated_to_pds: pds_url_clean.to_string(),
172 migrated_at: now,
173 }),
174 )
175 .into_response()
176 }
177 Err(e) => {
178 tracing::error!("DB error updating migration forwarding: {:?}", e);
179 ApiError::InternalError.into_response()
180 }
181 }
182}
183
184pub async fn clear_migration_forwarding(
185 State(state): State<AppState>,
186 headers: axum::http::HeaderMap,
187) -> Response {
188 let extracted = match crate::auth::extract_auth_token_from_header(
189 headers.get("Authorization").and_then(|h| h.to_str().ok()),
190 ) {
191 Some(t) => t,
192 None => return ApiError::AuthenticationRequired.into_response(),
193 };
194 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
195 let http_uri = format!(
196 "https://{}/xrpc/com.tranquil.account.clearMigrationForwarding",
197 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
198 );
199 let auth_user = match crate::auth::validate_token_with_dpop(
200 &state.db,
201 &extracted.token,
202 extracted.is_dpop,
203 dpop_proof,
204 "POST",
205 &http_uri,
206 true,
207 )
208 .await
209 {
210 Ok(user) => user,
211 Err(e) => return ApiError::from(e).into_response(),
212 };
213 if !auth_user.did.starts_with("did:web:") {
214 return (
215 StatusCode::BAD_REQUEST,
216 Json(json!({
217 "error": "InvalidRequest",
218 "message": "Migration forwarding is only available for did:web accounts"
219 })),
220 )
221 .into_response();
222 }
223 let result = sqlx::query!(
224 "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1",
225 auth_user.did
226 )
227 .execute(&state.db)
228 .await;
229 match result {
230 Ok(_) => {
231 tracing::info!("Cleared migration forwarding for {}", auth_user.did);
232 (StatusCode::OK, Json(json!({ "success": true }))).into_response()
233 }
234 Err(e) => {
235 tracing::error!("DB error clearing migration forwarding: {:?}", e);
236 ApiError::InternalError.into_response()
237 }
238 }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase")]
243pub struct VerificationMethod {
244 pub id: String,
245 #[serde(rename = "type")]
246 pub method_type: String,
247 pub public_key_multibase: String,
248}
249
250#[derive(Deserialize)]
251#[serde(rename_all = "camelCase")]
252pub struct UpdateDidDocumentInput {
253 pub verification_methods: Option<Vec<VerificationMethod>>,
254 pub also_known_as: Option<Vec<String>>,
255 pub service_endpoint: Option<String>,
256}
257
258#[derive(Serialize)]
259#[serde(rename_all = "camelCase")]
260pub struct UpdateDidDocumentOutput {
261 pub success: bool,
262 pub did_document: serde_json::Value,
263}
264
265pub async fn update_did_document(
266 State(state): State<AppState>,
267 headers: axum::http::HeaderMap,
268 Json(input): Json<UpdateDidDocumentInput>,
269) -> Response {
270 let extracted = match crate::auth::extract_auth_token_from_header(
271 headers.get("Authorization").and_then(|h| h.to_str().ok()),
272 ) {
273 Some(t) => t,
274 None => return ApiError::AuthenticationRequired.into_response(),
275 };
276 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
277 let http_uri = format!(
278 "https://{}/xrpc/com.tranquil.account.updateDidDocument",
279 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
280 );
281 let auth_user = match crate::auth::validate_token_with_dpop(
282 &state.db,
283 &extracted.token,
284 extracted.is_dpop,
285 dpop_proof,
286 "POST",
287 &http_uri,
288 true,
289 )
290 .await
291 {
292 Ok(user) => user,
293 Err(e) => return ApiError::from(e).into_response(),
294 };
295
296 if !auth_user.did.starts_with("did:web:") {
297 return (
298 StatusCode::BAD_REQUEST,
299 Json(json!({
300 "error": "InvalidRequest",
301 "message": "DID document updates are only available for did:web accounts"
302 })),
303 )
304 .into_response();
305 }
306
307 let user = match sqlx::query!(
308 "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1",
309 auth_user.did
310 )
311 .fetch_optional(&state.db)
312 .await
313 {
314 Ok(Some(row)) => row,
315 Ok(None) => return ApiError::AccountNotFound.into_response(),
316 Err(e) => {
317 tracing::error!("DB error getting user: {:?}", e);
318 return ApiError::InternalError.into_response();
319 }
320 };
321
322 if user.migrated_to_pds.is_none() {
323 return (
324 StatusCode::BAD_REQUEST,
325 Json(json!({
326 "error": "InvalidRequest",
327 "message": "DID document updates are only available for migrated accounts. Use the migration flow to migrate first."
328 })),
329 )
330 .into_response();
331 }
332
333 if let Some(ref methods) = input.verification_methods {
334 if methods.is_empty() {
335 return ApiError::InvalidRequest("verification_methods cannot be empty".into())
336 .into_response();
337 }
338 for method in methods {
339 if method.id.is_empty() {
340 return ApiError::InvalidRequest("verification method id is required".into())
341 .into_response();
342 }
343 if method.method_type != "Multikey" {
344 return ApiError::InvalidRequest(
345 "verification method type must be 'Multikey'".into(),
346 )
347 .into_response();
348 }
349 if !method.public_key_multibase.starts_with('z') {
350 return ApiError::InvalidRequest(
351 "publicKeyMultibase must start with 'z' (base58btc)".into(),
352 )
353 .into_response();
354 }
355 if method.public_key_multibase.len() < 40 {
356 return ApiError::InvalidRequest(
357 "publicKeyMultibase appears too short for a valid key".into(),
358 )
359 .into_response();
360 }
361 }
362 }
363
364 if let Some(ref handles) = input.also_known_as {
365 for handle in handles {
366 if !handle.starts_with("at://") {
367 return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into())
368 .into_response();
369 }
370 }
371 }
372
373 if let Some(ref endpoint) = input.service_endpoint {
374 let endpoint = endpoint.trim();
375 if !endpoint.starts_with("https://") {
376 return ApiError::InvalidRequest("serviceEndpoint must start with https://".into())
377 .into_response();
378 }
379 }
380
381 let verification_methods_json = input
382 .verification_methods
383 .as_ref()
384 .map(|v| serde_json::to_value(v).unwrap_or_default());
385
386 let also_known_as: Option<Vec<String>> = input.also_known_as.clone();
387
388 let now = Utc::now();
389
390 let upsert_result = sqlx::query!(
391 r#"
392 INSERT INTO did_web_overrides (user_id, verification_methods, also_known_as, updated_at)
393 VALUES ($1, COALESCE($2, '[]'::jsonb), COALESCE($3, '{}'::text[]), $4)
394 ON CONFLICT (user_id) DO UPDATE SET
395 verification_methods = CASE WHEN $2 IS NOT NULL THEN $2 ELSE did_web_overrides.verification_methods END,
396 also_known_as = CASE WHEN $3 IS NOT NULL THEN $3 ELSE did_web_overrides.also_known_as END,
397 updated_at = $4
398 "#,
399 user.id,
400 verification_methods_json,
401 also_known_as.as_deref(),
402 now
403 )
404 .execute(&state.db)
405 .await;
406
407 if let Err(e) = upsert_result {
408 tracing::error!("DB error upserting did_web_overrides: {:?}", e);
409 return ApiError::InternalError.into_response();
410 }
411
412 if let Some(ref endpoint) = input.service_endpoint {
413 let endpoint_clean = endpoint.trim().trim_end_matches('/');
414 let update_result = sqlx::query!(
415 "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
416 endpoint_clean,
417 now,
418 auth_user.did
419 )
420 .execute(&state.db)
421 .await;
422
423 if let Err(e) = update_result {
424 tracing::error!("DB error updating service endpoint: {:?}", e);
425 return ApiError::InternalError.into_response();
426 }
427 }
428
429 let did_doc = build_did_document(&state.db, &auth_user.did).await;
430
431 tracing::info!("Updated DID document for {}", auth_user.did);
432
433 (
434 StatusCode::OK,
435 Json(UpdateDidDocumentOutput {
436 success: true,
437 did_document: did_doc,
438 }),
439 )
440 .into_response()
441}
442
443pub async fn get_did_document(
444 State(state): State<AppState>,
445 headers: axum::http::HeaderMap,
446) -> Response {
447 let extracted = match crate::auth::extract_auth_token_from_header(
448 headers.get("Authorization").and_then(|h| h.to_str().ok()),
449 ) {
450 Some(t) => t,
451 None => return ApiError::AuthenticationRequired.into_response(),
452 };
453 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
454 let http_uri = format!(
455 "https://{}/xrpc/com.tranquil.account.getDidDocument",
456 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
457 );
458 let auth_user = match crate::auth::validate_token_with_dpop(
459 &state.db,
460 &extracted.token,
461 extracted.is_dpop,
462 dpop_proof,
463 "GET",
464 &http_uri,
465 true,
466 )
467 .await
468 {
469 Ok(user) => user,
470 Err(e) => return ApiError::from(e).into_response(),
471 };
472
473 if !auth_user.did.starts_with("did:web:") {
474 return (
475 StatusCode::BAD_REQUEST,
476 Json(json!({
477 "error": "InvalidRequest",
478 "message": "This endpoint is only available for did:web accounts"
479 })),
480 )
481 .into_response();
482 }
483
484 let did_doc = build_did_document(&state.db, &auth_user.did).await;
485
486 (StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response()
487}
488
489async fn build_did_document(db: &sqlx::PgPool, did: &str) -> serde_json::Value {
490 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
491
492 let user = match sqlx::query!(
493 "SELECT id, handle, migrated_to_pds FROM users WHERE did = $1",
494 did
495 )
496 .fetch_optional(db)
497 .await
498 {
499 Ok(Some(row)) => row,
500 _ => {
501 return json!({
502 "error": "User not found"
503 });
504 }
505 };
506
507 let overrides = sqlx::query!(
508 "SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
509 user.id
510 )
511 .fetch_optional(db)
512 .await
513 .ok()
514 .flatten();
515
516 let service_endpoint = user
517 .migrated_to_pds
518 .unwrap_or_else(|| format!("https://{}", hostname));
519
520 if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| {
521 serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone())
522 .ok()
523 .filter(|p| !p.is_empty())
524 .map(|p| (ovr, p))
525 }) {
526 let also_known_as = if !ovr.also_known_as.is_empty() {
527 ovr.also_known_as.clone()
528 } else {
529 vec![format!("at://{}", user.handle)]
530 };
531 return json!({
532 "@context": [
533 "https://www.w3.org/ns/did/v1",
534 "https://w3id.org/security/multikey/v1",
535 "https://w3id.org/security/suites/secp256k1-2019/v1"
536 ],
537 "id": did,
538 "alsoKnownAs": also_known_as,
539 "verificationMethod": parsed.iter().map(|m| json!({
540 "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
541 "type": m.method_type,
542 "controller": did,
543 "publicKeyMultibase": m.public_key_multibase
544 })).collect::<Vec<_>>(),
545 "service": [{
546 "id": "#atproto_pds",
547 "type": "AtprotoPersonalDataServer",
548 "serviceEndpoint": service_endpoint
549 }]
550 });
551 }
552
553 let key_row = sqlx::query!(
554 "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
555 user.id
556 )
557 .fetch_optional(db)
558 .await;
559
560 let public_key_multibase = match key_row {
561 Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
562 Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes)
563 .unwrap_or_else(|_| "error".to_string()),
564 Err(_) => "error".to_string(),
565 },
566 _ => "error".to_string(),
567 };
568
569 let also_known_as = if let Some(ref ovr) = overrides {
570 if !ovr.also_known_as.is_empty() {
571 ovr.also_known_as.clone()
572 } else {
573 vec![format!("at://{}", user.handle)]
574 }
575 } else {
576 vec![format!("at://{}", user.handle)]
577 };
578
579 json!({
580 "@context": [
581 "https://www.w3.org/ns/did/v1",
582 "https://w3id.org/security/multikey/v1",
583 "https://w3id.org/security/suites/secp256k1-2019/v1"
584 ],
585 "id": did,
586 "alsoKnownAs": also_known_as,
587 "verificationMethod": [{
588 "id": format!("{}#atproto", did),
589 "type": "Multikey",
590 "controller": did,
591 "publicKeyMultibase": public_key_multibase
592 }],
593 "service": [{
594 "id": "#atproto_pds",
595 "type": "AtprotoPersonalDataServer",
596 "serviceEndpoint": service_endpoint
597 }]
598 })
599}