diff --git a/.sqlx/query-1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5.json b/.sqlx/query-1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5.json
new file mode 100644
index 0000000..77d0b01
--- /dev/null
+++ b/.sqlx/query-1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5.json
@@ -0,0 +1,26 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT cid, data FROM blocks",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "cid",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 1,
+ "name": "data",
+ "type_info": "Bytea"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "1261d14a3763b98464b212d001c9a11da30a55869128320de6d62693415953f5"
+}
diff --git a/.sqlx/query-4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json b/.sqlx/query-4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json
new file mode 100644
index 0000000..b2375eb
--- /dev/null
+++ b/.sqlx/query-4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT uk.key_bytes, uk.encryption_version FROM user_keys uk JOIN users u ON uk.user_id = u.id WHERE u.did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "key_bytes",
+ "type_info": "Bytea"
+ },
+ {
+ "ordinal": 1,
+ "name": "encryption_version",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ true
+ ]
+ },
+ "hash": "4993f00aedabc48d6d1c722c0928d8bd792cc2d5ade5a8ec0296258cc2a2d1db"
+}
diff --git a/.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json b/.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json
new file mode 100644
index 0000000..08185e9
--- /dev/null
+++ b/.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1"
+}
diff --git a/.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json b/.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json
new file mode 100644
index 0000000..ae8df54
--- /dev/null
+++ b/.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json
@@ -0,0 +1,34 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "did",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 1,
+ "name": "migrated_to_pds",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "migrated_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ true
+ ]
+ },
+ "hash": "791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e"
+}
diff --git a/.sqlx/query-f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93.json b/.sqlx/query-f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93.json
new file mode 100644
index 0000000..13a3011
--- /dev/null
+++ b/.sqlx/query-f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Timestamptz",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "f64d6af0147ae292b98685fd50669174009b0b6bc86c5c649c5ce9927deb3d93"
+}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index fca7ef4..c0ab1cd 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -32,6 +32,7 @@
import Controllers from './routes/Controllers.svelte'
import DelegationAudit from './routes/DelegationAudit.svelte'
import ActAs from './routes/ActAs.svelte'
+ import Migration from './routes/Migration.svelte'
import Home from './routes/Home.svelte'
initI18n()
@@ -113,6 +114,8 @@
return DelegationAudit
case '/act-as':
return ActAs
+ case '/migrate':
+ return Migration
default:
return Home
}
diff --git a/frontend/src/components/migration/InboundWizard.svelte b/frontend/src/components/migration/InboundWizard.svelte
new file mode 100644
index 0000000..84a8f75
--- /dev/null
+++ b/frontend/src/components/migration/InboundWizard.svelte
@@ -0,0 +1,1024 @@
+
+
+
+
+ {#each steps as stepName, i}
+
+
{i < getCurrentStepIndex() ? '✓' : i + 1}
+
{stepName}
+
+ {#if i < steps.length - 1}
+
+ {/if}
+ {/each}
+
+
+ {#if flow.state.error}
+
{flow.state.error}
+ {/if}
+
+ {#if flow.state.step === 'welcome'}
+
+
Migrate Your Account Here
+
This wizard will help you move your AT Protocol account from another PDS to this one.
+
+
+
What will happen:
+
+ Log in to your current PDS
+ Choose your new handle on this server
+ Your repository and blobs will be transferred
+ Verify the migration via email
+ Your identity will be updated to point here
+
+
+
+
+
Before you proceed:
+
+ You need access to the email registered with your current account
+ Large accounts may take several minutes to transfer
+ Your old account will be deactivated after migration
+
+
+
+
+
+ I understand the risks and want to proceed with migration
+
+
+
+ Cancel
+ flow.setStep('source-login')}>
+ Continue
+
+
+
+
+ {:else if flow.state.step === 'source-login'}
+
+
{isResumedMigration ? 'Resume Migration' : 'Log In to Your Current PDS'}
+
{isResumedMigration ? 'Enter your credentials to continue the migration.' : 'Enter your credentials for the account you want to migrate.'}
+
+ {#if isResumedMigration}
+
+
Your migration was interrupted. Log in to both accounts to resume.
+
Migrating: {flow.state.sourceHandle} → {flow.state.targetHandle}
+
+ {/if}
+
+
+
+
+ {:else if flow.state.step === 'choose-handle'}
+
+
Choose Your New Handle
+
Select a handle for your account on this PDS.
+
+
+ Migrating from:
+ {flow.state.sourceHandle}
+
+
+
+
New Handle
+
+
+ {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
+
+ {#each serverInfo.availableUserDomains as domain}
+ .{domain}
+ {/each}
+
+ {/if}
+
+
+ {#if checkingHandle}
+
Checking availability...
+ {:else if handleAvailable === true}
+
Handle is available!
+ {:else if handleAvailable === false}
+
Handle is already taken
+ {:else}
+
You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)
+ {/if}
+
+
+
+ Email Address
+ flow.updateField('targetEmail', (e.target as HTMLInputElement).value)}
+ required
+ />
+
+
+
+
Password
+
flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
+ required
+ minlength="8"
+ />
+
At least 8 characters
+
+
+ {#if serverInfo?.inviteCodeRequired}
+
+ Invite Code
+ flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
+ required
+ />
+
+ {/if}
+
+
+ flow.setStep('source-login')}>Back
+
+ Continue
+
+
+
+
+ {:else if flow.state.step === 'review'}
+
+
Review Migration
+
Please confirm the details of your migration.
+
+
+
+ Current Handle:
+ {flow.state.sourceHandle}
+
+
+ New Handle:
+ {flow.state.targetHandle}
+
+
+ DID:
+ {flow.state.sourceDid}
+
+
+ From PDS:
+ {flow.state.sourcePdsUrl}
+
+
+ To PDS:
+ {window.location.origin}
+
+
+ Email:
+ {flow.state.targetEmail}
+
+
+
+
+ Final confirmation: After you click "Start Migration", your repository and data will begin
+ transferring. This process cannot be easily undone.
+
+
+
+ flow.setStep('choose-handle')} disabled={loading}>Back
+
+ {loading ? 'Starting...' : 'Start Migration'}
+
+
+
+
+ {:else if flow.state.step === 'migrating'}
+
+
Migration in Progress
+
Please wait while your account is being transferred...
+
+
+
+ {flow.state.progress.repoExported ? '✓' : '○'}
+ Export repository
+
+
+ {flow.state.progress.repoImported ? '✓' : '○'}
+ Import repository
+
+
+ {flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}
+ Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})
+
+
+ {flow.state.progress.prefsMigrated ? '✓' : '○'}
+ Migrate preferences
+
+
+
+ {#if flow.state.progress.blobsTotal > 0}
+
+ {/if}
+
+
{flow.state.progress.currentOperation}
+
+
+ {:else if flow.state.step === 'email-verify'}
+
+
{$_('migration.inbound.emailVerify.title')}
+
{@html $_('migration.inbound.emailVerify.desc', { values: { email: `${flow.state.targetEmail} ` } })}
+
+
+
+ {$_('migration.inbound.emailVerify.hint')}
+
+
+
+ {#if flow.state.error}
+
+ {flow.state.error}
+
+ {/if}
+
+
+
+
+ {:else if flow.state.step === 'plc-token'}
+
+
Verify Migration
+
A verification code has been sent to the email registered with your old account.
+
+
+
+ This code confirms you have access to the account and authorizes updating your identity
+ to point to this PDS.
+
+
+
+
+
+
+ {:else if flow.state.step === 'finalizing'}
+
+
Finalizing Migration
+
Please wait while we complete the migration...
+
+
+
+ {flow.state.progress.plcSigned ? '✓' : '○'}
+ Sign identity update
+
+
+ {flow.state.progress.activated ? '✓' : '○'}
+ Activate new account
+
+
+ {flow.state.progress.deactivated ? '✓' : '○'}
+ Deactivate old account
+
+
+
+
{flow.state.progress.currentOperation}
+
+
+ {:else if flow.state.step === 'success'}
+
+
✓
+
Migration Complete!
+
Your account has been successfully migrated to this PDS.
+
+
+
+ Your new handle:
+ {flow.state.targetHandle}
+
+
+ DID:
+ {flow.state.sourceDid}
+
+
+
+ {#if flow.state.progress.blobsFailed.length > 0}
+
+ Note: {flow.state.progress.blobsFailed.length} blobs could not be migrated.
+ These may be images or other media that are no longer available.
+
+ {/if}
+
+
Redirecting to dashboard...
+
+
+ {:else if flow.state.step === 'error'}
+
+
Migration Error
+
An error occurred during migration.
+
+
+ {flow.state.error}
+
+
+
+ Start Over
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/components/migration/OutboundWizard.svelte b/frontend/src/components/migration/OutboundWizard.svelte
new file mode 100644
index 0000000..6e6df48
--- /dev/null
+++ b/frontend/src/components/migration/OutboundWizard.svelte
@@ -0,0 +1,992 @@
+
+
+
+ {#if flow.state.step !== 'welcome'}
+
+ {#each steps as stepName, i}
+
+
{i < getCurrentStepIndex() ? '✓' : i + 1}
+
{stepName}
+
+ {#if i < steps.length - 1}
+
+ {/if}
+ {/each}
+
+ {/if}
+
+ {#if flow.state.error}
+
{flow.state.error}
+ {/if}
+
+ {#if flow.state.step === 'welcome'}
+
+
Migrate Your Account Away
+
This wizard will help you move your AT Protocol account from this PDS to another one.
+
+
+ Current account:
+ @{auth.session?.handle}
+
+
+ {#if isDidWeb()}
+
+
did:web Migration Notice
+
+ Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will
+ continue serving your DID document with an updated service endpoint pointing to your new PDS.
+
+
+ You can return here anytime to update the forwarding if you migrate again in the future.
+
+
+ {/if}
+
+
+
What will happen:
+
+ Choose your new PDS
+ Set up your account on the new server
+ Your repository and blobs will be transferred
+ Verify the migration via email
+ Your identity will be updated to point to the new PDS
+ Your account here will be deactivated
+
+
+
+
+
Before you proceed:
+
+ You need access to the email registered with this account
+ You will lose access to this account on this PDS
+ Make sure you trust the destination PDS
+ Large accounts may take several minutes to transfer
+
+
+
+
+
+ I understand that my account will be moved and deactivated here
+
+
+
+ Cancel
+ flow.setStep('target-pds')}>
+ Continue
+
+
+
+
+ {:else if flow.state.step === 'target-pds'}
+
+
Choose Your New PDS
+
Enter the URL of the PDS you want to migrate to.
+
+
+
+ {#if flow.state.targetServerInfo}
+
+
Connected to PDS
+
+ Server:
+ {flow.state.targetPdsUrl}
+
+ {#if flow.state.targetServerInfo.availableUserDomains.length > 0}
+
+ Available domains:
+ {flow.state.targetServerInfo.availableUserDomains.join(', ')}
+
+ {/if}
+
+ Invite required:
+ {flow.state.targetServerInfo.inviteCodeRequired ? 'Yes' : 'No'}
+
+ {#if flow.state.targetServerInfo.links?.termsOfService}
+
+ Terms of Service
+
+ {/if}
+ {#if flow.state.targetServerInfo.links?.privacyPolicy}
+
+ Privacy Policy
+
+ {/if}
+
+ {/if}
+
+
+ {:else if flow.state.step === 'new-account'}
+
+
Set Up Your New Account
+
Configure your account details on the new PDS.
+
+
+ Migrating to:
+ {flow.state.targetPdsUrl}
+
+
+
+
New Handle
+
+
+ {#if flow.state.targetServerInfo && flow.state.targetServerInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
+
+ {#each flow.state.targetServerInfo.availableUserDomains as domain}
+ .{domain}
+ {/each}
+
+ {/if}
+
+
You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)
+
+
+
+ Email Address
+ flow.updateField('targetEmail', (e.target as HTMLInputElement).value)}
+ required
+ />
+
+
+
+
Password
+
flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
+ required
+ minlength="8"
+ />
+
At least 8 characters. This will be your password on the new PDS.
+
+
+ {#if flow.state.targetServerInfo?.inviteCodeRequired}
+
+
Invite Code
+
flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
+ required
+ />
+
Required by this PDS to create an account
+
+ {/if}
+
+
+ flow.setStep('target-pds')}>Back
+
+ Continue
+
+
+
+
+ {:else if flow.state.step === 'review'}
+
+
Review Migration
+
Please confirm the details of your migration.
+
+
+
+ Current Handle:
+ @{auth.session?.handle}
+
+
+ New Handle:
+ @{flow.state.targetHandle}
+
+
+ DID:
+ {auth.session?.did}
+
+
+ From PDS:
+ {window.location.origin}
+
+
+ To PDS:
+ {flow.state.targetPdsUrl}
+
+
+ New Email:
+ {flow.state.targetEmail}
+
+
+
+
+
This action cannot be easily undone!
+
+ After migration completes, your account on this PDS will be deactivated.
+ To return, you would need to migrate back from the new PDS.
+
+
+
+
+
+ I confirm I want to migrate my account to {flow.state.targetPdsUrl}
+
+
+
+ flow.setStep('new-account')} disabled={loading}>Back
+
+ {loading ? 'Starting...' : 'Start Migration'}
+
+
+
+
+ {:else if flow.state.step === 'migrating'}
+
+
Migration in Progress
+
Please wait while your account is being transferred...
+
+
+
+ {flow.state.progress.repoExported ? '✓' : '○'}
+ Export repository
+
+
+ {flow.state.progress.repoImported ? '✓' : '○'}
+ Import repository to new PDS
+
+
+ {flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}
+ Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})
+
+
+ {flow.state.progress.prefsMigrated ? '✓' : '○'}
+ Migrate preferences
+
+
+
+ {#if flow.state.progress.blobsTotal > 0}
+
+ {/if}
+
+
{flow.state.progress.currentOperation}
+
+
+ {:else if flow.state.step === 'plc-token'}
+
+
Verify Migration
+
A verification code has been sent to your email ({auth.session?.email}).
+
+
+
+ This code confirms you have access to the account and authorizes updating your identity
+ to point to the new PDS.
+
+
+
+
+
+
+ {:else if flow.state.step === 'finalizing'}
+
+
Finalizing Migration
+
Please wait while we complete the migration...
+
+
+
+ {flow.state.progress.plcSigned ? '✓' : '○'}
+ Sign identity update
+
+
+ {flow.state.progress.activated ? '✓' : '○'}
+ Activate account on new PDS
+
+
+ {flow.state.progress.deactivated ? '✓' : '○'}
+ Deactivate account here
+
+
+
+
{flow.state.progress.currentOperation}
+
+
+ {:else if flow.state.step === 'success'}
+
+
✓
+
Migration Complete!
+
Your account has been successfully migrated to your new PDS.
+
+
+
+ Your new handle:
+ @{flow.state.targetHandle}
+
+
+ New PDS:
+ {flow.state.targetPdsUrl}
+
+
+ DID:
+ {auth.session?.did}
+
+
+
+ {#if flow.state.progress.blobsFailed.length > 0}
+
+ Note: {flow.state.progress.blobsFailed.length} blobs could not be migrated.
+ These may be images or other media that are no longer available.
+
+ {/if}
+
+
+
Next Steps
+
+ Visit your new PDS at {flow.state.targetPdsUrl}
+ Log in with your new credentials
+ Your followers and following will continue to work
+
+
+
+
Logging out in a moment...
+
+
+ {:else if flow.state.step === 'error'}
+
+
Migration Error
+
An error occurred during migration.
+
+
+ {flow.state.error}
+
+
+
+ Start Over
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/lib/migration/atproto-client.ts b/frontend/src/lib/migration/atproto-client.ts
new file mode 100644
index 0000000..2121352
--- /dev/null
+++ b/frontend/src/lib/migration/atproto-client.ts
@@ -0,0 +1,448 @@
+import type {
+ AccountStatus,
+ BlobRef,
+ CreateAccountParams,
+ DidCredentials,
+ DidDocument,
+ MigrationError,
+ PlcOperation,
+ Preferences,
+ ServerDescription,
+ Session,
+} from "./types";
+
+export class AtprotoClient {
+ private baseUrl: string;
+ private accessToken: string | null = null;
+
+ constructor(pdsUrl: string) {
+ this.baseUrl = pdsUrl.replace(/\/$/, "");
+ }
+
+ setAccessToken(token: string | null) {
+ this.accessToken = token;
+ }
+
+ getAccessToken(): string | null {
+ return this.accessToken;
+ }
+
+ private async xrpc(
+ method: string,
+ options?: {
+ httpMethod?: "GET" | "POST";
+ params?: Record;
+ body?: unknown;
+ authToken?: string;
+ rawBody?: Uint8Array | Blob;
+ contentType?: string;
+ },
+ ): Promise {
+ const {
+ httpMethod = "GET",
+ params,
+ body,
+ authToken,
+ rawBody,
+ contentType,
+ } = options ?? {};
+
+ let url = `${this.baseUrl}/xrpc/${method}`;
+ if (params) {
+ const searchParams = new URLSearchParams(params);
+ url += `?${searchParams}`;
+ }
+
+ const headers: Record = {};
+ const token = authToken ?? this.accessToken;
+ if (token) {
+ headers["Authorization"] = `Bearer ${token}`;
+ }
+
+ let requestBody: BodyInit | undefined;
+ if (rawBody) {
+ headers["Content-Type"] = contentType ?? "application/octet-stream";
+ requestBody = rawBody;
+ } else if (body) {
+ headers["Content-Type"] = "application/json";
+ requestBody = JSON.stringify(body);
+ } else if (httpMethod === "POST") {
+ headers["Content-Type"] = "application/json";
+ }
+
+ const res = await fetch(url, {
+ method: httpMethod,
+ headers,
+ body: requestBody,
+ });
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({
+ error: "Unknown",
+ message: res.statusText,
+ }));
+ const error = new Error(err.message) as Error & {
+ status: number;
+ error: string;
+ };
+ error.status = res.status;
+ error.error = err.error;
+ throw error;
+ }
+
+ const responseContentType = res.headers.get("content-type") ?? "";
+ if (responseContentType.includes("application/json")) {
+ return res.json();
+ }
+ return res.arrayBuffer().then((buf) => new Uint8Array(buf)) as T;
+ }
+
+ async login(
+ identifier: string,
+ password: string,
+ authFactorToken?: string,
+ ): Promise {
+ const body: Record = { identifier, password };
+ if (authFactorToken) {
+ body.authFactorToken = authFactorToken;
+ }
+
+ const session = await this.xrpc("com.atproto.server.createSession", {
+ httpMethod: "POST",
+ body,
+ });
+
+ this.accessToken = session.accessJwt;
+ return session;
+ }
+
+ async refreshSession(refreshJwt: string): Promise {
+ const session = await this.xrpc(
+ "com.atproto.server.refreshSession",
+ {
+ httpMethod: "POST",
+ authToken: refreshJwt,
+ },
+ );
+ this.accessToken = session.accessJwt;
+ return session;
+ }
+
+ async describeServer(): Promise {
+ return this.xrpc("com.atproto.server.describeServer");
+ }
+
+ async getServiceAuth(
+ aud: string,
+ lxm?: string,
+ ): Promise<{ token: string }> {
+ const params: Record = { aud };
+ if (lxm) {
+ params.lxm = lxm;
+ }
+ return this.xrpc("com.atproto.server.getServiceAuth", { params });
+ }
+
+ async getRepo(did: string): Promise {
+ return this.xrpc("com.atproto.sync.getRepo", {
+ params: { did },
+ });
+ }
+
+ async listBlobs(
+ did: string,
+ cursor?: string,
+ limit = 100,
+ ): Promise<{ cids: string[]; cursor?: string }> {
+ const params: Record = { did, limit: String(limit) };
+ if (cursor) {
+ params.cursor = cursor;
+ }
+ return this.xrpc("com.atproto.sync.listBlobs", { params });
+ }
+
+ async getBlob(did: string, cid: string): Promise {
+ return this.xrpc("com.atproto.sync.getBlob", {
+ params: { did, cid },
+ });
+ }
+
+ async uploadBlob(
+ data: Uint8Array,
+ mimeType: string,
+ ): Promise<{ blob: BlobRef }> {
+ return this.xrpc("com.atproto.repo.uploadBlob", {
+ httpMethod: "POST",
+ rawBody: data,
+ contentType: mimeType,
+ });
+ }
+
+ async getPreferences(): Promise {
+ return this.xrpc("app.bsky.actor.getPreferences");
+ }
+
+ async putPreferences(preferences: Preferences): Promise {
+ await this.xrpc("app.bsky.actor.putPreferences", {
+ httpMethod: "POST",
+ body: preferences,
+ });
+ }
+
+ async createAccount(
+ params: CreateAccountParams,
+ serviceToken?: string,
+ ): Promise {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ if (serviceToken) {
+ headers["Authorization"] = `Bearer ${serviceToken}`;
+ }
+
+ const res = await fetch(
+ `${this.baseUrl}/xrpc/com.atproto.server.createAccount`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify(params),
+ },
+ );
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({
+ error: "Unknown",
+ message: res.statusText,
+ }));
+ const error = new Error(err.message) as Error & {
+ status: number;
+ error: string;
+ };
+ error.status = res.status;
+ error.error = err.error;
+ throw error;
+ }
+
+ const session = (await res.json()) as Session;
+ this.accessToken = session.accessJwt;
+ return session;
+ }
+
+ async importRepo(car: Uint8Array): Promise {
+ await this.xrpc("com.atproto.repo.importRepo", {
+ httpMethod: "POST",
+ rawBody: car,
+ contentType: "application/vnd.ipld.car",
+ });
+ }
+
+ async listMissingBlobs(
+ cursor?: string,
+ limit = 100,
+ ): Promise<{ blobs: Array<{ cid: string; recordUri: string }>; cursor?: string }> {
+ const params: Record = { limit: String(limit) };
+ if (cursor) {
+ params.cursor = cursor;
+ }
+ return this.xrpc("com.atproto.repo.listMissingBlobs", { params });
+ }
+
+ async requestPlcOperationSignature(): Promise {
+ await this.xrpc("com.atproto.identity.requestPlcOperationSignature", {
+ httpMethod: "POST",
+ });
+ }
+
+ async signPlcOperation(params: {
+ token?: string;
+ rotationKeys?: string[];
+ alsoKnownAs?: string[];
+ verificationMethods?: { atproto?: string };
+ services?: { atproto_pds?: { type: string; endpoint: string } };
+ }): Promise<{ operation: PlcOperation }> {
+ return this.xrpc("com.atproto.identity.signPlcOperation", {
+ httpMethod: "POST",
+ body: params,
+ });
+ }
+
+ async submitPlcOperation(operation: PlcOperation): Promise {
+ await this.xrpc("com.atproto.identity.submitPlcOperation", {
+ httpMethod: "POST",
+ body: { operation },
+ });
+ }
+
+ async getRecommendedDidCredentials(): Promise {
+ return this.xrpc("com.atproto.identity.getRecommendedDidCredentials");
+ }
+
+ async activateAccount(): Promise {
+ await this.xrpc("com.atproto.server.activateAccount", {
+ httpMethod: "POST",
+ });
+ }
+
+ async deactivateAccount(): Promise {
+ await this.xrpc("com.atproto.server.deactivateAccount", {
+ httpMethod: "POST",
+ });
+ }
+
+ async checkAccountStatus(): Promise {
+ return this.xrpc("com.atproto.server.checkAccountStatus");
+ }
+
+ async getMigrationStatus(): Promise<{
+ did: string;
+ didType: string;
+ migrated: boolean;
+ migratedToPds?: string;
+ migratedAt?: string;
+ }> {
+ return this.xrpc("com.tranquil.account.getMigrationStatus");
+ }
+
+ async updateMigrationForwarding(pdsUrl: string): Promise<{
+ success: boolean;
+ migratedToPds: string;
+ migratedAt: string;
+ }> {
+ return this.xrpc("com.tranquil.account.updateMigrationForwarding", {
+ httpMethod: "POST",
+ body: { pdsUrl },
+ });
+ }
+
+ async clearMigrationForwarding(): Promise<{ success: boolean }> {
+ return this.xrpc("com.tranquil.account.clearMigrationForwarding", {
+ httpMethod: "POST",
+ });
+ }
+
+ async resolveHandle(handle: string): Promise<{ did: string }> {
+ return this.xrpc("com.atproto.identity.resolveHandle", {
+ params: { handle },
+ });
+ }
+
+ async loginDeactivated(
+ identifier: string,
+ password: string,
+ ): Promise {
+ const session = await this.xrpc("com.atproto.server.createSession", {
+ httpMethod: "POST",
+ body: { identifier, password, allowDeactivated: true },
+ });
+ this.accessToken = session.accessJwt;
+ return session;
+ }
+
+ async verifyToken(
+ token: string,
+ identifier: string,
+ ): Promise<{ success: boolean; did: string; purpose: string; channel: string }> {
+ return this.xrpc("com.tranquil.account.verifyToken", {
+ httpMethod: "POST",
+ body: { token, identifier },
+ });
+ }
+
+ async resendMigrationVerification(): Promise {
+ await this.xrpc("com.atproto.server.resendMigrationVerification", {
+ httpMethod: "POST",
+ });
+ }
+}
+
+export async function resolveDidDocument(did: string): Promise {
+ if (did.startsWith("did:plc:")) {
+ const res = await fetch(`https://plc.directory/${did}`);
+ if (!res.ok) {
+ throw new Error(`Failed to resolve DID: ${res.statusText}`);
+ }
+ return res.json();
+ }
+
+ if (did.startsWith("did:web:")) {
+ const domain = did.slice(8).replace(/%3A/g, ":");
+ const url = domain.includes("/")
+ ? `https://${domain}/did.json`
+ : `https://${domain}/.well-known/did.json`;
+
+ const res = await fetch(url);
+ if (!res.ok) {
+ throw new Error(`Failed to resolve DID: ${res.statusText}`);
+ }
+ return res.json();
+ }
+
+ throw new Error(`Unsupported DID method: ${did}`);
+}
+
+export async function resolvePdsUrl(
+ handleOrDid: string,
+): Promise<{ did: string; pdsUrl: string }> {
+ let did: string;
+
+ if (handleOrDid.startsWith("did:")) {
+ did = handleOrDid;
+ } else {
+ const handle = handleOrDid.replace(/^@/, "");
+
+ if (handle.endsWith(".bsky.social")) {
+ const res = await fetch(
+ `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`,
+ );
+ if (!res.ok) {
+ throw new Error(`Failed to resolve handle: ${res.statusText}`);
+ }
+ const data = await res.json();
+ did = data.did;
+ } else {
+ const dnsRes = await fetch(
+ `https://dns.google/resolve?name=_atproto.${handle}&type=TXT`,
+ );
+ if (dnsRes.ok) {
+ const dnsData = await dnsRes.json();
+ const txtRecords = dnsData.Answer ?? [];
+ for (const record of txtRecords) {
+ const txt = record.data?.replace(/"/g, "") ?? "";
+ if (txt.startsWith("did=")) {
+ did = txt.slice(4);
+ break;
+ }
+ }
+ }
+
+ if (!did) {
+ const wellKnownRes = await fetch(
+ `https://${handle}/.well-known/atproto-did`,
+ );
+ if (wellKnownRes.ok) {
+ did = (await wellKnownRes.text()).trim();
+ }
+ }
+
+ if (!did) {
+ throw new Error(`Could not resolve handle: ${handle}`);
+ }
+ }
+ }
+
+ const didDoc = await resolveDidDocument(did);
+
+ const pdsService = didDoc.service?.find(
+ (s: { type: string }) => s.type === "AtprotoPersonalDataServer",
+ );
+
+ if (!pdsService) {
+ throw new Error("No PDS service found in DID document");
+ }
+
+ return { did, pdsUrl: pdsService.serviceEndpoint };
+}
+
+export function createLocalClient(): AtprotoClient {
+ return new AtprotoClient(window.location.origin);
+}
diff --git a/frontend/src/lib/migration/flow.svelte.ts b/frontend/src/lib/migration/flow.svelte.ts
new file mode 100644
index 0000000..d6f895a
--- /dev/null
+++ b/frontend/src/lib/migration/flow.svelte.ts
@@ -0,0 +1,734 @@
+import type {
+ InboundMigrationState,
+ InboundStep,
+ MigrationProgress,
+ OutboundMigrationState,
+ OutboundStep,
+ ServerDescription,
+ StoredMigrationState,
+} from "./types";
+import {
+ AtprotoClient,
+ createLocalClient,
+ resolvePdsUrl,
+} from "./atproto-client";
+import {
+ clearMigrationState,
+ loadMigrationState,
+ saveMigrationState,
+ updateProgress,
+ updateStep,
+} from "./storage";
+
+function createInitialProgress(): MigrationProgress {
+ return {
+ repoExported: false,
+ repoImported: false,
+ blobsTotal: 0,
+ blobsMigrated: 0,
+ blobsFailed: [],
+ prefsMigrated: false,
+ plcSigned: false,
+ activated: false,
+ deactivated: false,
+ currentOperation: "",
+ };
+}
+
+export function createInboundMigrationFlow() {
+ let state = $state({
+ direction: "inbound",
+ step: "welcome",
+ sourcePdsUrl: "",
+ sourceDid: "",
+ sourceHandle: "",
+ targetHandle: "",
+ targetEmail: "",
+ targetPassword: "",
+ inviteCode: "",
+ sourceAccessToken: null,
+ sourceRefreshToken: null,
+ serviceAuthToken: null,
+ emailVerifyToken: "",
+ plcToken: "",
+ progress: createInitialProgress(),
+ error: null,
+ requires2FA: false,
+ twoFactorCode: "",
+ });
+
+ let sourceClient: AtprotoClient | null = null;
+ let localClient: AtprotoClient | null = null;
+ let localServerInfo: ServerDescription | null = null;
+
+ function setStep(step: InboundStep) {
+ state.step = step;
+ state.error = null;
+ saveMigrationState(state);
+ updateStep(step);
+ }
+
+ function setError(error: string) {
+ state.error = error;
+ saveMigrationState(state);
+ }
+
+ function setProgress(updates: Partial) {
+ state.progress = { ...state.progress, ...updates };
+ updateProgress(updates);
+ }
+
+ async function loadLocalServerInfo(): Promise {
+ if (!localClient) {
+ localClient = createLocalClient();
+ }
+ if (!localServerInfo) {
+ localServerInfo = await localClient.describeServer();
+ }
+ return localServerInfo;
+ }
+
+ async function resolveSourcePds(handle: string): Promise {
+ try {
+ const { did, pdsUrl } = await resolvePdsUrl(handle);
+ state.sourcePdsUrl = pdsUrl;
+ state.sourceDid = did;
+ state.sourceHandle = handle;
+ sourceClient = new AtprotoClient(pdsUrl);
+ } catch (e) {
+ throw new Error(`Could not resolve handle: ${(e as Error).message}`);
+ }
+ }
+
+ async function loginToSource(
+ handle: string,
+ password: string,
+ twoFactorCode?: string,
+ ): Promise {
+ if (!state.sourcePdsUrl) {
+ await resolveSourcePds(handle);
+ }
+
+ if (!sourceClient) {
+ sourceClient = new AtprotoClient(state.sourcePdsUrl);
+ }
+
+ try {
+ const session = await sourceClient.login(handle, password, twoFactorCode);
+ state.sourceAccessToken = session.accessJwt;
+ state.sourceRefreshToken = session.refreshJwt;
+ state.sourceDid = session.did;
+ state.sourceHandle = session.handle;
+ state.requires2FA = false;
+ saveMigrationState(state);
+ } catch (e) {
+ const err = e as Error & { error?: string };
+ if (err.error === "AuthFactorTokenRequired") {
+ state.requires2FA = true;
+ throw new Error("Two-factor authentication required. Please enter the code sent to your email.");
+ }
+ throw e;
+ }
+ }
+
+ async function checkHandleAvailability(handle: string): Promise {
+ if (!localClient) {
+ localClient = createLocalClient();
+ }
+ try {
+ await localClient.resolveHandle(handle);
+ return false;
+ } catch {
+ return true;
+ }
+ }
+
+ async function authenticateToLocal(email: string, password: string): Promise {
+ if (!localClient) {
+ localClient = createLocalClient();
+ }
+ await localClient.loginDeactivated(email, password);
+ }
+
+ async function startMigration(): Promise {
+ if (!sourceClient || !state.sourceAccessToken) {
+ throw new Error("Not logged in to source PDS");
+ }
+
+ if (!localClient) {
+ localClient = createLocalClient();
+ }
+
+ setStep("migrating");
+ setProgress({ currentOperation: "Getting service auth token..." });
+
+ try {
+ const serverInfo = await loadLocalServerInfo();
+ const { token } = await sourceClient.getServiceAuth(
+ serverInfo.did,
+ "com.atproto.server.createAccount",
+ );
+ state.serviceAuthToken = token;
+
+ setProgress({ currentOperation: "Creating account on new PDS..." });
+
+ const accountParams = {
+ did: state.sourceDid,
+ handle: state.targetHandle,
+ email: state.targetEmail,
+ password: state.targetPassword,
+ inviteCode: state.inviteCode || undefined,
+ };
+
+ const session = await localClient.createAccount(accountParams, token);
+ localClient.setAccessToken(session.accessJwt);
+
+ setProgress({ currentOperation: "Exporting repository..." });
+
+ const car = await sourceClient.getRepo(state.sourceDid);
+ setProgress({ repoExported: true, currentOperation: "Importing repository..." });
+
+ await localClient.importRepo(car);
+ setProgress({ repoImported: true, currentOperation: "Counting blobs..." });
+
+ const accountStatus = await localClient.checkAccountStatus();
+ setProgress({
+ blobsTotal: accountStatus.expectedBlobs,
+ currentOperation: "Migrating blobs...",
+ });
+
+ await migrateBlobs();
+
+ setProgress({ currentOperation: "Migrating preferences..." });
+ await migratePreferences();
+
+ setStep("email-verify");
+ } catch (e) {
+ const err = e as Error & { error?: string; status?: number };
+ const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
+ setError(message);
+ setStep("error");
+ }
+ }
+
+ async function migrateBlobs(): Promise {
+ if (!sourceClient || !localClient) return;
+
+ let cursor: string | undefined;
+ let migrated = 0;
+
+ do {
+ const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
+ cursor,
+ 100,
+ );
+
+ for (const blob of blobs) {
+ try {
+ setProgress({
+ currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`,
+ });
+
+ const blobData = await sourceClient.getBlob(state.sourceDid, blob.cid);
+ await localClient.uploadBlob(blobData, "application/octet-stream");
+ migrated++;
+ setProgress({ blobsMigrated: migrated });
+ } catch (e) {
+ state.progress.blobsFailed.push(blob.cid);
+ }
+ }
+
+ cursor = nextCursor;
+ } while (cursor);
+ }
+
+ async function migratePreferences(): Promise {
+ if (!sourceClient || !localClient) return;
+
+ try {
+ const prefs = await sourceClient.getPreferences();
+ await localClient.putPreferences(prefs);
+ setProgress({ prefsMigrated: true });
+ } catch {
+ }
+ }
+
+ async function submitEmailVerifyToken(token: string, localPassword?: string): Promise {
+ if (!localClient) {
+ localClient = createLocalClient();
+ }
+
+ state.emailVerifyToken = token;
+ setError(null);
+
+ try {
+ await localClient.verifyToken(token, state.targetEmail);
+
+ if (!sourceClient) {
+ setStep("source-login");
+ setError("Email verified! Please log in to your old account again to complete the migration.");
+ return;
+ }
+
+ if (localPassword) {
+ setProgress({ currentOperation: "Authenticating to new PDS..." });
+ await localClient.loginDeactivated(state.targetEmail, localPassword);
+ }
+
+ if (!localClient.getAccessToken()) {
+ setError("Email verified! Please enter your password to continue.");
+ return;
+ }
+
+ setProgress({ currentOperation: "Requesting PLC operation token..." });
+ await sourceClient.requestPlcOperationSignature();
+ setStep("plc-token");
+ } catch (e) {
+ const err = e as Error & { error?: string; status?: number };
+ const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
+ setError(message);
+ }
+ }
+
+ async function resendEmailVerification(): Promise {
+ if (!localClient) {
+ localClient = createLocalClient();
+ }
+ await localClient.resendMigrationVerification();
+ }
+
+ let checkingEmailVerification = false;
+
+ async function checkEmailVerifiedAndProceed(): Promise {
+ if (checkingEmailVerification) return false;
+ if (!sourceClient || !localClient) return false;
+
+ checkingEmailVerification = true;
+ try {
+ await localClient.loginDeactivated(state.targetEmail, state.targetPassword);
+ 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;
+ }
+ }
+
+ async function submitPlcToken(token: string): Promise {
+ if (!sourceClient || !localClient) {
+ throw new Error("Not connected to PDSes");
+ }
+
+ state.plcToken = token;
+ setStep("finalizing");
+ setProgress({ currentOperation: "Signing PLC operation..." });
+
+ try {
+ const credentials = await localClient.getRecommendedDidCredentials();
+
+ const { operation } = await sourceClient.signPlcOperation({
+ token,
+ ...credentials,
+ });
+
+ setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." });
+ await localClient.submitPlcOperation(operation);
+
+ setProgress({ currentOperation: "Activating account (waiting for DID propagation)..." });
+ await localClient.activateAccount();
+ setProgress({ activated: true });
+
+ setProgress({ currentOperation: "Deactivating old account..." });
+ try {
+ await sourceClient.deactivateAccount();
+ setProgress({ deactivated: true });
+ } catch {
+ }
+
+ setStep("success");
+ clearMigrationState();
+ } catch (e) {
+ const err = e as Error & { error?: string; status?: number };
+ const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
+ state.step = "plc-token";
+ state.error = message;
+ saveMigrationState(state);
+ }
+ }
+
+ async function requestPlcToken(): Promise {
+ if (!sourceClient) {
+ throw new Error("Not connected to source PDS");
+ }
+ setProgress({ currentOperation: "Requesting PLC operation token..." });
+ await sourceClient.requestPlcOperationSignature();
+ }
+
+ async function resendPlcToken(): Promise {
+ if (!sourceClient) {
+ throw new Error("Not connected to source PDS");
+ }
+ await sourceClient.requestPlcOperationSignature();
+ }
+
+ function reset(): void {
+ state = {
+ direction: "inbound",
+ step: "welcome",
+ sourcePdsUrl: "",
+ sourceDid: "",
+ sourceHandle: "",
+ targetHandle: "",
+ targetEmail: "",
+ targetPassword: "",
+ inviteCode: "",
+ sourceAccessToken: null,
+ sourceRefreshToken: null,
+ serviceAuthToken: null,
+ emailVerifyToken: "",
+ plcToken: "",
+ progress: createInitialProgress(),
+ error: null,
+ requires2FA: false,
+ twoFactorCode: "",
+ };
+ sourceClient = null;
+ clearMigrationState();
+ }
+
+ async function resumeFromState(stored: StoredMigrationState): Promise {
+ if (stored.direction !== "inbound") return;
+
+ state.sourcePdsUrl = stored.sourcePdsUrl;
+ state.sourceDid = stored.sourceDid;
+ state.sourceHandle = stored.sourceHandle;
+ state.targetHandle = stored.targetHandle;
+ state.targetEmail = stored.targetEmail;
+ state.progress = {
+ ...createInitialProgress(),
+ ...stored.progress,
+ };
+
+ state.step = "source-login";
+ }
+
+ function getLocalSession(): { accessJwt: string; did: string; handle: string } | null {
+ if (!localClient) return null;
+ const token = localClient.getAccessToken();
+ if (!token) return null;
+ return {
+ accessJwt: token,
+ did: state.sourceDid,
+ handle: state.targetHandle,
+ };
+ }
+
+ return {
+ get state() { return state; },
+ setStep,
+ setError,
+ loadLocalServerInfo,
+ loginToSource,
+ authenticateToLocal,
+ checkHandleAvailability,
+ startMigration,
+ submitEmailVerifyToken,
+ resendEmailVerification,
+ checkEmailVerifiedAndProceed,
+ requestPlcToken,
+ submitPlcToken,
+ resendPlcToken,
+ reset,
+ resumeFromState,
+ getLocalSession,
+
+ updateField(
+ field: K,
+ value: InboundMigrationState[K],
+ ) {
+ state[field] = value;
+ },
+ };
+}
+
+export function createOutboundMigrationFlow() {
+ let state = $state({
+ direction: "outbound",
+ step: "welcome",
+ localDid: "",
+ localHandle: "",
+ targetPdsUrl: "",
+ targetPdsDid: "",
+ targetHandle: "",
+ targetEmail: "",
+ targetPassword: "",
+ inviteCode: "",
+ targetAccessToken: null,
+ targetRefreshToken: null,
+ serviceAuthToken: null,
+ plcToken: "",
+ progress: createInitialProgress(),
+ error: null,
+ targetServerInfo: null,
+ });
+
+ let localClient: AtprotoClient | null = null;
+ let targetClient: AtprotoClient | null = null;
+
+ function setStep(step: OutboundStep) {
+ state.step = step;
+ state.error = null;
+ saveMigrationState(state);
+ updateStep(step);
+ }
+
+ function setError(error: string) {
+ state.error = error;
+ saveMigrationState(state);
+ }
+
+ function setProgress(updates: Partial) {
+ state.progress = { ...state.progress, ...updates };
+ updateProgress(updates);
+ }
+
+ async function validateTargetPds(url: string): Promise {
+ const normalizedUrl = url.replace(/\/$/, "");
+ targetClient = new AtprotoClient(normalizedUrl);
+
+ try {
+ const serverInfo = await targetClient.describeServer();
+ state.targetPdsUrl = normalizedUrl;
+ state.targetPdsDid = serverInfo.did;
+ state.targetServerInfo = serverInfo;
+ return serverInfo;
+ } catch (e) {
+ throw new Error(`Could not connect to PDS: ${(e as Error).message}`);
+ }
+ }
+
+ function initLocalClient(accessToken: string, did?: string, handle?: string): void {
+ localClient = createLocalClient();
+ localClient.setAccessToken(accessToken);
+ if (did) {
+ state.localDid = did;
+ }
+ if (handle) {
+ state.localHandle = handle;
+ }
+ }
+
+ async function startMigration(currentDid: string): Promise {
+ if (!localClient || !targetClient) {
+ throw new Error("Not connected to PDSes");
+ }
+
+ setStep("migrating");
+ setProgress({ currentOperation: "Getting service auth token..." });
+
+ try {
+ const { token } = await localClient.getServiceAuth(
+ state.targetPdsDid,
+ "com.atproto.server.createAccount",
+ );
+ state.serviceAuthToken = token;
+
+ setProgress({ currentOperation: "Creating account on new PDS..." });
+
+ const accountParams = {
+ did: currentDid,
+ handle: state.targetHandle,
+ email: state.targetEmail,
+ password: state.targetPassword,
+ inviteCode: state.inviteCode || undefined,
+ };
+
+ const session = await targetClient.createAccount(accountParams, token);
+ state.targetAccessToken = session.accessJwt;
+ state.targetRefreshToken = session.refreshJwt;
+ targetClient.setAccessToken(session.accessJwt);
+
+ setProgress({ currentOperation: "Exporting repository..." });
+
+ const car = await localClient.getRepo(currentDid);
+ setProgress({ repoExported: true, currentOperation: "Importing repository..." });
+
+ await targetClient.importRepo(car);
+ setProgress({ repoImported: true, currentOperation: "Counting blobs..." });
+
+ const accountStatus = await targetClient.checkAccountStatus();
+ setProgress({
+ blobsTotal: accountStatus.expectedBlobs,
+ currentOperation: "Migrating blobs...",
+ });
+
+ await migrateBlobs(currentDid);
+
+ setProgress({ currentOperation: "Migrating preferences..." });
+ await migratePreferences();
+
+ setProgress({ currentOperation: "Requesting PLC operation token..." });
+ await localClient.requestPlcOperationSignature();
+
+ setStep("plc-token");
+ } catch (e) {
+ const err = e as Error & { error?: string; status?: number };
+ const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
+ setError(message);
+ setStep("error");
+ }
+ }
+
+ async function migrateBlobs(did: string): Promise {
+ if (!localClient || !targetClient) return;
+
+ let cursor: string | undefined;
+ let migrated = 0;
+
+ do {
+ const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs(
+ cursor,
+ 100,
+ );
+
+ for (const blob of blobs) {
+ try {
+ setProgress({
+ currentOperation: `Migrating blob ${migrated + 1}/${state.progress.blobsTotal}...`,
+ });
+
+ const blobData = await localClient.getBlob(did, blob.cid);
+ await targetClient.uploadBlob(blobData, "application/octet-stream");
+ migrated++;
+ setProgress({ blobsMigrated: migrated });
+ } catch (e) {
+ state.progress.blobsFailed.push(blob.cid);
+ }
+ }
+
+ cursor = nextCursor;
+ } while (cursor);
+ }
+
+ async function migratePreferences(): Promise {
+ if (!localClient || !targetClient) return;
+
+ try {
+ const prefs = await localClient.getPreferences();
+ await targetClient.putPreferences(prefs);
+ setProgress({ prefsMigrated: true });
+ } catch {
+ }
+ }
+
+ async function submitPlcToken(token: string): Promise {
+ if (!localClient || !targetClient) {
+ throw new Error("Not connected to PDSes");
+ }
+
+ state.plcToken = token;
+ setStep("finalizing");
+ setProgress({ currentOperation: "Signing PLC operation..." });
+
+ try {
+ const credentials = await targetClient.getRecommendedDidCredentials();
+
+ const { operation } = await localClient.signPlcOperation({
+ token,
+ ...credentials,
+ });
+
+ setProgress({ plcSigned: true, currentOperation: "Submitting PLC operation..." });
+
+ await targetClient.submitPlcOperation(operation);
+
+ setProgress({ currentOperation: "Activating account on new PDS..." });
+ await targetClient.activateAccount();
+ setProgress({ activated: true });
+
+ setProgress({ currentOperation: "Deactivating old account..." });
+ try {
+ await localClient.deactivateAccount();
+ setProgress({ deactivated: true });
+ } catch {
+ }
+
+ if (state.localDid.startsWith("did:web:")) {
+ setProgress({ currentOperation: "Updating DID document forwarding..." });
+ try {
+ await localClient.updateMigrationForwarding(state.targetPdsUrl);
+ } catch (e) {
+ console.warn("Failed to update migration forwarding:", e);
+ }
+ }
+
+ setStep("success");
+ clearMigrationState();
+ } catch (e) {
+ const err = e as Error & { error?: string; status?: number };
+ const message = err.message || err.error || `Unknown error (status ${err.status || 'unknown'})`;
+ setError(message);
+ setStep("plc-token");
+ }
+ }
+
+ async function resendPlcToken(): Promise {
+ if (!localClient) {
+ throw new Error("Not connected to local PDS");
+ }
+ await localClient.requestPlcOperationSignature();
+ }
+
+ function reset(): void {
+ state = {
+ direction: "outbound",
+ step: "welcome",
+ localDid: "",
+ localHandle: "",
+ targetPdsUrl: "",
+ targetPdsDid: "",
+ targetHandle: "",
+ targetEmail: "",
+ targetPassword: "",
+ inviteCode: "",
+ targetAccessToken: null,
+ targetRefreshToken: null,
+ serviceAuthToken: null,
+ plcToken: "",
+ progress: createInitialProgress(),
+ error: null,
+ targetServerInfo: null,
+ };
+ localClient = null;
+ targetClient = null;
+ clearMigrationState();
+ }
+
+ return {
+ get state() { return state; },
+ setStep,
+ setError,
+ validateTargetPds,
+ initLocalClient,
+ startMigration,
+ submitPlcToken,
+ resendPlcToken,
+ reset,
+
+ updateField(
+ field: K,
+ value: OutboundMigrationState[K],
+ ) {
+ state[field] = value;
+ },
+ };
+}
+
+export type InboundMigrationFlow = ReturnType;
+export type OutboundMigrationFlow = ReturnType;
diff --git a/frontend/src/lib/migration/index.ts b/frontend/src/lib/migration/index.ts
new file mode 100644
index 0000000..fe08ff7
--- /dev/null
+++ b/frontend/src/lib/migration/index.ts
@@ -0,0 +1,9 @@
+export * from "./types";
+export * from "./atproto-client";
+export * from "./storage";
+export {
+ createInboundMigrationFlow,
+ createOutboundMigrationFlow,
+ type InboundMigrationFlow,
+ type OutboundMigrationFlow,
+} from "./flow.svelte";
diff --git a/frontend/src/lib/migration/storage.ts b/frontend/src/lib/migration/storage.ts
new file mode 100644
index 0000000..cecab9f
--- /dev/null
+++ b/frontend/src/lib/migration/storage.ts
@@ -0,0 +1,138 @@
+import type { MigrationDirection, MigrationState, StoredMigrationState } from "./types";
+
+const STORAGE_KEY = "tranquil_migration_state";
+const MAX_AGE_MS = 24 * 60 * 60 * 1000;
+
+export function saveMigrationState(state: MigrationState): void {
+ const storedState: StoredMigrationState = {
+ version: 1,
+ direction: state.direction,
+ step: state.direction === "inbound" ? state.step : state.step,
+ startedAt: new Date().toISOString(),
+ sourcePdsUrl: state.direction === "inbound" ? state.sourcePdsUrl : window.location.origin,
+ targetPdsUrl: state.direction === "inbound" ? window.location.origin : state.targetPdsUrl,
+ sourceDid: state.direction === "inbound" ? state.sourceDid : "",
+ sourceHandle: state.direction === "inbound" ? state.sourceHandle : "",
+ targetHandle: state.targetHandle,
+ targetEmail: state.targetEmail,
+ progress: {
+ repoExported: state.progress.repoExported,
+ repoImported: state.progress.repoImported,
+ blobsTotal: state.progress.blobsTotal,
+ blobsMigrated: state.progress.blobsMigrated,
+ prefsMigrated: state.progress.prefsMigrated,
+ plcSigned: state.progress.plcSigned,
+ },
+ lastError: state.error ?? undefined,
+ lastErrorStep: state.error ? state.step : undefined,
+ };
+
+ try {
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(storedState));
+ } catch {
+ }
+}
+
+export function loadMigrationState(): StoredMigrationState | null {
+ try {
+ const stored = sessionStorage.getItem(STORAGE_KEY);
+ if (!stored) return null;
+
+ const state = JSON.parse(stored) as StoredMigrationState;
+
+ if (state.version !== 1) return null;
+
+ const startedAt = new Date(state.startedAt).getTime();
+ if (Date.now() - startedAt > MAX_AGE_MS) {
+ clearMigrationState();
+ return null;
+ }
+
+ return state;
+ } catch {
+ return null;
+ }
+}
+
+export function clearMigrationState(): void {
+ try {
+ sessionStorage.removeItem(STORAGE_KEY);
+ } catch {
+ }
+}
+
+export function hasPendingMigration(): boolean {
+ return loadMigrationState() !== null;
+}
+
+export function getResumeInfo(): {
+ direction: MigrationDirection;
+ sourceHandle: string;
+ targetHandle: string;
+ sourcePdsUrl: string;
+ targetPdsUrl: string;
+ progressSummary: string;
+ step: string;
+} | null {
+ const state = loadMigrationState();
+ if (!state) return null;
+
+ const progressParts: string[] = [];
+ if (state.progress.repoExported) progressParts.push("repo exported");
+ if (state.progress.repoImported) progressParts.push("repo imported");
+ if (state.progress.blobsMigrated > 0) {
+ progressParts.push(
+ `${state.progress.blobsMigrated}/${state.progress.blobsTotal} blobs`,
+ );
+ }
+ if (state.progress.prefsMigrated) progressParts.push("preferences migrated");
+ if (state.progress.plcSigned) progressParts.push("PLC signed");
+
+ return {
+ direction: state.direction,
+ sourceHandle: state.sourceHandle,
+ targetHandle: state.targetHandle,
+ sourcePdsUrl: state.sourcePdsUrl,
+ targetPdsUrl: state.targetPdsUrl,
+ progressSummary: progressParts.length > 0
+ ? progressParts.join(", ")
+ : "just started",
+ step: state.step,
+ };
+}
+
+export function updateProgress(
+ updates: Partial,
+): void {
+ const state = loadMigrationState();
+ if (!state) return;
+
+ state.progress = { ...state.progress, ...updates };
+ try {
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ } catch {
+ }
+}
+
+export function updateStep(step: string): void {
+ const state = loadMigrationState();
+ if (!state) return;
+
+ state.step = step;
+ try {
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ } catch {
+ }
+}
+
+export function setError(error: string, step: string): void {
+ const state = loadMigrationState();
+ if (!state) return;
+
+ state.lastError = error;
+ state.lastErrorStep = step;
+ try {
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
+ } catch {
+ }
+}
diff --git a/frontend/src/lib/migration/types.ts b/frontend/src/lib/migration/types.ts
new file mode 100644
index 0000000..7bc7728
--- /dev/null
+++ b/frontend/src/lib/migration/types.ts
@@ -0,0 +1,214 @@
+export type InboundStep =
+ | "welcome"
+ | "source-login"
+ | "choose-handle"
+ | "review"
+ | "migrating"
+ | "email-verify"
+ | "plc-token"
+ | "finalizing"
+ | "success"
+ | "error";
+
+export type OutboundStep =
+ | "welcome"
+ | "target-pds"
+ | "new-account"
+ | "review"
+ | "migrating"
+ | "plc-token"
+ | "finalizing"
+ | "success"
+ | "error";
+
+export type MigrationDirection = "inbound" | "outbound";
+
+export interface MigrationProgress {
+ repoExported: boolean;
+ repoImported: boolean;
+ blobsTotal: number;
+ blobsMigrated: number;
+ blobsFailed: string[];
+ prefsMigrated: boolean;
+ plcSigned: boolean;
+ activated: boolean;
+ deactivated: boolean;
+ currentOperation: string;
+}
+
+export interface InboundMigrationState {
+ direction: "inbound";
+ step: InboundStep;
+ sourcePdsUrl: string;
+ sourceDid: string;
+ sourceHandle: string;
+ targetHandle: string;
+ targetEmail: string;
+ targetPassword: string;
+ inviteCode: string;
+ sourceAccessToken: string | null;
+ sourceRefreshToken: string | null;
+ serviceAuthToken: string | null;
+ emailVerifyToken: string;
+ plcToken: string;
+ progress: MigrationProgress;
+ error: string | null;
+ requires2FA: boolean;
+ twoFactorCode: string;
+}
+
+export interface OutboundMigrationState {
+ direction: "outbound";
+ step: OutboundStep;
+ localDid: string;
+ localHandle: string;
+ targetPdsUrl: string;
+ targetPdsDid: string;
+ targetHandle: string;
+ targetEmail: string;
+ targetPassword: string;
+ inviteCode: string;
+ targetAccessToken: string | null;
+ targetRefreshToken: string | null;
+ serviceAuthToken: string | null;
+ plcToken: string;
+ progress: MigrationProgress;
+ error: string | null;
+ targetServerInfo: ServerDescription | null;
+}
+
+export type MigrationState = InboundMigrationState | OutboundMigrationState;
+
+export interface StoredMigrationState {
+ version: 1;
+ direction: MigrationDirection;
+ step: string;
+ startedAt: string;
+ sourcePdsUrl: string;
+ targetPdsUrl: string;
+ sourceDid: string;
+ sourceHandle: string;
+ targetHandle: string;
+ targetEmail: string;
+ progress: {
+ repoExported: boolean;
+ repoImported: boolean;
+ blobsTotal: number;
+ blobsMigrated: number;
+ prefsMigrated: boolean;
+ plcSigned: boolean;
+ };
+ lastErrorStep?: string;
+ lastError?: string;
+}
+
+export interface ServerDescription {
+ did: string;
+ availableUserDomains: string[];
+ inviteCodeRequired: boolean;
+ phoneVerificationRequired?: boolean;
+ links?: {
+ privacyPolicy?: string;
+ termsOfService?: string;
+ };
+}
+
+export interface Session {
+ did: string;
+ handle: string;
+ email?: string;
+ accessJwt: string;
+ refreshJwt: string;
+ active?: boolean;
+}
+
+export interface DidDocument {
+ id: string;
+ alsoKnownAs?: string[];
+ verificationMethod?: Array<{
+ id: string;
+ type: string;
+ controller: string;
+ publicKeyMultibase?: string;
+ }>;
+ service?: Array<{
+ id: string;
+ type: string;
+ serviceEndpoint: string;
+ }>;
+}
+
+export interface DidCredentials {
+ rotationKeys?: string[];
+ alsoKnownAs?: string[];
+ verificationMethods?: {
+ atproto?: string;
+ };
+ services?: {
+ atproto_pds?: {
+ type: string;
+ endpoint: string;
+ };
+ };
+}
+
+export interface PlcOperation {
+ type: "plc_operation";
+ prev: string | null;
+ sig: string;
+ rotationKeys: string[];
+ verificationMethods: {
+ atproto: string;
+ };
+ alsoKnownAs: string[];
+ services: {
+ atproto_pds: {
+ type: string;
+ endpoint: string;
+ };
+ };
+}
+
+export interface AccountStatus {
+ activated: boolean;
+ validDid: boolean;
+ repoCommit: string;
+ repoRev: string;
+ repoBlocks: number;
+ indexedRecords: number;
+ privateStateValues: number;
+ expectedBlobs: number;
+ importedBlobs: number;
+}
+
+export interface BlobRef {
+ $type: "blob";
+ ref: { $link: string };
+ mimeType: string;
+ size: number;
+}
+
+export interface CreateAccountParams {
+ did?: string;
+ handle: string;
+ email: string;
+ password: string;
+ inviteCode?: string;
+ recoveryKey?: string;
+}
+
+export interface Preferences {
+ preferences: unknown[];
+}
+
+export class MigrationError extends Error {
+ constructor(
+ message: string,
+ public code: string,
+ public recoverable: boolean = false,
+ public details?: unknown,
+ ) {
+ super(message);
+ this.name = "MigrationError";
+ }
+}
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index 8827011..d05f57c 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -71,7 +71,7 @@
"infoNextDesc": "After creating your account, you'll verify your contact method and then you're ready to use any ATProto app with your new identity.",
"migrateTitle": "Already have a Bluesky account?",
"migrateDescription": "You can migrate your existing account to this PDS instead of creating a new one. Your followers, posts, and identity will come with you.",
- "migrateLink": "Migrate with PDS Moover",
+ "migrateLink": "Migrate your account",
"handle": "Handle",
"handlePlaceholder": "yourname",
"handleHint": "Your full handle will be: @{handle}",
@@ -991,5 +991,206 @@
"codeLabel": "Verification Code",
"codeHelp": "Copy the entire code from your message, including dashes.",
"verifyButton": "Verify"
+ },
+ "migration": {
+ "title": "Account Migration",
+ "subtitle": "Move your AT Protocol identity between servers",
+ "navTitle": "Migration",
+ "navDesc": "Move your account to or from another PDS",
+ "migrateHere": "Migrate Here",
+ "migrateHereDesc": "Move your existing AT Protocol account to this PDS from another server.",
+ "migrateAway": "Migrate Away",
+ "migrateAwayDesc": "Move your account from this PDS to another server.",
+ "loginRequired": "Login required",
+ "bringDid": "Bring your DID and identity",
+ "transferData": "Transfer all your data",
+ "keepFollowers": "Keep your followers",
+ "exportRepo": "Export your repository",
+ "transferToPds": "Transfer to new PDS",
+ "updateIdentity": "Update your identity",
+ "whatIsMigration": "What is account migration?",
+ "whatIsMigrationDesc": "Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes). Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.",
+ "beforeMigrate": "Before you migrate",
+ "beforeMigrate1": "You will need your current account credentials",
+ "beforeMigrate2": "Migration requires email verification for security",
+ "beforeMigrate3": "Large accounts with many images may take several minutes",
+ "beforeMigrate4": "Your old PDS will be notified to deactivate your account",
+ "importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.",
+ "learnMore": "Learn more about migration risks",
+ "resume": {
+ "title": "Resume Migration?",
+ "incomplete": "You have an incomplete migration in progress:",
+ "direction": "Direction",
+ "migratingHere": "Migrating here",
+ "migratingAway": "Migrating away",
+ "from": "From",
+ "to": "To",
+ "progress": "Progress",
+ "reenterCredentials": "You will need to re-enter your credentials to continue.",
+ "startOver": "Start Over",
+ "resumeButton": "Resume"
+ },
+ "inbound": {
+ "welcome": {
+ "title": "Migrate to This PDS",
+ "desc": "Move your existing AT Protocol account to this server.",
+ "understand": "I understand the risks and want to proceed"
+ },
+ "sourceLogin": {
+ "title": "Sign In to Your Current PDS",
+ "desc": "Enter your credentials for the account you want to migrate.",
+ "handle": "Handle",
+ "handlePlaceholder": "you.bsky.social",
+ "password": "Password",
+ "twoFactorCode": "Two-Factor Code",
+ "twoFactorRequired": "Two-factor authentication required",
+ "signIn": "Sign In & Continue"
+ },
+ "chooseHandle": {
+ "title": "Choose Your New Handle",
+ "desc": "Select a handle for your account on this PDS.",
+ "handleHint": "Your full handle will be: @{handle}"
+ },
+ "review": {
+ "title": "Review Migration",
+ "desc": "Please review and confirm your migration details.",
+ "currentHandle": "Current Handle",
+ "newHandle": "New Handle",
+ "sourcePds": "Source PDS",
+ "targetPds": "This PDS",
+ "email": "Email",
+ "inviteCode": "Invite Code",
+ "confirm": "I confirm I want to migrate my account",
+ "startMigration": "Start Migration"
+ },
+ "migrating": {
+ "title": "Migrating Your Account",
+ "desc": "Please wait while we transfer your data...",
+ "gettingServiceAuth": "Getting service authorization...",
+ "creatingAccount": "Creating account on new PDS...",
+ "exportingRepo": "Exporting repository...",
+ "importingRepo": "Importing repository...",
+ "countingBlobs": "Counting blobs...",
+ "migratingBlobs": "Migrating blobs ({current}/{total})...",
+ "migratingPrefs": "Migrating preferences...",
+ "requestingPlc": "Requesting PLC operation..."
+ },
+ "emailVerify": {
+ "title": "Verify Your Email",
+ "desc": "A verification code has been sent to {email}.",
+ "hint": "Enter the code below, or click the link in the email to continue automatically.",
+ "tokenLabel": "Verification Code",
+ "tokenPlaceholder": "Enter code from email",
+ "resend": "Resend Code",
+ "verify": "Verify Email",
+ "verifying": "Verifying..."
+ },
+ "plcToken": {
+ "title": "Verify Your Identity",
+ "desc": "A verification code has been sent to your email on your current PDS.",
+ "tokenLabel": "Verification Token",
+ "tokenPlaceholder": "Enter the token from your email",
+ "resend": "Resend Token",
+ "resending": "Resending..."
+ },
+ "finalizing": {
+ "title": "Finalizing Migration",
+ "desc": "Please wait while we complete the migration...",
+ "signingPlc": "Sign identity update",
+ "activating": "Activate account on new PDS",
+ "deactivating": "Deactivate account on old PDS"
+ },
+ "success": {
+ "title": "Migration Complete!",
+ "desc": "Your account has been successfully migrated to this PDS.",
+ "newHandle": "New Handle",
+ "did": "DID",
+ "goToDashboard": "Go to Dashboard"
+ }
+ },
+ "outbound": {
+ "welcome": {
+ "title": "Migrate Away from This PDS",
+ "desc": "Move your account to another Personal Data Server.",
+ "warning": "After migration, your account here will be deactivated.",
+ "didWebNotice": "did:web Migration Notice",
+ "didWebNoticeDesc": "Your account uses a did:web identifier ({did}). After migrating, this PDS will continue to serve your DID document pointing to the new PDS. Your identity will remain functional as long as this server is online.",
+ "understand": "I understand the risks and want to proceed"
+ },
+ "targetPds": {
+ "title": "Choose Target PDS",
+ "desc": "Enter the URL of the PDS you want to migrate to.",
+ "url": "PDS URL",
+ "urlPlaceholder": "https://pds.example.com",
+ "validate": "Validate & Continue",
+ "validating": "Validating...",
+ "connected": "Connected to {name}",
+ "inviteRequired": "Invite code required",
+ "privacyPolicy": "Privacy Policy",
+ "termsOfService": "Terms of Service"
+ },
+ "newAccount": {
+ "title": "New Account Details",
+ "desc": "Set up your account on the new PDS.",
+ "handle": "Handle",
+ "availableDomains": "Available domains",
+ "email": "Email",
+ "password": "Password",
+ "confirmPassword": "Confirm Password",
+ "inviteCode": "Invite Code"
+ },
+ "review": {
+ "title": "Review Migration",
+ "desc": "Please review and confirm your migration details.",
+ "currentHandle": "Current Handle",
+ "newHandle": "New Handle",
+ "sourcePds": "This PDS",
+ "targetPds": "Target PDS",
+ "confirm": "I confirm I want to migrate my account",
+ "startMigration": "Start Migration"
+ },
+ "migrating": {
+ "title": "Migrating Your Account",
+ "desc": "Please wait while we transfer your data..."
+ },
+ "plcToken": {
+ "title": "Verify Your Identity",
+ "desc": "A verification code has been sent to your email."
+ },
+ "finalizing": {
+ "title": "Finalizing Migration",
+ "desc": "Please wait while we complete the migration...",
+ "updatingForwarding": "Updating DID document forwarding..."
+ },
+ "success": {
+ "title": "Migration Complete!",
+ "desc": "Your account has been successfully migrated to your new PDS.",
+ "newHandle": "New Handle",
+ "newPds": "New PDS",
+ "nextSteps": "Next Steps",
+ "nextSteps1": "Sign in to your new PDS",
+ "nextSteps2": "Update any apps with your new credentials",
+ "nextSteps3": "Your followers will automatically see your new location",
+ "loggingOut": "Logging you out in {seconds} seconds..."
+ }
+ },
+ "progress": {
+ "repoExported": "Repository exported",
+ "repoImported": "Repository imported",
+ "blobsMigrated": "{count} blobs migrated",
+ "prefsMigrated": "Preferences migrated",
+ "plcSigned": "Identity updated",
+ "activated": "Account activated",
+ "deactivated": "Old account deactivated"
+ },
+ "errors": {
+ "connectionFailed": "Could not connect to PDS",
+ "invalidCredentials": "Invalid credentials",
+ "twoFactorRequired": "Two-factor authentication required",
+ "accountExists": "Account already exists on target PDS",
+ "plcFailed": "PLC operation failed",
+ "blobFailed": "Failed to migrate blob: {cid}",
+ "networkError": "Network error. Please try again."
+ }
}
}
diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json
index 6fdebad..7060827 100644
--- a/frontend/src/locales/fi.json
+++ b/frontend/src/locales/fi.json
@@ -1007,5 +1007,206 @@
"permissionsLimitedDesc": "Todelliset oikeutesi rajoitetaan {level}-käyttöoikeustasoosi riippumatta siitä, mitä sovellus pyytää.",
"viewerLimitedDesc": "Katselijana sinulla on vain lukuoikeus. Tämä sovellus ei voi luoda, muokata tai poistaa sisältöä tällä tilillä.",
"editorLimitedDesc": "Muokkaajana voit luoda ja muokata sisältöä, mutta et voi hallita tilin asetuksia tai tietoturvaa."
+ },
+ "migration": {
+ "title": "Tilin siirto",
+ "subtitle": "Siirrä AT Protocol -identiteettisi palvelimien välillä",
+ "navTitle": "Siirto",
+ "navDesc": "Siirrä tilisi toiseen tai toisesta PDS:stä",
+ "migrateHere": "Siirrä tänne",
+ "migrateHereDesc": "Siirrä olemassa oleva AT Protocol -tilisi tähän PDS:ään toiselta palvelimelta.",
+ "migrateAway": "Siirrä pois",
+ "migrateAwayDesc": "Siirrä tilisi tästä PDS:stä toiselle palvelimelle.",
+ "loginRequired": "Kirjautuminen vaaditaan",
+ "bringDid": "Tuo DID ja identiteettisi",
+ "transferData": "Siirrä kaikki tietosi",
+ "keepFollowers": "Säilytä seuraajasi",
+ "exportRepo": "Vie tietovarastosi",
+ "transferToPds": "Siirrä uuteen PDS:ään",
+ "updateIdentity": "Päivitä identiteettisi",
+ "whatIsMigration": "Mikä on tilin siirto?",
+ "whatIsMigrationDesc": "Tilin siirto mahdollistaa AT Protocol -identiteettisi siirtämisen henkilökohtaisten datapalvelimien (PDS) välillä. DID (hajautettu tunniste) pysyy samana, joten seuraajasi ja sosiaaliset yhteytesi säilyvät.",
+ "beforeMigrate": "Ennen siirtoa",
+ "beforeMigrate1": "Tarvitset nykyisen tilisi tunnukset",
+ "beforeMigrate2": "Siirto vaatii sähköpostivahvistuksen turvallisuussyistä",
+ "beforeMigrate3": "Suuret tilit, joissa on paljon kuvia, voivat kestää useita minuutteja",
+ "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista",
+ "importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.",
+ "learnMore": "Lue lisää siirron riskeistä",
+ "resume": {
+ "title": "Jatka siirtoa?",
+ "incomplete": "Sinulla on keskeneräinen siirto:",
+ "direction": "Suunta",
+ "migratingHere": "Siirretään tänne",
+ "migratingAway": "Siirretään pois",
+ "from": "Mistä",
+ "to": "Minne",
+ "progress": "Edistyminen",
+ "reenterCredentials": "Sinun täytyy syöttää tunnuksesi uudelleen jatkaaksesi.",
+ "startOver": "Aloita alusta",
+ "resumeButton": "Jatka"
+ },
+ "inbound": {
+ "welcome": {
+ "title": "Siirrä tähän PDS:ään",
+ "desc": "Siirrä olemassa oleva AT Protocol -tilisi tälle palvelimelle.",
+ "understand": "Ymmärrän riskit ja haluan jatkaa"
+ },
+ "sourceLogin": {
+ "title": "Kirjaudu nykyiseen PDS:ääsi",
+ "desc": "Syötä siirrettävän tilin tunnukset.",
+ "handle": "Käyttäjätunnus",
+ "handlePlaceholder": "sinä.bsky.social",
+ "password": "Salasana",
+ "twoFactorCode": "Kaksivaiheinen koodi",
+ "twoFactorRequired": "Kaksivaiheinen tunnistautuminen vaaditaan",
+ "signIn": "Kirjaudu ja jatka"
+ },
+ "chooseHandle": {
+ "title": "Valitse uusi käyttäjätunnuksesi",
+ "desc": "Valitse käyttäjätunnus tilillesi tässä PDS:ssä.",
+ "handleHint": "Täydellinen käyttäjätunnuksesi on: @{handle}"
+ },
+ "review": {
+ "title": "Tarkista siirto",
+ "desc": "Tarkista ja vahvista siirtotietosi.",
+ "currentHandle": "Nykyinen käyttäjätunnus",
+ "newHandle": "Uusi käyttäjätunnus",
+ "sourcePds": "Lähde-PDS",
+ "targetPds": "Tämä PDS",
+ "email": "Sähköposti",
+ "inviteCode": "Kutsukoodi",
+ "confirm": "Vahvistan haluavani siirtää tilini",
+ "startMigration": "Aloita siirto"
+ },
+ "migrating": {
+ "title": "Siirretään tiliäsi",
+ "desc": "Odota, kun siirrämme tietojasi...",
+ "gettingServiceAuth": "Haetaan palveluvaltuutusta...",
+ "creatingAccount": "Luodaan tiliä uuteen PDS:ään...",
+ "exportingRepo": "Viedään tietovarastoa...",
+ "importingRepo": "Tuodaan tietovarastoa...",
+ "countingBlobs": "Lasketaan blob-tiedostoja...",
+ "migratingBlobs": "Siirretään blob-tiedostoja ({current}/{total})...",
+ "migratingPrefs": "Siirretään asetuksia...",
+ "requestingPlc": "Pyydetään PLC-toimintoa..."
+ },
+ "emailVerify": {
+ "title": "Vahvista sähköpostisi",
+ "desc": "Vahvistuskoodi on lähetetty osoitteeseen {email}.",
+ "hint": "Syötä koodi alle tai klikkaa sähköpostissa olevaa linkkiä jatkaaksesi automaattisesti.",
+ "tokenLabel": "Vahvistuskoodi",
+ "tokenPlaceholder": "Syötä sähköpostista saatu koodi",
+ "resend": "Lähetä koodi uudelleen",
+ "verify": "Vahvista sähköposti",
+ "verifying": "Vahvistetaan..."
+ },
+ "plcToken": {
+ "title": "Vahvista henkilöllisyytesi",
+ "desc": "Vahvistuskoodi on lähetetty sähköpostiisi nykyisessä PDS:ssäsi.",
+ "tokenLabel": "Vahvistuskoodi",
+ "tokenPlaceholder": "Syötä sähköpostista saatu koodi",
+ "resend": "Lähetä uudelleen",
+ "resending": "Lähetetään..."
+ },
+ "finalizing": {
+ "title": "Viimeistellään siirtoa",
+ "desc": "Odota, kun viimeistelemme siirtoa...",
+ "signingPlc": "Allekirjoita identiteettipäivitys",
+ "activating": "Aktivoi tili uudessa PDS:ssä",
+ "deactivating": "Deaktivoi tili vanhassa PDS:ssä"
+ },
+ "success": {
+ "title": "Siirto valmis!",
+ "desc": "Tilisi on siirretty onnistuneesti tähän PDS:ään.",
+ "newHandle": "Uusi käyttäjätunnus",
+ "did": "DID",
+ "goToDashboard": "Siirry hallintapaneeliin"
+ }
+ },
+ "outbound": {
+ "welcome": {
+ "title": "Siirrä pois tästä PDS:stä",
+ "desc": "Siirrä tilisi toiseen henkilökohtaiseen datapalvelimeen.",
+ "warning": "Siirron jälkeen tilisi täällä deaktivoidaan.",
+ "didWebNotice": "did:web-siirtoilmoitus",
+ "didWebNoticeDesc": "Tilisi käyttää did:web-tunnistetta ({did}). Siirron jälkeen tämä PDS jatkaa DID-dokumenttisi tarjoamista osoittaen uuteen PDS:ään. Identiteettisi toimii niin kauan kuin tämä palvelin on päällä.",
+ "understand": "Ymmärrän riskit ja haluan jatkaa"
+ },
+ "targetPds": {
+ "title": "Valitse kohde-PDS",
+ "desc": "Syötä sen PDS:n URL, johon haluat siirtyä.",
+ "url": "PDS URL",
+ "urlPlaceholder": "https://pds.example.com",
+ "validate": "Vahvista ja jatka",
+ "validating": "Vahvistetaan...",
+ "connected": "Yhdistetty: {name}",
+ "inviteRequired": "Kutsukoodi vaaditaan",
+ "privacyPolicy": "Tietosuojakäytäntö",
+ "termsOfService": "Käyttöehdot"
+ },
+ "newAccount": {
+ "title": "Uuden tilin tiedot",
+ "desc": "Määritä tilisi uudessa PDS:ssä.",
+ "handle": "Käyttäjätunnus",
+ "availableDomains": "Käytettävissä olevat verkkotunnukset",
+ "email": "Sähköposti",
+ "password": "Salasana",
+ "confirmPassword": "Vahvista salasana",
+ "inviteCode": "Kutsukoodi"
+ },
+ "review": {
+ "title": "Tarkista siirto",
+ "desc": "Tarkista ja vahvista siirtotietosi.",
+ "currentHandle": "Nykyinen käyttäjätunnus",
+ "newHandle": "Uusi käyttäjätunnus",
+ "sourcePds": "Tämä PDS",
+ "targetPds": "Kohde-PDS",
+ "confirm": "Vahvistan haluavani siirtää tilini",
+ "startMigration": "Aloita siirto"
+ },
+ "migrating": {
+ "title": "Siirretään tiliäsi",
+ "desc": "Odota, kun siirrämme tietojasi..."
+ },
+ "plcToken": {
+ "title": "Vahvista henkilöllisyytesi",
+ "desc": "Vahvistuskoodi on lähetetty sähköpostiisi."
+ },
+ "finalizing": {
+ "title": "Viimeistellään siirtoa",
+ "desc": "Odota, kun viimeistelemme siirtoa...",
+ "updatingForwarding": "Päivitetään DID-dokumentin uudelleenohjausta..."
+ },
+ "success": {
+ "title": "Siirto valmis!",
+ "desc": "Tilisi on siirretty onnistuneesti uuteen PDS:ääsi.",
+ "newHandle": "Uusi käyttäjätunnus",
+ "newPds": "Uusi PDS",
+ "nextSteps": "Seuraavat vaiheet",
+ "nextSteps1": "Kirjaudu uuteen PDS:ääsi",
+ "nextSteps2": "Päivitä sovellukset uusilla tunnuksillasi",
+ "nextSteps3": "Seuraajasi näkevät automaattisesti uuden sijaintisi",
+ "loggingOut": "Kirjaudutaan ulos {seconds} sekunnin kuluttua..."
+ }
+ },
+ "progress": {
+ "repoExported": "Tietovarasto viety",
+ "repoImported": "Tietovarasto tuotu",
+ "blobsMigrated": "{count} blob-tiedostoa siirretty",
+ "prefsMigrated": "Asetukset siirretty",
+ "plcSigned": "Identiteetti päivitetty",
+ "activated": "Tili aktivoitu",
+ "deactivated": "Vanha tili deaktivoitu"
+ },
+ "errors": {
+ "connectionFailed": "Yhteys PDS:ään epäonnistui",
+ "invalidCredentials": "Virheelliset tunnukset",
+ "twoFactorRequired": "Kaksivaiheinen tunnistautuminen vaaditaan",
+ "accountExists": "Tili on jo olemassa kohde-PDS:ssä",
+ "plcFailed": "PLC-toiminto epäonnistui",
+ "blobFailed": "Blob-tiedoston siirto epäonnistui: {cid}",
+ "networkError": "Verkkovirhe. Yritä uudelleen."
+ }
}
}
diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json
index c2e7c6c..edef215 100644
--- a/frontend/src/locales/ja.json
+++ b/frontend/src/locales/ja.json
@@ -1029,5 +1029,206 @@
"permissionsLimitedDesc": "アプリが何を要求しても、実際の権限は{level}アクセスレベルに制限されます。",
"viewerLimitedDesc": "閲覧者として、読み取り専用アクセスのみ可能です。このアプリはこのアカウントでコンテンツの作成、更新、削除ができません。",
"editorLimitedDesc": "編集者として、コンテンツの作成と編集が可能ですが、アカウント設定やセキュリティの管理はできません。"
+ },
+ "migration": {
+ "title": "アカウント移行",
+ "subtitle": "AT Protocolアイデンティティをサーバー間で移動",
+ "navTitle": "移行",
+ "navDesc": "別のPDSへ、または別のPDSからアカウントを移動",
+ "migrateHere": "ここに移行",
+ "migrateHereDesc": "既存のAT ProtocolアカウントをこのPDSに移動します。",
+ "migrateAway": "別の場所に移行",
+ "migrateAwayDesc": "このPDSから別のサーバーにアカウントを移動します。",
+ "loginRequired": "ログインが必要です",
+ "bringDid": "DIDとアイデンティティを持ち込む",
+ "transferData": "すべてのデータを転送",
+ "keepFollowers": "フォロワーを維持",
+ "exportRepo": "リポジトリをエクスポート",
+ "transferToPds": "新しいPDSに転送",
+ "updateIdentity": "アイデンティティを更新",
+ "whatIsMigration": "アカウント移行とは?",
+ "whatIsMigrationDesc": "アカウント移行により、AT Protocolアイデンティティをパーソナルデータサーバー(PDS)間で移動できます。DID(分散型識別子)は変わらないため、フォロワーやソーシャルコネクションは維持されます。",
+ "beforeMigrate": "移行前の確認事項",
+ "beforeMigrate1": "現在のアカウント認証情報が必要です",
+ "beforeMigrate2": "セキュリティのためメール認証が必要です",
+ "beforeMigrate3": "画像が多い大きなアカウントは数分かかる場合があります",
+ "beforeMigrate4": "古いPDSにアカウントの無効化が通知されます",
+ "importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。",
+ "learnMore": "移行のリスクについて詳しく",
+ "resume": {
+ "title": "移行を再開しますか?",
+ "incomplete": "未完了の移行があります:",
+ "direction": "方向",
+ "migratingHere": "ここに移行中",
+ "migratingAway": "別の場所に移行中",
+ "from": "移行元",
+ "to": "移行先",
+ "progress": "進行状況",
+ "reenterCredentials": "続行するには認証情報を再入力する必要があります。",
+ "startOver": "最初からやり直す",
+ "resumeButton": "再開"
+ },
+ "inbound": {
+ "welcome": {
+ "title": "このPDSに移行",
+ "desc": "既存のAT Protocolアカウントをこのサーバーに移動します。",
+ "understand": "リスクを理解し、続行します"
+ },
+ "sourceLogin": {
+ "title": "現在のPDSにサインイン",
+ "desc": "移行するアカウントの認証情報を入力してください。",
+ "handle": "ハンドル",
+ "handlePlaceholder": "you.bsky.social",
+ "password": "パスワード",
+ "twoFactorCode": "2要素認証コード",
+ "twoFactorRequired": "2要素認証が必要です",
+ "signIn": "サインインして続行"
+ },
+ "chooseHandle": {
+ "title": "新しいハンドルを選択",
+ "desc": "このPDSでのアカウントのハンドルを選択してください。",
+ "handleHint": "完全なハンドル: @{handle}"
+ },
+ "review": {
+ "title": "移行の確認",
+ "desc": "移行の詳細を確認してください。",
+ "currentHandle": "現在のハンドル",
+ "newHandle": "新しいハンドル",
+ "sourcePds": "移行元PDS",
+ "targetPds": "このPDS",
+ "email": "メール",
+ "inviteCode": "招待コード",
+ "confirm": "アカウントを移行することを確認します",
+ "startMigration": "移行を開始"
+ },
+ "migrating": {
+ "title": "アカウントを移行中",
+ "desc": "データを転送しています...",
+ "gettingServiceAuth": "サービス認証を取得中...",
+ "creatingAccount": "新しいPDSにアカウントを作成中...",
+ "exportingRepo": "リポジトリをエクスポート中...",
+ "importingRepo": "リポジトリをインポート中...",
+ "countingBlobs": "blobをカウント中...",
+ "migratingBlobs": "blobを移行中 ({current}/{total})...",
+ "migratingPrefs": "設定を移行中...",
+ "requestingPlc": "PLC操作をリクエスト中..."
+ },
+ "emailVerify": {
+ "title": "メールアドレスを確認",
+ "desc": "確認コードが {email} に送信されました。",
+ "hint": "下記にコードを入力するか、メール内のリンクをクリックして自動的に続行できます。",
+ "tokenLabel": "確認コード",
+ "tokenPlaceholder": "メールに記載されたコードを入力",
+ "resend": "コードを再送信",
+ "verify": "メールを確認",
+ "verifying": "確認中..."
+ },
+ "plcToken": {
+ "title": "本人確認",
+ "desc": "現在のPDSに登録されているメールアドレスに確認コードが送信されました。",
+ "tokenLabel": "確認トークン",
+ "tokenPlaceholder": "メールに記載されたトークンを入力",
+ "resend": "再送信",
+ "resending": "送信中..."
+ },
+ "finalizing": {
+ "title": "移行を完了中",
+ "desc": "移行を完了しています...",
+ "signingPlc": "アイデンティティ更新に署名",
+ "activating": "新しいPDSでアカウントを有効化",
+ "deactivating": "古いPDSでアカウントを無効化"
+ },
+ "success": {
+ "title": "移行完了!",
+ "desc": "アカウントはこのPDSに正常に移行されました。",
+ "newHandle": "新しいハンドル",
+ "did": "DID",
+ "goToDashboard": "ダッシュボードへ"
+ }
+ },
+ "outbound": {
+ "welcome": {
+ "title": "このPDSから移行",
+ "desc": "アカウントを別のパーソナルデータサーバーに移動します。",
+ "warning": "移行後、ここでのアカウントは無効化されます。",
+ "didWebNotice": "did:web移行のお知らせ",
+ "didWebNoticeDesc": "あなたのアカウントはdid:web識別子({did})を使用しています。移行後、このPDSは新しいPDSを指すDIDドキュメントを引き続き提供します。このサーバーがオンラインである限り、アイデンティティは機能し続けます。",
+ "understand": "リスクを理解し、続行します"
+ },
+ "targetPds": {
+ "title": "移行先PDSを選択",
+ "desc": "移行先のPDSのURLを入力してください。",
+ "url": "PDS URL",
+ "urlPlaceholder": "https://pds.example.com",
+ "validate": "検証して続行",
+ "validating": "検証中...",
+ "connected": "{name}に接続しました",
+ "inviteRequired": "招待コードが必要です",
+ "privacyPolicy": "プライバシーポリシー",
+ "termsOfService": "利用規約"
+ },
+ "newAccount": {
+ "title": "新しいアカウントの詳細",
+ "desc": "新しいPDSでアカウントを設定します。",
+ "handle": "ハンドル",
+ "availableDomains": "利用可能なドメイン",
+ "email": "メール",
+ "password": "パスワード",
+ "confirmPassword": "パスワードを確認",
+ "inviteCode": "招待コード"
+ },
+ "review": {
+ "title": "移行の確認",
+ "desc": "移行の詳細を確認してください。",
+ "currentHandle": "現在のハンドル",
+ "newHandle": "新しいハンドル",
+ "sourcePds": "このPDS",
+ "targetPds": "移行先PDS",
+ "confirm": "アカウントを移行することを確認します",
+ "startMigration": "移行を開始"
+ },
+ "migrating": {
+ "title": "アカウントを移行中",
+ "desc": "データを転送しています..."
+ },
+ "plcToken": {
+ "title": "本人確認",
+ "desc": "確認コードがメールに送信されました。"
+ },
+ "finalizing": {
+ "title": "移行を完了中",
+ "desc": "移行を完了しています...",
+ "updatingForwarding": "DIDドキュメントの転送先を更新中..."
+ },
+ "success": {
+ "title": "移行完了!",
+ "desc": "アカウントは新しいPDSに正常に移行されました。",
+ "newHandle": "新しいハンドル",
+ "newPds": "新しいPDS",
+ "nextSteps": "次のステップ",
+ "nextSteps1": "新しいPDSにサインイン",
+ "nextSteps2": "アプリの認証情報を更新",
+ "nextSteps3": "フォロワーは自動的に新しい場所を確認できます",
+ "loggingOut": "{seconds}秒後にログアウトします..."
+ }
+ },
+ "progress": {
+ "repoExported": "リポジトリをエクスポートしました",
+ "repoImported": "リポジトリをインポートしました",
+ "blobsMigrated": "{count}個のblobを移行しました",
+ "prefsMigrated": "設定を移行しました",
+ "plcSigned": "アイデンティティを更新しました",
+ "activated": "アカウントを有効化しました",
+ "deactivated": "古いアカウントを無効化しました"
+ },
+ "errors": {
+ "connectionFailed": "PDSに接続できませんでした",
+ "invalidCredentials": "認証情報が無効です",
+ "twoFactorRequired": "2要素認証が必要です",
+ "accountExists": "移行先PDSにアカウントが既に存在します",
+ "plcFailed": "PLC操作に失敗しました",
+ "blobFailed": "blobの移行に失敗しました: {cid}",
+ "networkError": "ネットワークエラー。再試行してください。"
+ }
}
}
diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json
index dc60d5e..e71bcad 100644
--- a/frontend/src/locales/ko.json
+++ b/frontend/src/locales/ko.json
@@ -1029,5 +1029,206 @@
"permissionsLimitedDesc": "앱이 무엇을 요청하든 실제 권한은 {level} 액세스 수준으로 제한됩니다.",
"viewerLimitedDesc": "뷰어로서 읽기 전용 액세스 권한만 있습니다. 이 앱은 이 계정에서 콘텐츠를 생성, 수정 또는 삭제할 수 없습니다.",
"editorLimitedDesc": "편집자로서 콘텐츠를 생성하고 편집할 수 있지만 계정 설정이나 보안을 관리할 수 없습니다."
+ },
+ "migration": {
+ "title": "계정 마이그레이션",
+ "subtitle": "AT Protocol 아이덴티티를 서버 간에 이동",
+ "navTitle": "마이그레이션",
+ "navDesc": "다른 PDS로 또는 다른 PDS에서 계정 이동",
+ "migrateHere": "여기로 마이그레이션",
+ "migrateHereDesc": "기존 AT Protocol 계정을 다른 서버에서 이 PDS로 이동합니다.",
+ "migrateAway": "다른 곳으로 마이그레이션",
+ "migrateAwayDesc": "이 PDS에서 다른 서버로 계정을 이동합니다.",
+ "loginRequired": "로그인 필요",
+ "bringDid": "DID와 아이덴티티 가져오기",
+ "transferData": "모든 데이터 전송",
+ "keepFollowers": "팔로워 유지",
+ "exportRepo": "저장소 내보내기",
+ "transferToPds": "새 PDS로 전송",
+ "updateIdentity": "아이덴티티 업데이트",
+ "whatIsMigration": "계정 마이그레이션이란?",
+ "whatIsMigrationDesc": "계정 마이그레이션을 통해 AT Protocol 아이덴티티를 개인 데이터 서버(PDS) 간에 이동할 수 있습니다. DID(분산 식별자)는 동일하게 유지되므로 팔로워와 소셜 연결이 보존됩니다.",
+ "beforeMigrate": "마이그레이션 전 확인사항",
+ "beforeMigrate1": "현재 계정 인증 정보가 필요합니다",
+ "beforeMigrate2": "보안을 위해 이메일 인증이 필요합니다",
+ "beforeMigrate3": "이미지가 많은 대용량 계정은 몇 분이 걸릴 수 있습니다",
+ "beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다",
+ "importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.",
+ "learnMore": "마이그레이션 위험에 대해 자세히 알아보기",
+ "resume": {
+ "title": "마이그레이션을 재개하시겠습니까?",
+ "incomplete": "완료되지 않은 마이그레이션이 있습니다:",
+ "direction": "방향",
+ "migratingHere": "여기로 마이그레이션 중",
+ "migratingAway": "다른 곳으로 마이그레이션 중",
+ "from": "출발지",
+ "to": "목적지",
+ "progress": "진행 상황",
+ "reenterCredentials": "계속하려면 인증 정보를 다시 입력해야 합니다.",
+ "startOver": "처음부터 다시 시작",
+ "resumeButton": "재개"
+ },
+ "inbound": {
+ "welcome": {
+ "title": "이 PDS로 마이그레이션",
+ "desc": "기존 AT Protocol 계정을 이 서버로 이동합니다.",
+ "understand": "위험을 이해하고 계속 진행합니다"
+ },
+ "sourceLogin": {
+ "title": "현재 PDS에 로그인",
+ "desc": "마이그레이션할 계정의 인증 정보를 입력하세요.",
+ "handle": "핸들",
+ "handlePlaceholder": "you.bsky.social",
+ "password": "비밀번호",
+ "twoFactorCode": "2단계 인증 코드",
+ "twoFactorRequired": "2단계 인증이 필요합니다",
+ "signIn": "로그인 및 계속"
+ },
+ "chooseHandle": {
+ "title": "새 핸들 선택",
+ "desc": "이 PDS에서 사용할 계정 핸들을 선택하세요.",
+ "handleHint": "전체 핸들: @{handle}"
+ },
+ "review": {
+ "title": "마이그레이션 검토",
+ "desc": "마이그레이션 세부 정보를 검토하고 확인하세요.",
+ "currentHandle": "현재 핸들",
+ "newHandle": "새 핸들",
+ "sourcePds": "소스 PDS",
+ "targetPds": "이 PDS",
+ "email": "이메일",
+ "inviteCode": "초대 코드",
+ "confirm": "계정 마이그레이션을 확인합니다",
+ "startMigration": "마이그레이션 시작"
+ },
+ "migrating": {
+ "title": "계정 마이그레이션 중",
+ "desc": "데이터를 전송하는 중입니다...",
+ "gettingServiceAuth": "서비스 인증 획득 중...",
+ "creatingAccount": "새 PDS에 계정 생성 중...",
+ "exportingRepo": "저장소 내보내기 중...",
+ "importingRepo": "저장소 가져오기 중...",
+ "countingBlobs": "blob 개수 세는 중...",
+ "migratingBlobs": "blob 마이그레이션 중 ({current}/{total})...",
+ "migratingPrefs": "환경설정 마이그레이션 중...",
+ "requestingPlc": "PLC 작업 요청 중..."
+ },
+ "emailVerify": {
+ "title": "이메일 인증",
+ "desc": "인증 코드가 {email}(으)로 전송되었습니다.",
+ "hint": "아래에 코드를 입력하거나, 이메일의 링크를 클릭하여 자동으로 계속할 수 있습니다.",
+ "tokenLabel": "인증 코드",
+ "tokenPlaceholder": "이메일에서 받은 코드 입력",
+ "resend": "코드 재전송",
+ "verify": "이메일 인증",
+ "verifying": "인증 중..."
+ },
+ "plcToken": {
+ "title": "신원 확인",
+ "desc": "현재 PDS에 등록된 이메일로 인증 코드가 전송되었습니다.",
+ "tokenLabel": "인증 토큰",
+ "tokenPlaceholder": "이메일에서 받은 토큰 입력",
+ "resend": "재전송",
+ "resending": "전송 중..."
+ },
+ "finalizing": {
+ "title": "마이그레이션 완료 중",
+ "desc": "마이그레이션을 완료하는 중입니다...",
+ "signingPlc": "아이덴티티 업데이트 서명",
+ "activating": "새 PDS에서 계정 활성화",
+ "deactivating": "이전 PDS에서 계정 비활성화"
+ },
+ "success": {
+ "title": "마이그레이션 완료!",
+ "desc": "계정이 이 PDS로 성공적으로 마이그레이션되었습니다.",
+ "newHandle": "새 핸들",
+ "did": "DID",
+ "goToDashboard": "대시보드로 이동"
+ }
+ },
+ "outbound": {
+ "welcome": {
+ "title": "이 PDS에서 마이그레이션",
+ "desc": "계정을 다른 개인 데이터 서버로 이동합니다.",
+ "warning": "마이그레이션 후 이 PDS에서 계정이 비활성화됩니다.",
+ "didWebNotice": "did:web 마이그레이션 알림",
+ "didWebNoticeDesc": "귀하의 계정은 did:web 식별자({did})를 사용합니다. 마이그레이션 후 이 PDS는 새 PDS를 가리키는 DID 문서를 계속 제공합니다. 이 서버가 온라인인 한 아이덴티티는 계속 작동합니다.",
+ "understand": "위험을 이해하고 계속 진행합니다"
+ },
+ "targetPds": {
+ "title": "대상 PDS 선택",
+ "desc": "마이그레이션할 PDS의 URL을 입력하세요.",
+ "url": "PDS URL",
+ "urlPlaceholder": "https://pds.example.com",
+ "validate": "확인 및 계속",
+ "validating": "확인 중...",
+ "connected": "{name}에 연결됨",
+ "inviteRequired": "초대 코드 필요",
+ "privacyPolicy": "개인정보 처리방침",
+ "termsOfService": "서비스 약관"
+ },
+ "newAccount": {
+ "title": "새 계정 세부 정보",
+ "desc": "새 PDS에서 계정을 설정합니다.",
+ "handle": "핸들",
+ "availableDomains": "사용 가능한 도메인",
+ "email": "이메일",
+ "password": "비밀번호",
+ "confirmPassword": "비밀번호 확인",
+ "inviteCode": "초대 코드"
+ },
+ "review": {
+ "title": "마이그레이션 검토",
+ "desc": "마이그레이션 세부 정보를 검토하고 확인하세요.",
+ "currentHandle": "현재 핸들",
+ "newHandle": "새 핸들",
+ "sourcePds": "이 PDS",
+ "targetPds": "대상 PDS",
+ "confirm": "계정 마이그레이션을 확인합니다",
+ "startMigration": "마이그레이션 시작"
+ },
+ "migrating": {
+ "title": "계정 마이그레이션 중",
+ "desc": "데이터를 전송하는 중입니다..."
+ },
+ "plcToken": {
+ "title": "신원 확인",
+ "desc": "이메일로 인증 코드가 전송되었습니다."
+ },
+ "finalizing": {
+ "title": "마이그레이션 완료 중",
+ "desc": "마이그레이션을 완료하는 중입니다...",
+ "updatingForwarding": "DID 문서 포워딩 업데이트 중..."
+ },
+ "success": {
+ "title": "마이그레이션 완료!",
+ "desc": "계정이 새 PDS로 성공적으로 마이그레이션되었습니다.",
+ "newHandle": "새 핸들",
+ "newPds": "새 PDS",
+ "nextSteps": "다음 단계",
+ "nextSteps1": "새 PDS에 로그인",
+ "nextSteps2": "새 인증 정보로 앱 업데이트",
+ "nextSteps3": "팔로워가 자동으로 새 위치를 확인할 수 있습니다",
+ "loggingOut": "{seconds}초 후 로그아웃됩니다..."
+ }
+ },
+ "progress": {
+ "repoExported": "저장소 내보내기 완료",
+ "repoImported": "저장소 가져오기 완료",
+ "blobsMigrated": "{count}개 blob 마이그레이션됨",
+ "prefsMigrated": "환경설정 마이그레이션됨",
+ "plcSigned": "아이덴티티 업데이트됨",
+ "activated": "계정 활성화됨",
+ "deactivated": "이전 계정 비활성화됨"
+ },
+ "errors": {
+ "connectionFailed": "PDS에 연결할 수 없습니다",
+ "invalidCredentials": "잘못된 인증 정보",
+ "twoFactorRequired": "2단계 인증이 필요합니다",
+ "accountExists": "대상 PDS에 계정이 이미 존재합니다",
+ "plcFailed": "PLC 작업 실패",
+ "blobFailed": "blob 마이그레이션 실패: {cid}",
+ "networkError": "네트워크 오류. 다시 시도하세요."
+ }
}
}
diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json
index ea2bc07..cc0bb30 100644
--- a/frontend/src/locales/sv.json
+++ b/frontend/src/locales/sv.json
@@ -1029,5 +1029,206 @@
"permissionsLimitedDesc": "Dina faktiska behörigheter begränsas till din {level}-åtkomstnivå, oavsett vad appen begär.",
"viewerLimitedDesc": "Som visare har du endast läsåtkomst. Denna app kommer inte att kunna skapa, uppdatera eller ta bort innehåll på detta konto.",
"editorLimitedDesc": "Som redigerare kan du skapa och redigera innehåll men kan inte hantera kontoinställningar eller säkerhet."
+ },
+ "migration": {
+ "title": "Kontoflyttning",
+ "subtitle": "Flytta din AT Protocol-identitet mellan servrar",
+ "navTitle": "Flytta",
+ "navDesc": "Flytta ditt konto till eller från en annan PDS",
+ "migrateHere": "Flytta hit",
+ "migrateHereDesc": "Flytta ditt befintliga AT Protocol-konto till denna PDS från en annan server.",
+ "migrateAway": "Flytta bort",
+ "migrateAwayDesc": "Flytta ditt konto från denna PDS till en annan server.",
+ "loginRequired": "Inloggning krävs",
+ "bringDid": "Ta med din DID och identitet",
+ "transferData": "Överför all din data",
+ "keepFollowers": "Behåll dina följare",
+ "exportRepo": "Exportera ditt arkiv",
+ "transferToPds": "Överför till ny PDS",
+ "updateIdentity": "Uppdatera din identitet",
+ "whatIsMigration": "Vad är kontoflyttning?",
+ "whatIsMigrationDesc": "Kontoflyttning låter dig flytta din AT Protocol-identitet mellan personliga dataservrar (PDS). Din DID (decentraliserad identifierare) förblir densamma, så dina följare och sociala kopplingar bevaras.",
+ "beforeMigrate": "Innan du flyttar",
+ "beforeMigrate1": "Du behöver dina nuvarande kontouppgifter",
+ "beforeMigrate2": "Flytt kräver e-postverifiering för säkerhet",
+ "beforeMigrate3": "Stora konton med många bilder kan ta flera minuter",
+ "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering",
+ "importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.",
+ "learnMore": "Läs mer om flyttningsrisker",
+ "resume": {
+ "title": "Återuppta flytt?",
+ "incomplete": "Du har en ofullständig flytt pågående:",
+ "direction": "Riktning",
+ "migratingHere": "Flyttar hit",
+ "migratingAway": "Flyttar bort",
+ "from": "Från",
+ "to": "Till",
+ "progress": "Framsteg",
+ "reenterCredentials": "Du måste ange dina uppgifter igen för att fortsätta.",
+ "startOver": "Börja om",
+ "resumeButton": "Återuppta"
+ },
+ "inbound": {
+ "welcome": {
+ "title": "Flytta till denna PDS",
+ "desc": "Flytta ditt befintliga AT Protocol-konto till denna server.",
+ "understand": "Jag förstår riskerna och vill fortsätta"
+ },
+ "sourceLogin": {
+ "title": "Logga in på din nuvarande PDS",
+ "desc": "Ange uppgifterna för kontot du vill flytta.",
+ "handle": "Användarnamn",
+ "handlePlaceholder": "du.bsky.social",
+ "password": "Lösenord",
+ "twoFactorCode": "Tvåfaktorkod",
+ "twoFactorRequired": "Tvåfaktorautentisering krävs",
+ "signIn": "Logga in och fortsätt"
+ },
+ "chooseHandle": {
+ "title": "Välj ditt nya användarnamn",
+ "desc": "Välj ett användarnamn för ditt konto på denna PDS.",
+ "handleHint": "Ditt fullständiga användarnamn blir: @{handle}"
+ },
+ "review": {
+ "title": "Granska flytt",
+ "desc": "Granska och bekräfta dina flyttdetaljer.",
+ "currentHandle": "Nuvarande användarnamn",
+ "newHandle": "Nytt användarnamn",
+ "sourcePds": "Käll-PDS",
+ "targetPds": "Denna PDS",
+ "email": "E-post",
+ "inviteCode": "Inbjudningskod",
+ "confirm": "Jag bekräftar att jag vill flytta mitt konto",
+ "startMigration": "Starta flytt"
+ },
+ "migrating": {
+ "title": "Flyttar ditt konto",
+ "desc": "Vänta medan vi överför din data...",
+ "gettingServiceAuth": "Hämtar tjänstauktorisering...",
+ "creatingAccount": "Skapar konto på ny PDS...",
+ "exportingRepo": "Exporterar arkiv...",
+ "importingRepo": "Importerar arkiv...",
+ "countingBlobs": "Räknar blobbar...",
+ "migratingBlobs": "Flyttar blobbar ({current}/{total})...",
+ "migratingPrefs": "Flyttar inställningar...",
+ "requestingPlc": "Begär PLC-operation..."
+ },
+ "emailVerify": {
+ "title": "Verifiera din e-post",
+ "desc": "En verifieringskod har skickats till {email}.",
+ "hint": "Ange koden nedan eller klicka på länken i e-postmeddelandet för att fortsätta automatiskt.",
+ "tokenLabel": "Verifieringskod",
+ "tokenPlaceholder": "Ange kod från e-post",
+ "resend": "Skicka kod igen",
+ "verify": "Verifiera e-post",
+ "verifying": "Verifierar..."
+ },
+ "plcToken": {
+ "title": "Verifiera din identitet",
+ "desc": "En verifieringskod har skickats till din e-post på din nuvarande PDS.",
+ "tokenLabel": "Verifieringstoken",
+ "tokenPlaceholder": "Ange token från din e-post",
+ "resend": "Skicka igen",
+ "resending": "Skickar..."
+ },
+ "finalizing": {
+ "title": "Slutför flytt",
+ "desc": "Vänta medan vi slutför flytten...",
+ "signingPlc": "Signera identitetsuppdatering",
+ "activating": "Aktivera konto på ny PDS",
+ "deactivating": "Inaktivera konto på gammal PDS"
+ },
+ "success": {
+ "title": "Flytt klar!",
+ "desc": "Ditt konto har framgångsrikt flyttats till denna PDS.",
+ "newHandle": "Nytt användarnamn",
+ "did": "DID",
+ "goToDashboard": "Gå till instrumentpanel"
+ }
+ },
+ "outbound": {
+ "welcome": {
+ "title": "Flytta från denna PDS",
+ "desc": "Flytta ditt konto till en annan personlig dataserver.",
+ "warning": "Efter flytten kommer ditt konto här att inaktiveras.",
+ "didWebNotice": "did:web-flyttmeddelande",
+ "didWebNoticeDesc": "Ditt konto använder en did:web-identifierare ({did}). Efter flytten kommer denna PDS att fortsätta servera ditt DID-dokument som pekar till den nya PDS. Din identitet kommer att fungera så länge denna server är online.",
+ "understand": "Jag förstår riskerna och vill fortsätta"
+ },
+ "targetPds": {
+ "title": "Välj mål-PDS",
+ "desc": "Ange URL:en för PDS du vill flytta till.",
+ "url": "PDS URL",
+ "urlPlaceholder": "https://pds.example.com",
+ "validate": "Validera och fortsätt",
+ "validating": "Validerar...",
+ "connected": "Ansluten till {name}",
+ "inviteRequired": "Inbjudningskod krävs",
+ "privacyPolicy": "Integritetspolicy",
+ "termsOfService": "Användarvillkor"
+ },
+ "newAccount": {
+ "title": "Nya kontouppgifter",
+ "desc": "Konfigurera ditt konto på den nya PDS.",
+ "handle": "Användarnamn",
+ "availableDomains": "Tillgängliga domäner",
+ "email": "E-post",
+ "password": "Lösenord",
+ "confirmPassword": "Bekräfta lösenord",
+ "inviteCode": "Inbjudningskod"
+ },
+ "review": {
+ "title": "Granska flytt",
+ "desc": "Granska och bekräfta dina flyttdetaljer.",
+ "currentHandle": "Nuvarande användarnamn",
+ "newHandle": "Nytt användarnamn",
+ "sourcePds": "Denna PDS",
+ "targetPds": "Mål-PDS",
+ "confirm": "Jag bekräftar att jag vill flytta mitt konto",
+ "startMigration": "Starta flytt"
+ },
+ "migrating": {
+ "title": "Flyttar ditt konto",
+ "desc": "Vänta medan vi överför din data..."
+ },
+ "plcToken": {
+ "title": "Verifiera din identitet",
+ "desc": "En verifieringskod har skickats till din e-post."
+ },
+ "finalizing": {
+ "title": "Slutför flytt",
+ "desc": "Vänta medan vi slutför flytten...",
+ "updatingForwarding": "Uppdaterar DID-dokumentvidarebefordran..."
+ },
+ "success": {
+ "title": "Flytt klar!",
+ "desc": "Ditt konto har framgångsrikt flyttats till din nya PDS.",
+ "newHandle": "Nytt användarnamn",
+ "newPds": "Ny PDS",
+ "nextSteps": "Nästa steg",
+ "nextSteps1": "Logga in på din nya PDS",
+ "nextSteps2": "Uppdatera dina appar med nya uppgifter",
+ "nextSteps3": "Dina följare kommer automatiskt se din nya plats",
+ "loggingOut": "Loggar ut om {seconds} sekunder..."
+ }
+ },
+ "progress": {
+ "repoExported": "Arkiv exporterat",
+ "repoImported": "Arkiv importerat",
+ "blobsMigrated": "{count} blobbar flyttade",
+ "prefsMigrated": "Inställningar flyttade",
+ "plcSigned": "Identitet uppdaterad",
+ "activated": "Konto aktiverat",
+ "deactivated": "Gammalt konto inaktiverat"
+ },
+ "errors": {
+ "connectionFailed": "Kunde inte ansluta till PDS",
+ "invalidCredentials": "Ogiltiga uppgifter",
+ "twoFactorRequired": "Tvåfaktorautentisering krävs",
+ "accountExists": "Konto finns redan på mål-PDS",
+ "plcFailed": "PLC-operation misslyckades",
+ "blobFailed": "Kunde inte flytta blob: {cid}",
+ "networkError": "Nätverksfel. Försök igen."
+ }
}
}
diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json
index 4fcca43..c048f2e 100644
--- a/frontend/src/locales/zh.json
+++ b/frontend/src/locales/zh.json
@@ -1013,5 +1013,206 @@
"permissionsLimitedDesc": "无论应用请求什么权限,您的实际权限将限制在{level}访问级别。",
"viewerLimitedDesc": "作为查看者,您只有只读权限。此应用无法在此账户上创建、更新或删除内容。",
"editorLimitedDesc": "作为编辑者,您可以创建和编辑内容,但无法管理账户设置或安全选项。"
+ },
+ "migration": {
+ "title": "账户迁移",
+ "subtitle": "在服务器之间移动您的AT Protocol身份",
+ "navTitle": "迁移",
+ "navDesc": "将您的账户移至其他PDS或从其他PDS移入",
+ "migrateHere": "迁移到此处",
+ "migrateHereDesc": "将您现有的AT Protocol账户从其他服务器移至此PDS。",
+ "migrateAway": "迁移离开",
+ "migrateAwayDesc": "将您的账户从此PDS移至其他服务器。",
+ "loginRequired": "需要登录",
+ "bringDid": "携带您的DID和身份",
+ "transferData": "转移所有数据",
+ "keepFollowers": "保留您的关注者",
+ "exportRepo": "导出您的存储库",
+ "transferToPds": "转移到新PDS",
+ "updateIdentity": "更新您的身份",
+ "whatIsMigration": "什么是账户迁移?",
+ "whatIsMigrationDesc": "账户迁移允许您在个人数据服务器(PDS)之间移动AT Protocol身份。您的DID(去中心化标识符)保持不变,因此您的关注者和社交连接得以保留。",
+ "beforeMigrate": "迁移前须知",
+ "beforeMigrate1": "您需要当前账户的凭据",
+ "beforeMigrate2": "为确保安全,迁移需要邮箱验证",
+ "beforeMigrate3": "包含大量图片的大型账户可能需要几分钟",
+ "beforeMigrate4": "您的旧PDS将收到账户停用通知",
+ "importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。",
+ "learnMore": "了解更多迁移风险",
+ "resume": {
+ "title": "恢复迁移?",
+ "incomplete": "您有一个未完成的迁移:",
+ "direction": "方向",
+ "migratingHere": "正在迁移到此处",
+ "migratingAway": "正在迁移离开",
+ "from": "从",
+ "to": "到",
+ "progress": "进度",
+ "reenterCredentials": "您需要重新输入凭据以继续。",
+ "startOver": "重新开始",
+ "resumeButton": "恢复"
+ },
+ "inbound": {
+ "welcome": {
+ "title": "迁移到此PDS",
+ "desc": "将您现有的AT Protocol账户移至此服务器。",
+ "understand": "我了解风险并希望继续"
+ },
+ "sourceLogin": {
+ "title": "登录到您当前的PDS",
+ "desc": "输入您要迁移的账户凭据。",
+ "handle": "用户名",
+ "handlePlaceholder": "you.bsky.social",
+ "password": "密码",
+ "twoFactorCode": "双因素验证码",
+ "twoFactorRequired": "需要双因素认证",
+ "signIn": "登录并继续"
+ },
+ "chooseHandle": {
+ "title": "选择新用户名",
+ "desc": "为您在此PDS上的账户选择用户名。",
+ "handleHint": "您的完整用户名将是:@{handle}"
+ },
+ "review": {
+ "title": "检查迁移",
+ "desc": "请检查并确认您的迁移详情。",
+ "currentHandle": "当前用户名",
+ "newHandle": "新用户名",
+ "sourcePds": "源PDS",
+ "targetPds": "此PDS",
+ "email": "邮箱",
+ "inviteCode": "邀请码",
+ "confirm": "我确认要迁移我的账户",
+ "startMigration": "开始迁移"
+ },
+ "migrating": {
+ "title": "正在迁移您的账户",
+ "desc": "请稍候,正在转移您的数据...",
+ "gettingServiceAuth": "正在获取服务授权...",
+ "creatingAccount": "正在新PDS上创建账户...",
+ "exportingRepo": "正在导出存储库...",
+ "importingRepo": "正在导入存储库...",
+ "countingBlobs": "正在统计blob...",
+ "migratingBlobs": "正在迁移blob ({current}/{total})...",
+ "migratingPrefs": "正在迁移偏好设置...",
+ "requestingPlc": "正在请求PLC操作..."
+ },
+ "emailVerify": {
+ "title": "验证您的邮箱",
+ "desc": "验证码已发送至 {email}。",
+ "hint": "在下方输入验证码,或点击邮件中的链接自动继续。",
+ "tokenLabel": "验证码",
+ "tokenPlaceholder": "输入邮件中的验证码",
+ "resend": "重新发送",
+ "verify": "验证邮箱",
+ "verifying": "验证中..."
+ },
+ "plcToken": {
+ "title": "验证您的身份",
+ "desc": "验证码已发送到您在当前PDS注册的邮箱。",
+ "tokenLabel": "验证令牌",
+ "tokenPlaceholder": "输入邮件中的令牌",
+ "resend": "重新发送",
+ "resending": "发送中..."
+ },
+ "finalizing": {
+ "title": "正在完成迁移",
+ "desc": "请稍候,正在完成迁移...",
+ "signingPlc": "签署身份更新",
+ "activating": "在新PDS上激活账户",
+ "deactivating": "在旧PDS上停用账户"
+ },
+ "success": {
+ "title": "迁移完成!",
+ "desc": "您的账户已成功迁移到此PDS。",
+ "newHandle": "新用户名",
+ "did": "DID",
+ "goToDashboard": "前往仪表板"
+ }
+ },
+ "outbound": {
+ "welcome": {
+ "title": "从此PDS迁移离开",
+ "desc": "将您的账户移至另一个个人数据服务器。",
+ "warning": "迁移后,您在此处的账户将被停用。",
+ "didWebNotice": "did:web迁移通知",
+ "didWebNoticeDesc": "您的账户使用did:web标识符({did})。迁移后,此PDS将继续提供指向新PDS的DID文档。只要此服务器在线,您的身份将继续有效。",
+ "understand": "我了解风险并希望继续"
+ },
+ "targetPds": {
+ "title": "选择目标PDS",
+ "desc": "输入您要迁移到的PDS的URL。",
+ "url": "PDS URL",
+ "urlPlaceholder": "https://pds.example.com",
+ "validate": "验证并继续",
+ "validating": "验证中...",
+ "connected": "已连接到 {name}",
+ "inviteRequired": "需要邀请码",
+ "privacyPolicy": "隐私政策",
+ "termsOfService": "服务条款"
+ },
+ "newAccount": {
+ "title": "新账户详情",
+ "desc": "在新PDS上设置您的账户。",
+ "handle": "用户名",
+ "availableDomains": "可用域名",
+ "email": "邮箱",
+ "password": "密码",
+ "confirmPassword": "确认密码",
+ "inviteCode": "邀请码"
+ },
+ "review": {
+ "title": "检查迁移",
+ "desc": "请检查并确认您的迁移详情。",
+ "currentHandle": "当前用户名",
+ "newHandle": "新用户名",
+ "sourcePds": "此PDS",
+ "targetPds": "目标PDS",
+ "confirm": "我确认要迁移我的账户",
+ "startMigration": "开始迁移"
+ },
+ "migrating": {
+ "title": "正在迁移您的账户",
+ "desc": "请稍候,正在转移您的数据..."
+ },
+ "plcToken": {
+ "title": "验证您的身份",
+ "desc": "验证码已发送到您的邮箱。"
+ },
+ "finalizing": {
+ "title": "正在完成迁移",
+ "desc": "请稍候,正在完成迁移...",
+ "updatingForwarding": "正在更新DID文档转发..."
+ },
+ "success": {
+ "title": "迁移完成!",
+ "desc": "您的账户已成功迁移到新PDS。",
+ "newHandle": "新用户名",
+ "newPds": "新PDS",
+ "nextSteps": "后续步骤",
+ "nextSteps1": "登录到您的新PDS",
+ "nextSteps2": "使用新凭据更新您的应用",
+ "nextSteps3": "您的关注者将自动看到您的新位置",
+ "loggingOut": "{seconds}秒后退出登录..."
+ }
+ },
+ "progress": {
+ "repoExported": "存储库已导出",
+ "repoImported": "存储库已导入",
+ "blobsMigrated": "已迁移{count}个blob",
+ "prefsMigrated": "偏好设置已迁移",
+ "plcSigned": "身份已更新",
+ "activated": "账户已激活",
+ "deactivated": "旧账户已停用"
+ },
+ "errors": {
+ "connectionFailed": "无法连接到PDS",
+ "invalidCredentials": "凭据无效",
+ "twoFactorRequired": "需要双因素认证",
+ "accountExists": "目标PDS上已存在账户",
+ "plcFailed": "PLC操作失败",
+ "blobFailed": "blob迁移失败:{cid}",
+ "networkError": "网络错误,请重试。"
+ }
}
}
diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte
index e272f54..8f86589 100644
--- a/frontend/src/routes/Dashboard.svelte
+++ b/frontend/src/routes/Dashboard.svelte
@@ -190,6 +190,10 @@
{$_('dashboard.navDelegation')}
{$_('dashboard.navDelegationDesc')}
+
+ {$_('migration.navTitle')}
+ {$_('migration.navDesc')}
+
{#if auth.session.isAdmin}
{$_('dashboard.navAdmin')}
diff --git a/frontend/src/routes/Migration.svelte b/frontend/src/routes/Migration.svelte
new file mode 100644
index 0000000..18a5aee
--- /dev/null
+++ b/frontend/src/routes/Migration.svelte
@@ -0,0 +1,413 @@
+
+
+
+ {#if showResumeModal && resumeInfo}
+
+
+
Resume Migration?
+
You have an incomplete migration in progress:
+
+
+ Direction:
+ {resumeInfo.direction === 'inbound' ? 'Migrating here' : 'Migrating away'}
+
+ {#if resumeInfo.sourceHandle}
+
+ From:
+ {resumeInfo.sourceHandle}
+
+ {/if}
+ {#if resumeInfo.targetHandle}
+
+ To:
+ {resumeInfo.targetHandle}
+
+ {/if}
+
+ Progress:
+ {resumeInfo.progressSummary}
+
+
+
You will need to re-enter your credentials to continue.
+
+ Start Over
+ Resume
+
+
+
+ {/if}
+
+ {#if direction === 'select'}
+
+
+
+
+ ↓
+ Migrate Here
+ Move your existing AT Protocol account to this PDS from another server.
+
+ Bring your DID and identity
+ Transfer all your data
+ Keep your followers
+
+
+
+
+ ↑
+ Migrate Away
+ Move your account from this PDS to another server.
+
+ Export your repository
+ Transfer to new PDS
+ Update your identity
+
+ {#if !auth.session}
+ Login required
+ {/if}
+
+
+
+
+
What is account migration?
+
+ Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes).
+ Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.
+
+
+
Before you migrate
+
+ You will need your current account credentials
+ Migration requires email verification for security
+ Large accounts with many images may take several minutes
+ Your old PDS will be notified to deactivate your account
+
+
+
+
Important: Account migration is a significant action. Make sure you trust the destination PDS
+ and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.
+
+ Learn more about migration risks
+
+
+
+
+ {:else if direction === 'inbound' && inboundFlow}
+
+
+ {:else if direction === 'outbound' && outboundFlow}
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte
index 2ceea87..76f3654 100644
--- a/frontend/src/routes/Register.svelte
+++ b/frontend/src/routes/Register.svelte
@@ -173,7 +173,7 @@
{$_('register.migrateTitle')}
{$_('register.migrateDescription')}
-
+
{$_('register.migrateLink')} →
diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs
index d83d23d..754d943 100644
--- a/src/api/identity/did.rs
+++ b/src/api/identity/did.rs
@@ -449,6 +449,7 @@ pub struct VerificationMethods {
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Services {
+ #[serde(rename = "atproto_pds")]
pub atproto_pds: AtprotoPds,
}
diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs
index c1a50ef..a4674bc 100644
--- a/src/api/repo/blob.rs
+++ b/src/api/repo/blob.rs
@@ -295,7 +295,7 @@ pub async fn list_missing_blobs(
.into_response();
}
};
- let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await {
+ let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await {
Ok(user) => user,
Err(_) => {
return (
diff --git a/src/api/repo/import.rs b/src/api/repo/import.rs
index dfb03d1..15066fc 100644
--- a/src/api/repo/import.rs
+++ b/src/api/repo/import.rs
@@ -318,8 +318,10 @@ pub async fn import_repo(
records.len(),
did
);
- if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await {
- warn!("Failed to sequence import event: {:?}", e);
+ if !is_migration {
+ if let Err(e) = sequence_import_event(&state, did, &root.to_string()).await {
+ warn!("Failed to sequence import event: {:?}", e);
+ }
}
(StatusCode::OK, Json(json!({}))).into_response()
}
diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs
index 8ea3304..91d7641 100644
--- a/src/api/repo/record/batch.rs
+++ b/src/api/repo/record/batch.rs
@@ -1,6 +1,6 @@
use super::validation::validate_record;
use super::write::has_verified_comms_channel;
-use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
+use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
use crate::delegation::{self, DelegationActionType};
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
@@ -295,6 +295,7 @@ pub async fn apply_writes(
let mut results: Vec = Vec::new();
let mut ops: Vec = Vec::new();
let mut modified_keys: Vec = Vec::new();
+ let mut all_blob_cids: Vec = Vec::new();
for write in &input.writes {
match write {
WriteOp::Create {
@@ -307,6 +308,7 @@ pub async fn apply_writes(
{
return *err_response;
}
+ all_blob_cids.extend(extract_blob_cids(value));
let rkey = rkey
.clone()
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
@@ -359,6 +361,7 @@ pub async fn apply_writes(
{
return *err_response;
}
+ all_blob_cids.extend(extract_blob_cids(value));
let mut record_bytes = Vec::new();
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
@@ -468,6 +471,7 @@ pub async fn apply_writes(
new_mst_root,
ops,
blocks_cids: &written_cids_str,
+ blobs: &all_blob_cids,
},
)
.await
diff --git a/src/api/repo/record/delete.rs b/src/api/repo/record/delete.rs
index ab9661c..2808bd7 100644
--- a/src/api/repo/record/delete.rs
+++ b/src/api/repo/record/delete.rs
@@ -168,6 +168,7 @@ pub async fn delete_record(
new_mst_root,
ops: vec![op],
blocks_cids: &written_cids_str,
+ blobs: &[],
},
)
.await
diff --git a/src/api/repo/record/read.rs b/src/api/repo/record/read.rs
index 9d74dba..28a2846 100644
--- a/src/api/repo/record/read.rs
+++ b/src/api/repo/record/read.rs
@@ -6,14 +6,47 @@ use axum::{
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
+use base64::Engine;
use cid::Cid;
+use ipld_core::ipld::Ipld;
use jacquard_repo::storage::BlockStore;
use serde::{Deserialize, Serialize};
-use serde_json::json;
+use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::str::FromStr;
use tracing::{error, info};
+fn ipld_to_json(ipld: Ipld) -> Value {
+ match ipld {
+ Ipld::Null => Value::Null,
+ Ipld::Bool(b) => Value::Bool(b),
+ Ipld::Integer(i) => {
+ if let Ok(n) = i64::try_from(i) {
+ Value::Number(n.into())
+ } else {
+ Value::String(i.to_string())
+ }
+ }
+ Ipld::Float(f) => serde_json::Number::from_f64(f)
+ .map(Value::Number)
+ .unwrap_or(Value::Null),
+ Ipld::String(s) => Value::String(s),
+ Ipld::Bytes(b) => {
+ let encoded = base64::engine::general_purpose::STANDARD.encode(&b);
+ json!({ "$bytes": encoded })
+ }
+ Ipld::List(arr) => Value::Array(arr.into_iter().map(ipld_to_json).collect()),
+ Ipld::Map(map) => {
+ let obj: Map = map
+ .into_iter()
+ .map(|(k, v)| (k, ipld_to_json(v)))
+ .collect();
+ Value::Object(obj)
+ }
+ Ipld::Link(cid) => json!({ "$link": cid.to_string() }),
+ }
+}
+
#[derive(Deserialize)]
pub struct GetRecordInput {
pub repo: String,
@@ -163,7 +196,7 @@ pub async fn get_record(
.into_response();
}
};
- let value: serde_json::Value = match serde_ipld_dagcbor::from_slice(&block) {
+ let ipld: Ipld = match serde_ipld_dagcbor::from_slice(&block) {
Ok(v) => v,
Err(e) => {
error!("Failed to deserialize record: {:?}", e);
@@ -174,6 +207,7 @@ pub async fn get_record(
.into_response();
}
};
+ let value = ipld_to_json(ipld);
Json(json!({
"uri": format!("at://{}/{}/{}", input.repo, input.collection, input.rkey),
"cid": record_cid_str,
@@ -323,8 +357,9 @@ pub async fn list_records(
for (cid, block_opt) in cids.iter().zip(blocks.into_iter()) {
if let Some(block) = block_opt
&& let Some((rkey, cid_str)) = cid_to_rkey.get(cid)
- && let Ok(value) = serde_ipld_dagcbor::from_slice::(&block)
+ && let Ok(ipld) = serde_ipld_dagcbor::from_slice::(&block)
{
+ let value = ipld_to_json(ipld);
records.push(json!({
"uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey),
"cid": cid_str,
diff --git a/src/api/repo/record/utils.rs b/src/api/repo/record/utils.rs
index 23cb1d4..31c2329 100644
--- a/src/api/repo/record/utils.rs
+++ b/src/api/repo/record/utils.rs
@@ -5,10 +5,39 @@ use jacquard::types::{integer::LimitedU32, string::Tid};
use jacquard_repo::commit::Commit;
use jacquard_repo::storage::BlockStore;
use k256::ecdsa::SigningKey;
-use serde_json::json;
+use serde_json::{json, Value};
use std::str::FromStr;
use uuid::Uuid;
+pub fn extract_blob_cids(record: &Value) -> Vec {
+ let mut blobs = Vec::new();
+ extract_blob_cids_recursive(record, &mut blobs);
+ blobs
+}
+
+fn extract_blob_cids_recursive(value: &Value, blobs: &mut Vec) {
+ match value {
+ Value::Object(map) => {
+ if map.get("$type").and_then(|v| v.as_str()) == Some("blob") {
+ if let Some(ref_obj) = map.get("ref") {
+ if let Some(link) = ref_obj.get("$link").and_then(|v| v.as_str()) {
+ blobs.push(link.to_string());
+ }
+ }
+ }
+ for v in map.values() {
+ extract_blob_cids_recursive(v, blobs);
+ }
+ }
+ Value::Array(arr) => {
+ for v in arr {
+ extract_blob_cids_recursive(v, blobs);
+ }
+ }
+ _ => {}
+ }
+}
+
pub fn create_signed_commit(
did: &str,
data: Cid,
@@ -63,6 +92,7 @@ pub struct CommitParams<'a> {
pub new_mst_root: Cid,
pub ops: Vec,
pub blocks_cids: &'a [String],
+ pub blobs: &'a [String],
}
pub async fn commit_and_log(
@@ -77,6 +107,7 @@ pub async fn commit_and_log(
new_mst_root,
ops,
blocks_cids,
+ blobs,
} = params;
let key_row = sqlx::query!(
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
@@ -274,7 +305,7 @@ pub async fn commit_and_log(
new_root_cid.to_string(),
prev_cid_str,
json!(ops_json),
- &[] as &[String],
+ blobs,
blocks_cids,
prev_data_cid_str,
)
@@ -368,6 +399,7 @@ pub async fn create_record_internal(
}
}
let written_cids_str: Vec = written_cids.iter().map(|c| c.to_string()).collect();
+ let blob_cids = extract_blob_cids(record);
let result = commit_and_log(
state,
CommitParams {
@@ -378,6 +410,7 @@ pub async fn create_record_internal(
new_mst_root,
ops: vec![op],
blocks_cids: &written_cids_str,
+ blobs: &blob_cids,
},
)
.await?;
diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs
index 42d9f0b..980a0d4 100644
--- a/src/api/repo/record/write.rs
+++ b/src/api/repo/record/write.rs
@@ -1,5 +1,5 @@
use super::validation::validate_record;
-use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
+use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids};
use crate::delegation::{self, DelegationActionType};
use crate::repo::tracking::TrackingBlockStore;
use crate::state::AppState;
@@ -334,6 +334,7 @@ pub async fn create_record(
.iter()
.map(|c| c.to_string())
.collect::>();
+ let blob_cids = extract_blob_cids(&input.record);
if let Err(e) = commit_and_log(
&state,
CommitParams {
@@ -344,6 +345,7 @@ pub async fn create_record(
new_mst_root,
ops: vec![op],
blocks_cids: &written_cids_str,
+ blobs: &blob_cids,
},
)
.await
@@ -582,6 +584,7 @@ pub async fn put_record(
.map(|c| c.to_string())
.collect::>();
let is_update = existing_cid.is_some();
+ let blob_cids = extract_blob_cids(&input.record);
if let Err(e) = commit_and_log(
&state,
CommitParams {
@@ -592,6 +595,7 @@ pub async fn put_record(
new_mst_root,
ops: vec![op],
blocks_cids: &written_cids_str,
+ blobs: &blob_cids,
},
)
.await
diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs
index 34124cf..02b8894 100644
--- a/src/api/server/account_status.rs
+++ b/src/api/server/account_status.rs
@@ -1,4 +1,5 @@
use crate::api::ApiError;
+use crate::plc::PlcClient;
use crate::state::AppState;
use axum::{
Json,
@@ -8,6 +9,7 @@ use axum::{
};
use bcrypt::verify;
use chrono::{Duration, Utc};
+use k256::ecdsa::SigningKey;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
@@ -118,6 +120,185 @@ pub async fn check_account_status(
.into_response()
}
+async fn assert_valid_did_document_for_service(
+ db: &sqlx::PgPool,
+ did: &str,
+) -> Result<(), (StatusCode, Json)> {
+ let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
+ let expected_endpoint = format!("https://{}", hostname);
+
+ if did.starts_with("did:plc:") {
+ let plc_client = PlcClient::new(None);
+
+ let mut last_error = None;
+ let mut doc_data = None;
+ for attempt in 0..5 {
+ if attempt > 0 {
+ let delay_ms = 500 * (1 << (attempt - 1));
+ info!(
+ "Waiting {}ms before retry {} for DID document validation ({})",
+ delay_ms, attempt, did
+ );
+ tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
+ }
+
+ match plc_client.get_document_data(did).await {
+ Ok(data) => {
+ let pds_endpoint = data
+ .get("services")
+ .and_then(|s| s.get("atproto_pds").or_else(|| s.get("atprotoPds")))
+ .and_then(|p| p.get("endpoint"))
+ .and_then(|e| e.as_str());
+
+ if pds_endpoint == Some(&expected_endpoint) {
+ doc_data = Some(data);
+ break;
+ } else {
+ info!(
+ "Attempt {}: DID {} has endpoint {:?}, expected {} - retrying",
+ attempt + 1,
+ did,
+ pds_endpoint,
+ expected_endpoint
+ );
+ last_error = Some(format!(
+ "DID document endpoint {:?} does not match expected {}",
+ pds_endpoint, expected_endpoint
+ ));
+ }
+ }
+ Err(e) => {
+ warn!(
+ "Attempt {}: Failed to fetch PLC document for {}: {:?}",
+ attempt + 1,
+ did,
+ e
+ );
+ last_error = Some(format!("Could not resolve DID document: {}", e));
+ }
+ }
+ }
+
+ let doc_data = match doc_data {
+ Some(d) => d,
+ None => {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "InvalidRequest",
+ "message": last_error.unwrap_or_else(|| "DID document validation failed".to_string())
+ })),
+ ));
+ }
+ };
+
+ let doc_signing_key = doc_data
+ .get("verificationMethods")
+ .and_then(|v| v.get("atproto"))
+ .and_then(|k| k.as_str());
+
+ let user_row = sqlx::query!(
+ "SELECT uk.key_bytes, uk.encryption_version FROM user_keys uk JOIN users u ON uk.user_id = u.id WHERE u.did = $1",
+ did
+ )
+ .fetch_optional(db)
+ .await
+ .map_err(|e| {
+ error!("Failed to fetch user key: {:?}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({"error": "InternalError"})),
+ )
+ })?;
+
+ if let Some(row) = user_row {
+ let key_bytes =
+ crate::config::decrypt_key(&row.key_bytes, row.encryption_version).map_err(|e| {
+ error!("Failed to decrypt user key: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({"error": "InternalError"})),
+ )
+ })?;
+ let signing_key = SigningKey::from_slice(&key_bytes).map_err(|e| {
+ error!("Failed to create signing key: {:?}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({"error": "InternalError"})),
+ )
+ })?;
+ let expected_did_key = crate::plc::signing_key_to_did_key(&signing_key);
+
+ if doc_signing_key != Some(&expected_did_key) {
+ warn!(
+ "DID {} has signing key {:?}, expected {}",
+ did, doc_signing_key, expected_did_key
+ );
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "InvalidRequest",
+ "message": "DID document verification method does not match expected signing key"
+ })),
+ ));
+ }
+ }
+ } else if did.starts_with("did:web:") {
+ let client = reqwest::Client::new();
+ let did_path = &did[8..];
+ let url = format!("https://{}/.well-known/did.json", did_path.replace(':', "/"));
+ let resp = client.get(&url).send().await.map_err(|e| {
+ warn!("Failed to fetch did:web document for {}: {:?}", did, e);
+ (
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "InvalidRequest",
+ "message": format!("Could not resolve DID document: {}", e)
+ })),
+ )
+ })?;
+ let doc: serde_json::Value = resp.json().await.map_err(|e| {
+ warn!("Failed to parse did:web document for {}: {:?}", did, e);
+ (
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "InvalidRequest",
+ "message": format!("Could not parse DID document: {}", e)
+ })),
+ )
+ })?;
+
+ let pds_endpoint = doc
+ .get("service")
+ .and_then(|s| s.as_array())
+ .and_then(|arr| {
+ arr.iter().find(|svc| {
+ svc.get("id").and_then(|id| id.as_str()) == Some("#atproto_pds")
+ || svc.get("type").and_then(|t| t.as_str())
+ == Some("AtprotoPersonalDataServer")
+ })
+ })
+ .and_then(|svc| svc.get("serviceEndpoint"))
+ .and_then(|e| e.as_str());
+
+ if pds_endpoint != Some(&expected_endpoint) {
+ warn!(
+ "DID {} has endpoint {:?}, expected {}",
+ did, pds_endpoint, expected_endpoint
+ );
+ return Err((
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "InvalidRequest",
+ "message": "DID document atproto_pds service endpoint does not match PDS public url"
+ })),
+ ));
+ }
+ }
+
+ Ok(())
+}
+
pub async fn activate_account(
State(state): State,
headers: axum::http::HeaderMap,
@@ -158,6 +339,15 @@ pub async fn activate_account(
}
let did = auth_user.did;
+
+ if let Err((status, json)) = assert_valid_did_document_for_service(&state.db, &did).await {
+ info!(
+ "activateAccount rejected for {}: DID document validation failed",
+ did
+ );
+ return (status, json).into_response();
+ }
+
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
@@ -182,13 +372,20 @@ pub async fn activate_account(
{
warn!("Failed to sequence identity event for activation: {}", e);
}
- if let Err(e) =
- crate::api::repo::record::sequence_empty_commit_event(&state, &did).await
- {
- warn!(
- "Failed to sequence empty commit event for activation: {}",
- e
- );
+ let repo_root = sqlx::query_scalar!(
+ "SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1",
+ did
+ )
+ .fetch_optional(&state.db)
+ .await
+ .ok()
+ .flatten();
+ if let Some(root_cid) = repo_root {
+ if let Err(e) =
+ crate::api::repo::record::sequence_sync_event(&state, &did, &root_cid).await
+ {
+ warn!("Failed to sequence sync event for activation: {}", e);
+ }
}
(StatusCode::OK, Json(json!({}))).into_response()
}
diff --git a/src/api/server/migration.rs b/src/api/server/migration.rs
new file mode 100644
index 0000000..096fe10
--- /dev/null
+++ b/src/api/server/migration.rs
@@ -0,0 +1,239 @@
+use crate::api::ApiError;
+use crate::state::AppState;
+use axum::{
+ Json,
+ extract::State,
+ http::StatusCode,
+ response::{IntoResponse, Response},
+};
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetMigrationStatusOutput {
+ pub did: String,
+ pub did_type: String,
+ pub migrated: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub migrated_to_pds: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub migrated_at: Option>,
+}
+
+pub async fn get_migration_status(
+ State(state): State,
+ headers: axum::http::HeaderMap,
+) -> Response {
+ let extracted = match crate::auth::extract_auth_token_from_header(
+ headers.get("Authorization").and_then(|h| h.to_str().ok()),
+ ) {
+ Some(t) => t,
+ None => return ApiError::AuthenticationRequired.into_response(),
+ };
+ let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
+ let http_uri = format!(
+ "https://{}/xrpc/com.tranquil.account.getMigrationStatus",
+ std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
+ );
+ let auth_user = match crate::auth::validate_token_with_dpop(
+ &state.db,
+ &extracted.token,
+ extracted.is_dpop,
+ dpop_proof,
+ "GET",
+ &http_uri,
+ true,
+ )
+ .await
+ {
+ Ok(user) => user,
+ Err(e) => return ApiError::from(e).into_response(),
+ };
+ let user = match sqlx::query!(
+ "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1",
+ auth_user.did
+ )
+ .fetch_optional(&state.db)
+ .await
+ {
+ Ok(Some(row)) => row,
+ Ok(None) => return ApiError::AccountNotFound.into_response(),
+ Err(e) => {
+ tracing::error!("DB error getting migration status: {:?}", e);
+ return ApiError::InternalError.into_response();
+ }
+ };
+ let did_type = if user.did.starts_with("did:plc:") {
+ "plc"
+ } else if user.did.starts_with("did:web:") {
+ "web"
+ } else {
+ "unknown"
+ };
+ let migrated = user.migrated_to_pds.is_some();
+ (
+ StatusCode::OK,
+ Json(GetMigrationStatusOutput {
+ did: user.did,
+ did_type: did_type.to_string(),
+ migrated,
+ migrated_to_pds: user.migrated_to_pds,
+ migrated_at: user.migrated_at,
+ }),
+ )
+ .into_response()
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateMigrationForwardingInput {
+ pub pds_url: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateMigrationForwardingOutput {
+ pub success: bool,
+ pub migrated_to_pds: String,
+ pub migrated_at: DateTime,
+}
+
+pub async fn update_migration_forwarding(
+ State(state): State,
+ headers: axum::http::HeaderMap,
+ Json(input): Json,
+) -> Response {
+ let extracted = match crate::auth::extract_auth_token_from_header(
+ headers.get("Authorization").and_then(|h| h.to_str().ok()),
+ ) {
+ Some(t) => t,
+ None => return ApiError::AuthenticationRequired.into_response(),
+ };
+ let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
+ let http_uri = format!(
+ "https://{}/xrpc/com.tranquil.account.updateMigrationForwarding",
+ std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
+ );
+ let auth_user = match crate::auth::validate_token_with_dpop(
+ &state.db,
+ &extracted.token,
+ extracted.is_dpop,
+ dpop_proof,
+ "POST",
+ &http_uri,
+ true,
+ )
+ .await
+ {
+ Ok(user) => user,
+ Err(e) => return ApiError::from(e).into_response(),
+ };
+ if !auth_user.did.starts_with("did:web:") {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "InvalidRequest",
+ "message": "Migration forwarding is only available for did:web accounts. did:plc accounts use PLC directory for identity updates."
+ })),
+ )
+ .into_response();
+ }
+ let pds_url = input.pds_url.trim();
+ if pds_url.is_empty() {
+ return ApiError::InvalidRequest("pds_url is required".into()).into_response();
+ }
+ if !pds_url.starts_with("https://") {
+ return ApiError::InvalidRequest("pds_url must start with https://".into()).into_response();
+ }
+ let pds_url_clean = pds_url.trim_end_matches('/');
+ let now = Utc::now();
+ let result = sqlx::query!(
+ "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
+ pds_url_clean,
+ now,
+ auth_user.did
+ )
+ .execute(&state.db)
+ .await;
+ match result {
+ Ok(_) => {
+ tracing::info!(
+ "Updated migration forwarding for {} to {}",
+ auth_user.did,
+ pds_url_clean
+ );
+ (
+ StatusCode::OK,
+ Json(UpdateMigrationForwardingOutput {
+ success: true,
+ migrated_to_pds: pds_url_clean.to_string(),
+ migrated_at: now,
+ }),
+ )
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("DB error updating migration forwarding: {:?}", e);
+ ApiError::InternalError.into_response()
+ }
+ }
+}
+
+pub async fn clear_migration_forwarding(
+ State(state): State,
+ headers: axum::http::HeaderMap,
+) -> Response {
+ let extracted = match crate::auth::extract_auth_token_from_header(
+ headers.get("Authorization").and_then(|h| h.to_str().ok()),
+ ) {
+ Some(t) => t,
+ None => return ApiError::AuthenticationRequired.into_response(),
+ };
+ let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
+ let http_uri = format!(
+ "https://{}/xrpc/com.tranquil.account.clearMigrationForwarding",
+ std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
+ );
+ let auth_user = match crate::auth::validate_token_with_dpop(
+ &state.db,
+ &extracted.token,
+ extracted.is_dpop,
+ dpop_proof,
+ "POST",
+ &http_uri,
+ true,
+ )
+ .await
+ {
+ Ok(user) => user,
+ Err(e) => return ApiError::from(e).into_response(),
+ };
+ if !auth_user.did.starts_with("did:web:") {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "InvalidRequest",
+ "message": "Migration forwarding is only available for did:web accounts"
+ })),
+ )
+ .into_response();
+ }
+ let result = sqlx::query!(
+ "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1",
+ auth_user.did
+ )
+ .execute(&state.db)
+ .await;
+ match result {
+ Ok(_) => {
+ tracing::info!("Cleared migration forwarding for {}", auth_user.did);
+ (StatusCode::OK, Json(json!({ "success": true }))).into_response()
+ }
+ Err(e) => {
+ tracing::error!("DB error clearing migration forwarding: {:?}", e);
+ ApiError::InternalError.into_response()
+ }
+ }
+}
diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs
index 588113c..7a1e41b 100644
--- a/src/api/server/mod.rs
+++ b/src/api/server/mod.rs
@@ -4,6 +4,7 @@ pub mod email;
pub mod invite;
pub mod logo;
pub mod meta;
+pub mod migration;
pub mod passkey_account;
pub mod passkeys;
pub mod password;
@@ -56,5 +57,8 @@ pub use trusted_devices::{
extend_device_trust, is_device_trusted, list_trusted_devices, revoke_trusted_device,
trust_device, update_trusted_device,
};
+pub use migration::{
+ clear_migration_forwarding, get_migration_status, update_migration_forwarding,
+};
pub use verify_email::{resend_migration_verification, verify_migration_email};
pub use verify_token::{VerifyTokenInput, VerifyTokenOutput, verify_token, verify_token_internal};
diff --git a/src/lib.rs b/src/lib.rs
index 78ecc0a..14cee3e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -280,6 +280,18 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.tranquil.account.recoverPasskeyAccount",
post(api::server::recover_passkey_account),
)
+ .route(
+ "/xrpc/com.tranquil.account.getMigrationStatus",
+ get(api::server::get_migration_status),
+ )
+ .route(
+ "/xrpc/com.tranquil.account.updateMigrationForwarding",
+ post(api::server::update_migration_forwarding),
+ )
+ .route(
+ "/xrpc/com.tranquil.account.clearMigrationForwarding",
+ post(api::server::clear_migration_forwarding),
+ )
.route(
"/xrpc/com.atproto.server.requestEmailUpdate",
post(api::server::request_email_update),
diff --git a/src/sync/import.rs b/src/sync/import.rs
index fa13fac..a8a6bd9 100644
--- a/src/sync/import.rs
+++ b/src/sync/import.rs
@@ -163,68 +163,91 @@ pub fn walk_mst(
root_cid: &Cid,
) -> Result, ImportError> {
let mut records = Vec::new();
- let mut stack = vec![*root_cid];
- let mut visited = std::collections::HashSet::new();
- while let Some(cid) = stack.pop() {
- if visited.contains(&cid) {
- continue;
+ walk_mst_node(blocks, root_cid, &[], &mut records)?;
+ Ok(records)
+}
+
+fn walk_mst_node(
+ blocks: &HashMap,
+ cid: &Cid,
+ prev_key: &[u8],
+ records: &mut Vec,
+) -> Result<(), ImportError> {
+ let block = blocks
+ .get(cid)
+ .ok_or_else(|| ImportError::BlockNotFound(cid.to_string()))?;
+ let value: Ipld = serde_ipld_dagcbor::from_slice(block)
+ .map_err(|e| ImportError::InvalidCbor(e.to_string()))?;
+
+ if let Ipld::Map(ref obj) = value {
+ if let Some(Ipld::Link(left_cid)) = obj.get("l") {
+ walk_mst_node(blocks, left_cid, prev_key, records)?;
}
- visited.insert(cid);
- let block = blocks
- .get(&cid)
- .ok_or_else(|| ImportError::BlockNotFound(cid.to_string()))?;
- let value: Ipld = serde_ipld_dagcbor::from_slice(block)
- .map_err(|e| ImportError::InvalidCbor(e.to_string()))?;
- if let Ipld::Map(ref obj) = value {
- if let Some(Ipld::List(entries)) = obj.get("e") {
- for entry in entries {
- if let Ipld::Map(entry_obj) = entry {
- let key = entry_obj.get("k").and_then(|k| {
- if let Ipld::Bytes(b) = k {
- String::from_utf8(b.clone()).ok()
- } else if let Ipld::String(s) = k {
- Some(s.clone())
- } else {
- None
- }
- });
- let record_cid = entry_obj.get("v").and_then(|v| {
- if let Ipld::Link(cid) = v {
- Some(*cid)
- } else {
- None
- }
- });
- if let (Some(key), Some(record_cid)) = (key, record_cid)
- && let Some(record_block) = blocks.get(&record_cid)
- && let Ok(record_value) =
- serde_ipld_dagcbor::from_slice::(record_block)
- {
- let blob_refs = find_blob_refs_ipld(&record_value, 0);
- let parts: Vec<&str> = key.split('/').collect();
- if parts.len() >= 2 {
- let collection = parts[..parts.len() - 1].join("/");
- let rkey = parts[parts.len() - 1].to_string();
- records.push(ImportedRecord {
- collection,
- rkey,
- cid: record_cid,
- blob_refs,
- });
- }
+
+ let mut current_key = prev_key.to_vec();
+
+ if let Some(Ipld::List(entries)) = obj.get("e") {
+ for entry in entries {
+ if let Ipld::Map(entry_obj) = entry {
+ let prefix_len = entry_obj.get("p").and_then(|p| {
+ if let Ipld::Integer(n) = p {
+ Some(*n as usize)
+ } else {
+ None
}
- if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") {
- stack.push(*tree_cid);
+ }).unwrap_or(0);
+
+ let key_suffix = entry_obj.get("k").and_then(|k| {
+ if let Ipld::Bytes(b) = k {
+ Some(b.clone())
+ } else {
+ None
+ }
+ });
+
+ if let Some(suffix) = key_suffix {
+ current_key.truncate(prefix_len);
+ current_key.extend_from_slice(&suffix);
+ }
+
+ if let Some(Ipld::Link(tree_cid)) = entry_obj.get("t") {
+ walk_mst_node(blocks, tree_cid, ¤t_key, records)?;
+ }
+
+ let record_cid = entry_obj.get("v").and_then(|v| {
+ if let Ipld::Link(cid) = v {
+ Some(*cid)
+ } else {
+ None
+ }
+ });
+
+ if let Some(record_cid) = record_cid {
+ if let Ok(full_key) = String::from_utf8(current_key.clone()) {
+ if let Some(record_block) = blocks.get(&record_cid)
+ && let Ok(record_value) =
+ serde_ipld_dagcbor::from_slice::(record_block)
+ {
+ let blob_refs = find_blob_refs_ipld(&record_value, 0);
+ let parts: Vec<&str> = full_key.split('/').collect();
+ if parts.len() >= 2 {
+ let collection = parts[..parts.len() - 1].join("/");
+ let rkey = parts[parts.len() - 1].to_string();
+ records.push(ImportedRecord {
+ collection,
+ rkey,
+ cid: record_cid,
+ blob_refs,
+ });
+ }
+ }
}
}
}
}
- if let Some(Ipld::Link(left_cid)) = obj.get("l") {
- stack.push(*left_cid);
- }
}
}
- Ok(records)
+ Ok(())
}
pub struct CommitInfo {
diff --git a/src/sync/util.rs b/src/sync/util.rs
index 34cb7de..7aff5d1 100644
--- a/src/sync/util.rs
+++ b/src/sync/util.rs
@@ -86,6 +86,15 @@ fn format_account_event(event: &SequencedEvent) -> Result, anyhow::Error
let mut bytes = Vec::new();
serde_ipld_dagcbor::to_writer(&mut bytes, &header)?;
serde_ipld_dagcbor::to_writer(&mut bytes, &frame)?;
+ let hex_str: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
+ tracing::info!(
+ did = %frame.did,
+ active = frame.active,
+ status = ?frame.status,
+ cbor_len = bytes.len(),
+ cbor_hex = %hex_str,
+ "Sending account event to firehose"
+ );
Ok(bytes)
}