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(
336 "verification_methods cannot be empty".into(),
337 )
338 .into_response();
339 }
340 for method in methods {
341 if method.id.is_empty() {
342 return ApiError::InvalidRequest("verification method id is required".into())
343 .into_response();
344 }
345 if method.method_type != "Multikey" {
346 return ApiError::InvalidRequest(
347 "verification method type must be 'Multikey'".into(),
348 )
349 .into_response();
350 }
351 if !method.public_key_multibase.starts_with('z') {
352 return ApiError::InvalidRequest(
353 "publicKeyMultibase must start with 'z' (base58btc)".into(),
354 )
355 .into_response();
356 }
357 if method.public_key_multibase.len() < 40 {
358 return ApiError::InvalidRequest(
359 "publicKeyMultibase appears too short for a valid key".into(),
360 )
361 .into_response();
362 }
363 }
364 }
365
366 if let Some(ref handles) = input.also_known_as {
367 for handle in handles {
368 if !handle.starts_with("at://") {
369 return ApiError::InvalidRequest(
370 "alsoKnownAs entries must be at:// URIs".into(),
371 )
372 .into_response();
373 }
374 }
375 }
376
377 if let Some(ref endpoint) = input.service_endpoint {
378 let endpoint = endpoint.trim();
379 if !endpoint.starts_with("https://") {
380 return ApiError::InvalidRequest(
381 "serviceEndpoint must start with https://".into(),
382 )
383 .into_response();
384 }
385 }
386
387 let verification_methods_json = input
388 .verification_methods
389 .as_ref()
390 .map(|v| serde_json::to_value(v).unwrap_or_default());
391
392 let also_known_as: Option<Vec<String>> = input.also_known_as.clone();
393
394 let now = Utc::now();
395
396 let upsert_result = sqlx::query!(
397 r#"
398 INSERT INTO did_web_overrides (user_id, verification_methods, also_known_as, updated_at)
399 VALUES ($1, COALESCE($2, '[]'::jsonb), COALESCE($3, '{}'::text[]), $4)
400 ON CONFLICT (user_id) DO UPDATE SET
401 verification_methods = CASE WHEN $2 IS NOT NULL THEN $2 ELSE did_web_overrides.verification_methods END,
402 also_known_as = CASE WHEN $3 IS NOT NULL THEN $3 ELSE did_web_overrides.also_known_as END,
403 updated_at = $4
404 "#,
405 user.id,
406 verification_methods_json,
407 also_known_as.as_deref(),
408 now
409 )
410 .execute(&state.db)
411 .await;
412
413 if let Err(e) = upsert_result {
414 tracing::error!("DB error upserting did_web_overrides: {:?}", e);
415 return ApiError::InternalError.into_response();
416 }
417
418 if let Some(ref endpoint) = input.service_endpoint {
419 let endpoint_clean = endpoint.trim().trim_end_matches('/');
420 let update_result = sqlx::query!(
421 "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
422 endpoint_clean,
423 now,
424 auth_user.did
425 )
426 .execute(&state.db)
427 .await;
428
429 if let Err(e) = update_result {
430 tracing::error!("DB error updating service endpoint: {:?}", e);
431 return ApiError::InternalError.into_response();
432 }
433 }
434
435 let did_doc = build_did_document(&state.db, &auth_user.did).await;
436
437 tracing::info!("Updated DID document for {}", auth_user.did);
438
439 (
440 StatusCode::OK,
441 Json(UpdateDidDocumentOutput {
442 success: true,
443 did_document: did_doc,
444 }),
445 )
446 .into_response()
447}
448
449pub async fn get_did_document(
450 State(state): State<AppState>,
451 headers: axum::http::HeaderMap,
452) -> Response {
453 let extracted = match crate::auth::extract_auth_token_from_header(
454 headers.get("Authorization").and_then(|h| h.to_str().ok()),
455 ) {
456 Some(t) => t,
457 None => return ApiError::AuthenticationRequired.into_response(),
458 };
459 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
460 let http_uri = format!(
461 "https://{}/xrpc/com.tranquil.account.getDidDocument",
462 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
463 );
464 let auth_user = match crate::auth::validate_token_with_dpop(
465 &state.db,
466 &extracted.token,
467 extracted.is_dpop,
468 dpop_proof,
469 "GET",
470 &http_uri,
471 true,
472 )
473 .await
474 {
475 Ok(user) => user,
476 Err(e) => return ApiError::from(e).into_response(),
477 };
478
479 if !auth_user.did.starts_with("did:web:") {
480 return (
481 StatusCode::BAD_REQUEST,
482 Json(json!({
483 "error": "InvalidRequest",
484 "message": "This endpoint is only available for did:web accounts"
485 })),
486 )
487 .into_response();
488 }
489
490 let did_doc = build_did_document(&state.db, &auth_user.did).await;
491
492 (StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response()
493}
494
495async fn build_did_document(db: &sqlx::PgPool, did: &str) -> serde_json::Value {
496 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
497
498 let user = match sqlx::query!(
499 "SELECT id, handle, migrated_to_pds FROM users WHERE did = $1",
500 did
501 )
502 .fetch_optional(db)
503 .await
504 {
505 Ok(Some(row)) => row,
506 _ => {
507 return json!({
508 "error": "User not found"
509 });
510 }
511 };
512
513 let overrides = sqlx::query!(
514 "SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
515 user.id
516 )
517 .fetch_optional(db)
518 .await
519 .ok()
520 .flatten();
521
522 let service_endpoint = user
523 .migrated_to_pds
524 .unwrap_or_else(|| format!("https://{}", hostname));
525
526 if let Some(ref ovr) = overrides {
527 if let Ok(parsed) = serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone()) {
528 if !parsed.is_empty() {
529 let also_known_as = if !ovr.also_known_as.is_empty() {
530 ovr.also_known_as.clone()
531 } else {
532 vec![format!("at://{}", user.handle)]
533 };
534 return json!({
535 "@context": [
536 "https://www.w3.org/ns/did/v1",
537 "https://w3id.org/security/multikey/v1",
538 "https://w3id.org/security/suites/secp256k1-2019/v1"
539 ],
540 "id": did,
541 "alsoKnownAs": also_known_as,
542 "verificationMethod": parsed.iter().map(|m| json!({
543 "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
544 "type": m.method_type,
545 "controller": did,
546 "publicKeyMultibase": m.public_key_multibase
547 })).collect::<Vec<_>>(),
548 "service": [{
549 "id": "#atproto_pds",
550 "type": "AtprotoPersonalDataServer",
551 "serviceEndpoint": service_endpoint
552 }]
553 });
554 }
555 }
556 }
557
558 let key_row = sqlx::query!(
559 "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
560 user.id
561 )
562 .fetch_optional(db)
563 .await;
564
565 let public_key_multibase = match key_row {
566 Ok(Some(row)) => {
567 match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
568 Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes)
569 .unwrap_or_else(|_| "error".to_string()),
570 Err(_) => "error".to_string(),
571 }
572 }
573 _ => "error".to_string(),
574 };
575
576 let also_known_as = if let Some(ref ovr) = overrides {
577 if !ovr.also_known_as.is_empty() {
578 ovr.also_known_as.clone()
579 } else {
580 vec![format!("at://{}", user.handle)]
581 }
582 } else {
583 vec![format!("at://{}", user.handle)]
584 };
585
586 json!({
587 "@context": [
588 "https://www.w3.org/ns/did/v1",
589 "https://w3id.org/security/multikey/v1",
590 "https://w3id.org/security/suites/secp256k1-2019/v1"
591 ],
592 "id": did,
593 "alsoKnownAs": also_known_as,
594 "verificationMethod": [{
595 "id": format!("{}#atproto", did),
596 "type": "Multikey",
597 "controller": did,
598 "publicKeyMultibase": public_key_multibase
599 }],
600 "service": [{
601 "id": "#atproto_pds",
602 "type": "AtprotoPersonalDataServer",
603 "serviceEndpoint": service_endpoint
604 }]
605 })
606}