feat: fun handle resolution during migration

This commit is contained in:
lewis
2026-02-05 20:37:08 +00:00
committed by Tangled
parent cd7400bc8b
commit 240f24718e
34 changed files with 1009 additions and 103 deletions
+1 -6
View File
@@ -74,11 +74,6 @@ example plugins
### Misc
migration handle preservation
- [ ] allow users to keep their existing handle during migration (eg. lewis.moe instead of forcing lewis.newpds.com)
- [ ] UI option to preserve external handle vs create new pds-subdomain handle
- [ ] handle the DNS verification flow for external handles during migration
cross-pds delegation
when a client (eg. tangled.org) tries to log into a delegated account:
- [ ] client starts oauth flow to delegated account's pds
@@ -162,4 +157,4 @@ App password scopes: Granular permissions for app passwords using the same scope
Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices.
Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management.
Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management, handle preservation (keep existing external handle via DNS/HTTP verification or create new PDS-subdomain handle).
+4 -4
View File
@@ -1159,10 +1159,10 @@ impl RepoRepository for PostgresRepoRepository {
None => return Err(ImportRepoError::RepoNotFound),
};
if let Some(expected) = expected_root_cid {
if repo.repo_root_cid.as_str() != expected.as_str() {
return Err(ImportRepoError::ConcurrentModification);
}
if let Some(expected) = expected_root_cid
&& repo.repo_root_cid.as_str() != expected.as_str()
{
return Err(ImportRepoError::ConcurrentModification);
}
let block_chunks: Vec<Vec<&ImportBlock>> = blocks
+12 -9
View File
@@ -108,7 +108,10 @@ pub async fn handle_discord_webhook(
1 => Json(json!({"type": 1})).into_response(),
2 => handle_command(state, interaction).await,
other => {
debug!(interaction_type = other, "Received unknown Discord interaction type");
debug!(
interaction_type = other,
"Received unknown Discord interaction type"
);
StatusCode::OK.into_response()
}
}
@@ -148,14 +151,14 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
let handle = parse_start_handle(interaction.data.as_ref().and_then(|d| d.options.as_deref()));
if let Some(ref h) = handle {
if Handle::new(h).is_err() {
return Json(json!({
"type": 4,
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
}))
.into_response();
}
if let Some(ref h) = handle
&& Handle::new(h).is_err()
{
return Json(json!({
"type": 4,
"data": {"content": "Invalid handle format. Handle should look like: alice.example.com", "flags": 64}
}))
.into_response();
}
debug!(
@@ -0,0 +1,76 @@
use crate::api::error::ApiError;
use crate::rate_limit::{HandleVerificationLimit, RateLimited};
use crate::types::{Did, Handle};
use axum::{
Json,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
pub struct VerifyHandleOwnershipInput {
pub handle: String,
pub did: Did,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VerifyHandleOwnershipOutput {
pub verified: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub async fn verify_handle_ownership(
_rate_limit: RateLimited<HandleVerificationLimit>,
Json(input): Json<VerifyHandleOwnershipInput>,
) -> Response {
let handle: Handle = match input.handle.parse() {
Ok(h) => h,
Err(_) => {
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
}
};
let handle_str = handle.as_str();
let did_str = input.did.as_str();
let dns_mismatch = match crate::handle::resolve_handle_dns(handle_str).await {
Ok(did) if did == did_str => {
return Json(VerifyHandleOwnershipOutput {
verified: true,
method: Some("dns".to_string()),
error: None,
})
.into_response();
}
Ok(did) => Some(format!(
"DNS record points to {}, expected {}",
did, did_str
)),
Err(_) => None,
};
match crate::handle::resolve_handle_http(handle_str).await {
Ok(did) if did == did_str => Json(VerifyHandleOwnershipOutput {
verified: true,
method: Some("http".to_string()),
error: None,
})
.into_response(),
Ok(did) => Json(VerifyHandleOwnershipOutput {
verified: false,
method: None,
error: Some(format!("Handle resolves to {}, expected {}", did, did_str)),
})
.into_response(),
Err(e) => Json(VerifyHandleOwnershipOutput {
verified: false,
method: None,
error: Some(dns_mismatch.unwrap_or_else(|| format!("Handle resolution failed: {}", e))),
})
.into_response(),
}
}
@@ -1,5 +1,6 @@
pub mod account;
pub mod did;
pub mod handle;
pub mod plc;
pub use account::create_account;
@@ -7,4 +8,5 @@ pub use did::{
get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc,
well_known_atproto_did, well_known_did,
};
pub use handle::verify_handle_ownership;
pub use plc::{request_plc_operation_signature, sign_plc_operation, submit_plc_operation};
+6 -2
View File
@@ -550,7 +550,9 @@ mod tests {
assert!(is_valid_discord_username("user.name"));
assert!(is_valid_discord_username("user123"));
assert!(is_valid_discord_username("a_b.c_d"));
assert!(is_valid_discord_username("12345678901234567890123456789012"));
assert!(is_valid_discord_username(
"12345678901234567890123456789012"
));
}
#[test]
@@ -564,6 +566,8 @@ mod tests {
assert!(!is_valid_discord_username("username."));
assert!(!is_valid_discord_username("user..name"));
assert!(!is_valid_discord_username("user name"));
assert!(!is_valid_discord_username("123456789012345678901234567890123"));
assert!(!is_valid_discord_username(
"123456789012345678901234567890123"
));
}
}
@@ -300,7 +300,7 @@ pub fn format_token_for_display(token: &str) -> String {
}
pub fn normalize_token_input(input: &str) -> String {
input.trim().to_string()
input.chars().filter(|c| !c.is_whitespace()).collect()
}
#[cfg(test)]
+4
View File
@@ -337,6 +337,10 @@ pub fn app(state: AppState) -> Router {
"/com.atproto.identity.submitPlcOperation",
post(api::identity::submit_plc_operation),
)
.route(
"/_identity.verifyHandleOwnership",
post(api::identity::verify_handle_ownership),
)
.route("/com.atproto.repo.importRepo", post(api::repo::import_repo))
.route(
"/com.atproto.admin.deleteAccount",
@@ -110,6 +110,11 @@ impl RateLimitPolicy for OAuthRegisterCompleteLimit {
const KIND: RateLimitKind = RateLimitKind::OAuthRegisterComplete;
}
pub struct HandleVerificationLimit;
impl RateLimitPolicy for HandleVerificationLimit {
const KIND: RateLimitKind = RateLimitKind::HandleVerification;
}
pub trait RateLimitRejection: IntoResponse + Send + 'static {
fn new() -> Self;
}
@@ -33,6 +33,7 @@ pub struct RateLimiters {
pub sso_callback: Arc<KeyedRateLimiter>,
pub sso_unlink: Arc<KeyedRateLimiter>,
pub oauth_register_complete: Arc<KeyedRateLimiter>,
pub handle_verification: Arc<KeyedRateLimiter>,
}
impl Default for RateLimiters {
@@ -109,6 +110,9 @@ impl RateLimiters {
.unwrap()
.allow_burst(NonZeroU32::new(5).unwrap()),
)),
handle_verification: Arc::new(RateLimiter::keyed(Quota::per_minute(
NonZeroU32::new(10).unwrap(),
))),
}
}
+4
View File
@@ -71,6 +71,7 @@ pub enum RateLimitKind {
SsoCallback,
SsoUnlink,
OAuthRegisterComplete,
HandleVerification,
}
impl RateLimitKind {
@@ -95,6 +96,7 @@ impl RateLimitKind {
Self::SsoCallback => "sso_callback",
Self::SsoUnlink => "sso_unlink",
Self::OAuthRegisterComplete => "oauth_register_complete",
Self::HandleVerification => "handle_verification",
}
}
@@ -119,6 +121,7 @@ impl RateLimitKind {
Self::SsoCallback => (30, 60_000),
Self::SsoUnlink => (10, 60_000),
Self::OAuthRegisterComplete => (5, 300_000),
Self::HandleVerification => (10, 60_000),
}
}
}
@@ -271,6 +274,7 @@ impl AppState {
RateLimitKind::SsoCallback => &self.rate_limiters.sso_callback,
RateLimitKind::SsoUnlink => &self.rate_limiters.sso_unlink,
RateLimitKind::OAuthRegisterComplete => &self.rate_limiters.oauth_register_complete,
RateLimitKind::HandleVerification => &self.rate_limiters.handle_verification,
};
let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+96
View File
@@ -531,3 +531,99 @@ async fn test_update_handle_too_long() {
assert_eq!(body["error"], "InvalidHandle");
assert!(body["message"].as_str().unwrap().contains("long"));
}
#[tokio::test]
async fn test_verify_handle_ownership_invalid_did() {
let client = client();
let res = client
.post(format!(
"{}/xrpc/_identity.verifyHandleOwnership",
base_url().await
))
.json(&json!({
"handle": "some.handle.test",
"did": "not-a-did"
}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn test_verify_handle_ownership_invalid_handle() {
let client = client();
let res = client
.post(format!(
"{}/xrpc/_identity.verifyHandleOwnership",
base_url().await
))
.json(&json!({
"handle": "@#$!",
"did": "did:plc:abc123"
}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let body: Value = res.json().await.expect("Response was not valid JSON");
assert_eq!(body["error"], "InvalidHandle");
}
#[tokio::test]
async fn test_verify_handle_ownership_missing_fields() {
let client = client();
let res = client
.post(format!(
"{}/xrpc/_identity.verifyHandleOwnership",
base_url().await
))
.json(&json!({}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn test_verify_handle_ownership_unresolvable() {
let client = client();
let res = client
.post(format!(
"{}/xrpc/_identity.verifyHandleOwnership",
base_url().await
))
.json(&json!({
"handle": "nonexistent.example.com",
"did": "did:plc:abc123def456"
}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response was not valid JSON");
assert_eq!(body["verified"], false);
assert!(body["error"].as_str().is_some());
}
#[tokio::test]
async fn test_verify_handle_ownership_wrong_did() {
let client = client();
let res = client
.post(format!(
"{}/xrpc/_identity.verifyHandleOwnership",
base_url().await
))
.json(&json!({
"handle": "nonexistent.example.com",
"did": "did:plc:aaaaaaaaaaaaaaaaaaaaaa"
}))
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response was not valid JSON");
assert_eq!(body["verified"], false);
assert!(body["error"].as_str().is_some());
assert!(body["method"].is_null());
}
+17 -10
View File
@@ -1,5 +1,7 @@
mod common;
use tranquil_pds::comms::{SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value};
use tranquil_pds::comms::{
SendError, is_valid_phone_number, is_valid_signal_username, sanitize_header_value,
};
use tranquil_pds::image::{ImageError, ImageProcessor};
#[test]
@@ -101,15 +103,20 @@ fn test_signal_username_validation() {
assert!(!is_valid_signal_username("a".repeat(33).as_str()));
["alice.01; rm -rf /", "bob.01 && cat /etc/passwd", "user.01`id`", "test.01$(whoami)"]
.iter()
.for_each(|malicious| {
assert!(
!is_valid_signal_username(malicious),
"Command injection '{}' should be rejected",
malicious
);
});
[
"alice.01; rm -rf /",
"bob.01 && cat /etc/passwd",
"user.01`id`",
"test.01$(whoami)",
]
.iter()
.for_each(|malicious| {
assert!(
!is_valid_signal_username(malicious),
"Command injection '{}' should be rejected",
malicious
);
});
}
#[test]
@@ -333,18 +333,14 @@
placeholder={$_('register.signalUsernamePlaceholder')}
disabled={saving}
/>
{#if signalUsername && signalUsername === savedSignalUsername && !signalVerified}
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
{/if}
</div>
{#if signalInUse}
<p class="hint warning">{$_('comms.signalInUseWarning')}</p>
{/if}
{#if verifyingChannel === 'signal'}
{#if signalUsername && signalUsername === savedSignalUsername && !signalVerified}
<div class="verify-form">
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="128" />
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="512" />
<button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button>
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
</div>
{/if}
</div>
@@ -553,20 +549,11 @@
color: var(--text-secondary);
}
.verify-btn {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
}
.verify-form {
display: flex;
flex-direction: column;
gap: var(--space-2);
align-items: center;
}
.verify-form input {
flex: 1;
min-width: 0;
}
.verify-form button {
@@ -574,12 +561,6 @@
font-size: var(--text-sm);
}
.verify-form button.cancel {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.actions {
margin-bottom: var(--space-5);
}
@@ -1,5 +1,5 @@
<script lang="ts">
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
import { _ } from '../../lib/i18n'
interface Props {
@@ -15,6 +15,12 @@
migratingFromLabel: string
migratingFromValue: string
loading?: boolean
sourceHandle: string
sourceDid: string
handlePreservation: HandlePreservation
existingHandleVerified: boolean
verifyingExistingHandle?: boolean
existingHandleError?: string | null
onHandleChange: (handle: string) => void
onDomainChange: (domain: string) => void
onCheckHandle: () => void
@@ -22,6 +28,8 @@
onPasswordChange: (password: string) => void
onAuthMethodChange: (method: AuthMethod) => void
onInviteCodeChange: (code: string) => void
onHandlePreservationChange?: (preservation: HandlePreservation) => void
onVerifyExistingHandle?: () => void
onBack: () => void
onContinue: () => void
}
@@ -39,6 +47,12 @@
migratingFromLabel,
migratingFromValue,
loading = false,
sourceHandle,
sourceDid,
handlePreservation,
existingHandleVerified,
verifyingExistingHandle = false,
existingHandleError = null,
onHandleChange,
onDomainChange,
onCheckHandle,
@@ -46,17 +60,27 @@
onPasswordChange,
onAuthMethodChange,
onInviteCodeChange,
onHandlePreservationChange,
onVerifyExistingHandle,
onBack,
onContinue,
}: Props = $props()
const handleTooShort = $derived(handleInput.trim().length > 0 && handleInput.trim().length < 3)
const isExternalHandle = $derived(
serverInfo != null &&
sourceHandle.includes('.') &&
!serverInfo.availableUserDomains.some(d => sourceHandle.endsWith(`.${d}`))
)
const canContinue = $derived(
handleInput.trim().length >= 3 &&
email &&
(authMethod === 'passkey' || password) &&
handleAvailable !== false
(
(handlePreservation === 'existing' && existingHandleVerified) ||
(handlePreservation === 'new' && handleInput.trim().length >= 3 && handleAvailable !== false)
)
)
</script>
@@ -69,38 +93,115 @@
<span class="value">{migratingFromValue}</span>
</div>
<div class="field">
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
<div class="handle-input-group">
<input
id="new-handle"
type="text"
placeholder="username"
value={handleInput}
oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)}
onblur={onCheckHandle}
/>
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
<select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}>
{#each serverInfo.availableUserDomains as domain}
<option value={domain}>.{domain}</option>
{/each}
</select>
{#if isExternalHandle}
<div class="field">
<span class="field-label">{$_('migration.inbound.chooseHandle.handleChoice')}</span>
<div class="handle-choice-options">
<label class="handle-choice-option" class:selected={handlePreservation === 'existing'}>
<input
type="radio"
name="handle-preservation"
value="existing"
checked={handlePreservation === 'existing'}
onchange={() => onHandlePreservationChange?.('existing')}
/>
<div class="handle-choice-content">
<strong>{$_('migration.inbound.chooseHandle.keepExisting')}</strong>
<span class="handle-preview">@{sourceHandle}</span>
</div>
</label>
<label class="handle-choice-option" class:selected={handlePreservation === 'new'}>
<input
type="radio"
name="handle-preservation"
value="new"
checked={handlePreservation === 'new'}
onchange={() => onHandlePreservationChange?.('new')}
/>
<div class="handle-choice-content">
<strong>{$_('migration.inbound.chooseHandle.createNew')}</strong>
</div>
</label>
</div>
</div>
{/if}
{#if handlePreservation === 'existing' && isExternalHandle}
<div class="field">
<span class="field-label">{$_('migration.inbound.chooseHandle.existingHandle')}</span>
<div class="existing-handle-display">
<span class="handle-value">@{sourceHandle}</span>
{#if existingHandleVerified}
<span class="verified-badge">{$_('migration.inbound.chooseHandle.verified')}</span>
{/if}
</div>
{#if !existingHandleVerified}
<div class="verification-instructions">
<p class="instruction-header">{$_('migration.inbound.chooseHandle.verifyInstructions')}</p>
<div class="verification-record">
<code>_atproto.{sourceHandle} TXT "did={sourceDid}"</code>
</div>
<p class="instruction-or">{$_('migration.inbound.chooseHandle.or')}</p>
<div class="verification-record">
<code>https://{sourceHandle}/.well-known/atproto-did</code>
<span class="record-content">{$_('migration.inbound.chooseHandle.returning')} <code>{sourceDid}</code></span>
</div>
</div>
<button
class="verify-btn"
onclick={() => onVerifyExistingHandle?.()}
disabled={verifyingExistingHandle}
>
{#if verifyingExistingHandle}
{$_('migration.inbound.chooseHandle.verifying')}
{:else if existingHandleError}
{$_('migration.inbound.chooseHandle.checkAgain')}
{:else}
{$_('migration.inbound.chooseHandle.verifyOwnership')}
{/if}
</button>
{#if existingHandleError}
<p class="hint error">{existingHandleError}</p>
{/if}
{/if}
</div>
{:else}
<div class="field">
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
<div class="handle-input-group">
<input
id="new-handle"
type="text"
placeholder="username"
value={handleInput}
oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)}
onblur={onCheckHandle}
/>
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
<select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}>
{#each serverInfo.availableUserDomains as domain}
<option value={domain}>.{domain}</option>
{/each}
</select>
{/if}
</div>
{#if handleTooShort}
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTooShort')}</p>
{:else if checkingHandle}
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
{:else if handleAvailable === true}
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
{:else if handleAvailable === false}
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
{:else}
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
{/if}
</div>
{#if handleTooShort}
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTooShort')}</p>
{:else if checkingHandle}
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
{:else if handleAvailable === true}
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
{:else if handleAvailable === false}
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
{:else}
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
{/if}
</div>
{/if}
<div class="field">
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
@@ -187,3 +288,123 @@
</button>
</div>
</div>
<style>
.handle-choice-options {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.handle-choice-option {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color var(--transition-normal), background var(--transition-normal);
}
.handle-choice-option:hover {
border-color: var(--accent);
}
.handle-choice-option.selected {
border-color: var(--accent);
background: var(--accent-muted);
}
.handle-choice-option input[type="radio"] {
flex-shrink: 0;
width: 18px;
height: 18px;
margin: 0;
}
.handle-choice-content {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.handle-preview {
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.existing-handle-display {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-4);
background: var(--bg-secondary);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
}
.handle-value {
font-family: var(--font-mono);
font-size: var(--text-base);
}
.verified-badge {
font-size: var(--text-xs);
padding: var(--space-1) var(--space-3);
background: var(--success-bg);
color: var(--success-text);
border-radius: var(--radius-md);
}
.verification-instructions {
background: var(--bg-secondary);
padding: var(--space-5);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
}
.instruction-header {
margin: 0 0 var(--space-4) 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.instruction-or {
margin: var(--space-3) 0;
font-size: var(--text-xs);
color: var(--text-muted);
text-align: center;
}
.verification-record {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.verification-record code {
font-size: var(--text-sm);
padding: var(--space-3);
background: var(--bg-tertiary);
border-radius: var(--radius-md);
overflow-x: auto;
word-break: break-all;
}
.record-content {
font-size: var(--text-xs);
color: var(--text-secondary);
padding-left: var(--space-3);
}
.record-content code {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
}
.verify-btn {
width: 100%;
}
</style>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { InboundMigrationFlow } from '../../lib/migration'
import type { AuthMethod, ServerDescription } from '../../lib/migration/types'
import type { AuthMethod, HandlePreservation, ServerDescription } from '../../lib/migration/types'
import { getErrorMessage } from '../../lib/migration/types'
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
import { _ } from '../../lib/i18n'
@@ -43,6 +43,8 @@
let checkingHandle = $state(false)
let selectedAuthMethod = $state<AuthMethod>('password')
let passkeyName = $state('')
let verifyingExistingHandle = $state(false)
let existingHandleError = $state<string | null>(null)
const isResuming = $derived(flow.state.needsReauth === true)
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
@@ -54,6 +56,9 @@
if (flow.state.step === 'choose-handle') {
handleInput = ''
handleAvailable = null
existingHandleError = null
flow.updateField('handlePreservation', 'new')
flow.updateField('existingHandleVerified', false)
}
if (flow.state.step === 'source-handle' && resumeInfo) {
handleInput = resumeInfo.sourceHandle
@@ -112,6 +117,30 @@
}
}
function handlePreservationChange(preservation: HandlePreservation) {
flow.updateField('handlePreservation', preservation)
existingHandleError = null
if (preservation === 'existing') {
flow.updateField('existingHandleVerified', false)
}
}
async function verifyExistingHandle() {
verifyingExistingHandle = true
existingHandleError = null
try {
const result = await flow.verifyExistingHandle()
if (!result.verified && result.error) {
existingHandleError = result.error
}
} catch (err) {
existingHandleError = getErrorMessage(err)
} finally {
verifyingExistingHandle = false
}
}
function proceedToReview() {
const fullHandle = handleInput.includes('.')
? handleInput
@@ -265,11 +294,16 @@
}
function proceedToReviewWithAuth() {
const fullHandle = handleInput.includes('.')
? handleInput
: `${handleInput}.${selectedDomain}`
let targetHandle: string
if (flow.state.handlePreservation === 'existing' && flow.state.existingHandleVerified) {
targetHandle = flow.state.sourceHandle
} else {
targetHandle = handleInput.includes('.')
? handleInput
: `${handleInput}.${selectedDomain}`
}
flow.updateField('targetHandle', fullHandle)
flow.updateField('targetHandle', targetHandle)
flow.updateField('authMethod', selectedAuthMethod)
flow.setStep('review')
}
@@ -420,6 +454,12 @@
migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')}
migratingFromValue={flow.state.sourceHandle}
{loading}
sourceHandle={flow.state.sourceHandle}
sourceDid={flow.state.sourceDid}
handlePreservation={flow.state.handlePreservation}
existingHandleVerified={flow.state.existingHandleVerified}
{verifyingExistingHandle}
{existingHandleError}
onHandleChange={(h) => handleInput = h}
onDomainChange={(d) => selectedDomain = d}
onCheckHandle={checkHandle}
@@ -427,6 +467,8 @@
onPasswordChange={(p) => flow.updateField('targetPassword', p)}
onAuthMethodChange={(m) => selectedAuthMethod = m}
onInviteCodeChange={(c) => flow.updateField('inviteCode', c)}
onHandlePreservationChange={handlePreservationChange}
onVerifyExistingHandle={verifyExistingHandle}
onBack={() => flow.setStep('source-handle')}
onContinue={proceedToReviewWithAuth}
/>
@@ -412,6 +412,12 @@
migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')}
migratingFromValue={flow.state.userDid}
{loading}
sourceHandle=""
sourceDid={flow.state.userDid}
handlePreservation="new"
existingHandleVerified={false}
verifyingExistingHandle={false}
existingHandleError={null}
onHandleChange={(h) => handleInput = h}
onDomainChange={(d) => selectedDomain = d}
onCheckHandle={checkHandle}
+21 -7
View File
@@ -327,8 +327,12 @@ function _castDelegationController(raw: unknown): DelegationController {
return {
did: unsafeAsDid(c.did as string),
handle: unsafeAsHandle(c.handle as string),
grantedScopes: unsafeAsScopeSet((c.granted_scopes ?? c.grantedScopes) as string),
grantedAt: unsafeAsISODate((c.granted_at ?? c.grantedAt ?? c.added_at) as string),
grantedScopes: unsafeAsScopeSet(
(c.granted_scopes ?? c.grantedScopes) as string,
),
grantedAt: unsafeAsISODate(
(c.granted_at ?? c.grantedAt ?? c.added_at) as string,
),
isActive: (c.is_active ?? c.isActive ?? true) as boolean,
};
}
@@ -340,15 +344,21 @@ function _castDelegationControlledAccount(
return {
did: unsafeAsDid(a.did as string),
handle: unsafeAsHandle(a.handle as string),
grantedScopes: unsafeAsScopeSet((a.granted_scopes ?? a.grantedScopes) as string),
grantedAt: unsafeAsISODate((a.granted_at ?? a.grantedAt ?? a.added_at) as string),
grantedScopes: unsafeAsScopeSet(
(a.granted_scopes ?? a.grantedScopes) as string,
),
grantedAt: unsafeAsISODate(
(a.granted_at ?? a.grantedAt ?? a.added_at) as string,
),
};
}
function _castDelegationAuditEntry(raw: unknown): DelegationAuditEntry {
const e = raw as Record<string, unknown>;
const actorDid = (e.actor_did ?? e.actorDid) as string;
const targetDid = (e.target_did ?? e.targetDid ?? e.delegatedDid) as string | undefined;
const targetDid = (e.target_did ?? e.targetDid ?? e.delegatedDid) as
| string
| undefined;
const createdAt = (e.created_at ?? e.createdAt) as string;
const action = (e.action ?? e.actionType) as string;
const details = e.details ?? e.actionDetails;
@@ -1434,7 +1444,9 @@ export const api = {
);
if (!result.ok) return result;
return ok({
controllers: (result.value.controllers ?? []).map(_castDelegationController),
controllers: (result.value.controllers ?? []).map(
_castDelegationController,
),
});
},
@@ -1447,7 +1459,9 @@ export const api = {
);
if (!result.ok) return result;
return ok({
accounts: (result.value.accounts ?? []).map(_castDelegationControlledAccount),
accounts: (result.value.accounts ?? []).map(
_castDelegationControlledAccount,
),
});
},
@@ -615,6 +615,16 @@ export class AtprotoClient {
});
}
async verifyHandleOwnership(
handle: string,
did: string,
): Promise<{ verified: boolean; method?: string; error?: string }> {
return this.xrpc("_identity.verifyHandleOwnership", {
httpMethod: "POST",
body: { handle, did },
});
}
async resendMigrationVerification(): Promise<void> {
await this.xrpc("com.atproto.server.resendMigrationVerification", {
httpMethod: "POST",
+30 -4
View File
@@ -80,6 +80,8 @@ export function createInboundMigrationFlow() {
localAccessToken: null,
generatedAppPassword: null,
generatedAppPasswordName: null,
handlePreservation: "new",
existingHandleVerified: false,
});
let sourceClient: AtprotoClient | null = null;
@@ -118,11 +120,12 @@ export function createInboundMigrationFlow() {
}
async function resolveSourcePds(handle: string): Promise<void> {
const normalized = handle.startsWith("@") ? handle.slice(1) : handle;
try {
const { did, pdsUrl } = await resolvePdsUrl(handle);
const { did, pdsUrl } = await resolvePdsUrl(normalized);
state.sourcePdsUrl = pdsUrl;
state.sourceDid = did;
state.sourceHandle = handle;
state.sourceHandle = normalized;
sourceClient = new AtprotoClient(pdsUrl);
} catch (e) {
throw new Error(`Could not resolve handle: ${(e as Error).message}`);
@@ -132,8 +135,9 @@ export function createInboundMigrationFlow() {
async function initiateOAuthLogin(handle: string): Promise<void> {
migrationLog("initiateOAuthLogin START", { handle });
if (!state.sourcePdsUrl) {
await resolveSourcePds(handle);
const normalizedHandle = handle.startsWith("@") ? handle.slice(1) : handle;
if (!state.sourcePdsUrl || state.sourceHandle !== normalizedHandle) {
await resolveSourcePds(normalizedHandle);
}
const metadata = await getOAuthServerMetadata(state.sourcePdsUrl);
@@ -322,6 +326,25 @@ export function createInboundMigrationFlow() {
}
}
async function verifyExistingHandle(): Promise<{
verified: boolean;
method?: string;
error?: string;
}> {
if (!localClient) {
localClient = createLocalClient();
}
const result = await localClient.verifyHandleOwnership(
state.sourceHandle,
state.sourceDid,
);
if (result.verified) {
state.existingHandleVerified = true;
state.targetHandle = state.sourceHandle;
}
return result;
}
async function authenticateToLocal(
email: string,
password: string,
@@ -921,6 +944,8 @@ export function createInboundMigrationFlow() {
localAccessToken: null,
generatedAppPassword: null,
generatedAppPasswordName: null,
handlePreservation: "new",
existingHandleVerified: false,
};
sourceClient = null;
passkeySetup = null;
@@ -1005,6 +1030,7 @@ export function createInboundMigrationFlow() {
handleOAuthCallback,
authenticateToLocal,
checkHandleAvailability,
verifyExistingHandle,
startMigration,
submitEmailVerifyToken,
resendEmailVerification,
@@ -169,6 +169,8 @@ export function createOfflineInboundMigrationFlow() {
progress: createInitialProgress(),
error: null,
plcUpdatedTemporarily: false,
handlePreservation: "new",
existingHandleVerified: false,
});
let localServerInfo: ServerDescription | null = null;
@@ -671,6 +673,8 @@ export function createOfflineInboundMigrationFlow() {
progress: createInitialProgress(),
error: null,
plcUpdatedTemporarily: false,
handlePreservation: "new",
existingHandleVerified: false,
};
localServerInfo = null;
}
+6
View File
@@ -48,6 +48,8 @@ export interface MigrationProgress {
currentOperation: string;
}
export type HandlePreservation = "new" | "existing";
export interface InboundMigrationState {
direction: "inbound";
step: InboundStep;
@@ -74,6 +76,8 @@ export interface InboundMigrationState {
generatedAppPasswordName: string | null;
needsReauth?: boolean;
resumeToStep?: InboundStep;
handlePreservation: HandlePreservation;
existingHandleVerified: boolean;
}
export interface OfflineInboundMigrationState {
@@ -101,6 +105,8 @@ export interface OfflineInboundMigrationState {
progress: MigrationProgress;
error: string | null;
plcUpdatedTemporarily: boolean;
handlePreservation: HandlePreservation;
existingHandleVerified: boolean;
}
export type MigrationState = InboundMigrationState;
+2
View File
@@ -233,6 +233,8 @@ export interface ServerDescription {
availableCommsChannels?: VerificationChannel[];
selfHostedDidWebEnabled?: boolean;
telegramBotUsername?: string;
discordBotUsername?: string;
discordAppId?: string;
}
export interface UpdateNotificationPrefsResponse {
+11
View File
@@ -986,6 +986,17 @@
"title": "Choose Your New Handle",
"desc": "Select a handle for your account on this PDS.",
"migratingFrom": "Migrating from",
"handleChoice": "Handle",
"keepExisting": "Keep existing handle",
"createNew": "Create new handle",
"existingHandle": "Your handle",
"verifyInstructions": "Verify ownership via DNS or HTTP",
"or": "or",
"returning": "returning",
"verifyOwnership": "Verify ownership",
"verifying": "Verifying",
"verified": "Verified",
"checkAgain": "Check again",
"newHandle": "New Handle",
"checkingAvailability": "Checking availability...",
"handleAvailable": "Handle is available!",
+11
View File
@@ -986,6 +986,17 @@
"title": "Valitse uusi käyttäjätunnuksesi",
"desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.",
"migratingFrom": "Siirretään tililtä",
"handleChoice": "Käyttäjätunnus",
"keepExisting": "Säilytä nykyinen tunnus",
"createNew": "Luo uusi tunnus",
"existingHandle": "Nykyinen tunnus",
"verifyInstructions": "Vahvista omistajuus DNS- tai HTTP-tietueella",
"or": "tai",
"returning": "palauttaa",
"verifyOwnership": "Vahvista omistajuus",
"verifying": "Vahvistetaan",
"verified": "Vahvistettu",
"checkAgain": "Tarkista uudelleen",
"newHandle": "Uusi käyttäjätunnus",
"checkingAvailability": "Tarkistetaan saatavuutta...",
"handleAvailable": "Käyttäjätunnus on saatavilla!",
+11
View File
@@ -986,6 +986,17 @@
"title": "新しいハンドルを選択",
"desc": "このPDSでのアカウントのハンドルを選択してください。",
"migratingFrom": "移行元",
"handleChoice": "ハンドル",
"keepExisting": "既存のハンドルを維持",
"createNew": "新しいハンドルを作成",
"existingHandle": "現在のハンドル",
"verifyInstructions": "DNSまたはHTTPで所有権を確認",
"or": "または",
"returning": "返却値",
"verifyOwnership": "所有権を確認",
"verifying": "確認中",
"verified": "確認済み",
"checkAgain": "再確認",
"newHandle": "新しいハンドル",
"checkingAvailability": "利用可能か確認中...",
"handleAvailable": "ハンドルは利用可能です!",
+11
View File
@@ -986,6 +986,17 @@
"title": "새 핸들 선택",
"desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.",
"migratingFrom": "마이그레이션 원본",
"handleChoice": "핸들",
"keepExisting": "기존 핸들 유지",
"createNew": "새 핸들 생성",
"existingHandle": "현재 핸들",
"verifyInstructions": "DNS 또는 HTTP로 소유권 확인",
"or": "또는",
"returning": "반환값",
"verifyOwnership": "소유권 확인",
"verifying": "확인 중",
"verified": "확인됨",
"checkAgain": "다시 확인",
"newHandle": "새 핸들",
"checkingAvailability": "사용 가능 여부 확인 중...",
"handleAvailable": "핸들을 사용할 수 있습니다!",
+11
View File
@@ -986,6 +986,17 @@
"title": "Välj ditt nya användarnamn",
"desc": "Välj ett användarnamn för ditt konto på denna PDS.",
"migratingFrom": "Flyttar från",
"handleChoice": "Användarnamn",
"keepExisting": "Behåll befintligt användarnamn",
"createNew": "Skapa nytt användarnamn",
"existingHandle": "Ditt användarnamn",
"verifyInstructions": "Verifiera ägarskap via DNS eller HTTP",
"or": "eller",
"returning": "returnerar",
"verifyOwnership": "Verifiera ägarskap",
"verifying": "Verifierar",
"verified": "Verifierad",
"checkAgain": "Kontrollera igen",
"newHandle": "Nytt användarnamn",
"checkingAvailability": "Kontrollerar tillgänglighet...",
"handleAvailable": "Användarnamnet är tillgängligt!",
+11
View File
@@ -986,6 +986,17 @@
"title": "选择新用户名",
"desc": "为您在此PDS上的账户选择用户名。",
"migratingFrom": "迁移自",
"handleChoice": "用户名",
"keepExisting": "保留现有用户名",
"createNew": "创建新用户名",
"existingHandle": "当前用户名",
"verifyInstructions": "通过DNS或HTTP验证所有权",
"or": "或",
"returning": "返回值",
"verifyOwnership": "验证所有权",
"verifying": "验证中",
"verified": "已验证",
"checkAgain": "重新验证",
"newHandle": "新用户名",
"checkingAvailability": "检查可用性...",
"handleAvailable": "用户名可用!",
+1 -1
View File
@@ -1,5 +1,5 @@
.migration-wizard {
max-width: var(--width-sm);
max-width: var(--width-md);
margin: 0 auto;
}
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
AtprotoClient,
base64UrlDecode,
base64UrlEncode,
buildOAuthAuthorizationUrl,
@@ -517,4 +518,90 @@ describe("migration/atproto-client", () => {
expect(prepared.user?.displayName).toBe("Test User");
});
});
describe("AtprotoClient.verifyHandleOwnership", () => {
function createMockJsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
it("sends POST with handle and did", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
createMockJsonResponse({ verified: true, method: "dns" }),
);
const client = new AtprotoClient("https://pds.example.com");
await client.verifyHandleOwnership("alice.custom.com", "did:plc:abc123");
expect(fetch).toHaveBeenCalledWith(
"https://pds.example.com/xrpc/_identity.verifyHandleOwnership",
expect.objectContaining({
method: "POST",
}),
);
});
it("returns verified result with method", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
createMockJsonResponse({ verified: true, method: "dns" }),
);
const client = new AtprotoClient("https://pds.example.com");
const result = await client.verifyHandleOwnership(
"alice.custom.com",
"did:plc:abc123",
);
expect(result.verified).toBe(true);
expect(result.method).toBe("dns");
});
it("returns unverified result with error", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
createMockJsonResponse({
verified: false,
error: "Handle resolution failed",
}),
);
const client = new AtprotoClient("https://pds.example.com");
const result = await client.verifyHandleOwnership(
"nonexistent.example.com",
"did:plc:abc123",
);
expect(result.verified).toBe(false);
expect(result.error).toBe("Handle resolution failed");
});
it("throws on server error responses", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
createMockJsonResponse(
{ error: "InvalidHandle", message: "Invalid handle format" },
400,
),
);
const client = new AtprotoClient("https://pds.example.com");
await expect(
client.verifyHandleOwnership("@#$!", "did:plc:abc123"),
).rejects.toThrow("Invalid handle format");
});
it("strips trailing slash from base URL", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
createMockJsonResponse({ verified: true, method: "http" }),
);
const client = new AtprotoClient("https://pds.example.com/");
await client.verifyHandleOwnership("alice.example.com", "did:plc:abc");
expect(fetch).toHaveBeenCalledWith(
"https://pds.example.com/xrpc/_identity.verifyHandleOwnership",
expect.anything(),
);
});
});
});
@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createInboundMigrationFlow } from "../../lib/migration/flow.svelte.ts";
const STORAGE_KEY = "tranquil_migration_state";
function createMockJsonResponse(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
describe("migration/flow handle preservation", () => {
beforeEach(() => {
localStorage.removeItem(STORAGE_KEY);
vi.restoreAllMocks();
});
describe("initial state", () => {
it("defaults handlePreservation to 'new'", () => {
const flow = createInboundMigrationFlow();
expect(flow.state.handlePreservation).toBe("new");
});
it("defaults existingHandleVerified to false", () => {
const flow = createInboundMigrationFlow();
expect(flow.state.existingHandleVerified).toBe(false);
});
});
describe("updateField for handle preservation", () => {
it("sets handlePreservation to 'existing'", () => {
const flow = createInboundMigrationFlow();
flow.updateField("handlePreservation", "existing");
expect(flow.state.handlePreservation).toBe("existing");
});
it("sets handlePreservation back to 'new'", () => {
const flow = createInboundMigrationFlow();
flow.updateField("handlePreservation", "existing");
flow.updateField("handlePreservation", "new");
expect(flow.state.handlePreservation).toBe("new");
});
it("sets existingHandleVerified", () => {
const flow = createInboundMigrationFlow();
flow.updateField("existingHandleVerified", true);
expect(flow.state.existingHandleVerified).toBe(true);
});
});
describe("verifyExistingHandle", () => {
it("sets existingHandleVerified and targetHandle on success", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
createMockJsonResponse({ verified: true, method: "dns" }),
);
const flow = createInboundMigrationFlow();
flow.updateField("sourceHandle", "alice.custom.com");
flow.updateField("sourceDid", "did:plc:abc123");
const result = await flow.verifyExistingHandle();
expect(result.verified).toBe(true);
expect(result.method).toBe("dns");
expect(flow.state.existingHandleVerified).toBe(true);
expect(flow.state.targetHandle).toBe("alice.custom.com");
});
it("does not set existingHandleVerified on failure", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
createMockJsonResponse({
verified: false,
error: "Handle resolution failed",
}),
);
const flow = createInboundMigrationFlow();
flow.updateField("sourceHandle", "alice.custom.com");
flow.updateField("sourceDid", "did:plc:abc123");
const result = await flow.verifyExistingHandle();
expect(result.verified).toBe(false);
expect(result.error).toBe("Handle resolution failed");
expect(flow.state.existingHandleVerified).toBe(false);
});
it("sends correct handle and did to endpoint", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
createMockJsonResponse({ verified: true, method: "http" }),
);
const flow = createInboundMigrationFlow();
flow.updateField("sourceHandle", "bob.example.org");
flow.updateField("sourceDid", "did:plc:xyz789");
await flow.verifyExistingHandle();
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining("_identity.verifyHandleOwnership"),
expect.objectContaining({
method: "POST",
body: JSON.stringify({
handle: "bob.example.org",
did: "did:plc:xyz789",
}),
}),
);
});
it("handles http verification method", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
createMockJsonResponse({ verified: true, method: "http" }),
);
const flow = createInboundMigrationFlow();
flow.updateField("sourceHandle", "alice.custom.com");
flow.updateField("sourceDid", "did:plc:abc123");
const result = await flow.verifyExistingHandle();
expect(result.method).toBe("http");
});
it("propagates xrpc errors as thrown exceptions", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(
createMockJsonResponse(
{ error: "InvalidHandle", message: "Invalid handle format" },
400,
),
);
const flow = createInboundMigrationFlow();
flow.updateField("sourceHandle", "@#$!");
flow.updateField("sourceDid", "did:plc:abc123");
await expect(flow.verifyExistingHandle()).rejects.toThrow();
});
});
describe("reset clears handle preservation state", () => {
it("resets handlePreservation to 'new'", () => {
const flow = createInboundMigrationFlow();
flow.updateField("handlePreservation", "existing");
flow.updateField("existingHandleVerified", true);
flow.reset();
expect(flow.state.handlePreservation).toBe("new");
expect(flow.state.existingHandleVerified).toBe(false);
});
});
describe("handle @ normalization", () => {
it("resolveSourcePds strips leading @", async () => {
globalThis.fetch = vi.fn()
.mockResolvedValueOnce(
createMockJsonResponse({
Answer: [{ data: '"did=did:plc:test123"' }],
}),
)
.mockResolvedValueOnce(
createMockJsonResponse({
id: "did:plc:test123",
service: [
{
type: "AtprotoPersonalDataServer",
serviceEndpoint: "https://pds.example.com",
},
],
}),
);
const flow = createInboundMigrationFlow();
await flow.resolveSourcePds("@alice.example.com");
expect(flow.state.sourceHandle).toBe("alice.example.com");
});
it("resolveSourcePds preserves handle without @", async () => {
globalThis.fetch = vi.fn()
.mockResolvedValueOnce(
createMockJsonResponse({
Answer: [{ data: '"did=did:plc:test456"' }],
}),
)
.mockResolvedValueOnce(
createMockJsonResponse({
id: "did:plc:test456",
service: [
{
type: "AtprotoPersonalDataServer",
serviceEndpoint: "https://pds.example.com",
},
],
}),
);
const flow = createInboundMigrationFlow();
await flow.resolveSourcePds("bob.example.com");
expect(flow.state.sourceHandle).toBe("bob.example.com");
});
});
});
@@ -86,6 +86,8 @@ function createInboundState(
localAccessToken: null,
generatedAppPassword: null,
generatedAppPasswordName: null,
handlePreservation: "new",
existingHandleVerified: false,
...overrides,
};
}
@@ -203,6 +205,24 @@ describe("migration/storage", () => {
expect(stored.passkeySetupToken).toBe("setup-token-123");
});
it("does not persist handlePreservation to storage (transient state)", () => {
const state = createInboundState({ handlePreservation: "existing" });
saveMigrationState(state);
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
expect(stored.handlePreservation).toBeUndefined();
});
it("does not persist existingHandleVerified to storage (transient state)", () => {
const state = createInboundState({ existingHandleVerified: true });
saveMigrationState(state);
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)!);
expect(stored.existingHandleVerified).toBeUndefined();
});
it("saves error information", () => {
const state = createInboundState({
step: "error",
+15
View File
@@ -10,6 +10,21 @@ init({
initialLocale: "en",
});
Object.defineProperty(window, "matchMedia", {
writable: true,
configurable: true,
value: vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
let locationHash = "";
Object.defineProperty(window, "location", {