From 19093b09d41cc49ec229d4bcd2e2951bb06faa88 Mon Sep 17 00:00:00 2001 From: scanash00 Date: Wed, 12 Aug 2026 09:08:31 -0800 Subject: [PATCH] more migration/account status fixes --- crates/tranquil-api/src/repo/meta.rs | 10 +++ .../src/server/passkey_account.rs | 29 +++++-- crates/tranquil-pds/src/sync/util.rs | 24 +++++- crates/tranquil-pds/tests/admin_moderation.rs | 76 +++++++++++++++++++ frontend/src/lib/migration/flow.svelte.ts | 65 +++++++++++----- .../migration/flow-passkey-resume.test.ts | 31 ++++++++ 6 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 frontend/src/tests/migration/flow-passkey-resume.test.ts diff --git a/crates/tranquil-api/src/repo/meta.rs b/crates/tranquil-api/src/repo/meta.rs index e08c6f9..58f4557 100644 --- a/crates/tranquil-api/src/repo/meta.rs +++ b/crates/tranquil-api/src/repo/meta.rs @@ -7,6 +7,7 @@ use axum::{ use serde::Deserialize; use serde_json::json; use tranquil_pds::state::AppState; +use tranquil_pds::sync::{RepoAccessLevel, assert_repo_availability}; use tranquil_pds::types::AtIdentifier; #[derive(Deserialize)] @@ -22,6 +23,15 @@ pub async fn describe_repo( Ok(r) => r, Err(e) => return e.into_response(), }; + if let Err(e) = assert_repo_availability( + state.repos.repo.as_ref(), + &resolved.did, + RepoAccessLevel::Public, + ) + .await + { + return e.into_response(); + } let collections = state .repos .repo diff --git a/crates/tranquil-api/src/server/passkey_account.rs b/crates/tranquil-api/src/server/passkey_account.rs index 33d96b9..df217e1 100644 --- a/crates/tranquil-api/src/server/passkey_account.rs +++ b/crates/tranquil-api/src/server/passkey_account.rs @@ -31,6 +31,10 @@ fn generate_setup_token() -> String { .collect() } +fn is_passkey_migration(byod_auth: Option<&Did>) -> bool { + byod_auth.is_some() +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreatePasskeyAccountInput { @@ -77,7 +81,7 @@ pub async fn create_passkey_account( { Ok(claims) => { debug!( - "Service token verified for BYOD did:web: iss={}", + "Service token verified for passkey migration: iss={}", claims.iss ); Some(claims.iss) @@ -103,6 +107,7 @@ pub async fn create_passkey_account( .as_ref() .map(|d| d.starts_with("did:web:")) .unwrap_or(false); + let is_migration = is_passkey_migration(byod_auth.as_ref()); let cfg = tranquil_config::get(); let hostname = &cfg.server.hostname; @@ -267,11 +272,8 @@ pub async fn create_passkey_account( let setup_token_hash = common::hash_or_internal_error(&setup_token)?; let setup_expires_at = Utc::now() + Duration::hours(1); - let deactivated_at: Option> = if is_byod_did_web { - Some(Utc::now()) - } else { - None - }; + let deactivated_at: Option> = + if is_migration { Some(Utc::now()) } else { None }; let repo = match crate::identity::provision::init_genesis_repo( &state, @@ -339,7 +341,7 @@ pub async fn create_passkey_account( }; let user_id = create_result.user_id; - if !is_byod_did_web { + if !is_migration { crate::identity::provision::sequence_new_account( &state, &did, @@ -794,3 +796,16 @@ pub async fn recover_passkey_account( info!(did = %input.did, "Passkey-only account recovered with temporary password"); Ok(Json(SuccessResponse { success: true })) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_authenticated_passkey_accounts_are_migrations() { + let did = Did::new("did:plc:alice").expect("valid DID"); + + assert!(is_passkey_migration(Some(&did))); + assert!(!is_passkey_migration(None)); + } +} diff --git a/crates/tranquil-pds/src/sync/util.rs b/crates/tranquil-pds/src/sync/util.rs index 1d4b07c..5989efd 100644 --- a/crates/tranquil-pds/src/sync/util.rs +++ b/crates/tranquil-pds/src/sync/util.rs @@ -5,7 +5,11 @@ use crate::sync::frame::{ AccountFrame, CommitFrame, ErrorFrameBody, ErrorFrameHeader, ErrorFrameName, FrameHeader, FrameType, IdentityFrame, InfoFrame, InfoFrameName, SyncFrame, }; -use axum::response::{IntoResponse, Response}; +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; use bytes::Bytes; use cid::Cid; use iroh_car::{CarHeader, CarWriter}; @@ -119,8 +123,22 @@ impl IntoResponse for RepoAvailabilityError { ApiError::RepoNotFound(Some(format!("Could not find repo for DID: {}", did))) .into_response() } - RepoAvailabilityError::Takendown(_) => ApiError::RepoTakendown.into_response(), - RepoAvailabilityError::Deactivated(_) => ApiError::RepoDeactivated.into_response(), + RepoAvailabilityError::Takendown(did) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "RepoTakendown", + "message": format!("Repo has been takendown: {did}"), + })), + ) + .into_response(), + RepoAvailabilityError::Deactivated(did) => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "RepoDeactivated", + "message": format!("Repo has been deactivated: {did}"), + })), + ) + .into_response(), RepoAvailabilityError::Internal(msg) => { ApiError::InternalError(Some(msg)).into_response() } diff --git a/crates/tranquil-pds/tests/admin_moderation.rs b/crates/tranquil-pds/tests/admin_moderation.rs index b6597f0..03f6b86 100644 --- a/crates/tranquil-pds/tests/admin_moderation.rs +++ b/crates/tranquil-pds/tests/admin_moderation.rs @@ -149,6 +149,23 @@ async fn test_update_subject_status_takedown_user_without_ref_updates_sync_statu .expect("Failed to send request"); assert_eq!(res.status(), StatusCode::OK); + let describe_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.describeRepo", + base_url().await + )) + .query(&[("repo", target_did.as_str())]) + .send() + .await + .expect("Failed to send request"); + assert_eq!(describe_res.status(), StatusCode::BAD_REQUEST); + let describe_body: Value = describe_res.json().await.unwrap(); + assert_eq!(describe_body["error"], "RepoTakendown"); + assert_eq!( + describe_body["message"], + format!("Repo has been takendown: {target_did}") + ); + let status_res = client .get(format!( "{}/xrpc/com.atproto.sync.getRepoStatus", @@ -280,6 +297,65 @@ async fn test_update_subject_status_deactivate_user() { .await .expect("Failed to send request"); assert_eq!(res.status(), StatusCode::OK); + let describe_res = client + .get(format!( + "{}/xrpc/com.atproto.repo.describeRepo", + base_url().await + )) + .query(&[("repo", target_did.as_str())]) + .send() + .await + .expect("Failed to send request"); + assert_eq!(describe_res.status(), StatusCode::BAD_REQUEST); + let describe_body: Value = describe_res.json().await.unwrap(); + assert_eq!(describe_body["error"], "RepoDeactivated"); + assert_eq!( + describe_body["message"], + format!("Repo has been deactivated: {target_did}") + ); + + let repo_status_res = client + .get(format!( + "{}/xrpc/com.atproto.sync.getRepoStatus", + base_url().await + )) + .query(&[("did", target_did.as_str())]) + .send() + .await + .expect("Failed to send request"); + assert_eq!(repo_status_res.status(), StatusCode::OK); + let repo_status_body: Value = repo_status_res.json().await.unwrap(); + assert_eq!(repo_status_body["active"], false); + assert_eq!(repo_status_body["status"], "deactivated"); + + let mut cursor: Option = None; + loop { + let mut url = format!( + "{}/xrpc/com.atproto.sync.listRepos?limit=1000", + base_url().await + ); + if let Some(cursor) = &cursor { + url.push_str(&format!("&cursor={cursor}")); + } + let list_res = client.get(&url).send().await.unwrap(); + assert_eq!(list_res.status(), StatusCode::OK); + let list_body: Value = list_res.json().await.unwrap(); + if let Some(repo) = list_body["repos"] + .as_array() + .unwrap() + .iter() + .find(|repo| repo["did"] == target_did) + { + assert_eq!(repo["active"], false); + assert_eq!(repo["status"], "deactivated"); + break; + } + match list_body["cursor"].as_str() { + Some(next_cursor) => cursor = Some(next_cursor.to_string()), + None => panic!("Deactivated repo should be in listRepos"), + } + } + let status_res = client .get(format!( "{}/xrpc/com.atproto.admin.getSubjectStatus", diff --git a/frontend/src/lib/migration/flow.svelte.ts b/frontend/src/lib/migration/flow.svelte.ts index fb0a13b..cd0f715 100644 --- a/frontend/src/lib/migration/flow.svelte.ts +++ b/frontend/src/lib/migration/flow.svelte.ts @@ -32,6 +32,14 @@ import { updateStep, } from "./storage.ts"; import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts"; +import { + createInitialProgress, + checkHandleAvailabilityViaClient, + finalizeAccountMigration, + loadServerInfo, + resolveVerificationIdentifier, +} from "../flows/migration-shared.ts"; +import { createEmailVerificationPoller } from "../flows/email-verification.ts"; function migrationLog(stage: string, data?: Record) { const timestamp = new Date().toISOString(); @@ -43,14 +51,15 @@ function migrationLog(stage: string, data?: Record) { } } -import { - createInitialProgress, - checkHandleAvailabilityViaClient, - finalizeAccountMigration, - loadServerInfo, - resolveVerificationIdentifier, -} from "../flows/migration-shared.ts"; -import { createEmailVerificationPoller } from "../flows/email-verification.ts"; +export function getPasskeyResumeStep( + targetStep: InboundStep, + sourceDid: string, +): InboundStep { + if (targetStep === "app-password" || targetStep === "finalizing") { + return sourceDid.startsWith("did:web:") ? "did-web-update" : "plc-token"; + } + return targetStep; +} export function createInboundMigrationFlow() { let state = $state({ @@ -298,11 +307,17 @@ export function createInboundMigrationFlow() { if (state.localRefreshToken) { localClient.setRefreshToken(state.localRefreshToken); } - if (state.authMethod === "passkey" && state.passkeySetupToken) { - setStep("passkey-setup"); - migrationLog( - "handleOAuthCallback: Resuming passkey flow at passkey-setup", - ); + if (state.authMethod === "passkey") { + const resumeStep = getPasskeyResumeStep(targetStep, state.sourceDid); + if (resumeStep !== targetStep) { + await proceedFromAppPassword(); + } else { + setStep(resumeStep); + } + migrationLog("handleOAuthCallback: Resuming completed passkey flow", { + targetStep, + resumeStep, + }); } else { const alreadyVerified = await localClient .checkChannelVerified(state.sourceDid, state.verificationChannel) @@ -951,13 +966,27 @@ export function createInboundMigrationFlow() { appPassword: "***", }); - setProgress({ currentOperation: "Authenticating with app password..." }); - await localClient.loginDeactivated(state.targetEmail, result.appPassword); - migrationLog("completePasskeyRegistration: Authenticated to new PDS"); - + state.passkeySetupToken = null; state.generatedAppPassword = result.appPassword; state.generatedAppPasswordName = result.appPasswordName; setStep("app-password"); + + setProgress({ currentOperation: "Authenticating with app password..." }); + try { + const session = await localClient.loginDeactivated( + state.targetEmail, + result.appPassword, + ); + state.localAccessToken = session.accessJwt; + state.localRefreshToken = session.refreshJwt; + saveMigrationState(state); + migrationLog("completePasskeyRegistration: Authenticated to new PDS"); + } catch (error) { + if (!localClient.getAccessToken()) throw error; + migrationLog( + "completePasskeyRegistration: Retaining migration access token", + ); + } } async function proceedFromAppPassword(): Promise { @@ -1027,6 +1056,7 @@ export function createInboundMigrationFlow() { state.targetHandle = stored.targetHandle; state.targetEmail = stored.targetEmail; state.authMethod = stored.authMethod ?? "password"; + state.passkeySetupToken = stored.passkeySetupToken ?? null; state.localAccessToken = stored.localAccessToken ?? null; state.localRefreshToken = stored.localRefreshToken ?? null; state.progress = { @@ -1053,7 +1083,6 @@ export function createInboundMigrationFlow() { originalStep: stored.step, }); } else if (stored.step === "passkey-setup" && stored.passkeySetupToken) { - state.passkeySetupToken = stored.passkeySetupToken; localClient = createLocalClient(); state.step = "passkey-setup"; migrationLog("resumeFromState: Restored passkey-setup with token"); diff --git a/frontend/src/tests/migration/flow-passkey-resume.test.ts b/frontend/src/tests/migration/flow-passkey-resume.test.ts new file mode 100644 index 0000000..119fd17 --- /dev/null +++ b/frontend/src/tests/migration/flow-passkey-resume.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { getPasskeyResumeStep } from "../../lib/migration/flow.svelte.ts"; + +describe("migration/passkey resume", () => { + it("does not replay passkey setup after reaching PLC authorization", () => { + expect(getPasskeyResumeStep("plc-token", "did:plc:alice")).toBe( + "plc-token", + ); + }); + + it("does not replay passkey setup after reaching did:web finalization", () => { + expect(getPasskeyResumeStep("did-web-update", "did:web:alice.example")).toBe( + "did-web-update", + ); + }); + + it("rewinds interrupted finalization to the last safe user step", () => { + expect(getPasskeyResumeStep("finalizing", "did:plc:alice")).toBe( + "plc-token", + ); + expect(getPasskeyResumeStep("finalizing", "did:web:alice.example")).toBe( + "did-web-update", + ); + }); + + it("continues past an app password that cannot be displayed after reload", () => { + expect(getPasskeyResumeStep("app-password", "did:plc:alice")).toBe( + "plc-token", + ); + }); +}); \ No newline at end of file -- 2.51.2