refactor(frontend): refactor migration and registration lib

This commit is contained in:
Lewis
2026-03-18 18:36:16 +02:00
committed by Tangled
parent f3f55e239f
commit 81fc03c705
15 changed files with 706 additions and 425 deletions

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import type { VerificationChannel } from '../lib/types/api'
import { _ } from '../lib/i18n'
interface Props {
channel: VerificationChannel
email: string
discordUsername: string
telegramUsername: string
signalUsername: string
availableChannels: VerificationChannel[]
disabled?: boolean
discordInUse?: boolean
telegramInUse?: boolean
signalInUse?: boolean
onChannelChange: (channel: VerificationChannel) => void
onEmailChange: (value: string) => void
onDiscordChange: (value: string) => void
onTelegramChange: (value: string) => void
onSignalChange: (value: string) => void
onCheckInUse?: (channel: 'discord' | 'telegram' | 'signal', identifier: string) => void
}
let {
channel,
email,
discordUsername,
telegramUsername,
signalUsername,
availableChannels,
disabled = false,
discordInUse = false,
telegramInUse = false,
signalInUse = false,
onChannelChange,
onEmailChange,
onDiscordChange,
onTelegramChange,
onSignalChange,
onCheckInUse,
}: Props = $props()
function channelLabel(ch: string): string {
switch (ch) {
case 'email': return $_('register.email')
case 'discord': return $_('register.discord')
case 'telegram': return $_('register.telegram')
case 'signal': return $_('register.signal')
default: return ch
}
}
function isAvailable(ch: VerificationChannel): boolean {
return availableChannels.includes(ch)
}
</script>
<div>
<label for="verification-channel">{$_('register.verificationMethod')}</label>
<select id="verification-channel" value={channel} onchange={(e) => onChannelChange((e.target as HTMLSelectElement).value as VerificationChannel)} {disabled}>
<option value="email">{channelLabel('email')}</option>
{#if isAvailable('discord')}
<option value="discord">{channelLabel('discord')}</option>
{/if}
{#if isAvailable('telegram')}
<option value="telegram">{channelLabel('telegram')}</option>
{/if}
{#if isAvailable('signal')}
<option value="signal">{channelLabel('signal')}</option>
{/if}
</select>
</div>
{#if channel === 'email'}
<div>
<label for="comms-email">{$_('register.emailAddress')}</label>
<input
id="comms-email"
type="email"
value={email}
oninput={(e) => onEmailChange((e.target as HTMLInputElement).value)}
placeholder={$_('register.emailPlaceholder')}
{disabled}
required
/>
</div>
{:else if channel === 'discord'}
<div>
<label for="comms-discord">{$_('register.discordUsername')}</label>
<input
id="comms-discord"
type="text"
value={discordUsername}
oninput={(e) => onDiscordChange((e.target as HTMLInputElement).value)}
onblur={() => onCheckInUse?.('discord', discordUsername)}
placeholder={$_('register.discordUsernamePlaceholder')}
{disabled}
required
/>
{#if discordInUse}
<p class="hint warning">{$_('register.discordInUseWarning')}</p>
{/if}
</div>
{:else if channel === 'telegram'}
<div>
<label for="comms-telegram">{$_('register.telegramUsername')}</label>
<input
id="comms-telegram"
type="text"
value={telegramUsername}
oninput={(e) => onTelegramChange((e.target as HTMLInputElement).value)}
onblur={() => onCheckInUse?.('telegram', telegramUsername)}
placeholder={$_('register.telegramUsernamePlaceholder')}
{disabled}
required
/>
{#if telegramInUse}
<p class="hint warning">{$_('register.telegramInUseWarning')}</p>
{/if}
</div>
{:else if channel === 'signal'}
<div>
<label for="comms-signal">{$_('register.signalUsername')}</label>
<input
id="comms-signal"
type="tel"
value={signalUsername}
oninput={(e) => onSignalChange((e.target as HTMLInputElement).value)}
onblur={() => onCheckInUse?.('signal', signalUsername)}
placeholder={$_('register.signalUsernamePlaceholder')}
{disabled}
required
/>
<p class="hint">{$_('register.signalUsernameHint')}</p>
{#if signalInUse}
<p class="hint warning">{$_('register.signalInUseWarning')}</p>
{/if}
</div>
{/if}

View File

@@ -7,6 +7,9 @@
placeholder?: string
id?: string
autocomplete?: HTMLInputElement['autocomplete']
checkAvailability?: (fullHandle: string) => Promise<boolean>
available?: boolean | null
checking?: boolean
onInput: (value: string) => void
onDomainChange: (domain: string) => void
}
@@ -19,11 +22,42 @@
placeholder = 'username',
id = 'handle',
autocomplete = 'off',
checkAvailability,
available = $bindable<boolean | null>(null),
checking = $bindable(false),
onInput,
onDomainChange,
}: Props = $props()
const showDomainSelect = $derived(domains.length > 1 && !value.includes('.'))
let checkTimeout: ReturnType<typeof setTimeout> | null = null
$effect(() => {
void value
void selectedDomain
if (!checkAvailability) return
if (checkTimeout) clearTimeout(checkTimeout)
available = null
if (value.trim().length >= 3 && !value.includes('.')) {
checkTimeout = setTimeout(() => runCheck(), 400)
}
})
async function runCheck() {
if (!checkAvailability) return
const fullHandle = value.includes('.')
? value.trim()
: `${value.trim()}.${selectedDomain}`
checking = true
try {
available = await checkAvailability(fullHandle)
} catch {
available = null
} finally {
checking = false
}
}
</script>
<div class="handle-input-group">

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { _ } from '../lib/i18n'
interface Props {
didType: 'plc' | 'web' | 'web-external'
externalDid: string
disabled: boolean
selfHostedDidWebEnabled: boolean
defaultDomain: string
onDidTypeChange: (value: 'plc' | 'web' | 'web-external') => void
onExternalDidChange: (value: string) => void
}
let {
didType,
externalDid,
disabled,
selfHostedDidWebEnabled,
defaultDomain,
onDidTypeChange,
onExternalDidChange,
}: Props = $props()
function extractDomain(did: string): string {
return did.replace(/^did:web:/, '').split(':')[0] || 'yourdomain.com'
}
</script>
<fieldset class="identity-section">
<legend>{$_('registerPasskey.identityType')}</legend>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="didType" value="plc" checked={didType === 'plc'} onchange={() => onDidTypeChange('plc')} {disabled} />
<span class="radio-content">
<strong>{$_('registerPasskey.didPlcRecommended')}</strong>
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
</span>
</label>
<label class="radio-label" class:disabled={!selfHostedDidWebEnabled}>
<input type="radio" name="didType" value="web" checked={didType === 'web'} onchange={() => onDidTypeChange('web')} disabled={disabled || !selfHostedDidWebEnabled} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWeb')}</strong>
{#if !selfHostedDidWebEnabled}
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
{:else}
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
{/if}
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web-external" checked={didType === 'web-external'} onchange={() => onDidTypeChange('web-external')} {disabled} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWebBYOD')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebBYODHint')}</span>
</span>
</label>
</div>
</fieldset>
{#if didType === 'web'}
<div class="warning-box">
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
<ul>
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${defaultDomain}</code>` } })}</li>
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
</ul>
</div>
{/if}
{#if didType === 'web-external'}
<div>
<label for="external-did">{$_('registerPasskey.externalDid')}</label>
<input id="external-did" type="text" value={externalDid} oninput={(e) => onExternalDidChange(e.currentTarget.value)} placeholder={$_('registerPasskey.externalDidPlaceholder')} {disabled} required />
<p class="hint">{$_('registerPasskey.externalDidHint')} <code>https://{externalDid ? extractDomain(externalDid) : 'yourdomain.com'}/.well-known/did.json</code></p>
</div>
{/if}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { portal } from '../lib/portal'
import { getAuthState, getValidToken } from '../lib/auth.svelte'
import { api, ApiError } from '../lib/api'
import { _ } from '../lib/i18n'
@@ -136,7 +137,7 @@
</script>
{#if show}
<div class="modal-backdrop" onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
<div class="modal-backdrop" use:portal onclick={handleClose} onkeydown={(e) => e.key === 'Escape' && handleClose()} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="dialog" aria-modal="true" tabindex="-1">
<div class="modal-header">
<h2>{$_('reauth.title')}</h2>
@@ -181,7 +182,7 @@
<div class="modal-content">
{#if activeMethod === 'password'}
<form onsubmit={handlePasswordSubmit}>
<form id="reauth-form" onsubmit={handlePasswordSubmit}>
<div>
<label for="reauth-password">{$_('reauth.password')}</label>
<input
@@ -192,12 +193,9 @@
autocomplete="current-password"
/>
</div>
<button type="submit" disabled={loading || !password}>
{loading ? $_('common.verifying') : $_('common.verify')}
</button>
</form>
{:else if activeMethod === 'totp'}
<form onsubmit={handleTotpSubmit}>
<form id="reauth-form" onsubmit={handleTotpSubmit}>
<div>
<label for="reauth-totp">{$_('reauth.authenticatorCode')}</label>
<input
@@ -211,15 +209,10 @@
maxlength="6"
/>
</div>
<button type="submit" disabled={loading || !totpCode}>
{loading ? $_('common.verifying') : $_('common.verify')}
</button>
</form>
{:else if activeMethod === 'passkey'}
<div class="passkey-auth">
<button onclick={handlePasskeyAuth} disabled={loading}>
{loading ? $_('reauth.authenticating') : $_('reauth.usePasskey')}
</button>
<p>{$_('reauth.usePasskey')}</p>
</div>
{/if}
</div>
@@ -228,6 +221,15 @@
<button class="secondary" onclick={handleClose} disabled={loading}>
{$_('reauth.cancel')}
</button>
{#if activeMethod === 'passkey'}
<button onclick={handlePasskeyAuth} disabled={loading}>
{loading ? $_('reauth.authenticating') : $_('common.verify')}
</button>
{:else}
<button type="submit" form="reauth-form" disabled={loading || (activeMethod === 'password' ? !password : !totpCode)}>
{loading ? $_('common.verifying') : $_('common.verify')}
</button>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,29 @@
export interface EmailVerificationDeps {
checkVerified: () => Promise<boolean>;
onVerified: () => Promise<void>;
}
export function createEmailVerificationPoller(
deps: EmailVerificationDeps,
): { checkAndAdvance: () => Promise<boolean> } {
let checking = false;
return {
async checkAndAdvance(): Promise<boolean> {
if (checking) return false;
checking = true;
try {
const verified = await deps.checkVerified();
if (!verified) return false;
await deps.onVerified();
return true;
} catch {
return false;
} finally {
checking = false;
}
},
};
}

View File

@@ -0,0 +1,56 @@
import type {
MigrationProgress,
ServerDescription,
VerificationChannel,
} from "../migration/types.ts";
import type { AtprotoClient } from "../migration/atproto-client.ts";
export function createInitialProgress(): MigrationProgress {
return {
repoExported: false,
repoImported: false,
blobsTotal: 0,
blobsMigrated: 0,
blobsFailed: [],
prefsMigrated: false,
plcSigned: false,
activated: false,
deactivated: false,
currentOperation: "",
};
}
export async function checkHandleAvailabilityViaClient(
client: AtprotoClient,
handle: string,
): Promise<boolean> {
try {
await client.resolveHandle(handle);
return false;
} catch {
return true;
}
}
export function resolveVerificationIdentifier(
channel: VerificationChannel,
email: string,
discordUsername: string,
telegramUsername: string,
signalUsername: string,
): string {
switch (channel) {
case "email": return email;
case "discord": return discordUsername;
case "telegram": return telegramUsername;
case "signal": return signalUsername;
}
}
export async function loadServerInfo(
client: AtprotoClient,
cached: ServerDescription | null,
): Promise<ServerDescription> {
if (cached) return cached;
return client.describeServer();
}

View File

@@ -0,0 +1,54 @@
import {
type CredentialAttestationJSON,
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
} from "../webauthn.ts";
export class PasskeyCancelledError extends Error {
constructor() {
super("Passkey creation was cancelled");
this.name = "PasskeyCancelledError";
}
}
export async function createPasskeyCredential(
startRegistration: () => Promise<{ options: unknown }>,
): Promise<CredentialAttestationJSON> {
if (!globalThis.PublicKeyCredential) {
throw new Error("Passkeys are not supported in this browser");
}
const { options } = await startRegistration();
const publicKeyOptions = prepareCreationOptions(
options as unknown as WebAuthnCreationOptionsResponse,
);
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions,
});
if (!credential) {
throw new PasskeyCancelledError();
}
return serializeAttestationResponse(credential as PublicKeyCredential);
}
export interface PasskeyRegistrationApi {
startRegistration(): Promise<{ options: unknown }>;
completeSetup(
credential: CredentialAttestationJSON,
name?: string,
): Promise<{ appPassword: string; appPasswordName: string }>;
}
export async function performPasskeyRegistration(
passkeyApi: PasskeyRegistrationApi,
friendlyName?: string,
): Promise<{ appPassword: string; appPasswordName: string }> {
const serialized = await createPasskeyCredential(
passkeyApi.startRegistration,
);
return passkeyApi.completeSetup(serialized, friendlyName);
}

View File

@@ -603,6 +603,20 @@ export class AtprotoClient {
return result.verified;
}
async checkChannelVerified(
did: string,
channel: string,
): Promise<boolean> {
const result = await this.xrpc<{ verified: boolean }>(
"_checkChannelVerified",
{
httpMethod: "POST",
body: { did, channel },
},
);
return result.verified;
}
async verifyToken(
token: string,
identifier: string,
@@ -625,9 +639,13 @@ export class AtprotoClient {
});
}
async resendMigrationVerification(): Promise<void> {
async resendMigrationVerification(
channel: string,
identifier: string,
): Promise<void> {
await this.xrpc("com.atproto.server.resendMigrationVerification", {
httpMethod: "POST",
body: { channel, identifier },
});
}
@@ -731,23 +749,7 @@ export async function getOAuthServerMetadata(
}
}
export async function generatePKCE(): Promise<{
codeVerifier: string;
codeChallenge: string;
}> {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const codeVerifier = base64UrlEncode(array);
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await crypto.subtle.digest("SHA-256", data);
const codeChallenge = base64UrlEncode(new Uint8Array(digest));
return { codeVerifier, codeChallenge };
}
export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join(
"",
@@ -758,34 +760,6 @@ export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
);
}
export function base64UrlDecode(base64url: string): Uint8Array {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
const binary = atob(padded);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
export function prepareWebAuthnCreationOptions(
options: { publicKey: Record<string, unknown> },
): PublicKeyCredentialCreationOptions {
const pk = options.publicKey;
return {
...pk,
challenge: base64UrlDecode(pk.challenge as string),
user: {
...(pk.user as Record<string, unknown>),
id: base64UrlDecode((pk.user as Record<string, unknown>).id as string),
},
excludeCredentials:
((pk.excludeCredentials as Array<Record<string, unknown>>) ?? []).map(
(cred) => ({
...cred,
id: base64UrlDecode(cred.id as string),
}),
),
} as unknown as PublicKeyCredentialCreationOptions;
}
async function computeAccessTokenHash(accessToken: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(accessToken);
@@ -793,12 +767,6 @@ async function computeAccessTokenHash(accessToken: string): Promise<string> {
return base64UrlEncode(new Uint8Array(hash));
}
export function generateOAuthState(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
export function buildOAuthAuthorizationUrl(
metadata: OAuthServerMetadata,
params: {

View File

@@ -12,8 +12,6 @@ import {
createLocalClient,
exchangeOAuthCode,
generateDPoPKeyPair,
generateOAuthState,
generatePKCE,
getMigrationOAuthClientId,
getMigrationOAuthRedirectUri,
getOAuthServerMetadata,
@@ -22,6 +20,11 @@ import {
resolvePdsUrl,
saveDPoPKey,
} from "./atproto-client.ts";
import {
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from "../oauth.ts";
import {
clearMigrationState,
saveMigrationState,
@@ -40,20 +43,13 @@ function migrationLog(stage: string, data?: Record<string, unknown>) {
}
}
function createInitialProgress(): MigrationProgress {
return {
repoExported: false,
repoImported: false,
blobsTotal: 0,
blobsMigrated: 0,
blobsFailed: [],
prefsMigrated: false,
plcSigned: false,
activated: false,
deactivated: false,
currentOperation: "",
};
}
import {
createInitialProgress,
checkHandleAvailabilityViaClient,
loadServerInfo,
resolveVerificationIdentifier,
} from "../flows/migration-shared.ts";
import { createEmailVerificationPoller } from "../flows/email-verification.ts";
export function createInboundMigrationFlow() {
let state = $state<InboundMigrationState>({
@@ -82,11 +78,16 @@ export function createInboundMigrationFlow() {
generatedAppPasswordName: null,
handlePreservation: "new",
existingHandleVerified: false,
verificationChannel: "email",
discordUsername: "",
telegramUsername: "",
signalUsername: "",
});
let sourceClient: AtprotoClient | null = null;
let localClient: AtprotoClient | null = null;
let localServerInfo: ServerDescription | null = null;
let sourcePdsDomains: string[] = [];
function setStep(step: InboundStep) {
state.step = step;
@@ -113,10 +114,9 @@ export function createInboundMigrationFlow() {
if (!localClient) {
localClient = createLocalClient();
}
if (!localServerInfo) {
localServerInfo = await localClient.describeServer();
}
return localServerInfo;
const info = await loadServerInfo(localClient, localServerInfo);
localServerInfo = info;
return info;
}
async function resolveSourcePds(handle: string): Promise<void> {
@@ -147,8 +147,9 @@ export function createInboundMigrationFlow() {
);
}
const { codeVerifier, codeChallenge } = await generatePKCE();
const oauthState = generateOAuthState();
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const oauthState = generateState();
const dpopKeyPair = await generateDPoPKeyPair();
await saveDPoPKey(dpopKeyPair);
@@ -314,16 +315,23 @@ export function createInboundMigrationFlow() {
saveMigrationState(state);
}
async function loadSourcePdsDomains(): Promise<string[]> {
if (sourcePdsDomains.length > 0) return sourcePdsDomains;
if (!sourceClient) return [];
try {
const info = await sourceClient.describeServer();
sourcePdsDomains = info.availableUserDomains;
} catch {
sourcePdsDomains = [];
}
return sourcePdsDomains;
}
async function checkHandleAvailability(handle: string): Promise<boolean> {
if (!localClient) {
localClient = createLocalClient();
}
try {
await localClient.resolveHandle(handle);
return false;
} catch {
return true;
}
return checkHandleAvailabilityViaClient(localClient, handle);
}
async function verifyExistingHandle(): Promise<{
@@ -401,8 +409,12 @@ export function createInboundMigrationFlow() {
const passkeyParams = {
did: state.sourceDid,
handle: state.targetHandle,
email: state.targetEmail,
email: state.targetEmail || undefined,
inviteCode: state.inviteCode || undefined,
verificationChannel: state.verificationChannel,
discordUsername: state.discordUsername || undefined,
telegramUsername: state.telegramUsername || undefined,
signalUsername: state.signalUsername || undefined,
};
migrationLog("startMigration: Creating passkey account on NEW PDS", {
@@ -428,9 +440,13 @@ export function createInboundMigrationFlow() {
const accountParams = {
did: state.sourceDid,
handle: state.targetHandle,
email: state.targetEmail,
email: state.targetEmail || undefined,
password: state.targetPassword,
inviteCode: state.inviteCode || undefined,
verificationChannel: state.verificationChannel,
discordUsername: state.discordUsername || undefined,
telegramUsername: state.telegramUsername || undefined,
signalUsername: state.signalUsername || undefined,
};
migrationLog("startMigration: Creating account on NEW PDS", {
@@ -618,30 +634,40 @@ export function createInboundMigrationFlow() {
if (!localClient) {
localClient = createLocalClient();
}
await localClient.resendMigrationVerification();
await localClient.resendMigrationVerification(
state.verificationChannel,
resolveVerificationIdentifier(
state.verificationChannel,
state.targetEmail,
state.discordUsername,
state.telegramUsername,
state.signalUsername,
),
);
}
let checkingEmailVerification = false;
async function checkEmailVerifiedAndProceed(): Promise<boolean> {
if (checkingEmailVerification) return false;
if (!localClient) return false;
checkingEmailVerification = true;
try {
const verified = await localClient.checkEmailVerified(state.targetEmail);
if (!verified) return false;
const verificationPoller = createEmailVerificationPoller({
async checkVerified() {
if (!localClient) return false;
if (state.verificationChannel === "email") {
return localClient.checkEmailVerified(state.targetEmail);
}
return localClient.checkChannelVerified(
state.sourceDid,
state.verificationChannel,
);
},
async onVerified() {
if (state.authMethod === "passkey") {
migrationLog(
"checkEmailVerifiedAndProceed: Email verified, proceeding to passkey setup",
);
setStep("passkey-setup");
return true;
return;
}
if (!localClient.getAccessToken()) {
await localClient.loginDeactivated(
if (!localClient!.getAccessToken()) {
await localClient!.loginDeactivated(
state.targetEmail,
state.targetPassword,
);
@@ -652,11 +678,11 @@ export function createInboundMigrationFlow() {
setError(
"Email verified! Please log in to your old account again to complete the migration.",
);
return true;
return;
}
if (state.sourceDid.startsWith("did:web:")) {
const credentials = await localClient.getRecommendedDidCredentials();
const credentials = await localClient!.getRecommendedDidCredentials();
state.targetVerificationMethod =
credentials.verificationMethods?.atproto || null;
setStep("did-web-update");
@@ -664,16 +690,11 @@ export function createInboundMigrationFlow() {
await sourceClient.requestPlcOperationSignature();
setStep("plc-token");
}
return true;
} catch (e) {
const err = e as Error & { error?: string };
if (err.error === "AccountNotVerified") {
return false;
}
return false;
} finally {
checkingEmailVerification = false;
}
},
});
function checkEmailVerifiedAndProceed(): Promise<boolean> {
return verificationPoller.checkAndAdvance();
}
async function submitPlcToken(token: string): Promise<void> {
@@ -946,6 +967,10 @@ export function createInboundMigrationFlow() {
generatedAppPasswordName: null,
handlePreservation: "new",
existingHandleVerified: false,
verificationChannel: "email",
discordUsername: "",
telegramUsername: "",
signalUsername: "",
};
sourceClient = null;
passkeySetup = null;
@@ -1025,6 +1050,7 @@ export function createInboundMigrationFlow() {
setStep,
setError,
loadLocalServerInfo,
loadSourcePdsDomains,
resolveSourcePds,
initiateOAuthLogin,
handleOAuthCallback,

View File

@@ -7,10 +7,9 @@ import type {
} from "./types.ts";
import {
AtprotoClient,
base64UrlEncode,
createLocalClient,
prepareWebAuthnCreationOptions,
} from "./atproto-client.ts";
import { createPasskeyCredential } from "../flows/perform-passkey-registration.ts";
import { api } from "../api.ts";
import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops.ts";
import { migrateBlobs as migrateBlobsUtil } from "./blob-migration.ts";
@@ -124,20 +123,13 @@ export function getOfflineResumeInfo(): {
export { clearOfflineState };
function createInitialProgress(): MigrationProgress {
return {
repoExported: false,
repoImported: false,
blobsTotal: 0,
blobsMigrated: 0,
blobsFailed: [],
prefsMigrated: false,
plcSigned: false,
activated: false,
deactivated: false,
currentOperation: "",
};
}
import {
createInitialProgress,
checkHandleAvailabilityViaClient,
loadServerInfo,
resolveVerificationIdentifier,
} from "../flows/migration-shared.ts";
import { createEmailVerificationPoller } from "../flows/email-verification.ts";
export type OfflineInboundMigrationFlow = ReturnType<
typeof createOfflineInboundMigrationFlow
@@ -171,6 +163,10 @@ export function createOfflineInboundMigrationFlow() {
plcUpdatedTemporarily: false,
handlePreservation: "new",
existingHandleVerified: false,
verificationChannel: "email",
discordUsername: "",
telegramUsername: "",
signalUsername: "",
});
let localServerInfo: ServerDescription | null = null;
@@ -198,21 +194,13 @@ export function createOfflineInboundMigrationFlow() {
}
async function loadLocalServerInfo(): Promise<ServerDescription> {
if (!localServerInfo) {
const client = createLocalClient();
localServerInfo = await client.describeServer();
}
return localServerInfo;
const info = await loadServerInfo(createLocalClient(), localServerInfo);
localServerInfo = info;
return info;
}
async function checkHandleAvailability(handle: string): Promise<boolean> {
const client = createLocalClient();
try {
await client.resolveHandle(handle);
return false;
} catch {
return true;
}
return checkHandleAvailabilityViaClient(createLocalClient(), handle);
}
async function validateRotationKey(): Promise<boolean> {
@@ -235,18 +223,6 @@ export function createOfflineInboundMigrationFlow() {
const pdsService = lastOperation.services?.atproto_pds;
if (pdsService?.endpoint) {
state.oldPdsUrl = pdsService.endpoint;
console.log(
"[offline-migration] Captured old PDS URL:",
state.oldPdsUrl,
);
} else {
console.warn(
"[offline-migration] No PDS service endpoint found in PLC document",
);
console.log(
"[offline-migration] PLC services:",
JSON.stringify(lastOperation.services),
);
}
saveOfflineState(state);
@@ -315,9 +291,13 @@ export function createOfflineInboundMigrationFlow() {
{
did: unsafeAsDid(state.userDid),
handle: unsafeAsHandle(fullHandle),
email: unsafeAsEmail(state.targetEmail),
email: state.targetEmail ? unsafeAsEmail(state.targetEmail) : undefined,
password: state.targetPassword,
inviteCode: state.inviteCode || undefined,
verificationChannel: state.verificationChannel,
discordUsername: state.discordUsername || undefined,
telegramUsername: state.telegramUsername || undefined,
signalUsername: state.signalUsername || undefined,
},
);
@@ -338,8 +318,12 @@ export function createOfflineInboundMigrationFlow() {
const createResult = await api.createPasskeyAccount({
did: unsafeAsDid(state.userDid),
handle: unsafeAsHandle(fullHandle),
email: unsafeAsEmail(state.targetEmail),
email: state.targetEmail ? unsafeAsEmail(state.targetEmail) : undefined,
inviteCode: state.inviteCode || undefined,
verificationChannel: state.verificationChannel,
discordUsername: state.discordUsername || undefined,
telegramUsername: state.telegramUsername || undefined,
signalUsername: state.signalUsername || undefined,
}, serviceAuthToken);
state.targetHandle = fullHandle;
@@ -487,20 +471,32 @@ export function createOfflineInboundMigrationFlow() {
}
async function resendEmailVerification(): Promise<void> {
await api.resendMigrationVerification(unsafeAsEmail(state.targetEmail));
await api.resendMigrationVerification(
state.verificationChannel,
resolveVerificationIdentifier(
state.verificationChannel,
state.targetEmail,
state.discordUsername,
state.telegramUsername,
state.signalUsername,
),
);
}
let checkingEmailVerification = false;
async function checkEmailVerifiedAndProceed(): Promise<boolean> {
if (checkingEmailVerification) return false;
if (state.authMethod === "passkey") return false;
checkingEmailVerification = true;
try {
const { verified } = await api.checkEmailVerified(state.targetEmail);
if (!verified) return false;
const verificationPoller = createEmailVerificationPoller({
async checkVerified() {
if (state.authMethod === "passkey") return false;
if (state.verificationChannel === "email") {
const { verified } = await api.checkEmailVerified(state.targetEmail);
return verified;
}
const { verified } = await api.checkChannelVerified(
state.userDid,
state.verificationChannel,
);
return verified;
},
async onVerified() {
if (!state.localAccessToken) {
const session = await api.createSession(
state.targetEmail,
@@ -519,12 +515,11 @@ export function createOfflineInboundMigrationFlow() {
cleanup();
setStep("success");
return true;
} catch {
return false;
} finally {
checkingEmailVerification = false;
}
},
});
function checkEmailVerifiedAndProceed(): Promise<boolean> {
return verificationPoller.checkAndAdvance();
}
async function startPasskeyRegistration(): Promise<{ options: unknown }> {
@@ -543,41 +538,14 @@ export function createOfflineInboundMigrationFlow() {
throw new Error("No passkey setup token");
}
if (!globalThis.PublicKeyCredential) {
throw new Error("Passkeys are not supported in this browser");
}
const { options } = await startPasskeyRegistration();
const publicKeyOptions = prepareWebAuthnCreationOptions(
options as { publicKey: Record<string, unknown> },
const credential = await createPasskeyCredential(
() => startPasskeyRegistration(),
);
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions,
});
if (!credential) {
throw new Error("Passkey creation was cancelled");
}
const publicKeyCredential = credential as PublicKeyCredential;
const response = publicKeyCredential
.response as AuthenticatorAttestationResponse;
const credentialData = {
id: publicKeyCredential.id,
rawId: base64UrlEncode(publicKeyCredential.rawId),
type: publicKeyCredential.type,
response: {
clientDataJSON: base64UrlEncode(response.clientDataJSON),
attestationObject: base64UrlEncode(response.attestationObject),
},
};
const result = await api.completePasskeySetup(
unsafeAsDid(state.userDid),
state.passkeySetupToken,
credentialData,
credential,
passkeyName,
);
@@ -675,6 +643,10 @@ export function createOfflineInboundMigrationFlow() {
plcUpdatedTemporarily: false,
handlePreservation: "new",
existingHandleVerified: false,
verificationChannel: "email",
discordUsername: "",
telegramUsername: "",
signalUsername: "",
};
localServerInfo = null;
}

View File

@@ -50,6 +50,8 @@ export interface MigrationProgress {
export type HandlePreservation = "new" | "existing";
export type VerificationChannel = "email" | "discord" | "telegram" | "signal";
export interface InboundMigrationState {
direction: "inbound";
step: InboundStep;
@@ -78,6 +80,10 @@ export interface InboundMigrationState {
resumeToStep?: InboundStep;
handlePreservation: HandlePreservation;
existingHandleVerified: boolean;
verificationChannel: VerificationChannel;
discordUsername: string;
telegramUsername: string;
signalUsername: string;
}
export interface OfflineInboundMigrationState {
@@ -107,6 +113,10 @@ export interface OfflineInboundMigrationState {
plcUpdatedTemporarily: boolean;
handlePreservation: HandlePreservation;
existingHandleVerified: boolean;
verificationChannel: VerificationChannel;
discordUsername: string;
telegramUsername: string;
signalUsername: string;
}
export type MigrationState = InboundMigrationState;
@@ -142,6 +152,7 @@ export interface ServerDescription {
availableUserDomains: string[];
inviteCodeRequired: boolean;
phoneVerificationRequired?: boolean;
availableCommsChannels?: VerificationChannel[];
links?: {
privacyPolicy?: string;
termsOfService?: string;
@@ -226,17 +237,25 @@ export interface BlobRef {
export interface CreateAccountParams {
did?: string;
handle: string;
email: string;
email?: string;
password: string;
inviteCode?: string;
recoveryKey?: string;
verificationChannel?: VerificationChannel;
discordUsername?: string;
telegramUsername?: string;
signalUsername?: string;
}
export interface CreatePasskeyAccountParams {
did?: string;
handle: string;
email: string;
email?: string;
inviteCode?: string;
verificationChannel?: VerificationChannel;
discordUsername?: string;
telegramUsername?: string;
signalUsername?: string;
}
export interface PasskeyAccountSetup {

View File

@@ -0,0 +1,11 @@
export function portal(node: HTMLElement): { destroy: () => void } {
const target = document.body;
target.appendChild(node);
return {
destroy() {
if (node.parentNode === target) {
target.removeChild(node);
}
},
};
}

View File

@@ -1,51 +0,0 @@
<script lang="ts">
import type { RegistrationFlow } from './flow.svelte'
interface Props {
flow: RegistrationFlow
}
let { flow }: Props = $props()
let copied = $state(false)
let acknowledged = $state(false)
function copyToClipboard() {
if (flow.account?.appPassword) {
navigator.clipboard.writeText(flow.account.appPassword)
copied = true
}
}
</script>
<div class="app-password-step">
<div class="warning-box">
<strong>Important: Save this app password!</strong>
<p>
This app password is required to sign into apps that don't support passkeys yet (like bsky.app).
You will only see this password once.
</p>
</div>
<div class="app-password-display">
<div class="app-password-label">
App Password for: <strong>{flow.account?.appPasswordName}</strong>
</div>
<code class="app-password-code">{flow.account?.appPassword}</code>
<button type="button" class="copy-btn" onclick={copyToClipboard}>
{copied ? 'Copied!' : 'Copy to Clipboard'}
</button>
</div>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" bind:checked={acknowledged} />
<span>I have saved my app password in a secure location</span>
</label>
</div>
<button onclick={() => flow.proceedFromAppPassword()} disabled={!acknowledged}>
Continue
</button>
</div>

View File

@@ -1,4 +1,5 @@
import { api, ApiError } from "../api.ts";
import { createEmailVerificationPoller } from "../flows/email-verification.ts";
import { setSession } from "../auth.svelte.ts";
import {
createServiceJwt,
@@ -223,43 +224,51 @@ export function createRegistrationFlow(
state.step = "creating";
}
async function generateByodToken(): Promise<string | undefined> {
if (
state.info.didType !== "web-external" ||
state.externalDidWeb.keyMode !== "byod" ||
!state.externalDidWeb.byodPrivateKey
) {
return undefined;
}
return createServiceJwt(
state.externalDidWeb.byodPrivateKey,
state.info.externalDid!.trim(),
getPdsDid(),
"com.atproto.server.createAccount",
);
}
function commonAccountParams() {
return {
didType: state.info.didType,
did: state.info.didType === "web-external"
? unsafeAsDid(state.info.externalDid!.trim())
: undefined,
signingKey: state.info.didType === "web-external" &&
state.externalDidWeb.keyMode === "reserved"
? state.externalDidWeb.reservedSigningKey
: undefined,
inviteCode: state.info.inviteCode?.trim() || undefined,
verificationChannel: state.info.verificationChannel,
discordUsername: state.info.discordUsername?.trim() || undefined,
telegramUsername: state.info.telegramUsername?.trim() || undefined,
signalUsername: state.info.signalUsername?.trim() || undefined,
};
}
async function createPasswordAccount() {
state.submitting = true;
state.error = null;
try {
let byodToken: string | undefined;
if (
state.info.didType === "web-external" &&
state.externalDidWeb.keyMode === "byod" &&
state.externalDidWeb.byodPrivateKey
) {
byodToken = await createServiceJwt(
state.externalDidWeb.byodPrivateKey,
state.info.externalDid!.trim(),
getPdsDid(),
"com.atproto.server.createAccount",
);
}
const byodToken = await generateByodToken();
const result = await api.createAccount({
handle: getFullHandle(),
email: state.info.email.trim(),
password: state.info.password!,
inviteCode: state.info.inviteCode?.trim() || undefined,
didType: state.info.didType,
did: state.info.didType === "web-external"
? state.info.externalDid!.trim()
: undefined,
signingKey: state.info.didType === "web-external" &&
state.externalDidWeb.keyMode === "reserved"
? state.externalDidWeb.reservedSigningKey
: undefined,
verificationChannel: state.info.verificationChannel,
discordUsername: state.info.discordUsername?.trim() || undefined,
telegramUsername: state.info.telegramUsername?.trim() || undefined,
signalUsername: state.info.signalUsername?.trim() || undefined,
...commonAccountParams(),
}, byodToken);
state.account = {
@@ -280,39 +289,13 @@ export function createRegistrationFlow(
state.error = null;
try {
let byodToken: string | undefined;
if (
state.info.didType === "web-external" &&
state.externalDidWeb.keyMode === "byod" &&
state.externalDidWeb.byodPrivateKey
) {
byodToken = await createServiceJwt(
state.externalDidWeb.byodPrivateKey,
state.info.externalDid!.trim(),
getPdsDid(),
"com.atproto.server.createAccount",
);
}
const byodToken = await generateByodToken();
const result = await api.createPasskeyAccount({
handle: unsafeAsHandle(getFullHandle()),
email: state.info.email?.trim()
? unsafeAsEmail(state.info.email.trim())
: undefined,
inviteCode: state.info.inviteCode?.trim() || undefined,
didType: state.info.didType,
did: state.info.didType === "web-external"
? unsafeAsDid(state.info.externalDid!.trim())
: undefined,
signingKey: state.info.didType === "web-external" &&
state.externalDidWeb.keyMode === "reserved"
? state.externalDidWeb.reservedSigningKey
: undefined,
verificationChannel: state.info.verificationChannel,
discordUsername: state.info.discordUsername?.trim() || undefined,
telegramUsername: state.info.telegramUsername?.trim() || undefined,
signalUsername: state.info.signalUsername?.trim() || undefined,
...commonAccountParams(),
}, byodToken);
state.account = {
@@ -343,6 +326,50 @@ export function createRegistrationFlow(
persistState();
}
function getAccountPassword(): string {
return state.mode === "passkey"
? state.account!.appPassword!
: state.info.password!;
}
async function handlePostVerification(
session: SessionState,
): Promise<void> {
state.session = session;
if (
state.info.didType === "web-external" &&
state.externalDidWeb.keyMode === "byod"
) {
const credentials = await api.getRecommendedDidCredentials(
session.accessJwt,
);
const newPublicKeyMultibase =
credentials.verificationMethods?.atproto?.replace("did:key:", "") || "";
const didDoc = generateDidDocument(
state.info.externalDid!.trim(),
newPublicKeyMultibase,
state.account!.handle,
getPdsEndpoint(),
);
state.externalDidWeb.updatedDidDocument = JSON.stringify(
didDoc,
null,
"\t",
);
state.step = "updated-did-doc";
persistState();
} else if (state.info.didType === "web-external") {
await api.activateAccount(session.accessJwt);
await finalizeSession();
state.step = "redirect-to-dashboard";
} else {
await finalizeSession();
state.step = "redirect-to-dashboard";
}
}
async function verifyAccount(code: string) {
state.submitting = true;
state.error = null;
@@ -354,48 +381,13 @@ export function createRegistrationFlow(
);
if (state.info.didType === "web-external") {
const password = state.mode === "passkey"
? state.account!.appPassword!
: state.info.password!;
const session = await api.createSession(state.account!.did, password);
state.session = {
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
};
if (state.externalDidWeb.keyMode === "byod") {
const credentials = await api.getRecommendedDidCredentials(
session.accessJwt,
);
const newPublicKeyMultibase =
credentials.verificationMethods?.atproto?.replace("did:key:", "") ||
"";
const didDoc = generateDidDocument(
state.info.externalDid!.trim(),
newPublicKeyMultibase,
state.account!.handle,
getPdsEndpoint(),
);
state.externalDidWeb.updatedDidDocument = JSON.stringify(
didDoc,
null,
"\t",
);
state.step = "updated-did-doc";
persistState();
} else {
await api.activateAccount(session.accessJwt);
await finalizeSession();
state.step = "redirect-to-dashboard";
}
const session = await api.createSession(
state.account!.did,
getAccountPassword(),
);
await handlePostVerification(session);
} else {
state.session = {
accessJwt: confirmResult.accessJwt,
refreshJwt: confirmResult.refreshJwt,
};
await finalizeSession();
state.step = "redirect-to-dashboard";
await handlePostVerification(confirmResult);
}
} catch (err) {
setError(err);
@@ -419,74 +411,26 @@ export function createRegistrationFlow(
}
}
let checkingVerification = false;
async function checkAndAdvanceIfVerified(): Promise<boolean> {
if (checkingVerification || !state.account) return false;
checkingVerification = true;
try {
const verificationPoller = createEmailVerificationPoller({
async checkVerified() {
if (!state.account) return false;
const result = await api.checkChannelVerified(
state.account.did,
state.info.verificationChannel,
);
if (!result.verified) return false;
return result.verified;
},
async onVerified() {
const session = await api.createSession(
state.account!.did,
getAccountPassword(),
);
await handlePostVerification(session);
},
});
if (state.info.didType === "web-external") {
const password = state.mode === "passkey"
? state.account.appPassword!
: state.info.password!;
const session = await api.createSession(state.account.did, password);
state.session = {
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
};
if (state.externalDidWeb.keyMode === "byod") {
const credentials = await api.getRecommendedDidCredentials(
session.accessJwt,
);
const newPublicKeyMultibase =
credentials.verificationMethods?.atproto?.replace("did:key:", "") ||
"";
const didDoc = generateDidDocument(
state.info.externalDid!.trim(),
newPublicKeyMultibase,
state.account.handle,
getPdsEndpoint(),
);
state.externalDidWeb.updatedDidDocument = JSON.stringify(
didDoc,
null,
"\t",
);
state.step = "updated-did-doc";
persistState();
} else {
await api.activateAccount(session.accessJwt);
await finalizeSession();
state.step = "redirect-to-dashboard";
}
} else {
const password = state.mode === "passkey"
? state.account.appPassword!
: state.info.password!;
const session = await api.createSession(state.account.did, password);
state.session = {
accessJwt: session.accessJwt,
refreshJwt: session.refreshJwt,
};
await finalizeSession();
state.step = "redirect-to-dashboard";
}
return true;
} catch {
return false;
} finally {
checkingVerification = false;
}
function checkAndAdvanceIfVerified(): Promise<boolean> {
return verificationPoller.checkAndAdvance();
}
function goBack() {

View File

@@ -3,4 +3,4 @@ export * from "./flow.svelte.ts";
export { default as VerificationStep } from "./VerificationStep.svelte";
export { default as KeyChoiceStep } from "./KeyChoiceStep.svelte";
export { default as DidDocStep } from "./DidDocStep.svelte";
export { default as AppPasswordStep } from "./AppPasswordStep.svelte";