Graphical PDS migrator for AT Protocol

Compare changes

Choose any two refs to compare.

Changed files
+183 -117
islands
lib
routes
api
oauth
oauth-client-metadata.json
static
tests
+2 -1
dev.ts
··· 1 1 import { Builder } from "fresh/dev"; 2 2 import { tailwind } from "@fresh/plugin-tailwind"; 3 + import { State } from "./utils.ts"; 3 4 4 - const builder = new Builder({ target: "safari12" }); 5 + const builder = new Builder<State>({ target: "safari12" }); 5 6 tailwind(builder); 6 7 7 8 if (Deno.args.includes("build")) {
+93 -54
islands/DidPlcProgress.tsx
··· 162 162 const [emailToken, setEmailToken] = useState<string>(""); 163 163 const [hasDownloadedKey, setHasDownloadedKey] = useState(false); 164 164 const [downloadedKeyId, setDownloadedKeyId] = useState<string | null>(null); 165 + const [hasContinuedPastDownload, setHasContinuedPastDownload] = useState( 166 + false, 167 + ); 165 168 166 169 const updateStepStatus = ( 167 170 index: number, ··· 444 447 445 448 try { 446 449 const jsonString = JSON.stringify(keyJson, null, 2); 447 - const blob = new Blob([jsonString], { 448 - type: "application/json", 449 - }); 450 - const url = URL.createObjectURL(blob); 451 - const a = document.createElement("a"); 452 - a.href = url; 453 - a.download = `plc-key-${keyJson.publicKeyDid || "unknown"}.json`; 454 - a.style.display = "none"; 455 - document.body.appendChild(a); 456 - a.click(); 457 - document.body.removeChild(a); 458 - URL.revokeObjectURL(url); 450 + const filename = `plc-key-${keyJson.publicKeyDid || "unknown"}.json`; 451 + 452 + // Create data URL 453 + const dataStr = "data:text/json;charset=utf-8," + 454 + encodeURIComponent(jsonString); 455 + 456 + // Create download link 457 + const downloadAnchorNode = document.createElement("a"); 458 + downloadAnchorNode.setAttribute("href", dataStr); 459 + downloadAnchorNode.setAttribute("download", filename); 460 + 461 + // For Chrome/Firefox compatibility 462 + downloadAnchorNode.style.display = "none"; 463 + document.body.appendChild(downloadAnchorNode); 464 + 465 + // Trigger download 466 + downloadAnchorNode.click(); 467 + 468 + // Cleanup 469 + document.body.removeChild(downloadAnchorNode); 459 470 460 - console.log("Download completed, proceeding to next step..."); 471 + console.log("Download completed, showing continue button..."); 461 472 setHasDownloadedKey(true); 462 473 setDownloadedKeyId(keyJson.publicKeyDid); 463 - 464 - // Automatically proceed to the next step after successful download 465 - setTimeout(() => { 466 - console.log("Auto-proceeding with key:", keyJson.publicKeyDid); 467 - handleStartPlcUpdate(keyJson.publicKeyDid); 468 - }, 1000); 474 + // Keep step 0 in completed state but don't auto-proceed 469 475 } catch (error) { 470 476 console.error("Download failed:", error); 471 477 } ··· 845 851 {/* Key Download Warning */} 846 852 {index === 0 && 847 853 step.status === "completed" && 848 - !hasDownloadedKey && ( 854 + !hasContinuedPastDownload && ( 849 855 <div class="mt-4 space-y-4"> 850 856 <div class="bg-yellow-50 dark:bg-yellow-900/50 p-4 rounded-lg border border-yellow-200 dark:border-yellow-800"> 851 857 <div class="flex items-start"> ··· 893 899 </div> 894 900 895 901 <div class="flex items-center justify-between"> 896 - <button 897 - type="button" 898 - onClick={handleDownload} 899 - class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2" 900 - > 901 - <svg 902 - class="w-5 h-5" 903 - fill="none" 904 - stroke="currentColor" 905 - viewBox="0 0 24 24" 902 + <div class="flex items-center space-x-3"> 903 + <button 904 + type="button" 905 + onClick={handleDownload} 906 + class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2" 906 907 > 907 - <path 908 - stroke-linecap="round" 909 - stroke-linejoin="round" 910 - stroke-width="2" 911 - d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" 912 - /> 913 - </svg> 914 - <span>Download Key</span> 915 - </button> 908 + <svg 909 + class="w-5 h-5" 910 + fill="none" 911 + stroke="currentColor" 912 + viewBox="0 0 24 24" 913 + > 914 + <path 915 + stroke-linecap="round" 916 + stroke-linejoin="round" 917 + stroke-width="2" 918 + d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" 919 + /> 920 + </svg> 921 + <span>Download Key</span> 922 + </button> 916 923 917 - <div class="flex items-center text-sm text-red-600 dark:text-red-400"> 918 - <svg 919 - class="w-4 h-4 mr-1" 920 - fill="none" 921 - stroke="currentColor" 922 - viewBox="0 0 24 24" 923 - > 924 - <path 925 - stroke-linecap="round" 926 - stroke-linejoin="round" 927 - stroke-width="2" 928 - d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" 929 - /> 930 - </svg> 931 - Download required to proceed 924 + {hasDownloadedKey && ( 925 + <button 926 + type="button" 927 + onClick={() => { 928 + console.log( 929 + "Continue clicked, proceeding to PLC update", 930 + ); 931 + setHasContinuedPastDownload(true); 932 + handleStartPlcUpdate(keyJson.publicKeyDid); 933 + }} 934 + class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200 flex items-center space-x-2" 935 + > 936 + <svg 937 + class="w-5 h-5" 938 + fill="none" 939 + stroke="currentColor" 940 + viewBox="0 0 24 24" 941 + > 942 + <path 943 + stroke-linecap="round" 944 + stroke-linejoin="round" 945 + stroke-width="2" 946 + d="M9 5l7 7-7 7" 947 + /> 948 + </svg> 949 + <span>Continue</span> 950 + </button> 951 + )} 932 952 </div> 953 + 954 + {!hasDownloadedKey && ( 955 + <div class="flex items-center text-sm text-red-600 dark:text-red-400"> 956 + <svg 957 + class="w-4 h-4 mr-1" 958 + fill="none" 959 + stroke="currentColor" 960 + viewBox="0 0 24 24" 961 + > 962 + <path 963 + stroke-linecap="round" 964 + stroke-linejoin="round" 965 + stroke-width="2" 966 + d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" 967 + /> 968 + </svg> 969 + Download required to proceed 970 + </div> 971 + )} 933 972 </div> 934 973 </div> 935 974 )}
+21 -21
islands/MigrationProgress.tsx
··· 37 37 }`, 38 38 ); 39 39 setSteps((prevSteps) => 40 - prevSteps.map((step, i) => 41 - i === index 42 - ? { ...step, status, error, isVerificationError } 43 - : i > index 44 - ? { 45 - ...step, 46 - status: "pending", 47 - error: undefined, 48 - isVerificationError: undefined, 40 + prevSteps.map((step, i) => { 41 + if (i === index) { 42 + // Update the current step 43 + return { ...step, status, error, isVerificationError }; 44 + } else if (i > index) { 45 + // Reset future steps to pending only if current step is erroring 46 + if (status === "error") { 47 + return { 48 + ...step, 49 + status: "pending", 50 + error: undefined, 51 + isVerificationError: undefined, 52 + }; 49 53 } 50 - : step 51 - ) 54 + // Otherwise keep future steps as they are 55 + return step; 56 + } else { 57 + // Keep previous steps as they are (preserve completed status) 58 + return step; 59 + } 60 + }) 52 61 ); 53 62 }; 54 63 ··· 152 161 return; 153 162 } 154 163 155 - try { 156 - await client.startMigration(props); 157 - } catch (error) { 158 - console.error("Unhandled migration error:", error); 159 - updateStepStatus( 160 - 0, 161 - "error", 162 - error as string ?? "Unknown error occurred", 163 - ); 164 - } 164 + await client.startMigration(props); 165 165 })(); 166 166 }, []); 167 167
+2 -6
lib/client.ts
··· 205 205 await this.nextStepHook(2); 206 206 } 207 207 208 - // Step 4: Finalize Migration 209 - await this.finalizeMigration(); 210 - if (this.nextStepHook) { 211 - await this.nextStepHook(3); 212 - } 213 - 208 + // Stop here - finalization will be called from handleIdentityMigration 209 + // after user enters the token 214 210 return; 215 211 } catch (error) { 216 212 console.error("Migration error in try/catch:", error);
+22 -11
lib/oauth/client.ts
··· 1 1 import { AtprotoOAuthClient } from "@bigmoves/atproto-oauth-client"; 2 2 import { SessionStore, StateStore } from "../storage.ts"; 3 3 4 + const isDev = Deno.env.get("NODE_ENV") !== "production"; 5 + export const scope = [ 6 + "atproto", 7 + "account:email", 8 + "account:status?action=manage", 9 + "identity:*", 10 + "rpc:*?aud=did:web:api.bsky.app#bsky_appview", 11 + "rpc:com.atproto.server.createAccount?aud=*", 12 + ].join(" "); 13 + const publicUrl = Deno.env.get("PUBLIC_URL"); 14 + const url = publicUrl || `http://127.0.0.1:8000`; 15 + export const clientId = publicUrl 16 + ? `${url}/oauth-client-metadata.json` 17 + : `http://localhost?redirect_uri=${ 18 + encodeURIComponent(`${url}/api/oauth/callback`) 19 + }&scope=${encodeURIComponent(scope)}`; 20 + console.log(`ClientId: ${clientId}`); 21 + 4 22 /** 5 23 * Create the OAuth client. 6 24 * @param db - The Deno KV instance for the database ··· 11 29 throw new Error("PUBLIC_URL is not set"); 12 30 } 13 31 14 - const publicUrl = Deno.env.get("PUBLIC_URL"); 15 - const url = publicUrl || `http://127.0.0.1:8000`; 16 - const enc = encodeURIComponent; 17 - const clientId = publicUrl 18 - ? `${url}/oauth-client-metadata.json` 19 - : `http://localhost?redirect_uri=${ 20 - enc(`${url}/api/oauth/callback`) 21 - }&scope=${enc("atproto transition:generic transition:chat.bsky")}`; 22 - console.log(`ClientId: ${clientId}`); 23 - 24 32 return new AtprotoOAuthClient({ 25 33 clientMetadata: { 26 34 client_name: "Statusphere React App", 27 35 client_id: clientId, 28 36 client_uri: url, 29 37 redirect_uris: [`${url}/api/oauth/callback`], 30 - scope: "atproto transition:generic transition:chat.bsky", 38 + scope: scope, 31 39 grant_types: ["authorization_code", "refresh_token"], 32 40 response_types: ["code"], 33 41 application_type: "web", ··· 36 44 }, 37 45 stateStore: new StateStore(db), 38 46 sessionStore: new SessionStore(db), 47 + didCache: undefined, 48 + allowHttp: isDev, 49 + plcDirectoryUrl: Deno.env.get("PLC_URL") ?? "https://plc.directory", 39 50 }); 40 51 }; 41 52
+4 -3
routes/api/oauth/initiate.ts
··· 1 1 import { isValidHandle } from "npm:@atproto/syntax"; 2 - import { oauthClient } from "../../../lib/oauth/client.ts"; 2 + import { oauthClient, scope } from "../../../lib/oauth/client.ts"; 3 3 import { define } from "../../../utils.ts"; 4 4 5 5 function isValidUrl(url: string): boolean { ··· 18 18 const handle = data.handle; 19 19 if ( 20 20 typeof handle !== "string" || 21 - !(isValidHandle(handle) || isValidUrl(handle)) 21 + !(isValidHandle(handle) || isValidUrl(handle) || 22 + handle.startsWith("did:")) 22 23 ) { 23 24 return new Response("Invalid Handle", { status: 400 }); 24 25 } ··· 26 27 // Initiate the OAuth flow 27 28 try { 28 29 const url = await oauthClient.authorize(handle, { 29 - scope: "atproto transition:generic transition:chat.bsky", 30 + scope, 30 31 }); 31 32 return Response.json({ redirectUrl: url.toString() }); 32 33 } catch (err) {
+35
routes/oauth-client-metadata.json/index.ts
··· 1 + import { clientId, scope } from "../../lib/oauth/client.ts"; 2 + import { define } from "../../utils.ts"; 3 + 4 + /** 5 + * API endpoint to check the current migration state. 6 + * Returns the migration state information including whether migrations are allowed. 7 + */ 8 + export const handler = define.handlers({ 9 + GET(_ctx) { 10 + return Response.json({ 11 + client_name: "ATP Airport", 12 + client_id: clientId, 13 + client_uri: "https://atpairport.com", 14 + redirect_uris: [ 15 + "https://atpairport.com/api/oauth/callback", 16 + ], 17 + scope, 18 + grant_types: [ 19 + "authorization_code", 20 + "refresh_token", 21 + ], 22 + response_types: [ 23 + "code", 24 + ], 25 + application_type: "web", 26 + token_endpoint_auth_method: "none", 27 + dpop_bound_access_tokens: true, 28 + }, { 29 + status: 200, 30 + headers: { 31 + "Content-Type": "application/json", 32 + }, 33 + }); 34 + }, 35 + });
-19
static/oauth-client-metadata.json
··· 1 - { 2 - "client_name": "ATP Airport", 3 - "client_id": "https://atpairport.com/oauth-client-metadata.json", 4 - "client_uri": "https://atpairport.com", 5 - "redirect_uris": [ 6 - "https://atpairport.com/api/oauth/callback" 7 - ], 8 - "scope": "atproto transition:generic transition:chat.bsky", 9 - "grant_types": [ 10 - "authorization_code", 11 - "refresh_token" 12 - ], 13 - "response_types": [ 14 - "code" 15 - ], 16 - "application_type": "web", 17 - "token_endpoint_auth_method": "none", 18 - "dpop_bound_access_tokens": true 19 - }
tests/.yarn/install-state.gz

This is a binary file and will not be displayed.

+1 -1
tests/e2e/migration.test.ts
··· 138 138 139 139 await migrationClient.handleIdentityMigration(verificationCode); 140 140 // If successful, continue to next step 141 - migrationClient.continueToNextStep(3); 141 + await migrationClient.finalizeMigration(); 142 142 } 143 143 }, 144 144 },
+3 -1
tests/utils/test-env.ts
··· 3 3 */ 4 4 5 5 import { Agent } from "@atproto/api"; 6 - import { TestPds, TestPlc } from "@atproto/dev-env"; 6 + import { TestBsky, TestPds, TestPlc } from "@atproto/dev-env"; 7 7 import { ComAtprotoServerCreateAccount } from "@atproto/api"; 8 8 import { SMTPServer, SMTPServerAddress } from "smtp-server"; 9 9 import * as cheerio from "cheerio"; ··· 171 171 devMode: true, 172 172 emailSmtpUrl: `smtp://localhost:${SMTP_PORT}`, 173 173 emailFromAddress: `noreply@localhost:${SMTP_PORT}`, 174 + bskyAppViewDid: "did:web:api.bsky.app", 174 175 }); 175 176 176 177 const targetPds = await TestPds.create({ ··· 181 182 devMode: true, 182 183 emailSmtpUrl: `smtp://localhost:${SMTP_PORT}`, 183 184 emailFromAddress: `noreply@localhost:${SMTP_PORT}`, 185 + bskyAppViewDid: "did:web:api.bsky.app", 184 186 }); 185 187 186 188 return {