···4141 pub password: String,
4242 pub invite_code: Option<String>,
4343 pub did: Option<String>,
4444+ pub did_type: Option<String>,
4445 pub signing_key: Option<String>,
4546 pub verification_channel: Option<String>,
4647 pub discord_id: Option<String>,
···268269 .into_response();
269270 }
270271 };
271271- let did = if let Some(d) = &input.did {
272272- if d.trim().is_empty() {
273273- let rotation_key = std::env::var("PLC_ROTATION_KEY")
274274- .unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
275275- let genesis_result = match create_genesis_operation(
276276- &signing_key,
277277- &rotation_key,
278278- &full_handle,
279279- &pds_endpoint,
280280- ) {
281281- Ok(r) => r,
282282- Err(e) => {
283283- error!("Error creating PLC genesis operation: {:?}", e);
272272+ let did_type = input.did_type.as_deref().unwrap_or("plc");
273273+ let did = match did_type {
274274+ "web" => {
275275+ let subdomain_host = format!("{}.{}", input.handle, hostname);
276276+ let encoded_subdomain = subdomain_host.replace(':', "%3A");
277277+ let self_hosted_did = format!("did:web:{}", encoded_subdomain);
278278+ info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
279279+ self_hosted_did
280280+ }
281281+ "web-external" => {
282282+ let d = match &input.did {
283283+ Some(d) if !d.trim().is_empty() => d,
284284+ _ => {
284285 return (
285285- StatusCode::INTERNAL_SERVER_ERROR,
286286- Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
286286+ StatusCode::BAD_REQUEST,
287287+ Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})),
287288 )
288289 .into_response();
289290 }
290291 };
291291- let plc_client = PlcClient::new(None);
292292- if let Err(e) = plc_client
293293- .send_operation(&genesis_result.did, &genesis_result.signed_operation)
294294- .await
295295- {
296296- error!("Failed to submit PLC genesis operation: {:?}", e);
292292+ if !d.starts_with("did:web:") {
297293 return (
298298- StatusCode::BAD_GATEWAY,
299299- Json(json!({
300300- "error": "UpstreamError",
301301- "message": format!("Failed to register DID with PLC directory: {}", e)
302302- })),
294294+ StatusCode::BAD_REQUEST,
295295+ Json(
296296+ json!({"error": "InvalidDid", "message": "External DID must be a did:web"}),
297297+ ),
303298 )
304299 .into_response();
305300 }
306306- info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
307307- genesis_result.did
308308- } else if d.starts_with("did:web:") {
309301 if let Err(e) = verify_did_web(d, &hostname, &input.handle).await {
310302 return (
311303 StatusCode::BAD_REQUEST,
···313305 )
314306 .into_response();
315307 }
316316- d.clone()
317317- } else if d.starts_with("did:plc:") && is_migration {
308308+ info!(did = %d, "Creating external did:web account");
318309 d.clone()
319319- } else {
320320- return (
321321- StatusCode::BAD_REQUEST,
322322- 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."})),
323323- )
324324- .into_response();
325310 }
326326- } else {
327327- let rotation_key = std::env::var("PLC_ROTATION_KEY")
328328- .unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
329329- let genesis_result = match create_genesis_operation(
330330- &signing_key,
331331- &rotation_key,
332332- &full_handle,
333333- &pds_endpoint,
334334- ) {
335335- Ok(r) => r,
336336- Err(e) => {
337337- error!("Error creating PLC genesis operation: {:?}", e);
338338- return (
339339- StatusCode::INTERNAL_SERVER_ERROR,
340340- Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
341341- )
342342- .into_response();
311311+ _ => {
312312+ if let Some(d) = &input.did {
313313+ if d.starts_with("did:plc:") && is_migration {
314314+ info!(did = %d, "Migration with existing did:plc");
315315+ d.clone()
316316+ } else if d.starts_with("did:web:") {
317317+ if let Err(e) = verify_did_web(d, &hostname, &input.handle).await {
318318+ return (
319319+ StatusCode::BAD_REQUEST,
320320+ Json(json!({"error": "InvalidDid", "message": e})),
321321+ )
322322+ .into_response();
323323+ }
324324+ d.clone()
325325+ } else if !d.trim().is_empty() {
326326+ return (
327327+ StatusCode::BAD_REQUEST,
328328+ 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."})),
329329+ )
330330+ .into_response();
331331+ } else {
332332+ let rotation_key = std::env::var("PLC_ROTATION_KEY")
333333+ .unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
334334+ let genesis_result = match create_genesis_operation(
335335+ &signing_key,
336336+ &rotation_key,
337337+ &full_handle,
338338+ &pds_endpoint,
339339+ ) {
340340+ Ok(r) => r,
341341+ Err(e) => {
342342+ error!("Error creating PLC genesis operation: {:?}", e);
343343+ return (
344344+ StatusCode::INTERNAL_SERVER_ERROR,
345345+ Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
346346+ )
347347+ .into_response();
348348+ }
349349+ };
350350+ let plc_client = PlcClient::new(None);
351351+ if let Err(e) = plc_client
352352+ .send_operation(&genesis_result.did, &genesis_result.signed_operation)
353353+ .await
354354+ {
355355+ error!("Failed to submit PLC genesis operation: {:?}", e);
356356+ return (
357357+ StatusCode::BAD_GATEWAY,
358358+ Json(json!({
359359+ "error": "UpstreamError",
360360+ "message": format!("Failed to register DID with PLC directory: {}", e)
361361+ })),
362362+ )
363363+ .into_response();
364364+ }
365365+ info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
366366+ genesis_result.did
367367+ }
368368+ } else {
369369+ let rotation_key = std::env::var("PLC_ROTATION_KEY")
370370+ .unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
371371+ let genesis_result = match create_genesis_operation(
372372+ &signing_key,
373373+ &rotation_key,
374374+ &full_handle,
375375+ &pds_endpoint,
376376+ ) {
377377+ Ok(r) => r,
378378+ Err(e) => {
379379+ error!("Error creating PLC genesis operation: {:?}", e);
380380+ return (
381381+ StatusCode::INTERNAL_SERVER_ERROR,
382382+ Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
383383+ )
384384+ .into_response();
385385+ }
386386+ };
387387+ let plc_client = PlcClient::new(None);
388388+ if let Err(e) = plc_client
389389+ .send_operation(&genesis_result.did, &genesis_result.signed_operation)
390390+ .await
391391+ {
392392+ error!("Failed to submit PLC genesis operation: {:?}", e);
393393+ return (
394394+ StatusCode::BAD_GATEWAY,
395395+ Json(json!({
396396+ "error": "UpstreamError",
397397+ "message": format!("Failed to register DID with PLC directory: {}", e)
398398+ })),
399399+ )
400400+ .into_response();
401401+ }
402402+ info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
403403+ genesis_result.did
343404 }
344344- };
345345- let plc_client = PlcClient::new(None);
346346- if let Err(e) = plc_client
347347- .send_operation(&genesis_result.did, &genesis_result.signed_operation)
348348- .await
349349- {
350350- error!("Failed to submit PLC genesis operation: {:?}", e);
351351- return (
352352- StatusCode::BAD_GATEWAY,
353353- Json(json!({
354354- "error": "UpstreamError",
355355- "message": format!("Failed to register DID with PLC directory: {}", e)
356356- })),
357357- )
358358- .into_response();
359405 }
360360- info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
361361- genesis_result.did
362406 };
363407 let mut tx = match state.db.begin().await {
364408 Ok(tx) => tx,
+231-68
src/api/identity/did.rs
···9595 }))
9696}
97979898-pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse {
9898+pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, &'static str> {
9999+ let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?;
100100+ let public_key = secret_key.public_key();
101101+ let compressed = public_key.to_encoded_point(true);
102102+ let compressed_bytes = compressed.as_bytes();
103103+ let mut multicodec_key = vec![0xe7, 0x01];
104104+ multicodec_key.extend_from_slice(compressed_bytes);
105105+ Ok(format!("z{}", bs58::encode(&multicodec_key).into_string()))
106106+}
107107+108108+pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
99109 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
100100- // Kinda for local dev, encode hostname if it contains port
110110+ let host_header = headers
111111+ .get("host")
112112+ .and_then(|h| h.to_str().ok())
113113+ .unwrap_or(&hostname);
114114+ let host_without_port = host_header.split(':').next().unwrap_or(host_header);
115115+ let hostname_without_port = hostname.split(':').next().unwrap_or(&hostname);
116116+ if host_without_port != hostname_without_port
117117+ && host_without_port.ends_with(&format!(".{}", hostname_without_port))
118118+ {
119119+ let handle = host_without_port
120120+ .strip_suffix(&format!(".{}", hostname_without_port))
121121+ .unwrap_or(host_without_port);
122122+ return serve_subdomain_did_doc(&state, handle, &hostname).await;
123123+ }
101124 let did = if hostname.contains(':') {
102125 format!("did:web:{}", hostname.replace(':', "%3A"))
103126 } else {
···112135 "serviceEndpoint": format!("https://{}", hostname)
113136 }]
114137 }))
138138+ .into_response()
139139+}
140140+141141+async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response {
142142+ let user = sqlx::query!(
143143+ "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
144144+ handle
145145+ )
146146+ .fetch_optional(&state.db)
147147+ .await;
148148+ let (user_id, did, migrated_to_pds) = match user {
149149+ Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds),
150150+ Ok(None) => {
151151+ return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response();
152152+ }
153153+ Err(e) => {
154154+ error!("DB Error: {:?}", e);
155155+ return (
156156+ StatusCode::INTERNAL_SERVER_ERROR,
157157+ Json(json!({"error": "InternalError"})),
158158+ )
159159+ .into_response();
160160+ }
161161+ };
162162+ if !did.starts_with("did:web:") {
163163+ return (
164164+ StatusCode::NOT_FOUND,
165165+ Json(json!({"error": "NotFound", "message": "User is not did:web"})),
166166+ )
167167+ .into_response();
168168+ }
169169+ let subdomain_host = format!("{}.{}", handle, hostname);
170170+ let encoded_subdomain = subdomain_host.replace(':', "%3A");
171171+ let expected_self_hosted = format!("did:web:{}", encoded_subdomain);
172172+ if did != expected_self_hosted {
173173+ return (
174174+ StatusCode::NOT_FOUND,
175175+ Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})),
176176+ )
177177+ .into_response();
178178+ }
179179+ let key_row = sqlx::query!(
180180+ "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
181181+ user_id
182182+ )
183183+ .fetch_optional(&state.db)
184184+ .await;
185185+ let key_bytes: Vec<u8> = match key_row {
186186+ Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
187187+ Ok(k) => k,
188188+ Err(_) => {
189189+ return (
190190+ StatusCode::INTERNAL_SERVER_ERROR,
191191+ Json(json!({"error": "InternalError"})),
192192+ )
193193+ .into_response();
194194+ }
195195+ },
196196+ _ => {
197197+ return (
198198+ StatusCode::INTERNAL_SERVER_ERROR,
199199+ Json(json!({"error": "InternalError"})),
200200+ )
201201+ .into_response();
202202+ }
203203+ };
204204+ let public_key_multibase = match get_public_key_multibase(&key_bytes) {
205205+ Ok(pk) => pk,
206206+ Err(e) => {
207207+ tracing::error!("Failed to generate public key multibase: {}", e);
208208+ return (
209209+ StatusCode::INTERNAL_SERVER_ERROR,
210210+ Json(json!({"error": "InternalError"})),
211211+ )
212212+ .into_response();
213213+ }
214214+ };
215215+ let full_handle = if handle.contains('.') {
216216+ handle.to_string()
217217+ } else {
218218+ format!("{}.{}", handle, hostname)
219219+ };
220220+ let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
221221+ Json(json!({
222222+ "@context": [
223223+ "https://www.w3.org/ns/did/v1",
224224+ "https://w3id.org/security/multikey/v1",
225225+ "https://w3id.org/security/suites/secp256k1-2019/v1"
226226+ ],
227227+ "id": did,
228228+ "alsoKnownAs": [format!("at://{}", full_handle)],
229229+ "verificationMethod": [{
230230+ "id": format!("{}#atproto", did),
231231+ "type": "Multikey",
232232+ "controller": did,
233233+ "publicKeyMultibase": public_key_multibase
234234+ }],
235235+ "service": [{
236236+ "id": "#atproto_pds",
237237+ "type": "AtprotoPersonalDataServer",
238238+ "serviceEndpoint": service_endpoint
239239+ }]
240240+ }))
241241+ .into_response()
115242}
116243117244pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
118245 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
119119- let user = sqlx::query!("SELECT id, did FROM users WHERE handle = $1", handle)
120120- .fetch_optional(&state.db)
121121- .await;
122122- let (user_id, did) = match user {
123123- Ok(Some(row)) => (row.id, row.did),
246246+ let user = sqlx::query!(
247247+ "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
248248+ handle
249249+ )
250250+ .fetch_optional(&state.db)
251251+ .await;
252252+ let (user_id, did, migrated_to_pds) = match user {
253253+ Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds),
124254 Ok(None) => {
125255 return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response();
126256 }
···140270 )
141271 .into_response();
142272 }
273273+ let encoded_hostname = hostname.replace(':', "%3A");
274274+ let old_path_format = format!("did:web:{}:u:{}", encoded_hostname, handle);
275275+ let subdomain_host = format!("{}.{}", handle, hostname);
276276+ let encoded_subdomain = subdomain_host.replace(':', "%3A");
277277+ let new_subdomain_format = format!("did:web:{}", encoded_subdomain);
278278+ if did != old_path_format && did != new_subdomain_format {
279279+ return (
280280+ StatusCode::NOT_FOUND,
281281+ Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})),
282282+ )
283283+ .into_response();
284284+ }
143285 let key_row = sqlx::query!(
144286 "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
145287 user_id
···165307 .into_response();
166308 }
167309 };
168168- let jwk = match get_jwk(&key_bytes) {
169169- Ok(j) => j,
310310+ let public_key_multibase = match get_public_key_multibase(&key_bytes) {
311311+ Ok(pk) => pk,
170312 Err(e) => {
171171- tracing::error!("Failed to generate JWK: {}", e);
313313+ tracing::error!("Failed to generate public key multibase: {}", e);
172314 return (
173315 StatusCode::INTERNAL_SERVER_ERROR,
174316 Json(json!({"error": "InternalError"})),
···176318 .into_response();
177319 }
178320 };
321321+ let full_handle = if handle.contains('.') {
322322+ handle.clone()
323323+ } else {
324324+ format!("{}.{}", handle, hostname)
325325+ };
326326+ let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
179327 Json(json!({
180180- "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"],
328328+ "@context": [
329329+ "https://www.w3.org/ns/did/v1",
330330+ "https://w3id.org/security/multikey/v1",
331331+ "https://w3id.org/security/suites/secp256k1-2019/v1"
332332+ ],
181333 "id": did,
182182- "alsoKnownAs": [format!("at://{}", handle)],
334334+ "alsoKnownAs": [format!("at://{}", full_handle)],
183335 "verificationMethod": [{
184336 "id": format!("{}#atproto", did),
185185- "type": "JsonWebKey2020",
337337+ "type": "Multikey",
186338 "controller": did,
187187- "publicKeyJwk": jwk
339339+ "publicKeyMultibase": public_key_multibase
188340 }],
189341 "service": [{
190342 "id": "#atproto_pds",
191343 "type": "AtprotoPersonalDataServer",
192192- "serviceEndpoint": format!("https://{}", hostname)
344344+ "serviceEndpoint": service_endpoint
193345 }]
194194- })).into_response()
346346+ }))
347347+ .into_response()
195348}
196349197350pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> {
351351+ let subdomain_host = format!("{}.{}", handle, hostname);
352352+ let encoded_subdomain = subdomain_host.replace(':', "%3A");
353353+ let expected_subdomain_did = format!("did:web:{}", encoded_subdomain);
354354+ if did == expected_subdomain_did {
355355+ return Ok(());
356356+ }
198357 let expected_prefix = if hostname.contains(':') {
199358 format!("did:web:{}", hostname.replace(':', "%3A"))
200359 } else {
···204363 let suffix = &did[expected_prefix.len()..];
205364 let expected_suffix = format!(":u:{}", handle);
206365 if suffix == expected_suffix {
207207- Ok(())
366366+ return Ok(());
208367 } else {
209209- Err(format!(
368368+ return Err(format!(
210369 "Invalid DID path for this PDS. Expected {}",
211370 expected_suffix
212212- ))
371371+ ));
213372 }
373373+ }
374374+ let parts: Vec<&str> = did.split(':').collect();
375375+ if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
376376+ return Err("Invalid did:web format".into());
377377+ }
378378+ let domain_segment = parts[2];
379379+ let domain = domain_segment.replace("%3A", ":");
380380+ let scheme = if domain.starts_with("localhost") || domain.starts_with("127.0.0.1") {
381381+ "http"
214382 } else {
215215- let parts: Vec<&str> = did.split(':').collect();
216216- if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
217217- return Err("Invalid did:web format".into());
218218- }
219219- let domain_segment = parts[2];
220220- let domain = domain_segment.replace("%3A", ":");
221221- let scheme = if domain.starts_with("localhost") || domain.starts_with("127.0.0.1") {
222222- "http"
223223- } else {
224224- "https"
225225- };
226226- let url = if parts.len() == 3 {
227227- format!("{}://{}/.well-known/did.json", scheme, domain)
228228- } else {
229229- let path = parts[3..].join("/");
230230- format!("{}://{}/{}/did.json", scheme, domain, path)
231231- };
232232- let client = reqwest::Client::builder()
233233- .timeout(std::time::Duration::from_secs(5))
234234- .build()
235235- .map_err(|e| format!("Failed to create client: {}", e))?;
236236- let resp = client
237237- .get(&url)
238238- .send()
239239- .await
240240- .map_err(|e| format!("Failed to fetch DID doc: {}", e))?;
241241- if !resp.status().is_success() {
242242- return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status()));
243243- }
244244- let doc: serde_json::Value = resp
245245- .json()
246246- .await
247247- .map_err(|e| format!("Failed to parse DID doc: {}", e))?;
248248- let services = doc["service"]
249249- .as_array()
250250- .ok_or("No services found in DID doc")?;
251251- let pds_endpoint = format!("https://{}", hostname);
252252- let has_valid_service = services.iter().any(|s| {
253253- s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint
254254- });
255255- if has_valid_service {
256256- Ok(())
257257- } else {
258258- Err(format!(
259259- "DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
260260- pds_endpoint
261261- ))
262262- }
383383+ "https"
384384+ };
385385+ let url = if parts.len() == 3 {
386386+ format!("{}://{}/.well-known/did.json", scheme, domain)
387387+ } else {
388388+ let path = parts[3..].join("/");
389389+ format!("{}://{}/{}/did.json", scheme, domain, path)
390390+ };
391391+ let client = reqwest::Client::builder()
392392+ .timeout(std::time::Duration::from_secs(5))
393393+ .build()
394394+ .map_err(|e| format!("Failed to create client: {}", e))?;
395395+ let resp = client
396396+ .get(&url)
397397+ .send()
398398+ .await
399399+ .map_err(|e| format!("Failed to fetch DID doc: {}", e))?;
400400+ if !resp.status().is_success() {
401401+ return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status()));
402402+ }
403403+ let doc: serde_json::Value = resp
404404+ .json()
405405+ .await
406406+ .map_err(|e| format!("Failed to parse DID doc: {}", e))?;
407407+ let services = doc["service"]
408408+ .as_array()
409409+ .ok_or("No services found in DID doc")?;
410410+ let pds_endpoint = format!("https://{}", hostname);
411411+ let has_valid_service = services
412412+ .iter()
413413+ .any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint);
414414+ if has_valid_service {
415415+ Ok(())
416416+ } else {
417417+ Err(format!(
418418+ "DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
419419+ pds_endpoint
420420+ ))
263421 }
264422}
265423···344502 Err(_) => return ApiError::InternalError.into_response(),
345503 };
346504 let did_key = signing_key_to_did_key(&signing_key);
505505+ let rotation_keys = if auth_user.did.starts_with("did:web:") {
506506+ vec![]
507507+ } else {
508508+ vec![did_key.clone()]
509509+ };
347510 (
348511 StatusCode::OK,
349512 Json(GetRecommendedDidCredentialsOutput {
350350- rotation_keys: vec![did_key.clone()],
513513+ rotation_keys,
351514 also_known_as: vec![format!("at://{}", full_handle)],
352515 verification_methods: VerificationMethods { atproto: did_key },
353516 services: Services {
+6
src/api/identity/plc/sign.rs
···6363 return e;
6464 }
6565 let did = &auth_user.did;
6666+ if did.starts_with("did:web:") {
6767+ return ApiError::InvalidRequest(
6868+ "PLC operations are only valid for did:plc identities".into(),
6969+ )
7070+ .into_response();
7171+ }
6672 let token = match &input.token {
6773 Some(t) => t,
6874 None => {
+6
src/api/identity/plc/submit.rs
···4242 return e;
4343 }
4444 let did = &auth_user.did;
4545+ if did.starts_with("did:web:") {
4646+ return ApiError::InvalidRequest(
4747+ "PLC operations are only valid for did:plc identities".into(),
4848+ )
4949+ .into_response();
5050+ }
4551 if let Err(e) = validate_plc_operation(&input.operation) {
4652 return ApiError::InvalidRequest(format!("Invalid operation: {}", e)).into_response();
4753 }
+324
tests/did_web.rs
···11+mod common;
22+use common::*;
33+use reqwest::StatusCode;
44+use serde_json::{Value, json};
55+use wiremock::matchers::{method, path};
66+use wiremock::{Mock, MockServer, ResponseTemplate};
77+88+#[tokio::test]
99+async fn test_create_self_hosted_did_web() {
1010+ let client = client();
1111+ let handle = format!("selfweb_{}", uuid::Uuid::new_v4());
1212+ let payload = json!({
1313+ "handle": handle,
1414+ "email": format!("{}@example.com", handle),
1515+ "password": "password",
1616+ "didType": "web"
1717+ });
1818+ let res = client
1919+ .post(format!(
2020+ "{}/xrpc/com.atproto.server.createAccount",
2121+ base_url().await
2222+ ))
2323+ .json(&payload)
2424+ .send()
2525+ .await
2626+ .expect("Failed to send request");
2727+ if res.status() != StatusCode::OK {
2828+ let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"}));
2929+ panic!("createAccount failed: {:?}", body);
3030+ }
3131+ let body: Value = res.json().await.expect("Response was not JSON");
3232+ let did = body["did"].as_str().expect("No DID in response");
3333+ assert!(
3434+ did.starts_with("did:web:"),
3535+ "DID should start with did:web:, got: {}",
3636+ did
3737+ );
3838+ assert!(
3939+ did.contains(&handle),
4040+ "DID should contain handle {}, got: {}",
4141+ handle,
4242+ did
4343+ );
4444+ assert!(
4545+ !did.contains(":u:"),
4646+ "Self-hosted did:web should use subdomain format (no :u:), got: {}",
4747+ did
4848+ );
4949+ let jwt = verify_new_account(&client, did).await;
5050+ let res = client
5151+ .get(format!("{}/u/{}/did.json", base_url().await, handle))
5252+ .send()
5353+ .await
5454+ .expect("Failed to fetch DID doc via path");
5555+ assert_eq!(
5656+ res.status(),
5757+ StatusCode::OK,
5858+ "Self-hosted did:web should have DID doc served by PDS (via path for backwards compat)"
5959+ );
6060+ let doc: Value = res.json().await.expect("DID doc was not JSON");
6161+ assert_eq!(doc["id"], did);
6262+ assert!(
6363+ doc["verificationMethod"][0]["publicKeyMultibase"].is_string(),
6464+ "DID doc should have publicKeyMultibase"
6565+ );
6666+ let res = client
6767+ .post(format!(
6868+ "{}/xrpc/com.atproto.repo.createRecord",
6969+ base_url().await
7070+ ))
7171+ .bearer_auth(&jwt)
7272+ .json(&json!({
7373+ "repo": did,
7474+ "collection": "app.bsky.feed.post",
7575+ "record": {
7676+ "$type": "app.bsky.feed.post",
7777+ "text": "Hello from did:web!",
7878+ "createdAt": chrono::Utc::now().to_rfc3339()
7979+ }
8080+ }))
8181+ .send()
8282+ .await
8383+ .expect("Failed to create post");
8484+ assert_eq!(
8585+ res.status(),
8686+ StatusCode::OK,
8787+ "Self-hosted did:web account should be able to create records"
8888+ );
8989+}
9090+9191+#[tokio::test]
9292+async fn test_external_did_web_no_local_doc() {
9393+ let client = client();
9494+ let mock_server = MockServer::start().await;
9595+ let mock_uri = mock_server.uri();
9696+ let mock_addr = mock_uri.trim_start_matches("http://");
9797+ let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
9898+ let handle = format!("extweb_{}", uuid::Uuid::new_v4());
9999+ let pds_endpoint = base_url().await.replace("http://", "https://");
100100+ let did_doc = json!({
101101+ "@context": ["https://www.w3.org/ns/did/v1"],
102102+ "id": did,
103103+ "service": [{
104104+ "id": "#atproto_pds",
105105+ "type": "AtprotoPersonalDataServer",
106106+ "serviceEndpoint": pds_endpoint
107107+ }]
108108+ });
109109+ Mock::given(method("GET"))
110110+ .and(path("/.well-known/did.json"))
111111+ .respond_with(ResponseTemplate::new(200).set_body_json(did_doc))
112112+ .mount(&mock_server)
113113+ .await;
114114+ let payload = json!({
115115+ "handle": handle,
116116+ "email": format!("{}@example.com", handle),
117117+ "password": "password",
118118+ "didType": "web-external",
119119+ "did": did
120120+ });
121121+ let res = client
122122+ .post(format!(
123123+ "{}/xrpc/com.atproto.server.createAccount",
124124+ base_url().await
125125+ ))
126126+ .json(&payload)
127127+ .send()
128128+ .await
129129+ .expect("Failed to send request");
130130+ if res.status() != StatusCode::OK {
131131+ let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"}));
132132+ panic!("createAccount failed: {:?}", body);
133133+ }
134134+ let res = client
135135+ .get(format!("{}/u/{}/did.json", base_url().await, handle))
136136+ .send()
137137+ .await
138138+ .expect("Failed to fetch DID doc");
139139+ assert_eq!(
140140+ res.status(),
141141+ StatusCode::NOT_FOUND,
142142+ "External did:web should NOT have DID doc served by PDS"
143143+ );
144144+ let body: Value = res.json().await.expect("Response was not JSON");
145145+ assert!(
146146+ body["message"].as_str().unwrap_or("").contains("External"),
147147+ "Error message should indicate external did:web"
148148+ );
149149+}
150150+151151+#[tokio::test]
152152+async fn test_plc_operations_blocked_for_did_web() {
153153+ let client = client();
154154+ let handle = format!("plcblock_{}", uuid::Uuid::new_v4());
155155+ let payload = json!({
156156+ "handle": handle,
157157+ "email": format!("{}@example.com", handle),
158158+ "password": "password",
159159+ "didType": "web"
160160+ });
161161+ let res = client
162162+ .post(format!(
163163+ "{}/xrpc/com.atproto.server.createAccount",
164164+ base_url().await
165165+ ))
166166+ .json(&payload)
167167+ .send()
168168+ .await
169169+ .expect("Failed to send request");
170170+ assert_eq!(res.status(), StatusCode::OK);
171171+ let body: Value = res.json().await.expect("Response was not JSON");
172172+ let did = body["did"].as_str().expect("No DID").to_string();
173173+ let jwt = verify_new_account(&client, &did).await;
174174+ let res = client
175175+ .post(format!(
176176+ "{}/xrpc/com.atproto.identity.signPlcOperation",
177177+ base_url().await
178178+ ))
179179+ .bearer_auth(&jwt)
180180+ .json(&json!({
181181+ "token": "fake-token"
182182+ }))
183183+ .send()
184184+ .await
185185+ .expect("Failed to send request");
186186+ assert_eq!(
187187+ res.status(),
188188+ StatusCode::BAD_REQUEST,
189189+ "signPlcOperation should be blocked for did:web users"
190190+ );
191191+ let body: Value = res.json().await.expect("Response was not JSON");
192192+ assert!(
193193+ body["message"].as_str().unwrap_or("").contains("did:plc"),
194194+ "Error should mention did:plc: {:?}",
195195+ body
196196+ );
197197+ let res = client
198198+ .post(format!(
199199+ "{}/xrpc/com.atproto.identity.submitPlcOperation",
200200+ base_url().await
201201+ ))
202202+ .bearer_auth(&jwt)
203203+ .json(&json!({
204204+ "operation": {}
205205+ }))
206206+ .send()
207207+ .await
208208+ .expect("Failed to send request");
209209+ assert_eq!(
210210+ res.status(),
211211+ StatusCode::BAD_REQUEST,
212212+ "submitPlcOperation should be blocked for did:web users"
213213+ );
214214+}
215215+216216+#[tokio::test]
217217+async fn test_get_recommended_did_credentials_no_rotation_keys_for_did_web() {
218218+ let client = client();
219219+ let handle = format!("creds_{}", uuid::Uuid::new_v4());
220220+ let payload = json!({
221221+ "handle": handle,
222222+ "email": format!("{}@example.com", handle),
223223+ "password": "password",
224224+ "didType": "web"
225225+ });
226226+ let res = client
227227+ .post(format!(
228228+ "{}/xrpc/com.atproto.server.createAccount",
229229+ base_url().await
230230+ ))
231231+ .json(&payload)
232232+ .send()
233233+ .await
234234+ .expect("Failed to send request");
235235+ assert_eq!(res.status(), StatusCode::OK);
236236+ let body: Value = res.json().await.expect("Response was not JSON");
237237+ let did = body["did"].as_str().expect("No DID").to_string();
238238+ let jwt = verify_new_account(&client, &did).await;
239239+ let res = client
240240+ .get(format!(
241241+ "{}/xrpc/com.atproto.identity.getRecommendedDidCredentials",
242242+ base_url().await
243243+ ))
244244+ .bearer_auth(&jwt)
245245+ .send()
246246+ .await
247247+ .expect("Failed to send request");
248248+ assert_eq!(res.status(), StatusCode::OK);
249249+ let body: Value = res.json().await.expect("Response was not JSON");
250250+ let rotation_keys = body["rotationKeys"]
251251+ .as_array()
252252+ .expect("rotationKeys should be an array");
253253+ assert!(
254254+ rotation_keys.is_empty(),
255255+ "did:web should have no rotation keys, got: {:?}",
256256+ rotation_keys
257257+ );
258258+ assert!(
259259+ body["verificationMethods"].is_object(),
260260+ "verificationMethods should be present"
261261+ );
262262+ assert!(body["services"].is_object(), "services should be present");
263263+}
264264+265265+#[tokio::test]
266266+async fn test_did_plc_still_works_with_did_type_param() {
267267+ let client = client();
268268+ let handle = format!("plctype_{}", uuid::Uuid::new_v4());
269269+ let payload = json!({
270270+ "handle": handle,
271271+ "email": format!("{}@example.com", handle),
272272+ "password": "password",
273273+ "didType": "plc"
274274+ });
275275+ let res = client
276276+ .post(format!(
277277+ "{}/xrpc/com.atproto.server.createAccount",
278278+ base_url().await
279279+ ))
280280+ .json(&payload)
281281+ .send()
282282+ .await
283283+ .expect("Failed to send request");
284284+ assert_eq!(res.status(), StatusCode::OK);
285285+ let body: Value = res.json().await.expect("Response was not JSON");
286286+ let did = body["did"].as_str().expect("No DID").to_string();
287287+ assert!(
288288+ did.starts_with("did:plc:"),
289289+ "DID with didType=plc should be did:plc:, got: {}",
290290+ did
291291+ );
292292+}
293293+294294+#[tokio::test]
295295+async fn test_external_did_web_requires_did_field() {
296296+ let client = client();
297297+ let handle = format!("nodid_{}", uuid::Uuid::new_v4());
298298+ let payload = json!({
299299+ "handle": handle,
300300+ "email": format!("{}@example.com", handle),
301301+ "password": "password",
302302+ "didType": "web-external"
303303+ });
304304+ let res = client
305305+ .post(format!(
306306+ "{}/xrpc/com.atproto.server.createAccount",
307307+ base_url().await
308308+ ))
309309+ .json(&payload)
310310+ .send()
311311+ .await
312312+ .expect("Failed to send request");
313313+ assert_eq!(
314314+ res.status(),
315315+ StatusCode::BAD_REQUEST,
316316+ "web-external without did should fail"
317317+ );
318318+ let body: Value = res.json().await.expect("Response was not JSON");
319319+ assert!(
320320+ body["message"].as_str().unwrap_or("").contains("did"),
321321+ "Error should mention did field is required: {:?}",
322322+ body
323323+ );
324324+}
+5-6
tests/identity.rs
···143143 .send()
144144 .await
145145 .expect("Failed to fetch DID doc");
146146- assert_eq!(res.status(), StatusCode::OK);
147147- let doc: Value = res.json().await.expect("DID doc was not JSON");
148148- assert_eq!(doc["id"], did);
149149- assert_eq!(doc["alsoKnownAs"][0], format!("at://{}", handle));
150150- assert_eq!(doc["verificationMethod"][0]["controller"], did);
151151- assert!(doc["verificationMethod"][0]["publicKeyJwk"].is_object());
146146+ assert_eq!(
147147+ res.status(),
148148+ StatusCode::NOT_FOUND,
149149+ "External did:web should not have DID doc served by PDS (user hosts their own)"
150150+ );
152151}
153152154153#[tokio::test]