Graphical PDS migrator for AT Protocol

Compare changes

Choose any two refs to compare.

.DS_Store

This is a binary file and will not be displayed.

+3
.env.example
··· 1 + # generate with `openssl ecparam --name secp256k1 --genkey --noout --outform DER | tail --bytes=+8 | head --bytes=32 | xxd --plain --cols 32` 2 + COOKIE_SECRET=my_secret 3 + MIGRATION_STATE=up
-36
.github/workflows/deploy.yml
··· 1 - name: Deploy 2 - on: 3 - push: 4 - branches: main 5 - pull_request: 6 - branches: main 7 - 8 - jobs: 9 - deploy: 10 - name: Deploy 11 - runs-on: ubuntu-latest 12 - 13 - permissions: 14 - id-token: write # Needed for auth with Deno Deploy 15 - contents: read # Needed to clone the repository 16 - 17 - steps: 18 - - name: Clone repository 19 - uses: actions/checkout@v4 20 - 21 - - name: Install Deno 22 - uses: denoland/setup-deno@v2 23 - with: 24 - deno-version: v2.x 25 - 26 - - name: Build step 27 - run: "deno task build" 28 - 29 - - name: Upload to Deno Deploy 30 - uses: denoland/deployctl@v1 31 - with: 32 - project: "roscoerubin-airport-67" 33 - entrypoint: "main.ts" 34 - root: "." 35 - 36 -
+2
.gitignore
··· 5 5 .env.production.local 6 6 .env.local 7 7 8 + .DS_Store 9 + 8 10 # Fresh build directory 9 11 _fresh/ 10 12 # npm dependencies
+35 -9
.zed/settings.json
··· 1 + // Folder-specific settings 2 + // 3 + // For a full list of overridable settings, and general information on folder-specific settings, 4 + // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 1 5 { 6 + "lsp": { 7 + "deno": { 8 + "settings": { 9 + "deno": { 10 + "enable": true, 11 + "cacheOnSave": true, 12 + "suggest": { 13 + "imports": { 14 + "autoDiscover": true 15 + } 16 + } 17 + } 18 + } 19 + } 20 + }, 2 21 "languages": { 22 + "JavaScript": { 23 + "language_servers": [ 24 + "deno", 25 + "!vtsls", 26 + "!eslint", 27 + "..." 28 + ] 29 + }, 3 30 "TypeScript": { 4 31 "language_servers": [ 5 - "wakatime", 6 32 "deno", 7 33 "!typescript-language-server", 8 34 "!vtsls", 9 - "!eslint" 10 - ], 11 - "formatter": "language_server" 35 + "!eslint", 36 + "..." 37 + ] 12 38 }, 13 39 "TSX": { 14 40 "language_servers": [ 15 - "wakatime", 16 41 "deno", 17 42 "!typescript-language-server", 18 43 "!vtsls", 19 - "!eslint" 20 - ], 21 - "formatter": "language_server" 44 + "!eslint", 45 + "..." 46 + ] 22 47 } 23 - } 48 + }, 49 + "formatter": "language_server" 24 50 }
+18 -20
README.md
··· 1 1 # Airport 2 2 3 - Your terminal for seamless AT Protocol PDS (Personal Data Server) migration and backup. 3 + Your terminal for seamless AT Protocol PDS (Personal Data Server) migration and 4 + backup. 4 5 5 - Airport is a web application built with Fresh and Deno that helps users safely migrate and backup their Bluesky PDS data. It provides a user-friendly interface for managing your AT Protocol data. 6 - 7 - โš ๏ธ **Alpha Status**: Airport is currently in alpha. Please use migration tools at your own risk and avoid using with main accounts during this phase. 6 + Airport is a web application built with Fresh and Deno that helps users safely 7 + migrate and backup their Bluesky PDS data. It provides a user-friendly interface 8 + for managing your AT Protocol data. 8 9 9 10 ## Features 10 11 ··· 13 14 - User-friendly interface 14 15 - Coming soon: PLC Key retrieval, data backup 15 16 16 - ## Technology Stack 17 + ## Tech Stack 17 18 18 - - [Fresh](https://fresh.deno.dev/) - The next-gen web framework 19 - - [Deno](https://deno.com/) - A modern runtime for JavaScript and TypeScript 20 - - [Tailwind CSS](https://tailwindcss.com/) - For styling 21 - - AT Protocol Integration 22 - 23 - ## Getting Started 19 + - [Fresh](https://fresh.deno.dev/) - Web Framework 20 + - [Deno](https://deno.com/) - Runtime 21 + - [Tailwind](https://tailwindcss.com/) - Styling 24 22 25 - ### Prerequisites 23 + ## Development 26 24 27 - Make sure to install Deno: 25 + Make sure you have Deno installed: 28 26 https://docs.deno.com/runtime/getting_started/installation 29 27 30 - ### Development 31 - 32 28 Start the project in development mode: 33 29 34 - ``` 30 + ```shell 35 31 deno task dev 36 32 ``` 37 33 38 - This will watch the project directory and restart as necessary. 39 - 40 34 ## About 41 35 42 - Airport is developed with โค๏ธ by [Roscoe](https://bsky.app/profile/knotbin.com) for [Spark](https://sprk.so), a new short-video platform for AT Protocol. 36 + Airport is developed with โค๏ธ by [Roscoe](https://bsky.app/profile/knotbin.com) 37 + for [Spark](https://sprk.so), a new short-video platform for AT Protocol. 43 38 44 39 ## Contributing 45 40 46 - We welcome contributions! Please feel free to submit a Pull Request. Please only submit pull requests that are relevant to the project. This project targets people with a non-advanced understanding of AT Protocol, so please avoid submitting pull requests that add features that complicate the user experience. 41 + We welcome contributions! Please feel free to submit a Pull Request. Please only 42 + submit pull requests that are relevant to the project. This project targets 43 + people with a non-advanced understanding of AT Protocol, so please avoid 44 + submitting pull requests that add features that complicate the user experience. 47 45 48 46 ## License 49 47
+6
components/AirportSign.tsx
··· 1 + /** 2 + * The airport sign component, used on the landing page. 3 + * Looks like a physical airport sign with a screen. 4 + * @returns The airport sign component 5 + * @component 6 + */ 1 7 export default function AirportSign() { 2 8 return ( 3 9 <div class="relative inline-block mb-8 sm:mb-12">
+43 -11
components/Button.tsx
··· 9 9 condensed?: boolean; 10 10 }; 11 11 12 - type ButtonProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLButtonElement>, keyof ButtonBaseProps>; 13 - type AnchorProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLAnchorElement>, keyof ButtonBaseProps> & { href: string }; 12 + type ButtonProps = 13 + & ButtonBaseProps 14 + & Omit<JSX.HTMLAttributes<HTMLButtonElement>, keyof ButtonBaseProps>; 15 + type AnchorProps = 16 + & ButtonBaseProps 17 + & Omit<JSX.HTMLAttributes<HTMLAnchorElement>, keyof ButtonBaseProps> 18 + & { href: string }; 14 19 20 + /** 21 + * The button props or anchor props for a button or link. 22 + * @type {Props} 23 + */ 15 24 type Props = ButtonProps | AnchorProps; 16 25 26 + /** 27 + * Styled button component. 28 + * @param props - The button props 29 + * @returns The button component 30 + * @component 31 + */ 17 32 export function Button(props: Props) { 18 - const { color = "blue", icon, iconAlt, label, className = "", condensed = false, ...rest } = props; 19 - const isAnchor = 'href' in props; 33 + const { 34 + color = "blue", 35 + icon, 36 + iconAlt, 37 + label, 38 + className = "", 39 + condensed = false, 40 + ...rest 41 + } = props; 42 + const isAnchor = "href" in props; 20 43 21 44 const baseStyles = "airport-sign flex items-center [transition:none]"; 22 - const paddingStyles = condensed ? 'px-2 py-1.5' : 'px-3 py-2 sm:px-6 sm:py-3'; 23 - const transformStyles = "translate-y-0 hover:translate-y-1 hover:transition-transform hover:duration-200 hover:ease-in-out"; 45 + const paddingStyles = condensed ? "px-2 py-1.5" : "px-3 py-2 sm:px-6 sm:py-3"; 46 + const transformStyles = 47 + "translate-y-0 hover:translate-y-1 hover:transition-transform hover:duration-200 hover:ease-in-out"; 24 48 const colorStyles = { 25 - blue: "bg-gradient-to-r from-blue-400 to-blue-500 text-white hover:from-blue-500 hover:to-blue-600", 26 - amber: "bg-gradient-to-r from-amber-400 to-amber-500 text-slate-900 hover:from-amber-500 hover:to-amber-600", 49 + blue: 50 + "bg-gradient-to-r from-blue-400 to-blue-500 text-white hover:from-blue-500 hover:to-blue-600", 51 + amber: 52 + "bg-gradient-to-r from-amber-400 to-amber-500 text-slate-900 hover:from-amber-500 hover:to-amber-600", 27 53 }; 28 54 29 55 const buttonContent = ( ··· 32 58 <img 33 59 src={icon} 34 60 alt={iconAlt || ""} 35 - className={`${condensed ? 'w-4 h-4' : 'w-6 h-6'} mr-2`} 36 - style={{ filter: color === 'blue' ? "brightness(0) invert(1)" : "brightness(0)" }} 61 + className={`${condensed ? "w-4 h-4" : "w-6 h-6"} mr-2`} 62 + style={{ 63 + filter: color === "blue" 64 + ? "brightness(0) invert(1)" 65 + : "brightness(0)", 66 + }} 37 67 /> 38 68 )} 39 69 {label && ( ··· 44 74 </> 45 75 ); 46 76 47 - const buttonStyles = `${baseStyles} ${paddingStyles} ${transformStyles} ${colorStyles[color]} ${className}`; 77 + const buttonStyles = `${baseStyles} ${paddingStyles} ${transformStyles} ${ 78 + colorStyles[color] 79 + } ${className}`; 48 80 49 81 if (isAnchor) { 50 82 return (
+65
components/Link.tsx
··· 1 + import { JSX } from "preact"; 2 + 3 + /** 4 + * Props for the Link component 5 + */ 6 + type Props = Omit<JSX.HTMLAttributes<HTMLAnchorElement>, "href"> & { 7 + /** URL for the link */ 8 + href: string; 9 + /** Whether this is an external link that should show an outbound icon */ 10 + isExternal?: boolean; 11 + /** Link text content */ 12 + children: JSX.Element | string; 13 + }; 14 + 15 + /** 16 + * A link component that handles external links with appropriate styling and accessibility. 17 + * Automatically adds external link icon and proper attributes for external links. 18 + */ 19 + export function Link(props: Props) { 20 + const { 21 + isExternal = false, 22 + class: className = "", 23 + children, 24 + href, 25 + ...rest 26 + } = props; 27 + 28 + // SVG for external link icon 29 + const externalLinkIcon = ( 30 + <svg 31 + xmlns="http://www.w3.org/2000/svg" 32 + viewBox="0 0 20 20" 33 + fill="currentColor" 34 + className="w-4 h-4 inline-block ml-1" 35 + aria-hidden="true" 36 + > 37 + <path 38 + fillRule="evenodd" 39 + d="M4.25 5.5a.75.75 0 00-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 00.75-.75v-4a.75.75 0 011.5 0v4A2.25 2.25 0 0112.75 17h-8.5A2.25 2.25 0 012 14.75v-8.5A2.25 2.25 0 014.25 4h5a.75.75 0 010 1.5h-5z" 40 + /> 41 + <path 42 + fillRule="evenodd" 43 + d="M6.194 12.753a.75.75 0 001.06.053L16.5 4.44v2.81a.75.75 0 001.5 0v-4.5a.75.75 0 00-.75-.75h-4.5a.75.75 0 000 1.5h2.553l-9.056 8.194a.75.75 0 00-.053 1.06z" 44 + /> 45 + </svg> 46 + ); 47 + 48 + return ( 49 + <a 50 + href={href} 51 + {...rest} 52 + className={`inline-flex items-center hover:underline ${className}`} 53 + {...(isExternal && { 54 + target: "_blank", 55 + rel: "noopener noreferrer", 56 + "aria-label": `${ 57 + typeof children === "string" ? children : "" 58 + } (opens in new tab)`, 59 + })} 60 + > 61 + {children} 62 + {isExternal && externalLinkIcon} 63 + </a> 64 + ); 65 + }
+77
components/MigrationCompletion.tsx
··· 1 + export interface MigrationCompletionProps { 2 + isVisible: boolean; 3 + } 4 + 5 + export default function MigrationCompletion( 6 + { isVisible }: MigrationCompletionProps, 7 + ) { 8 + if (!isVisible) return null; 9 + 10 + const handleLogout = async () => { 11 + try { 12 + const response = await fetch("/api/logout", { 13 + method: "POST", 14 + credentials: "include", 15 + }); 16 + if (!response.ok) { 17 + throw new Error("Logout failed"); 18 + } 19 + globalThis.location.href = "/"; 20 + } catch (error) { 21 + console.error("Failed to logout:", error); 22 + } 23 + }; 24 + 25 + return ( 26 + <div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800"> 27 + <p class="text-sm text-green-800 dark:text-green-200 pb-2"> 28 + Migration completed successfully! Sign out to finish the process and 29 + return home.<br /> 30 + Please consider donating to Airport to support server and development 31 + costs. 32 + </p> 33 + <div class="flex space-x-4"> 34 + <button 35 + type="button" 36 + onClick={handleLogout} 37 + 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" 38 + > 39 + <svg 40 + class="w-5 h-5" 41 + fill="none" 42 + stroke="currentColor" 43 + viewBox="0 0 24 24" 44 + > 45 + <path 46 + stroke-linecap="round" 47 + stroke-linejoin="round" 48 + stroke-width="2" 49 + d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" 50 + /> 51 + </svg> 52 + <span>Sign Out</span> 53 + </button> 54 + <a 55 + href="https://ko-fi.com/knotbin" 56 + target="_blank" 57 + 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" 58 + > 59 + <svg 60 + class="w-5 h-5" 61 + fill="none" 62 + stroke="currentColor" 63 + viewBox="0 0 24 24" 64 + > 65 + <path 66 + stroke-linecap="round" 67 + stroke-linejoin="round" 68 + stroke-width="2" 69 + d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" 70 + /> 71 + </svg> 72 + <span>Support Us</span> 73 + </a> 74 + </div> 75 + </div> 76 + ); 77 + }
+208
components/MigrationStep.tsx
··· 1 + import { IS_BROWSER } from "fresh/runtime"; 2 + import { ComponentChildren } from "preact"; 3 + 4 + export type StepStatus = 5 + | "pending" 6 + | "in-progress" 7 + | "verifying" 8 + | "completed" 9 + | "error"; 10 + 11 + export interface MigrationStepProps { 12 + name: string; 13 + status: StepStatus; 14 + error?: string; 15 + isVerificationError?: boolean; 16 + index: number; 17 + onRetryVerification?: (index: number) => void; 18 + children?: ComponentChildren; 19 + } 20 + 21 + export function MigrationStep({ 22 + name, 23 + status, 24 + error, 25 + isVerificationError, 26 + index, 27 + onRetryVerification, 28 + children, 29 + }: MigrationStepProps) { 30 + return ( 31 + <div key={name} class={getStepClasses(status)}> 32 + {getStepIcon(status)} 33 + <div class="flex-1"> 34 + <p 35 + class={`font-medium ${ 36 + status === "error" 37 + ? "text-red-900 dark:text-red-200" 38 + : status === "completed" 39 + ? "text-green-900 dark:text-green-200" 40 + : status === "in-progress" 41 + ? "text-blue-900 dark:text-blue-200" 42 + : "text-gray-900 dark:text-gray-200" 43 + }`} 44 + > 45 + {getStepDisplayName( 46 + { name, status, error, isVerificationError }, 47 + index, 48 + )} 49 + </p> 50 + {error && ( 51 + <div class="mt-1"> 52 + <p class="text-sm text-red-600 dark:text-red-400"> 53 + {(() => { 54 + try { 55 + const err = JSON.parse(error); 56 + return err.message || error; 57 + } catch { 58 + return error; 59 + } 60 + })()} 61 + </p> 62 + {isVerificationError && onRetryVerification && ( 63 + <div class="flex space-x-2 mt-2"> 64 + <button 65 + type="button" 66 + onClick={() => onRetryVerification(index)} 67 + class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors duration-200 dark:bg-blue-500 dark:hover:bg-blue-400" 68 + disabled={!IS_BROWSER} 69 + > 70 + Retry Verification 71 + </button> 72 + </div> 73 + )} 74 + </div> 75 + )} 76 + {children} 77 + </div> 78 + </div> 79 + ); 80 + } 81 + 82 + function getStepDisplayName( 83 + step: Pick< 84 + MigrationStepProps, 85 + "name" | "status" | "error" | "isVerificationError" 86 + >, 87 + index: number, 88 + ) { 89 + if (step.status === "completed") { 90 + switch (index) { 91 + case 0: 92 + return "Account Created"; 93 + case 1: 94 + return "Data Migrated"; 95 + case 2: 96 + return "Identity Migrated"; 97 + case 3: 98 + return "Migration Finalized"; 99 + } 100 + } 101 + 102 + if (step.status === "in-progress") { 103 + switch (index) { 104 + case 0: 105 + return "Creating your new account..."; 106 + case 1: 107 + return "Migrating your data..."; 108 + case 2: 109 + return step.name === 110 + "Enter the token sent to your email to complete identity migration" 111 + ? step.name 112 + : "Migrating your identity..."; 113 + case 3: 114 + return "Finalizing migration..."; 115 + } 116 + } 117 + 118 + if (step.status === "verifying") { 119 + switch (index) { 120 + case 0: 121 + return "Verifying account creation..."; 122 + case 1: 123 + return "Verifying data migration..."; 124 + case 2: 125 + return "Verifying identity migration..."; 126 + case 3: 127 + return "Verifying migration completion..."; 128 + } 129 + } 130 + 131 + return step.name; 132 + } 133 + 134 + function getStepIcon(status: StepStatus) { 135 + switch (status) { 136 + case "pending": 137 + return ( 138 + <div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center"> 139 + <div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" /> 140 + </div> 141 + ); 142 + case "in-progress": 143 + return ( 144 + <div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center"> 145 + <div class="w-3 h-3 rounded-full bg-blue-500" /> 146 + </div> 147 + ); 148 + case "verifying": 149 + return ( 150 + <div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center"> 151 + <div class="w-3 h-3 rounded-full bg-yellow-500" /> 152 + </div> 153 + ); 154 + case "completed": 155 + return ( 156 + <div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center"> 157 + <svg 158 + class="w-5 h-5 text-white" 159 + fill="none" 160 + stroke="currentColor" 161 + viewBox="0 0 24 24" 162 + > 163 + <path 164 + stroke-linecap="round" 165 + stroke-linejoin="round" 166 + stroke-width="2" 167 + d="M5 13l4 4L19 7" 168 + /> 169 + </svg> 170 + </div> 171 + ); 172 + case "error": 173 + return ( 174 + <div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center"> 175 + <svg 176 + class="w-5 h-5 text-white" 177 + fill="none" 178 + stroke="currentColor" 179 + viewBox="0 0 24 24" 180 + > 181 + <path 182 + stroke-linecap="round" 183 + stroke-linejoin="round" 184 + stroke-width="2" 185 + d="M6 18L18 6M6 6l12 12" 186 + /> 187 + </svg> 188 + </div> 189 + ); 190 + } 191 + } 192 + 193 + function getStepClasses(status: StepStatus) { 194 + const baseClasses = 195 + "flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200"; 196 + switch (status) { 197 + case "pending": 198 + return `${baseClasses} bg-gray-50 dark:bg-gray-800`; 199 + case "in-progress": 200 + return `${baseClasses} bg-blue-50 dark:bg-blue-900`; 201 + case "verifying": 202 + return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`; 203 + case "completed": 204 + return `${baseClasses} bg-green-50 dark:bg-green-900`; 205 + case "error": 206 + return `${baseClasses} bg-red-50 dark:bg-red-900`; 207 + } 208 + }
+33 -9
deno.json
··· 2 2 "tasks": { 3 3 "check": "deno fmt --check . && deno lint . && deno check **/*.ts && deno check **/*.tsx", 4 4 "dev": "deno run -A --env --watch=static/,routes/ dev.ts", 5 - "build": "deno run -A --unstable-otel dev.ts build", 6 - "start": "deno run -A --unstable-otel main.ts", 5 + "build": "deno run -A dev.ts build", 6 + "start": "deno run -A main.ts", 7 7 "update": "deno run -A -r jsr:@fresh/update ." 8 8 }, 9 9 "lint": { 10 10 "rules": { 11 - "tags": ["fresh", "recommended"] 11 + "tags": [ 12 + "fresh", 13 + "recommended" 14 + ] 12 15 } 13 16 }, 14 - "exclude": ["**/_fresh/*"], 17 + "exclude": [ 18 + "**/_fresh/*" 19 + ], 15 20 "imports": { 16 21 "@atproto/api": "npm:@atproto/api@^0.15.6", 17 22 "@bigmoves/atproto-oauth-client": "jsr:@bigmoves/atproto-oauth-client@^0.2.0", ··· 21 26 "posthog-js": "npm:posthog-js@1.120.0", 22 27 "preact": "npm:preact@^10.26.6", 23 28 "@preact/signals": "npm:@preact/signals@^2.0.4", 24 - "tailwindcss": "npm:tailwindcss@^3.4.3" 29 + "tailwindcss": "npm:tailwindcss@^3.4.3", 30 + "@atproto/crypto": "npm:@atproto/crypto@^0.4.4", 31 + "@did-plc/lib": "npm:@did-plc/lib@^0.0.4" 25 32 }, 26 33 "compilerOptions": { 27 - "lib": ["dom", "dom.asynciterable", "dom.iterable", "deno.ns"], 34 + "lib": [ 35 + "dom", 36 + "dom.asynciterable", 37 + "dom.iterable", 38 + "deno.ns" 39 + ], 28 40 "jsx": "precompile", 29 41 "jsxImportSource": "preact", 30 - "jsxPrecompileSkipElements": ["a", "img", "source", "body", "html", "head"], 31 - "types": ["node"] 42 + "jsxPrecompileSkipElements": [ 43 + "a", 44 + "img", 45 + "source", 46 + "body", 47 + "html", 48 + "head" 49 + ], 50 + "types": [ 51 + "node" 52 + ] 32 53 }, 33 - "unstable": ["kv", "otel"] 54 + "unstable": [ 55 + "kv", 56 + "otel" 57 + ] 34 58 }
+284 -1
deno.lock
··· 33 33 "npm:@atproto/api@*": "0.15.6", 34 34 "npm:@atproto/api@~0.15.6": "0.15.6", 35 35 "npm:@atproto/crypto@*": "0.4.4", 36 + "npm:@atproto/crypto@~0.4.4": "0.4.4", 36 37 "npm:@atproto/identity@*": "0.4.8", 37 38 "npm:@atproto/jwk@0.1.4": "0.1.4", 38 39 "npm:@atproto/oauth-client@~0.3.13": "0.3.16", 39 40 "npm:@atproto/oauth-types@~0.2.4": "0.2.7", 40 41 "npm:@atproto/syntax@*": "0.4.0", 41 42 "npm:@atproto/xrpc@*": "0.7.0", 43 + "npm:@did-plc/lib@^0.0.4": "0.0.4", 42 44 "npm:@lucide/lab@*": "0.1.2", 43 45 "npm:@opentelemetry/api@^1.9.0": "1.9.0", 44 46 "npm:@preact/signals@^1.2.3": "1.3.2_preact@10.26.6", ··· 293 295 "zod" 294 296 ] 295 297 }, 298 + "@atproto/common@0.1.1": { 299 + "integrity": "sha512-GYwot5wF/z8iYGSPjrLHuratLc0CVgovmwfJss7+BUOB6y2/Vw8+1Vw0n9DDI0gb5vmx3UI8z0uJgC8aa8yuJg==", 300 + "dependencies": [ 301 + "@ipld/dag-cbor", 302 + "multiformats@9.9.0", 303 + "pino", 304 + "zod" 305 + ] 306 + }, 307 + "@atproto/crypto@0.1.0": { 308 + "integrity": "sha512-9xgFEPtsCiJEPt9o3HtJT30IdFTGw5cQRSJVIy5CFhqBA4vDLcdXiRDLCjkzHEVbtNCsHUW6CrlfOgbeLPcmcg==", 309 + "dependencies": [ 310 + "@noble/secp256k1", 311 + "big-integer", 312 + "multiformats@9.9.0", 313 + "one-webcrypto", 314 + "uint8arrays@3.0.0" 315 + ] 316 + }, 296 317 "@atproto/crypto@0.4.4": { 297 318 "integrity": "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA==", 298 319 "dependencies": [ ··· 311 332 "integrity": "sha512-Z0sLnJ87SeNdAifT+rqpgE1Rc3layMMW25gfWNo4u40RGuRODbdfAZlTwBSU2r+Vk45hU+iE+xeQspfednCEnA==", 312 333 "dependencies": [ 313 334 "@atproto/common-web", 314 - "@atproto/crypto" 335 + "@atproto/crypto@0.4.4" 315 336 ] 316 337 }, 317 338 "@atproto/jwk@0.1.4": { ··· 369 390 "integrity": "sha512-SfhP9dGx2qclaScFDb58Jnrmim5nk4geZXCqg6sB0I/KZhZEkr9iIx1hLCp+sxkIfEsmEJjeWO4B0rjUIJW5cw==", 370 391 "dependencies": [ 371 392 "@atproto/lexicon", 393 + "zod" 394 + ] 395 + }, 396 + "@did-plc/lib@0.0.4": { 397 + "integrity": "sha512-Omeawq3b8G/c/5CtkTtzovSOnWuvIuCI4GTJNrt1AmCskwEQV7zbX5d6km1mjJNbE0gHuQPTVqZxLVqetNbfwA==", 398 + "dependencies": [ 399 + "@atproto/common", 400 + "@atproto/crypto@0.1.0", 401 + "@ipld/dag-cbor", 402 + "axios", 403 + "multiformats@9.9.0", 404 + "uint8arrays@3.0.0", 372 405 "zod" 373 406 ] 374 407 }, ··· 492 525 "os": ["win32"], 493 526 "cpu": ["x64"] 494 527 }, 528 + "@ipld/dag-cbor@7.0.3": { 529 + "integrity": "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA==", 530 + "dependencies": [ 531 + "cborg", 532 + "multiformats@9.9.0" 533 + ] 534 + }, 495 535 "@isaacs/cliui@8.0.2": { 496 536 "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", 497 537 "dependencies": [ ··· 538 578 }, 539 579 "@noble/hashes@1.8.0": { 540 580 "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" 581 + }, 582 + "@noble/secp256k1@1.7.2": { 583 + "integrity": "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==" 541 584 }, 542 585 "@nodelib/fs.scandir@2.1.5": { 543 586 "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", ··· 588 631 "undici-types" 589 632 ] 590 633 }, 634 + "abort-controller@3.0.0": { 635 + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", 636 + "dependencies": [ 637 + "event-target-shim" 638 + ] 639 + }, 591 640 "ansi-regex@5.0.1": { 592 641 "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" 593 642 }, ··· 616 665 "arg@5.0.2": { 617 666 "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" 618 667 }, 668 + "asynckit@0.4.0": { 669 + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" 670 + }, 671 + "atomic-sleep@1.0.0": { 672 + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==" 673 + }, 619 674 "autoprefixer@10.4.17_postcss@8.4.35": { 620 675 "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", 621 676 "dependencies": [ ··· 632 687 "await-lock@2.2.2": { 633 688 "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" 634 689 }, 690 + "axios@1.10.0": { 691 + "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", 692 + "dependencies": [ 693 + "follow-redirects", 694 + "form-data", 695 + "proxy-from-env" 696 + ] 697 + }, 635 698 "balanced-match@1.0.2": { 636 699 "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" 700 + }, 701 + "base64-js@1.5.1": { 702 + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" 703 + }, 704 + "big-integer@1.6.52": { 705 + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==" 637 706 }, 638 707 "binary-extensions@2.3.0": { 639 708 "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==" ··· 663 732 ], 664 733 "bin": true 665 734 }, 735 + "buffer@6.0.3": { 736 + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", 737 + "dependencies": [ 738 + "base64-js", 739 + "ieee754" 740 + ] 741 + }, 742 + "call-bind-apply-helpers@1.0.2": { 743 + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 744 + "dependencies": [ 745 + "es-errors", 746 + "function-bind" 747 + ] 748 + }, 666 749 "camelcase-css@2.0.1": { 667 750 "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==" 668 751 }, ··· 677 760 }, 678 761 "caniuse-lite@1.0.30001717": { 679 762 "integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==" 763 + }, 764 + "cborg@1.10.2": { 765 + "integrity": "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug==", 766 + "bin": true 680 767 }, 681 768 "chokidar@3.6.0": { 682 769 "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", ··· 705 792 "colord@2.9.3": { 706 793 "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==" 707 794 }, 795 + "combined-stream@1.0.8": { 796 + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 797 + "dependencies": [ 798 + "delayed-stream" 799 + ] 800 + }, 708 801 "commander@4.1.1": { 709 802 "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==" 710 803 }, ··· 819 912 "css-tree@2.2.1" 820 913 ] 821 914 }, 915 + "delayed-stream@1.0.0": { 916 + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" 917 + }, 822 918 "didyoumean@1.2.2": { 823 919 "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" 824 920 }, ··· 850 946 "domhandler" 851 947 ] 852 948 }, 949 + "dunder-proto@1.0.1": { 950 + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", 951 + "dependencies": [ 952 + "call-bind-apply-helpers", 953 + "es-errors", 954 + "gopd" 955 + ] 956 + }, 853 957 "eastasianwidth@0.2.0": { 854 958 "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" 855 959 }, ··· 865 969 "entities@4.5.0": { 866 970 "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" 867 971 }, 972 + "es-define-property@1.0.1": { 973 + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" 974 + }, 975 + "es-errors@1.3.0": { 976 + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" 977 + }, 978 + "es-object-atoms@1.1.1": { 979 + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", 980 + "dependencies": [ 981 + "es-errors" 982 + ] 983 + }, 984 + "es-set-tostringtag@2.1.0": { 985 + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", 986 + "dependencies": [ 987 + "es-errors", 988 + "get-intrinsic", 989 + "has-tostringtag", 990 + "hasown" 991 + ] 992 + }, 868 993 "esbuild-wasm@0.23.1": { 869 994 "integrity": "sha512-L3vn7ctvBrtScRfoB0zG1eOCiV4xYvpLYWfe6PDZuV+iDFDm4Mt3xeLIDllG8cDHQ8clUouK3XekulE+cxgkgw==", 870 995 "bin": true ··· 903 1028 "escalade@3.2.0": { 904 1029 "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" 905 1030 }, 1031 + "event-target-shim@5.0.1": { 1032 + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" 1033 + }, 1034 + "events@3.3.0": { 1035 + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" 1036 + }, 906 1037 "fast-glob@3.3.3": { 907 1038 "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", 908 1039 "dependencies": [ ··· 913 1044 "micromatch" 914 1045 ] 915 1046 }, 1047 + "fast-redact@3.5.0": { 1048 + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==" 1049 + }, 916 1050 "fastq@1.19.1": { 917 1051 "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", 918 1052 "dependencies": [ ··· 928 1062 "to-regex-range" 929 1063 ] 930 1064 }, 1065 + "follow-redirects@1.15.9": { 1066 + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==" 1067 + }, 931 1068 "foreground-child@3.3.1": { 932 1069 "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", 933 1070 "dependencies": [ ··· 935 1072 "signal-exit" 936 1073 ] 937 1074 }, 1075 + "form-data@4.0.3": { 1076 + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", 1077 + "dependencies": [ 1078 + "asynckit", 1079 + "combined-stream", 1080 + "es-set-tostringtag", 1081 + "hasown", 1082 + "mime-types" 1083 + ] 1084 + }, 938 1085 "fraction.js@4.3.7": { 939 1086 "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==" 940 1087 }, ··· 946 1093 "function-bind@1.1.2": { 947 1094 "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" 948 1095 }, 1096 + "get-intrinsic@1.3.0": { 1097 + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", 1098 + "dependencies": [ 1099 + "call-bind-apply-helpers", 1100 + "es-define-property", 1101 + "es-errors", 1102 + "es-object-atoms", 1103 + "function-bind", 1104 + "get-proto", 1105 + "gopd", 1106 + "has-symbols", 1107 + "hasown", 1108 + "math-intrinsics" 1109 + ] 1110 + }, 1111 + "get-proto@1.0.1": { 1112 + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", 1113 + "dependencies": [ 1114 + "dunder-proto", 1115 + "es-object-atoms" 1116 + ] 1117 + }, 949 1118 "glob-parent@5.1.2": { 950 1119 "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", 951 1120 "dependencies": [ ··· 970 1139 ], 971 1140 "bin": true 972 1141 }, 1142 + "gopd@1.2.0": { 1143 + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" 1144 + }, 973 1145 "graphemer@1.4.0": { 974 1146 "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" 975 1147 }, 1148 + "has-symbols@1.1.0": { 1149 + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" 1150 + }, 1151 + "has-tostringtag@1.0.2": { 1152 + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", 1153 + "dependencies": [ 1154 + "has-symbols" 1155 + ] 1156 + }, 976 1157 "hasown@2.0.2": { 977 1158 "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", 978 1159 "dependencies": [ 979 1160 "function-bind" 980 1161 ] 1162 + }, 1163 + "ieee754@1.2.1": { 1164 + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" 981 1165 }, 982 1166 "ipaddr.js@2.2.0": { 983 1167 "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==" ··· 1063 1247 "preact@10.26.6" 1064 1248 ] 1065 1249 }, 1250 + "math-intrinsics@1.1.0": { 1251 + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" 1252 + }, 1066 1253 "mdn-data@2.0.28": { 1067 1254 "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==" 1068 1255 }, ··· 1079 1266 "picomatch" 1080 1267 ] 1081 1268 }, 1269 + "mime-db@1.52.0": { 1270 + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" 1271 + }, 1272 + "mime-types@2.1.35": { 1273 + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 1274 + "dependencies": [ 1275 + "mime-db" 1276 + ] 1277 + }, 1082 1278 "minimatch@9.0.5": { 1083 1279 "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 1084 1280 "dependencies": [ ··· 1127 1323 "object-hash@3.0.0": { 1128 1324 "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==" 1129 1325 }, 1326 + "on-exit-leak-free@2.1.2": { 1327 + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==" 1328 + }, 1329 + "one-webcrypto@1.0.3": { 1330 + "integrity": "sha512-fu9ywBVBPx0gS9K0etIROTiCkvI5S1TDjFsYFb3rC1ewFxeOqsbzq7aIMBHsYfrTHBcGXJaONXXjTl8B01cW1Q==" 1331 + }, 1130 1332 "package-json-from-dist@1.0.1": { 1131 1333 "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" 1132 1334 }, ··· 1152 1354 "pify@2.3.0": { 1153 1355 "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==" 1154 1356 }, 1357 + "pino-abstract-transport@1.2.0": { 1358 + "integrity": "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==", 1359 + "dependencies": [ 1360 + "readable-stream", 1361 + "split2" 1362 + ] 1363 + }, 1364 + "pino-std-serializers@6.2.2": { 1365 + "integrity": "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==" 1366 + }, 1367 + "pino@8.21.0": { 1368 + "integrity": "sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q==", 1369 + "dependencies": [ 1370 + "atomic-sleep", 1371 + "fast-redact", 1372 + "on-exit-leak-free", 1373 + "pino-abstract-transport", 1374 + "pino-std-serializers", 1375 + "process-warning", 1376 + "quick-format-unescaped", 1377 + "real-require", 1378 + "safe-stable-stringify", 1379 + "sonic-boom", 1380 + "thread-stream" 1381 + ], 1382 + "bin": true 1383 + }, 1155 1384 "pirates@4.0.7": { 1156 1385 "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==" 1157 1386 }, ··· 1451 1680 "preact@10.26.7": { 1452 1681 "integrity": "sha512-43xS+QYc1X1IPbw03faSgY6I6OYWcLrJRv3hU0+qMOfh/XCHcP0MX2CVjNARYR2cC/guu975sta4OcjlczxD7g==" 1453 1682 }, 1683 + "process-warning@3.0.0": { 1684 + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==" 1685 + }, 1686 + "process@0.11.10": { 1687 + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==" 1688 + }, 1689 + "proxy-from-env@1.1.0": { 1690 + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" 1691 + }, 1454 1692 "psl@1.15.0": { 1455 1693 "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", 1456 1694 "dependencies": [ ··· 1463 1701 "queue-microtask@1.2.3": { 1464 1702 "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" 1465 1703 }, 1704 + "quick-format-unescaped@4.0.4": { 1705 + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" 1706 + }, 1466 1707 "read-cache@1.0.0": { 1467 1708 "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", 1468 1709 "dependencies": [ 1469 1710 "pify" 1470 1711 ] 1471 1712 }, 1713 + "readable-stream@4.7.0": { 1714 + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", 1715 + "dependencies": [ 1716 + "abort-controller", 1717 + "buffer", 1718 + "events", 1719 + "process", 1720 + "string_decoder" 1721 + ] 1722 + }, 1472 1723 "readdirp@3.6.0": { 1473 1724 "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", 1474 1725 "dependencies": [ 1475 1726 "picomatch" 1476 1727 ] 1728 + }, 1729 + "real-require@0.2.0": { 1730 + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==" 1477 1731 }, 1478 1732 "resolve@1.22.10": { 1479 1733 "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", ··· 1493 1747 "queue-microtask" 1494 1748 ] 1495 1749 }, 1750 + "safe-buffer@5.2.1": { 1751 + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" 1752 + }, 1753 + "safe-stable-stringify@2.5.0": { 1754 + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==" 1755 + }, 1496 1756 "shebang-command@2.0.0": { 1497 1757 "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 1498 1758 "dependencies": [ ··· 1504 1764 }, 1505 1765 "signal-exit@4.1.0": { 1506 1766 "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" 1767 + }, 1768 + "sonic-boom@3.8.1": { 1769 + "integrity": "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg==", 1770 + "dependencies": [ 1771 + "atomic-sleep" 1772 + ] 1507 1773 }, 1508 1774 "source-map-js@1.2.1": { 1509 1775 "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" 1510 1776 }, 1777 + "split2@4.2.0": { 1778 + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==" 1779 + }, 1511 1780 "string-width@4.2.3": { 1512 1781 "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 1513 1782 "dependencies": [ ··· 1522 1791 "eastasianwidth", 1523 1792 "emoji-regex@9.2.2", 1524 1793 "strip-ansi@7.1.0" 1794 + ] 1795 + }, 1796 + "string_decoder@1.3.0": { 1797 + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", 1798 + "dependencies": [ 1799 + "safe-buffer" 1525 1800 ] 1526 1801 }, 1527 1802 "strip-ansi@6.0.1": { ··· 1613 1888 "any-promise" 1614 1889 ] 1615 1890 }, 1891 + "thread-stream@2.7.0": { 1892 + "integrity": "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==", 1893 + "dependencies": [ 1894 + "real-require" 1895 + ] 1896 + }, 1616 1897 "tlds@1.258.0": { 1617 1898 "integrity": "sha512-XGhStWuOlBA5D8QnyN2xtgB2cUOdJ3ztisne1DYVWMcVH29qh8eQIpRmP3HnuJLdgyzG0HpdGzRMu1lm/Oictw==", 1618 1899 "bin": true ··· 1709 1990 "jsr:@fresh/plugin-tailwind@^0.0.1-alpha.7", 1710 1991 "jsr:@knotbin/posthog-fresh@~0.1.3", 1711 1992 "npm:@atproto/api@~0.15.6", 1993 + "npm:@atproto/crypto@~0.4.4", 1994 + "npm:@did-plc/lib@^0.0.4", 1712 1995 "npm:@preact/signals@^2.0.4", 1713 1996 "npm:posthog-js@1.120.0", 1714 1997 "npm:preact@^10.26.6",
+5
islands/CredLogin.tsx
··· 1 1 import { useState } from "preact/hooks"; 2 2 import { JSX } from "preact"; 3 3 4 + /** 5 + * The credential login form. 6 + * @returns The credential login form 7 + * @component 8 + */ 4 9 export default function CredLogin() { 5 10 const [handle, setHandle] = useState(""); 6 11 const [password, setPassword] = useState("");
+1134
islands/DidPlcProgress.tsx
··· 1 + import { useState } from "preact/hooks"; 2 + import { Link } from "../components/Link.tsx"; 3 + 4 + interface PlcUpdateStep { 5 + name: string; 6 + status: "pending" | "in-progress" | "verifying" | "completed" | "error"; 7 + error?: string; 8 + } 9 + 10 + interface KeyJson { 11 + publicKeyDid: string; 12 + [key: string]: unknown; 13 + } 14 + 15 + // Content chunks for the description 16 + const contentChunks = [ 17 + { 18 + title: "Welcome to Key Management", 19 + subtitle: "BOARDING PASS - SECTION A", 20 + content: ( 21 + <> 22 + <div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4"> 23 + GATE: KEY-01 โ€ข SEAT: DID-1A 24 + </div> 25 + <p class="text-slate-700 dark:text-slate-300 mb-4"> 26 + This tool helps you add a new rotation key to your{" "} 27 + <Link 28 + href="https://web.plc.directory/" 29 + isExternal 30 + class="text-blue-600 dark:text-blue-400" 31 + > 32 + PLC (Public Ledger of Credentials) 33 + </Link> 34 + . Having control of a rotation key gives you sovereignty over your DID 35 + (Decentralized Identifier). 36 + </p> 37 + </> 38 + ), 39 + }, 40 + { 41 + title: "Key Benefits", 42 + subtitle: "BOARDING PASS - SECTION B", 43 + content: ( 44 + <> 45 + <div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4"> 46 + GATE: KEY-02 โ€ข SEAT: DID-1B 47 + </div> 48 + <div class="space-y-4"> 49 + <div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700"> 50 + <h4 class="font-mono font-bold text-amber-500 dark:text-amber-400 mb-2"> 51 + PROVIDER MOBILITY โœˆ๏ธ 52 + </h4> 53 + <p class="text-slate-700 dark:text-slate-300"> 54 + Change your PDS without losing your identity, protecting you if 55 + your provider becomes hostile. 56 + </p> 57 + </div> 58 + <div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700"> 59 + <h4 class="font-mono font-bold text-amber-500 dark:text-amber-400 mb-2"> 60 + IDENTITY CONTROL โœจ 61 + </h4> 62 + <p class="text-slate-700 dark:text-slate-300"> 63 + Modify your DID document independently of your provider. 64 + </p> 65 + </div> 66 + <div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700"> 67 + <p class="text-slate-700 dark:text-slate-300"> 68 + ๐Ÿ’ก It's good practice to have a rotation key so you can move to a 69 + different provider if you need to. 70 + </p> 71 + </div> 72 + </div> 73 + </> 74 + ), 75 + }, 76 + { 77 + title: "โš ๏ธ CRITICAL SECURITY WARNING", 78 + subtitle: "BOARDING PASS - SECTION C", 79 + content: ( 80 + <> 81 + <div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4"> 82 + GATE: KEY-03 โ€ข SEAT: DID-1C 83 + </div> 84 + <div class="p-4 bg-red-50 dark:bg-red-900 rounded-lg border-2 border-red-500 dark:border-red-700 mb-4"> 85 + <div class="flex items-center mb-3"> 86 + <span class="text-2xl mr-2">โš ๏ธ</span> 87 + <h4 class="font-mono font-bold text-red-700 dark:text-red-400 text-lg"> 88 + NON-REVOCABLE KEY WARNING 89 + </h4> 90 + </div> 91 + <div class="space-y-3 text-red-700 dark:text-red-300"> 92 + <p class="font-bold"> 93 + This rotation key CANNOT BE DISABLED OR DELETED once added: 94 + </p> 95 + <ul class="list-disc pl-5 space-y-2"> 96 + <li> 97 + If compromised, the attacker can take complete control of your 98 + account and identity 99 + </li> 100 + <li> 101 + Malicious actors with this key have COMPLETE CONTROL of your 102 + account and identity 103 + </li> 104 + <li> 105 + Store securely, like a password (e.g. <strong>DO NOT</strong> 106 + {" "} 107 + keep it in Notes or any easily accessible app on an unlocked 108 + device). 109 + </li> 110 + </ul> 111 + </div> 112 + </div> 113 + <div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700"> 114 + <p class="text-slate-700 dark:text-slate-300"> 115 + ๐Ÿ’ก We recommend adding a custom rotation key but recommend{" "} 116 + <strong class="italic">against</strong>{" "} 117 + having more than one custom rotation key, as more than one increases 118 + risk. 119 + </p> 120 + </div> 121 + </> 122 + ), 123 + }, 124 + { 125 + title: "Technical Overview", 126 + subtitle: "BOARDING PASS - SECTION C", 127 + content: ( 128 + <> 129 + <div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4"> 130 + GATE: KEY-03 โ€ข SEAT: DID-1C 131 + </div> 132 + <div class="p-4 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700"> 133 + <div class="flex items-center mb-3"> 134 + <span class="text-lg mr-2">๐Ÿ“</span> 135 + <h4 class="font-mono font-bold text-amber-500 dark:text-amber-400"> 136 + TECHNICAL DETAILS 137 + </h4> 138 + </div> 139 + <p class="text-slate-700 dark:text-slate-300"> 140 + The rotation key is a did:key that will be added to your PLC 141 + document's rotationKeys array. This process uses the AT Protocol's 142 + PLC operations to update your DID document. 143 + <Link 144 + href="https://web.plc.directory/" 145 + class="block ml-1 text-blue-600 dark:text-blue-400" 146 + isExternal 147 + > 148 + Learn more about did:plc 149 + </Link> 150 + </p> 151 + </div> 152 + </> 153 + ), 154 + }, 155 + ]; 156 + 157 + export default function PlcUpdateProgress() { 158 + const [hasStarted, setHasStarted] = useState(false); 159 + const [currentChunkIndex, setCurrentChunkIndex] = useState(0); 160 + const [steps, setSteps] = useState<PlcUpdateStep[]>([ 161 + { name: "Generate Rotation Key", status: "pending" }, 162 + { name: "Start PLC update", status: "pending" }, 163 + { name: "Complete PLC update", status: "pending" }, 164 + ]); 165 + const [generatedKey, setGeneratedKey] = useState<string>(""); 166 + const [keyJson, setKeyJson] = useState<KeyJson | null>(null); 167 + const [emailToken, setEmailToken] = useState<string>(""); 168 + const [hasDownloadedKey, setHasDownloadedKey] = useState(false); 169 + const [downloadedKeyId, setDownloadedKeyId] = useState<string | null>(null); 170 + 171 + const updateStepStatus = ( 172 + index: number, 173 + status: PlcUpdateStep["status"], 174 + error?: string, 175 + ) => { 176 + console.log( 177 + `Updating step ${index} to ${status}${ 178 + error ? ` with error: ${error}` : "" 179 + }`, 180 + ); 181 + setSteps((prevSteps) => 182 + prevSteps.map((step, i) => 183 + i === index 184 + ? { ...step, status, error } 185 + : i > index 186 + ? { ...step, status: "pending", error: undefined } 187 + : step 188 + ) 189 + ); 190 + }; 191 + 192 + const handleStart = () => { 193 + setHasStarted(true); 194 + // Automatically start the first step 195 + setTimeout(() => { 196 + handleGenerateKey(); 197 + }, 100); 198 + }; 199 + 200 + const getStepDisplayName = (step: PlcUpdateStep, index: number) => { 201 + if (step.status === "completed") { 202 + switch (index) { 203 + case 0: 204 + return "Rotation Key Generated"; 205 + case 1: 206 + return "PLC Operation Requested"; 207 + case 2: 208 + return "PLC Update Completed"; 209 + } 210 + } 211 + 212 + if (step.status === "in-progress") { 213 + switch (index) { 214 + case 0: 215 + return "Generating Rotation Key..."; 216 + case 1: 217 + return "Requesting PLC Operation Token..."; 218 + case 2: 219 + return step.name === 220 + "Enter the code sent to your email to complete PLC update" 221 + ? step.name 222 + : "Completing PLC Update..."; 223 + } 224 + } 225 + 226 + if (step.status === "verifying") { 227 + switch (index) { 228 + case 0: 229 + return "Verifying Rotation Key Generation..."; 230 + case 1: 231 + return "Verifying PLC Operation Token Request..."; 232 + case 2: 233 + return "Verifying PLC Update Completion..."; 234 + } 235 + } 236 + 237 + return step.name; 238 + }; 239 + 240 + const handleStartPlcUpdate = async (keyToUse?: string) => { 241 + const key = keyToUse || generatedKey; 242 + 243 + // Debug logging 244 + console.log("=== PLC Update Debug ==="); 245 + console.log("Current state:", { 246 + keyToUse, 247 + generatedKey, 248 + key, 249 + hasKeyJson: !!keyJson, 250 + keyJsonId: keyJson?.publicKeyDid, 251 + hasDownloadedKey, 252 + downloadedKeyId, 253 + steps: steps.map((s) => ({ name: s.name, status: s.status })), 254 + }); 255 + 256 + if (!key) { 257 + console.log("No key generated yet"); 258 + updateStepStatus(1, "error", "No key generated yet"); 259 + return; 260 + } 261 + 262 + if (!keyJson || keyJson.publicKeyDid !== key) { 263 + console.log("Key mismatch or missing:", { 264 + hasKeyJson: !!keyJson, 265 + keyJsonId: keyJson?.publicKeyDid, 266 + expectedKey: key, 267 + }); 268 + updateStepStatus( 269 + 1, 270 + "error", 271 + "Please ensure you have the correct key loaded", 272 + ); 273 + return; 274 + } 275 + 276 + updateStepStatus(1, "in-progress"); 277 + try { 278 + // First request the token 279 + console.log("Requesting PLC token..."); 280 + const tokenRes = await fetch("/api/plc/token", { 281 + method: "GET", 282 + }); 283 + const tokenText = await tokenRes.text(); 284 + console.log("Token response:", tokenText); 285 + 286 + if (!tokenRes.ok) { 287 + try { 288 + const json = JSON.parse(tokenText); 289 + throw new Error(json.message || "Failed to request PLC token"); 290 + } catch { 291 + throw new Error(tokenText || "Failed to request PLC token"); 292 + } 293 + } 294 + 295 + let data; 296 + try { 297 + data = JSON.parse(tokenText); 298 + if (!data.success) { 299 + throw new Error(data.message || "Failed to request token"); 300 + } 301 + } catch { 302 + throw new Error("Invalid response from server"); 303 + } 304 + 305 + console.log("Token request successful, updating UI..."); 306 + // Update step name to prompt for token 307 + setSteps((prevSteps) => 308 + prevSteps.map((step, i) => 309 + i === 1 310 + ? { 311 + ...step, 312 + name: "Enter the code sent to your email to complete PLC update", 313 + status: "in-progress", 314 + } 315 + : step 316 + ) 317 + ); 318 + } catch (error) { 319 + console.error("Token request failed:", error); 320 + updateStepStatus( 321 + 1, 322 + "error", 323 + error instanceof Error ? error.message : String(error), 324 + ); 325 + } 326 + }; 327 + 328 + const handleTokenSubmit = async () => { 329 + console.log("=== Token Submit Debug ==="); 330 + console.log("Current state:", { 331 + emailToken, 332 + generatedKey, 333 + keyJsonId: keyJson?.publicKeyDid, 334 + steps: steps.map((s) => ({ name: s.name, status: s.status })), 335 + }); 336 + 337 + if (!emailToken) { 338 + console.log("No token provided"); 339 + updateStepStatus(1, "error", "Please enter the email token"); 340 + return; 341 + } 342 + 343 + if (!keyJson || !keyJson.publicKeyDid) { 344 + console.log("Missing key data"); 345 + updateStepStatus(1, "error", "Key data is missing, please try again"); 346 + return; 347 + } 348 + 349 + // Prevent duplicate submissions 350 + if (steps[1].status === "completed" || steps[2].status === "completed") { 351 + console.log("Update already completed, preventing duplicate submission"); 352 + return; 353 + } 354 + 355 + updateStepStatus(1, "completed"); 356 + try { 357 + updateStepStatus(2, "in-progress"); 358 + console.log("Submitting update request with token..."); 359 + // Send the update request with both key and token 360 + const res = await fetch("/api/plc/update", { 361 + method: "POST", 362 + headers: { "Content-Type": "application/json" }, 363 + body: JSON.stringify({ 364 + key: keyJson.publicKeyDid, 365 + token: emailToken, 366 + }), 367 + }); 368 + const text = await res.text(); 369 + console.log("Update response:", text); 370 + 371 + let data; 372 + try { 373 + data = JSON.parse(text); 374 + } catch { 375 + throw new Error("Invalid response from server"); 376 + } 377 + 378 + // Check for error responses 379 + if (!res.ok || !data.success) { 380 + const errorMessage = data.message || "Failed to complete PLC update"; 381 + console.error("Update failed:", errorMessage); 382 + throw new Error(errorMessage); 383 + } 384 + 385 + // Only proceed if we have a successful response 386 + console.log("Update completed successfully!"); 387 + 388 + // Add a delay before marking steps as completed for better UX 389 + updateStepStatus(2, "verifying"); 390 + 391 + const verifyRes = await fetch("/api/plc/verify", { 392 + method: "POST", 393 + headers: { "Content-Type": "application/json" }, 394 + body: JSON.stringify({ 395 + key: keyJson.publicKeyDid, 396 + }), 397 + }); 398 + 399 + const verifyText = await verifyRes.text(); 400 + console.log("Verification response:", verifyText); 401 + 402 + let verifyData; 403 + try { 404 + verifyData = JSON.parse(verifyText); 405 + } catch { 406 + throw new Error("Invalid verification response from server"); 407 + } 408 + 409 + if (!verifyRes.ok || !verifyData.success) { 410 + const errorMessage = verifyData.message || 411 + "Failed to verify PLC update"; 412 + console.error("Verification failed:", errorMessage); 413 + throw new Error(errorMessage); 414 + } 415 + 416 + console.log("Verification successful, marking steps as completed"); 417 + updateStepStatus(2, "completed"); 418 + } catch (error) { 419 + console.error("Update failed:", error); 420 + // Reset the steps to error state 421 + updateStepStatus( 422 + 1, 423 + "error", 424 + error instanceof Error ? error.message : String(error), 425 + ); 426 + updateStepStatus(2, "pending"); // Reset the final step 427 + 428 + // If token is invalid, we should clear it so user can try again 429 + if ( 430 + error instanceof Error && 431 + error.message.toLowerCase().includes("token is invalid") 432 + ) { 433 + setEmailToken(""); 434 + } 435 + } 436 + }; 437 + 438 + const handleDownload = () => { 439 + console.log("=== Download Debug ==="); 440 + console.log("Download started with:", { 441 + hasKeyJson: !!keyJson, 442 + keyJsonId: keyJson?.publicKeyDid, 443 + }); 444 + 445 + if (!keyJson) { 446 + console.error("No key JSON to download"); 447 + return; 448 + } 449 + 450 + try { 451 + const jsonString = JSON.stringify(keyJson, null, 2); 452 + const blob = new Blob([jsonString], { 453 + type: "application/json", 454 + }); 455 + const url = URL.createObjectURL(blob); 456 + const a = document.createElement("a"); 457 + a.href = url; 458 + a.download = `plc-key-${keyJson.publicKeyDid || "unknown"}.json`; 459 + a.style.display = "none"; 460 + document.body.appendChild(a); 461 + a.click(); 462 + document.body.removeChild(a); 463 + URL.revokeObjectURL(url); 464 + 465 + console.log("Download completed, proceeding to next step..."); 466 + setHasDownloadedKey(true); 467 + setDownloadedKeyId(keyJson.publicKeyDid); 468 + 469 + // Automatically proceed to the next step after successful download 470 + setTimeout(() => { 471 + console.log("Auto-proceeding with key:", keyJson.publicKeyDid); 472 + handleStartPlcUpdate(keyJson.publicKeyDid); 473 + }, 1000); 474 + } catch (error) { 475 + console.error("Download failed:", error); 476 + } 477 + }; 478 + 479 + const handleGenerateKey = async () => { 480 + console.log("=== Generate Key Debug ==="); 481 + updateStepStatus(0, "in-progress"); 482 + setKeyJson(null); 483 + setGeneratedKey(""); 484 + setHasDownloadedKey(false); 485 + setDownloadedKeyId(null); 486 + 487 + try { 488 + console.log("Requesting new key..."); 489 + const res = await fetch("/api/plc/keys"); 490 + const text = await res.text(); 491 + console.log("Key generation response:", text); 492 + 493 + if (!res.ok) { 494 + try { 495 + const json = JSON.parse(text); 496 + throw new Error(json.message || "Failed to generate key"); 497 + } catch { 498 + throw new Error(text || "Failed to generate key"); 499 + } 500 + } 501 + 502 + let data; 503 + try { 504 + data = JSON.parse(text); 505 + } catch { 506 + throw new Error("Invalid response from /api/plc/keys"); 507 + } 508 + 509 + if (!data.publicKeyDid || !data.privateKeyHex) { 510 + throw new Error("Key generation failed: missing key data"); 511 + } 512 + 513 + console.log("Key generated successfully:", { 514 + keyId: data.publicKeyDid, 515 + }); 516 + 517 + setGeneratedKey(data.publicKeyDid); 518 + setKeyJson(data); 519 + updateStepStatus(0, "completed"); 520 + } catch (error) { 521 + console.error("Key generation failed:", error); 522 + updateStepStatus( 523 + 0, 524 + "error", 525 + error instanceof Error ? error.message : String(error), 526 + ); 527 + } 528 + }; 529 + 530 + const getStepIcon = (status: PlcUpdateStep["status"]) => { 531 + switch (status) { 532 + case "pending": 533 + return ( 534 + <div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center"> 535 + <div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" /> 536 + </div> 537 + ); 538 + case "in-progress": 539 + return ( 540 + <div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center"> 541 + <div class="w-3 h-3 rounded-full bg-blue-500" /> 542 + </div> 543 + ); 544 + case "verifying": 545 + return ( 546 + <div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center"> 547 + <div class="w-3 h-3 rounded-full bg-yellow-500" /> 548 + </div> 549 + ); 550 + case "completed": 551 + return ( 552 + <div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center"> 553 + <svg 554 + class="w-5 h-5 text-white" 555 + fill="none" 556 + stroke="currentColor" 557 + viewBox="0 0 24 24" 558 + > 559 + <path 560 + stroke-linecap="round" 561 + stroke-linejoin="round" 562 + stroke-width="2" 563 + d="M5 13l4 4L19 7" 564 + /> 565 + </svg> 566 + </div> 567 + ); 568 + case "error": 569 + return ( 570 + <div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center"> 571 + <svg 572 + class="w-5 h-5 text-white" 573 + fill="none" 574 + stroke="currentColor" 575 + viewBox="0 0 24 24" 576 + > 577 + <path 578 + stroke-linecap="round" 579 + stroke-linejoin="round" 580 + stroke-width="2" 581 + d="M6 18L18 6M6 6l12 12" 582 + /> 583 + </svg> 584 + </div> 585 + ); 586 + } 587 + }; 588 + 589 + const getStepClasses = (status: PlcUpdateStep["status"]) => { 590 + const baseClasses = 591 + "flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200"; 592 + switch (status) { 593 + case "pending": 594 + return `${baseClasses} bg-gray-50 dark:bg-gray-800`; 595 + case "in-progress": 596 + return `${baseClasses} bg-blue-50 dark:bg-blue-900`; 597 + case "verifying": 598 + return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`; 599 + case "completed": 600 + return `${baseClasses} bg-green-50 dark:bg-green-900`; 601 + case "error": 602 + return `${baseClasses} bg-red-50 dark:bg-red-900`; 603 + } 604 + }; 605 + 606 + const requestNewToken = async () => { 607 + try { 608 + console.log("Requesting new token..."); 609 + const res = await fetch("/api/plc/token", { 610 + method: "GET", 611 + }); 612 + const text = await res.text(); 613 + console.log("Token request response:", text); 614 + 615 + if (!res.ok) { 616 + throw new Error(text || "Failed to request new token"); 617 + } 618 + 619 + let data; 620 + try { 621 + data = JSON.parse(text); 622 + if (!data.success) { 623 + throw new Error(data.message || "Failed to request token"); 624 + } 625 + } catch { 626 + throw new Error("Invalid response from server"); 627 + } 628 + 629 + // Clear any existing error and token 630 + setEmailToken(""); 631 + updateStepStatus(1, "in-progress"); 632 + updateStepStatus(2, "pending"); 633 + } catch (error) { 634 + console.error("Failed to request new token:", error); 635 + updateStepStatus( 636 + 1, 637 + "error", 638 + error instanceof Error ? error.message : String(error), 639 + ); 640 + } 641 + }; 642 + 643 + if (!hasStarted) { 644 + return ( 645 + <div class="space-y-6"> 646 + <div class="ticket bg-white dark:bg-slate-800 p-6 relative"> 647 + <div class="boarding-label text-amber-500 dark:text-amber-400 font-mono font-bold tracking-wider text-sm mb-2"> 648 + {contentChunks[currentChunkIndex].subtitle} 649 + </div> 650 + 651 + <div class="flex justify-between items-start mb-4"> 652 + <h3 class="text-2xl font-mono text-slate-800 dark:text-slate-200"> 653 + {contentChunks[currentChunkIndex].title} 654 + </h3> 655 + </div> 656 + 657 + {/* Main Description */} 658 + <div class="mb-6">{contentChunks[currentChunkIndex].content}</div> 659 + 660 + {/* Navigation */} 661 + <div class="mt-8 border-t border-dashed border-slate-200 dark:border-slate-700 pt-4"> 662 + <div class="flex justify-between items-center"> 663 + <button 664 + type="button" 665 + onClick={() => 666 + setCurrentChunkIndex((prev) => Math.max(0, prev - 1))} 667 + class={`px-4 py-2 font-mono text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors duration-200 flex items-center space-x-2 ${ 668 + currentChunkIndex === 0 ? "invisible" : "" 669 + }`} 670 + > 671 + <svg 672 + class="w-5 h-5 rotate-180" 673 + fill="none" 674 + stroke="currentColor" 675 + viewBox="0 0 24 24" 676 + > 677 + <path 678 + stroke-linecap="round" 679 + stroke-linejoin="round" 680 + stroke-width="2" 681 + d="M9 5l7 7-7 7" 682 + /> 683 + </svg> 684 + <span>Previous Gate</span> 685 + </button> 686 + 687 + {currentChunkIndex === contentChunks.length - 1 688 + ? ( 689 + <button 690 + type="button" 691 + onClick={handleStart} 692 + class="px-6 py-2 bg-amber-500 hover:bg-amber-600 text-white font-mono rounded-md transition-colors duration-200 flex items-center space-x-2" 693 + > 694 + <span>Begin Key Generation</span> 695 + <svg 696 + class="w-5 h-5" 697 + fill="none" 698 + stroke="currentColor" 699 + viewBox="0 0 24 24" 700 + > 701 + <path 702 + stroke-linecap="round" 703 + stroke-linejoin="round" 704 + stroke-width="2" 705 + d="M9 5l7 7-7 7" 706 + /> 707 + </svg> 708 + </button> 709 + ) 710 + : ( 711 + <button 712 + type="button" 713 + onClick={() => 714 + setCurrentChunkIndex((prev) => 715 + Math.min(contentChunks.length - 1, prev + 1) 716 + )} 717 + class="px-4 py-2 font-mono text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors duration-200 flex items-center space-x-2" 718 + > 719 + <span>Next Gate</span> 720 + <svg 721 + class="w-5 h-5" 722 + fill="none" 723 + stroke="currentColor" 724 + viewBox="0 0 24 24" 725 + > 726 + <path 727 + stroke-linecap="round" 728 + stroke-linejoin="round" 729 + stroke-width="2" 730 + d="M9 5l7 7-7 7" 731 + /> 732 + </svg> 733 + </button> 734 + )} 735 + </div> 736 + 737 + {/* Progress Dots */} 738 + <div class="flex justify-center space-x-3 mt-4"> 739 + {contentChunks.map((_, index) => ( 740 + <div 741 + key={index} 742 + class={`h-1.5 w-8 rounded-full transition-colors duration-200 ${ 743 + index === currentChunkIndex 744 + ? "bg-amber-500" 745 + : "bg-slate-200 dark:bg-slate-700" 746 + }`} 747 + /> 748 + ))} 749 + </div> 750 + </div> 751 + </div> 752 + </div> 753 + ); 754 + } 755 + 756 + return ( 757 + <div class="space-y-8"> 758 + {/* Progress Steps */} 759 + <div class="space-y-4"> 760 + <div class="flex items-center justify-between"> 761 + <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100"> 762 + Key Generation Progress 763 + </h3> 764 + {/* Add a help tooltip */} 765 + <div class="relative group"> 766 + <button class="text-gray-400 hover:text-gray-500" type="button"> 767 + <svg 768 + class="w-5 h-5" 769 + fill="none" 770 + stroke="currentColor" 771 + viewBox="0 0 24 24" 772 + > 773 + <path 774 + stroke-linecap="round" 775 + stroke-linejoin="round" 776 + stroke-width="2" 777 + d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" 778 + /> 779 + </svg> 780 + </button> 781 + <div class="absolute right-0 w-64 p-2 mt-2 space-y-1 text-sm bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 hidden group-hover:block z-10"> 782 + <p class="text-gray-600 dark:text-gray-400"> 783 + Follow these steps to securely add a new rotation key to your 784 + PLC record. Each step requires completion before proceeding. 785 + </p> 786 + </div> 787 + </div> 788 + </div> 789 + 790 + {/* Steps with enhanced visual hierarchy */} 791 + {steps.map((step, index) => ( 792 + <div 793 + key={step.name} 794 + class={`${getStepClasses(step.status)} ${ 795 + step.status === "in-progress" 796 + ? "ring-2 ring-blue-500 ring-opacity-50" 797 + : "" 798 + }`} 799 + > 800 + <div class="flex-shrink-0">{getStepIcon(step.status)}</div> 801 + <div class="flex-1 min-w-0"> 802 + <div class="flex items-center justify-between"> 803 + <p 804 + class={`font-medium ${ 805 + step.status === "error" 806 + ? "text-red-900 dark:text-red-200" 807 + : step.status === "completed" 808 + ? "text-green-900 dark:text-green-200" 809 + : step.status === "in-progress" 810 + ? "text-blue-900 dark:text-blue-200" 811 + : "text-gray-900 dark:text-gray-200" 812 + }`} 813 + > 814 + {getStepDisplayName(step, index)} 815 + </p> 816 + {/* Add step number */} 817 + <span class="text-sm text-gray-500 dark:text-gray-400"> 818 + Step {index + 1} of {steps.length} 819 + </span> 820 + </div> 821 + 822 + {step.error && ( 823 + <div class="mt-2 p-2 bg-red-50 dark:bg-red-900/50 rounded-md"> 824 + <p class="text-sm text-red-600 dark:text-red-400 flex items-center"> 825 + <svg 826 + class="w-4 h-4 mr-1" 827 + fill="none" 828 + stroke="currentColor" 829 + viewBox="0 0 24 24" 830 + > 831 + <path 832 + stroke-linecap="round" 833 + stroke-linejoin="round" 834 + stroke-width="2" 835 + d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" 836 + /> 837 + </svg> 838 + {(() => { 839 + try { 840 + const err = JSON.parse(step.error); 841 + return err.message || step.error; 842 + } catch { 843 + return step.error; 844 + } 845 + })()} 846 + </p> 847 + </div> 848 + )} 849 + 850 + {/* Key Download Warning */} 851 + {index === 0 && 852 + step.status === "completed" && 853 + !hasDownloadedKey && ( 854 + <div class="mt-4 space-y-4"> 855 + <div class="bg-yellow-50 dark:bg-yellow-900/50 p-4 rounded-lg border border-yellow-200 dark:border-yellow-800"> 856 + <div class="flex items-start"> 857 + <div class="flex-shrink-0"> 858 + <svg 859 + class="h-5 w-5 text-yellow-400" 860 + viewBox="0 0 20 20" 861 + fill="currentColor" 862 + > 863 + <path 864 + fill-rule="evenodd" 865 + d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" 866 + clip-rule="evenodd" 867 + /> 868 + </svg> 869 + </div> 870 + <div class="ml-3"> 871 + <h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200"> 872 + Critical Security Step 873 + </h3> 874 + <div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300"> 875 + <p class="mb-2"> 876 + Your rotation key grants control over your identity: 877 + </p> 878 + <ul class="list-disc pl-5 space-y-2"> 879 + <li> 880 + <strong>Store Securely:</strong>{" "} 881 + Use a password manager 882 + </li> 883 + <li> 884 + <strong>Keep Private:</strong>{" "} 885 + Never share with anyone 886 + </li> 887 + <li> 888 + <strong>Backup:</strong> Keep a secure backup copy 889 + </li> 890 + <li> 891 + <strong>Required:</strong>{" "} 892 + Needed for future DID modifications 893 + </li> 894 + </ul> 895 + </div> 896 + </div> 897 + </div> 898 + </div> 899 + 900 + <div class="flex items-center justify-between"> 901 + <button 902 + type="button" 903 + onClick={handleDownload} 904 + 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" 905 + > 906 + <svg 907 + class="w-5 h-5" 908 + fill="none" 909 + stroke="currentColor" 910 + viewBox="0 0 24 24" 911 + > 912 + <path 913 + stroke-linecap="round" 914 + stroke-linejoin="round" 915 + stroke-width="2" 916 + d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" 917 + /> 918 + </svg> 919 + <span>Download Key</span> 920 + </button> 921 + 922 + <div class="flex items-center text-sm text-red-600 dark:text-red-400"> 923 + <svg 924 + class="w-4 h-4 mr-1" 925 + fill="none" 926 + stroke="currentColor" 927 + viewBox="0 0 24 24" 928 + > 929 + <path 930 + stroke-linecap="round" 931 + stroke-linejoin="round" 932 + stroke-width="2" 933 + 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" 934 + /> 935 + </svg> 936 + Download required to proceed 937 + </div> 938 + </div> 939 + </div> 940 + )} 941 + 942 + {/* Email Code Input */} 943 + {index === 1 && 944 + (step.status === "in-progress" || 945 + step.status === "verifying") && 946 + step.name === 947 + "Enter the code sent to your email to complete PLC update" && 948 + ( 949 + <div class="mt-4 space-y-4"> 950 + <div class="bg-blue-50 dark:bg-blue-900/50 p-4 rounded-lg"> 951 + <p class="text-sm text-blue-800 dark:text-blue-200 mb-3"> 952 + Check your email for the verification code to complete 953 + the PLC update: 954 + </p> 955 + <div class="flex space-x-2"> 956 + <div class="flex-1 relative"> 957 + <input 958 + type="text" 959 + value={emailToken} 960 + onChange={(e) => 961 + setEmailToken(e.currentTarget.value)} 962 + placeholder="Enter verification code" 963 + class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400" 964 + /> 965 + </div> 966 + <button 967 + type="button" 968 + onClick={handleTokenSubmit} 969 + disabled={!emailToken || step.status === "verifying"} 970 + class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2" 971 + > 972 + <span> 973 + {step.status === "verifying" 974 + ? "Verifying..." 975 + : "Verify"} 976 + </span> 977 + <svg 978 + class="w-4 h-4" 979 + fill="none" 980 + stroke="currentColor" 981 + viewBox="0 0 24 24" 982 + > 983 + <path 984 + stroke-linecap="round" 985 + stroke-linejoin="round" 986 + stroke-width="2" 987 + d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" 988 + /> 989 + </svg> 990 + </button> 991 + </div> 992 + {step.error && ( 993 + <div class="mt-2 p-2 bg-red-50 dark:bg-red-900/50 rounded-md"> 994 + <p class="text-sm text-red-600 dark:text-red-400 flex items-center"> 995 + <svg 996 + class="w-4 h-4 mr-1" 997 + fill="none" 998 + stroke="currentColor" 999 + viewBox="0 0 24 24" 1000 + > 1001 + <path 1002 + stroke-linecap="round" 1003 + stroke-linejoin="round" 1004 + stroke-width="2" 1005 + d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" 1006 + /> 1007 + </svg> 1008 + {step.error} 1009 + </p> 1010 + {step.error 1011 + .toLowerCase() 1012 + .includes("token is invalid") && ( 1013 + <div class="mt-2"> 1014 + <p class="text-sm text-red-500 dark:text-red-300 mb-2"> 1015 + The verification code may have expired. Request 1016 + a new code to try again. 1017 + </p> 1018 + <button 1019 + type="button" 1020 + onClick={requestNewToken} 1021 + class="text-sm px-3 py-1 bg-red-100 hover:bg-red-200 dark:bg-red-800 dark:hover:bg-red-700 text-red-700 dark:text-red-200 rounded-md transition-colors duration-200 flex items-center space-x-1" 1022 + > 1023 + <svg 1024 + class="w-4 h-4" 1025 + fill="none" 1026 + stroke="currentColor" 1027 + viewBox="0 0 24 24" 1028 + > 1029 + <path 1030 + stroke-linecap="round" 1031 + stroke-linejoin="round" 1032 + stroke-width="2" 1033 + d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" 1034 + /> 1035 + </svg> 1036 + <span>Request New Code</span> 1037 + </button> 1038 + </div> 1039 + )} 1040 + </div> 1041 + )} 1042 + </div> 1043 + </div> 1044 + )} 1045 + </div> 1046 + </div> 1047 + ))} 1048 + </div> 1049 + 1050 + {/* Success Message */} 1051 + {steps[2].status === "completed" && ( 1052 + <div class="p-4 bg-green-50 dark:bg-green-900/50 rounded-lg border-2 border-green-200 dark:border-green-800"> 1053 + <div class="flex items-center space-x-3 mb-4"> 1054 + <svg 1055 + class="w-6 h-6 text-green-500" 1056 + fill="none" 1057 + stroke="currentColor" 1058 + viewBox="0 0 24 24" 1059 + > 1060 + <path 1061 + stroke-linecap="round" 1062 + stroke-linejoin="round" 1063 + stroke-width="2" 1064 + d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" 1065 + /> 1066 + </svg> 1067 + <h4 class="text-lg font-medium text-green-800 dark:text-green-200"> 1068 + PLC Update Successful! 1069 + </h4> 1070 + </div> 1071 + <p class="text-sm text-green-700 dark:text-green-300 mb-4"> 1072 + Your rotation key has been successfully added to your PLC record. 1073 + You can now use this key for future DID modifications. 1074 + </p> 1075 + <div class="flex space-x-4"> 1076 + <button 1077 + type="button" 1078 + onClick={async () => { 1079 + try { 1080 + const response = await fetch("/api/logout", { 1081 + method: "POST", 1082 + credentials: "include", 1083 + }); 1084 + if (!response.ok) { 1085 + throw new Error("Logout failed"); 1086 + } 1087 + globalThis.location.href = "/"; 1088 + } catch (error) { 1089 + console.error("Failed to logout:", error); 1090 + } 1091 + }} 1092 + 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" 1093 + > 1094 + <svg 1095 + class="w-5 h-5" 1096 + fill="none" 1097 + stroke="currentColor" 1098 + viewBox="0 0 24 24" 1099 + > 1100 + <path 1101 + stroke-linecap="round" 1102 + stroke-linejoin="round" 1103 + stroke-width="2" 1104 + d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" 1105 + /> 1106 + </svg> 1107 + <span>Sign Out</span> 1108 + </button> 1109 + <a 1110 + href="https://ko-fi.com/knotbin" 1111 + target="_blank" 1112 + 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" 1113 + > 1114 + <svg 1115 + class="w-5 h-5" 1116 + fill="none" 1117 + stroke="currentColor" 1118 + viewBox="0 0 24 24" 1119 + > 1120 + <path 1121 + stroke-linecap="round" 1122 + stroke-linejoin="round" 1123 + stroke-width="2" 1124 + d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" 1125 + /> 1126 + </svg> 1127 + <span>Support Us</span> 1128 + </a> 1129 + </div> 1130 + </div> 1131 + )} 1132 + </div> 1133 + ); 1134 + }
+5
islands/HandleInput.tsx
··· 1 1 import { useState } from "preact/hooks"; 2 2 import { JSX } from "preact"; 3 3 4 + /** 5 + * The OAuth handle input form. 6 + * @returns The handle input form 7 + * @component 8 + */ 4 9 export default function HandleInput() { 5 10 const [handle, setHandle] = useState(""); 6 11 const [error, setError] = useState<string | null>(null);
+60 -34
islands/Header.tsx
··· 2 2 import { IS_BROWSER } from "fresh/runtime"; 3 3 import { Button } from "../components/Button.tsx"; 4 4 5 + /** 6 + * The user interface. 7 + * @type {User} 8 + */ 5 9 interface User { 6 10 did: string; 7 11 handle?: string; 8 12 } 9 13 14 + /** 15 + * Truncate text to a maximum length. 16 + * @param text - The text to truncate 17 + * @param maxLength - The maximum length 18 + * @returns The truncated text 19 + */ 10 20 function truncateText(text: string, maxLength: number) { 11 21 if (text.length <= maxLength) return text; 12 22 let truncated = text.slice(0, maxLength); ··· 17 27 return truncated + "..."; 18 28 } 19 29 30 + /** 31 + * The header component. 32 + * @returns The header component 33 + * @component 34 + */ 20 35 export default function Header() { 21 36 const [user, setUser] = useState<User | null>(null); 22 37 const [showDropdown, setShowDropdown] = useState(false); ··· 82 97 /> 83 98 84 99 <div className="flex items-center gap-3"> 100 + {/* Ticket booth (did:plc update) */} 101 + <Button 102 + href="/ticket-booth" 103 + color="amber" 104 + icon="/icons/ticket_bold.svg" 105 + iconAlt="Ticket" 106 + label="TICKET BOOTH" 107 + /> 108 + 85 109 {/* Departures (Migration) */} 86 110 <Button 87 111 href="/migrate" ··· 93 117 94 118 {/* Check-in (Login/Profile) */} 95 119 <div className="relative"> 96 - {user?.did ? ( 97 - <div className="relative"> 120 + {user?.did 121 + ? ( 122 + <div className="relative"> 123 + <Button 124 + color="amber" 125 + icon="/icons/account.svg" 126 + iconAlt="Check-in" 127 + label="CHECKED IN" 128 + onClick={() => setShowDropdown(!showDropdown)} 129 + /> 130 + {showDropdown && ( 131 + <div className="absolute right-0 mt-2 w-64 bg-white dark:bg-slate-800 rounded-lg shadow-lg p-4 border border-slate-200 dark:border-slate-700"> 132 + <div className="text-sm font-mono mb-2 pb-2 border-b border-slate-900/10"> 133 + <div title={user.handle || "Anonymous"}> 134 + {truncateText(user.handle || "Anonymous", 20)} 135 + </div> 136 + <div className="text-xs opacity-75" title={user.did}> 137 + {truncateText(user.did, 25)} 138 + </div> 139 + </div> 140 + <button 141 + type="button" 142 + onClick={handleLogout} 143 + className="text-sm font-mono text-slate-900 hover:text-slate-700 w-full text-left transition-colors" 144 + > 145 + Sign Out 146 + </button> 147 + </div> 148 + )} 149 + </div> 150 + ) 151 + : ( 98 152 <Button 153 + href="/login" 99 154 color="amber" 100 - icon="/icons/ticket_bold.svg" 155 + icon="/icons/account.svg" 101 156 iconAlt="Check-in" 102 - label="CHECKED IN" 103 - onClick={() => setShowDropdown(!showDropdown)} 157 + label="CHECK-IN" 104 158 /> 105 - {showDropdown && ( 106 - <div className="absolute right-0 mt-2 w-64 bg-white dark:bg-slate-800 rounded-lg shadow-lg p-4 border border-slate-200 dark:border-slate-700"> 107 - <div className="text-sm font-mono mb-2 pb-2 border-b border-slate-900/10"> 108 - <div title={user.handle || "Anonymous"}> 109 - {truncateText(user.handle || "Anonymous", 20)} 110 - </div> 111 - <div className="text-xs opacity-75" title={user.did}> 112 - {truncateText(user.did, 25)} 113 - </div> 114 - </div> 115 - <button 116 - type="button" 117 - onClick={handleLogout} 118 - className="text-sm font-mono text-slate-900 hover:text-slate-700 w-full text-left transition-colors" 119 - > 120 - Sign Out 121 - </button> 122 - </div> 123 - )} 124 - </div> 125 - ) : ( 126 - <Button 127 - href="/login" 128 - color="amber" 129 - icon="/icons/ticket_bold.svg" 130 - iconAlt="Check-in" 131 - label="CHECK-IN" 132 - /> 133 - )} 159 + )} 134 160 </div> 135 161 </div> 136 162 </div>
+37
islands/LoginButton.tsx
··· 1 + import { useEffect, useState } from "preact/hooks"; 2 + import { Button } from "../components/Button.tsx"; 3 + 4 + export default function LoginButton() { 5 + const [isMobile, setIsMobile] = useState(true); // Default to mobile for SSR 6 + 7 + useEffect(() => { 8 + const checkMobile = () => { 9 + setIsMobile(globalThis.innerWidth < 640); 10 + }; 11 + 12 + // Check on mount 13 + checkMobile(); 14 + 15 + // Listen for resize events 16 + globalThis.addEventListener("resize", checkMobile); 17 + return () => globalThis.removeEventListener("resize", checkMobile); 18 + }, []); 19 + 20 + return ( 21 + <div class="mt-6 sm:mt-8 text-center w-fit mx-auto"> 22 + <Button 23 + href={isMobile ? undefined : "/login"} 24 + color="blue" 25 + label={isMobile ? "MOBILE NOT SUPPORTED" : "GET STARTED"} 26 + className={isMobile 27 + ? "opacity-50 cursor-not-allowed" 28 + : "opacity-100 cursor-pointer"} 29 + onClick={(e: MouseEvent) => { 30 + if (isMobile) { 31 + e.preventDefault(); 32 + } 33 + }} 34 + /> 35 + </div> 36 + ); 37 + }
+22 -15
islands/LoginSelector.tsx
··· 1 - import { useState } from "preact/hooks" 2 - import HandleInput from "./HandleInput.tsx" 3 - import CredLogin from "./CredLogin.tsx" 1 + import { useState } from "preact/hooks"; 2 + import HandleInput from "./HandleInput.tsx"; 3 + import CredLogin from "./CredLogin.tsx"; 4 4 5 + /** 6 + * The login method selector for OAuth or Credential. 7 + * @returns The login method selector 8 + * @component 9 + */ 5 10 export default function LoginMethodSelector() { 6 - const [loginMethod, setLoginMethod] = useState<'oauth' | 'password'>('password') 11 + const [loginMethod, setLoginMethod] = useState<"oauth" | "password">( 12 + "password", 13 + ); 7 14 8 15 return ( 9 16 <div className="flex flex-col gap-8"> ··· 13 20 <div className="flex gap-4 mb-6"> 14 21 <button 15 22 type="button" 16 - onClick={() => setLoginMethod('oauth')} 23 + onClick={() => setLoginMethod("oauth")} 17 24 className={`flex-1 px-4 py-2 rounded-md transition-colors ${ 18 - loginMethod === 'oauth' 19 - ? 'bg-blue-500 text-white' 20 - : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300' 25 + loginMethod === "oauth" 26 + ? "bg-blue-500 text-white" 27 + : "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300" 21 28 }`} 22 29 > 23 30 OAuth 24 31 </button> 25 32 <button 26 33 type="button" 27 - onClick={() => setLoginMethod('password')} 34 + onClick={() => setLoginMethod("password")} 28 35 className={`flex-1 px-4 py-2 rounded-md transition-colors ${ 29 - loginMethod === 'password' 30 - ? 'bg-blue-500 text-white' 31 - : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300' 36 + loginMethod === "password" 37 + ? "bg-blue-500 text-white" 38 + : "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300" 32 39 }`} 33 40 > 34 41 Credential 35 42 </button> 36 43 </div> 37 44 38 - {loginMethod === 'oauth' && ( 45 + {loginMethod === "oauth" && ( 39 46 <div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/30 text-amber-800 dark:text-amber-200 rounded-md text-sm"> 40 47 Note: OAuth login cannot be used for migrations. 41 48 </div> 42 49 )} 43 50 44 - {loginMethod === 'oauth' ? <HandleInput /> : <CredLogin />} 51 + {loginMethod === "oauth" ? <HandleInput /> : <CredLogin />} 45 52 46 53 <div className="mt-4 text-center"> 47 54 <a ··· 53 60 </div> 54 61 </div> 55 62 </div> 56 - ) 63 + ); 57 64 }
+131 -475
islands/MigrationProgress.tsx
··· 1 1 import { useEffect, useState } from "preact/hooks"; 2 + import { MigrationStateInfo } from "../lib/migration-types.ts"; 3 + import AccountCreationStep from "./migration-steps/AccountCreationStep.tsx"; 4 + import DataMigrationStep from "./migration-steps/DataMigrationStep.tsx"; 5 + import IdentityMigrationStep from "./migration-steps/IdentityMigrationStep.tsx"; 6 + import FinalizationStep from "./migration-steps/FinalizationStep.tsx"; 7 + import MigrationCompletion from "../components/MigrationCompletion.tsx"; 2 8 9 + /** 10 + * The migration progress props. 11 + * @type {MigrationProgressProps} 12 + */ 3 13 interface MigrationProgressProps { 4 14 service: string; 5 15 handle: string; ··· 8 18 invite?: string; 9 19 } 10 20 11 - interface MigrationStep { 12 - name: string; 13 - status: "pending" | "in-progress" | "verifying" | "completed" | "error"; 14 - error?: string; 15 - } 16 - 21 + /** 22 + * The migration progress component. 23 + * @param props - The migration progress props 24 + * @returns The migration progress component 25 + * @component 26 + */ 17 27 export default function MigrationProgress(props: MigrationProgressProps) { 18 - const [token, setToken] = useState(""); 28 + const [migrationState, setMigrationState] = useState< 29 + MigrationStateInfo | null 30 + >(null); 31 + const [currentStep, setCurrentStep] = useState(0); 32 + const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set()); 33 + const [hasError, setHasError] = useState(false); 19 34 20 - const [steps, setSteps] = useState<MigrationStep[]>([ 21 - { name: "Create Account", status: "pending" }, 22 - { name: "Migrate Data", status: "pending" }, 23 - { name: "Migrate Identity", status: "pending" }, 24 - { name: "Finalize Migration", status: "pending" }, 25 - ]); 26 - 27 - const updateStepStatus = ( 28 - index: number, 29 - status: MigrationStep["status"], 30 - error?: string, 31 - ) => { 32 - console.log( 33 - `Updating step ${index} to ${status}${ 34 - error ? ` with error: ${error}` : "" 35 - }`, 36 - ); 37 - setSteps((prevSteps) => 38 - prevSteps.map((step, i) => 39 - i === index 40 - ? { ...step, status, error } 41 - : i > index 42 - ? { ...step, status: "pending", error: undefined } 43 - : step 44 - ) 45 - ); 35 + const credentials = { 36 + service: props.service, 37 + handle: props.handle, 38 + email: props.email, 39 + password: props.password, 40 + invite: props.invite, 46 41 }; 47 42 48 43 const validateParams = () => { 49 44 if (!props.service?.trim()) { 50 - updateStepStatus(0, "error", "Missing service URL"); 45 + setHasError(true); 51 46 return false; 52 47 } 53 48 if (!props.handle?.trim()) { 54 - updateStepStatus(0, "error", "Missing handle"); 49 + setHasError(true); 55 50 return false; 56 51 } 57 52 if (!props.email?.trim()) { 58 - updateStepStatus(0, "error", "Missing email"); 53 + setHasError(true); 59 54 return false; 60 55 } 61 56 if (!props.password?.trim()) { 62 - updateStepStatus(0, "error", "Missing password"); 57 + setHasError(true); 63 58 return false; 64 59 } 65 60 return true; ··· 74 69 invite: props.invite, 75 70 }); 76 71 77 - if (!validateParams()) { 78 - console.log("Parameter validation failed"); 79 - return; 80 - } 81 - 82 - startMigration().catch((error) => { 83 - console.error("Unhandled migration error:", error); 84 - updateStepStatus( 85 - 0, 86 - "error", 87 - error instanceof Error ? error.message : String(error), 88 - ); 89 - }); 90 - }, []); 91 - 92 - const getStepDisplayName = (step: MigrationStep, index: number) => { 93 - if (step.status === "completed") { 94 - switch (index) { 95 - case 0: return "Account Created"; 96 - case 1: return "Data Migrated"; 97 - case 2: return "Identity Migrated"; 98 - case 3: return "Migration Finalized"; 99 - } 100 - } 101 - 102 - if (step.status === "in-progress") { 103 - switch (index) { 104 - case 0: return "Creating your new account..."; 105 - case 1: return "Migrating your data..."; 106 - case 2: return step.name === "Enter the token sent to your email to complete identity migration" 107 - ? step.name 108 - : "Migrating your identity..."; 109 - case 3: return "Finalizing migration..."; 110 - } 111 - } 112 - 113 - if (step.status === "verifying") { 114 - switch (index) { 115 - case 0: return "Verifying account creation..."; 116 - case 1: return "Verifying data migration..."; 117 - case 2: return "Verifying identity migration..."; 118 - case 3: return "Verifying migration completion..."; 119 - } 120 - } 121 - 122 - return step.name; 123 - }; 124 - 125 - const startMigration = async () => { 126 - try { 127 - // Step 1: Create Account 128 - updateStepStatus(0, "in-progress"); 129 - console.log("Starting account creation..."); 130 - 72 + // Check migration state first 73 + const checkMigrationState = async () => { 131 74 try { 132 - const createRes = await fetch("/api/migrate/create", { 133 - method: "POST", 134 - headers: { "Content-Type": "application/json" }, 135 - body: JSON.stringify({ 136 - service: props.service, 137 - handle: props.handle, 138 - password: props.password, 139 - email: props.email, 140 - ...(props.invite ? { invite: props.invite } : {}), 141 - }), 142 - }); 75 + const migrationResponse = await fetch("/api/migration-state"); 76 + if (migrationResponse.ok) { 77 + const migrationData = await migrationResponse.json(); 78 + setMigrationState(migrationData); 143 79 144 - console.log("Create account response status:", createRes.status); 145 - const responseText = await createRes.text(); 146 - console.log("Create account response:", responseText); 147 - 148 - if (!createRes.ok) { 149 - try { 150 - const json = JSON.parse(responseText); 151 - throw new Error(json.message || "Failed to create account"); 152 - } catch { 153 - throw new Error(responseText || "Failed to create account"); 80 + if (!migrationData.allowMigration) { 81 + setHasError(true); 82 + return; 154 83 } 155 84 } 156 - 157 - try { 158 - const jsonData = JSON.parse(responseText); 159 - if (!jsonData.success) { 160 - throw new Error(jsonData.message || "Account creation failed"); 161 - } 162 - } catch (e) { 163 - console.log("Response is not JSON or lacks success field:", e); 164 - } 165 - 166 - updateStepStatus(0, "verifying"); 167 - const verified = await verifyStep(0); 168 - if (!verified) { 169 - throw new Error("Account creation verification failed"); 170 - } 171 85 } catch (error) { 172 - updateStepStatus( 173 - 0, 174 - "error", 175 - error instanceof Error ? error.message : String(error), 176 - ); 177 - throw error; 86 + console.error("Failed to check migration state:", error); 87 + setHasError(true); 88 + return; 178 89 } 179 90 180 - // Step 2: Migrate Data 181 - updateStepStatus(1, "in-progress"); 182 - console.log("Starting data migration..."); 183 - 184 - try { 185 - const dataRes = await fetch("/api/migrate/data", { 186 - method: "POST", 187 - headers: { "Content-Type": "application/json" }, 188 - }); 189 - 190 - console.log("Data migration response status:", dataRes.status); 191 - const dataText = await dataRes.text(); 192 - console.log("Data migration response:", dataText); 193 - 194 - if (!dataRes.ok) { 195 - try { 196 - const json = JSON.parse(dataText); 197 - throw new Error(json.message || "Failed to migrate data"); 198 - } catch { 199 - throw new Error(dataText || "Failed to migrate data"); 200 - } 201 - } 202 - 203 - try { 204 - const jsonData = JSON.parse(dataText); 205 - if (!jsonData.success) { 206 - throw new Error(jsonData.message || "Data migration failed"); 207 - } 208 - console.log("Data migration successful:", jsonData); 209 - } catch (e) { 210 - console.error("Failed to parse data migration response:", e); 211 - throw new Error("Invalid response from server during data migration"); 212 - } 213 - 214 - updateStepStatus(1, "verifying"); 215 - const verified = await verifyStep(1); 216 - if (!verified) { 217 - throw new Error("Data migration verification failed"); 218 - } 219 - } catch (error) { 220 - updateStepStatus( 221 - 1, 222 - "error", 223 - error instanceof Error ? error.message : String(error), 224 - ); 225 - throw error; 91 + if (!validateParams()) { 92 + console.log("Parameter validation failed"); 93 + return; 226 94 } 227 95 228 - // Step 3: Request Identity Migration 229 - updateStepStatus(2, "in-progress"); 230 - console.log("Requesting identity migration..."); 96 + // Start with the first step 97 + setCurrentStep(0); 98 + }; 231 99 232 - try { 233 - const requestRes = await fetch("/api/migrate/identity/request", { 234 - method: "POST", 235 - headers: { "Content-Type": "application/json" }, 236 - }); 100 + checkMigrationState(); 101 + }, []); 237 102 238 - console.log("Identity request response status:", requestRes.status); 239 - const requestText = await requestRes.text(); 240 - console.log("Identity request response:", requestText); 241 - 242 - if (!requestRes.ok) { 243 - try { 244 - const json = JSON.parse(requestText); 245 - throw new Error(json.message || "Failed to request identity migration"); 246 - } catch { 247 - throw new Error(requestText || "Failed to request identity migration"); 248 - } 249 - } 103 + const handleStepComplete = (stepIndex: number) => { 104 + console.log(`Step ${stepIndex} completed`); 105 + setCompletedSteps((prev) => new Set([...prev, stepIndex])); 250 106 251 - try { 252 - const jsonData = JSON.parse(requestText); 253 - if (!jsonData.success) { 254 - throw new Error( 255 - jsonData.message || "Identity migration request failed", 256 - ); 257 - } 258 - console.log("Identity migration requested successfully"); 259 - 260 - // Update step name to prompt for token 261 - setSteps(prevSteps => 262 - prevSteps.map((step, i) => 263 - i === 2 264 - ? { ...step, name: "Enter the token sent to your email to complete identity migration" } 265 - : step 266 - ) 267 - ); 268 - // Don't continue with migration - wait for token input 269 - return; 270 - } catch (e) { 271 - console.error("Failed to parse identity request response:", e); 272 - throw new Error( 273 - "Invalid response from server during identity request", 274 - ); 275 - } 276 - } catch (error) { 277 - updateStepStatus( 278 - 2, 279 - "error", 280 - error instanceof Error ? error.message : String(error), 281 - ); 282 - throw error; 283 - } 284 - } catch (error) { 285 - console.error("Migration error in try/catch:", error); 107 + // Move to next step if not the last one 108 + if (stepIndex < 3) { 109 + setCurrentStep(stepIndex + 1); 286 110 } 287 111 }; 288 112 289 - const handleIdentityMigration = async () => { 290 - if (!token) return; 291 - 292 - try { 293 - const identityRes = await fetch( 294 - `/api/migrate/identity/sign?token=${encodeURIComponent(token)}`, 295 - { 296 - method: "POST", 297 - headers: { "Content-Type": "application/json" }, 298 - }, 299 - ); 300 - 301 - const identityData = await identityRes.text(); 302 - if (!identityRes.ok) { 303 - try { 304 - const json = JSON.parse(identityData); 305 - throw new Error(json.message || "Failed to complete identity migration"); 306 - } catch { 307 - throw new Error(identityData || "Failed to complete identity migration"); 308 - } 309 - } 310 - 311 - let data; 312 - try { 313 - data = JSON.parse(identityData); 314 - if (!data.success) { 315 - throw new Error(data.message || "Identity migration failed"); 316 - } 317 - } catch { 318 - throw new Error("Invalid response from server"); 319 - } 320 - 321 - 322 - updateStepStatus(2, "verifying"); 323 - const verified = await verifyStep(2); 324 - if (!verified) { 325 - throw new Error("Identity migration verification failed"); 326 - } 327 - 328 - // Step 4: Finalize Migration 329 - updateStepStatus(3, "in-progress"); 330 - try { 331 - const finalizeRes = await fetch("/api/migrate/finalize", { 332 - method: "POST", 333 - headers: { "Content-Type": "application/json" }, 334 - }); 335 - 336 - const finalizeData = await finalizeRes.text(); 337 - if (!finalizeRes.ok) { 338 - try { 339 - const json = JSON.parse(finalizeData); 340 - throw new Error(json.message || "Failed to finalize migration"); 341 - } catch { 342 - throw new Error(finalizeData || "Failed to finalize migration"); 343 - } 344 - } 345 - 346 - try { 347 - const jsonData = JSON.parse(finalizeData); 348 - if (!jsonData.success) { 349 - throw new Error(jsonData.message || "Finalization failed"); 350 - } 351 - } catch { 352 - throw new Error("Invalid response from server during finalization"); 353 - } 354 - 355 - updateStepStatus(3, "verifying"); 356 - const verified = await verifyStep(3); 357 - if (!verified) { 358 - throw new Error("Migration finalization verification failed"); 359 - } 360 - } catch (error) { 361 - updateStepStatus( 362 - 3, 363 - "error", 364 - error instanceof Error ? error.message : String(error), 365 - ); 366 - throw error; 367 - } 368 - } catch (error) { 369 - console.error("Identity migration error:", error); 370 - updateStepStatus( 371 - 2, 372 - "error", 373 - error instanceof Error ? error.message : String(error), 374 - ); 375 - } 113 + const handleStepError = ( 114 + stepIndex: number, 115 + error: string, 116 + isVerificationError?: boolean, 117 + ) => { 118 + console.error(`Step ${stepIndex} error:`, error, { isVerificationError }); 119 + // Errors are handled within each step component 376 120 }; 377 121 378 - const getStepIcon = (status: MigrationStep["status"]) => { 379 - switch (status) { 380 - case "pending": 381 - return ( 382 - <div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center"> 383 - <div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" /> 384 - </div> 385 - ); 386 - case "in-progress": 387 - return ( 388 - <div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center"> 389 - <div class="w-3 h-3 rounded-full bg-blue-500" /> 390 - </div> 391 - ); 392 - case "verifying": 393 - return ( 394 - <div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center"> 395 - <div class="w-3 h-3 rounded-full bg-yellow-500" /> 396 - </div> 397 - ); 398 - case "completed": 399 - return ( 400 - <div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center"> 401 - <svg 402 - class="w-5 h-5 text-white" 403 - fill="none" 404 - stroke="currentColor" 405 - viewBox="0 0 24 24" 406 - > 407 - <path 408 - stroke-linecap="round" 409 - stroke-linejoin="round" 410 - stroke-width="2" 411 - d="M5 13l4 4L19 7" 412 - /> 413 - </svg> 414 - </div> 415 - ); 416 - case "error": 417 - return ( 418 - <div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center"> 419 - <svg 420 - class="w-5 h-5 text-white" 421 - fill="none" 422 - stroke="currentColor" 423 - viewBox="0 0 24 24" 424 - > 425 - <path 426 - stroke-linecap="round" 427 - stroke-linejoin="round" 428 - stroke-width="2" 429 - d="M6 18L18 6M6 6l12 12" 430 - /> 431 - </svg> 432 - </div> 433 - ); 434 - } 122 + const isStepActive = (stepIndex: number) => { 123 + return currentStep === stepIndex && !hasError; 435 124 }; 436 125 437 - const getStepClasses = (status: MigrationStep["status"]) => { 438 - const baseClasses = 439 - "flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200"; 440 - switch (status) { 441 - case "pending": 442 - return `${baseClasses} bg-gray-50 dark:bg-gray-800`; 443 - case "in-progress": 444 - return `${baseClasses} bg-blue-50 dark:bg-blue-900`; 445 - case "verifying": 446 - return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`; 447 - case "completed": 448 - return `${baseClasses} bg-green-50 dark:bg-green-900`; 449 - case "error": 450 - return `${baseClasses} bg-red-50 dark:bg-red-900`; 451 - } 126 + const _isStepCompleted = (stepIndex: number) => { 127 + return completedSteps.has(stepIndex); 452 128 }; 453 129 454 - // Helper to verify a step after completion 455 - const verifyStep = async (stepNum: number) => { 456 - updateStepStatus(stepNum, "verifying"); 457 - try { 458 - const res = await fetch(`/api/migrate/status?step=${stepNum + 1}`); 459 - const data = await res.json(); 460 - if (data.ready) { 461 - updateStepStatus(stepNum, "completed"); 462 - return true; 463 - } else { 464 - updateStepStatus(stepNum, "error", data.reason || "Verification failed"); 465 - return false; 466 - } 467 - } catch (e) { 468 - updateStepStatus(stepNum, "error", e instanceof Error ? e.message : String(e)); 469 - return false; 470 - } 471 - }; 130 + const allStepsCompleted = completedSteps.size === 4; 472 131 473 132 return ( 474 133 <div class="space-y-8"> 475 - <div class="space-y-4"> 476 - {steps.map((step, index) => ( 477 - <div key={step.name} class={getStepClasses(step.status)}> 478 - {getStepIcon(step.status)} 479 - <div class="flex-1"> 480 - <p 481 - class={`font-medium ${ 482 - step.status === "error" 483 - ? "text-red-900 dark:text-red-200" 484 - : step.status === "completed" 485 - ? "text-green-900 dark:text-green-200" 486 - : step.status === "in-progress" 487 - ? "text-blue-900 dark:text-blue-200" 488 - : "text-gray-900 dark:text-gray-200" 489 - }`} 490 - > 491 - {getStepDisplayName(step, index)} 492 - </p> 493 - {step.error && ( 494 - <p class="text-sm text-red-600 dark:text-red-400 mt-1"> 495 - {(() => { 496 - try { 497 - const err = JSON.parse(step.error); 498 - return err.message || step.error; 499 - } catch { 500 - return step.error; 501 - } 502 - })()} 503 - </p> 504 - )} 505 - {index === 2 && step.status === "in-progress" && 506 - step.name === "Enter the token sent to your email to complete identity migration" && ( 507 - <div class="mt-4 space-y-4"> 508 - <p class="text-sm text-blue-800 dark:text-blue-200"> 509 - Please check your email for the migration token and enter it below: 510 - </p> 511 - <div class="flex space-x-2"> 512 - <input 513 - type="text" 514 - value={token} 515 - onChange={(e) => setToken(e.currentTarget.value)} 516 - placeholder="Enter token" 517 - class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400" 518 - /> 519 - <button 520 - type="button" 521 - onClick={handleIdentityMigration} 522 - class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200" 523 - > 524 - Submit Token 525 - </button> 526 - </div> 527 - </div> 528 - ) 529 - } 134 + {/* Migration state alert */} 135 + {migrationState && !migrationState.allowMigration && ( 136 + <div 137 + class={`p-4 rounded-lg border ${ 138 + migrationState.state === "maintenance" 139 + ? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200" 140 + : "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200" 141 + }`} 142 + > 143 + <div class="flex items-center"> 144 + <div 145 + class={`mr-3 ${ 146 + migrationState.state === "maintenance" 147 + ? "text-yellow-600 dark:text-yellow-400" 148 + : "text-red-600 dark:text-red-400" 149 + }`} 150 + > 151 + {migrationState.state === "maintenance" ? "โš ๏ธ" : "๐Ÿšซ"} 152 + </div> 153 + <div> 154 + <h3 class="font-semibold mb-1"> 155 + {migrationState.state === "maintenance" 156 + ? "Maintenance Mode" 157 + : "Service Unavailable"} 158 + </h3> 159 + <p class="text-sm">{migrationState.message}</p> 530 160 </div> 531 161 </div> 532 - ))} 533 - </div> 162 + </div> 163 + )} 164 + 165 + <div class="space-y-4"> 166 + <AccountCreationStep 167 + credentials={credentials} 168 + onStepComplete={() => handleStepComplete(0)} 169 + onStepError={(error, isVerificationError) => 170 + handleStepError(0, error, isVerificationError)} 171 + isActive={isStepActive(0)} 172 + /> 534 173 174 + <DataMigrationStep 175 + credentials={credentials} 176 + onStepComplete={() => handleStepComplete(1)} 177 + onStepError={(error, isVerificationError) => 178 + handleStepError(1, error, isVerificationError)} 179 + isActive={isStepActive(1)} 180 + /> 535 181 182 + <IdentityMigrationStep 183 + credentials={credentials} 184 + onStepComplete={() => handleStepComplete(2)} 185 + onStepError={(error, isVerificationError) => 186 + handleStepError(2, error, isVerificationError)} 187 + isActive={isStepActive(2)} 188 + /> 536 189 537 - {steps[3].status === "completed" && ( 538 - <div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800"> 539 - <p class="text-sm text-green-800 dark:text-green-200"> 540 - Migration completed successfully! You can now close this page. 541 - </p> 542 - </div> 543 - )} 190 + <FinalizationStep 191 + credentials={credentials} 192 + onStepComplete={() => handleStepComplete(3)} 193 + onStepError={(error, isVerificationError) => 194 + handleStepError(3, error, isVerificationError)} 195 + isActive={isStepActive(3)} 196 + /> 197 + </div> 198 + 199 + <MigrationCompletion isVisible={allStepsCompleted} /> 544 200 </div> 545 201 ); 546 202 }
+432 -66
islands/MigrationSetup.tsx
··· 1 - import { useState } from "preact/hooks"; 1 + import { useEffect, useState } from "preact/hooks"; 2 + import { IS_BROWSER } from "fresh/runtime"; 2 3 4 + /** 5 + * The migration setup props. 6 + * @type {MigrationSetupProps} 7 + */ 3 8 interface MigrationSetupProps { 4 9 service?: string | null; 5 10 handle?: string | null; ··· 7 12 invite?: string | null; 8 13 } 9 14 15 + /** 16 + * The server description. 17 + * @type {ServerDescription} 18 + */ 10 19 interface ServerDescription { 11 20 inviteCodeRequired: boolean; 12 21 availableUserDomains: string[]; 13 22 } 14 23 24 + /** 25 + * The user passport. 26 + * @type {UserPassport} 27 + */ 28 + interface UserPassport { 29 + did: string; 30 + handle: string; 31 + pds: string; 32 + createdAt?: string; 33 + } 34 + 35 + /** 36 + * The migration state info. 37 + * @type {MigrationStateInfo} 38 + */ 39 + interface MigrationStateInfo { 40 + state: "up" | "issue" | "maintenance"; 41 + message: string; 42 + allowMigration: boolean; 43 + } 44 + 45 + /** 46 + * The migration setup component. 47 + * @param props - The migration setup props 48 + * @returns The migration setup component 49 + * @component 50 + */ 15 51 export default function MigrationSetup(props: MigrationSetupProps) { 16 52 const [service, setService] = useState(props.service || ""); 17 53 const [handlePrefix, setHandlePrefix] = useState( ··· 27 63 const [isLoading, setIsLoading] = useState(false); 28 64 const [showConfirmation, setShowConfirmation] = useState(false); 29 65 const [confirmationText, setConfirmationText] = useState(""); 66 + const [passport, setPassport] = useState<UserPassport | null>(null); 67 + const [migrationState, setMigrationState] = useState< 68 + MigrationStateInfo | null 69 + >(null); 70 + 71 + const ensureServiceUrl = (url: string): string => { 72 + if (!url) return url; 73 + try { 74 + // If it already has a protocol, return as is 75 + new URL(url); 76 + return url; 77 + } catch { 78 + // If no protocol, add https:// 79 + return `https://${url}`; 80 + } 81 + }; 82 + 83 + useEffect(() => { 84 + if (!IS_BROWSER) return; 85 + 86 + const fetchInitialData = async () => { 87 + try { 88 + // Check migration state first 89 + const migrationResponse = await fetch("/api/migration-state"); 90 + if (migrationResponse.ok) { 91 + const migrationData = await migrationResponse.json(); 92 + setMigrationState(migrationData); 93 + } 94 + 95 + // Fetch user passport 96 + const response = await fetch("/api/me", { 97 + credentials: "include", 98 + }); 99 + if (!response.ok) { 100 + throw new Error("Failed to fetch user profile"); 101 + } 102 + const userData = await response.json(); 103 + if (userData) { 104 + // Get PDS URL from the current service 105 + const pdsResponse = await fetch( 106 + `/api/resolve-pds?did=${userData.did}`, 107 + ); 108 + const pdsData = await pdsResponse.json(); 109 + 110 + setPassport({ 111 + did: userData.did, 112 + handle: userData.handle, 113 + pds: pdsData.pds || "Unknown", 114 + createdAt: new Date().toISOString(), // TODO: Get actual creation date from API 115 + }); 116 + } 117 + } catch (error) { 118 + console.error("Failed to fetch initial data:", error); 119 + } 120 + }; 121 + 122 + fetchInitialData(); 123 + }, []); 30 124 31 125 const checkServerDescription = async (serviceUrl: string) => { 32 126 try { ··· 59 153 }; 60 154 61 155 const handleServiceChange = (value: string) => { 62 - setService(value); 156 + const urlWithProtocol = ensureServiceUrl(value); 157 + setService(urlWithProtocol); 63 158 setError(""); 64 - if (value) { 65 - checkServerDescription(value); 159 + if (urlWithProtocol) { 160 + checkServerDescription(urlWithProtocol); 66 161 } else { 67 162 setAvailableDomains([]); 68 163 setSelectedDomain(""); ··· 72 167 const handleSubmit = (e: Event) => { 73 168 e.preventDefault(); 74 169 170 + // Check migration state first 171 + if (migrationState && !migrationState.allowMigration) { 172 + setError(migrationState.message); 173 + return; 174 + } 175 + 75 176 if (!service || !handlePrefix || !email || !password) { 76 177 setError("Please fill in all required fields"); 77 178 return; ··· 86 187 }; 87 188 88 189 const handleConfirmation = () => { 190 + // Double-check migration state before proceeding 191 + if (migrationState && !migrationState.allowMigration) { 192 + setError(migrationState.message); 193 + return; 194 + } 195 + 89 196 if (confirmationText !== "MIGRATE") { 90 197 setError("Please type 'MIGRATE' to confirm"); 91 198 return; ··· 114 221 <div class="max-w-2xl mx-auto p-6 bg-gradient-to-b from-blue-50 to-white dark:from-gray-800 dark:to-gray-900 rounded-lg shadow-xl relative overflow-hidden"> 115 222 {/* Decorative airport elements */} 116 223 <div class="absolute top-0 left-0 w-full h-1 bg-blue-500"></div> 117 - <div class="absolute top-2 left-4 text-blue-500 text-sm font-mono">TERMINAL 1</div> 118 - <div class="absolute top-2 right-4 text-blue-500 text-sm font-mono">GATE M1</div> 224 + <div class="absolute top-2 left-4 text-blue-500 text-sm font-mono"> 225 + TERMINAL 1 226 + </div> 227 + <div class="absolute top-2 right-4 text-blue-500 text-sm font-mono"> 228 + GATE M1 229 + </div> 230 + 231 + {/* Migration state alert */} 232 + {migrationState && !migrationState.allowMigration && ( 233 + <div 234 + class={`mb-6 mt-4 p-4 rounded-lg border ${ 235 + migrationState.state === "maintenance" 236 + ? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200" 237 + : "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200" 238 + }`} 239 + > 240 + <div class="flex items-center"> 241 + <div 242 + class={`mr-3 ${ 243 + migrationState.state === "maintenance" 244 + ? "text-yellow-600 dark:text-yellow-400" 245 + : "text-red-600 dark:text-red-400" 246 + }`} 247 + > 248 + {migrationState.state === "maintenance" ? "โš ๏ธ" : "๐Ÿšซ"} 249 + </div> 250 + <div> 251 + <h3 class="font-semibold mb-1"> 252 + {migrationState.state === "maintenance" 253 + ? "Maintenance Mode" 254 + : "Service Unavailable"} 255 + </h3> 256 + <p class="text-sm">{migrationState.message}</p> 257 + </div> 258 + </div> 259 + </div> 260 + )} 119 261 120 262 <div class="text-center mb-8 relative"> 121 - <p class="text-gray-600 dark:text-gray-400 mt-4">Please complete your migration check-in</p> 122 - <div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono">FLIGHT: MIG-2024</div> 263 + <p class="text-gray-600 dark:text-gray-400 mt-4"> 264 + Please complete your migration check-in 265 + </p> 266 + <div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono"> 267 + FLIGHT: MIG-2024 268 + </div> 123 269 </div> 124 270 271 + {/* Passport Section */} 272 + {passport && ( 273 + <div class="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border border-gray-200 dark:border-gray-700"> 274 + <div class="flex items-center justify-between mb-4"> 275 + <h3 class="text-lg font-semibold text-gray-900 dark:text-white"> 276 + Current Passport 277 + </h3> 278 + <div class="text-xs text-gray-500 dark:text-gray-400 font-mono"> 279 + ISSUED: {new Date().toLocaleDateString()} 280 + </div> 281 + </div> 282 + <div class="grid grid-cols-2 gap-4 text-sm"> 283 + <div> 284 + <div class="text-gray-500 dark:text-gray-400 mb-1">Handle</div> 285 + <div class="font-mono text-gray-900 dark:text-white"> 286 + {passport.handle} 287 + </div> 288 + </div> 289 + <div> 290 + <div class="text-gray-500 dark:text-gray-400 mb-1">DID</div> 291 + <div class="font-mono text-gray-900 dark:text-white break-all"> 292 + {passport.did} 293 + </div> 294 + </div> 295 + <div> 296 + <div class="text-gray-500 dark:text-gray-400 mb-1"> 297 + Citizen of PDS 298 + </div> 299 + <div class="font-mono text-gray-900 dark:text-white break-all"> 300 + {passport.pds} 301 + </div> 302 + </div> 303 + <div> 304 + <div class="text-gray-500 dark:text-gray-400 mb-1"> 305 + Account Age 306 + </div> 307 + <div class="font-mono text-gray-900 dark:text-white"> 308 + {passport.createdAt 309 + ? new Date(passport.createdAt).toLocaleDateString() 310 + : "Unknown"} 311 + </div> 312 + </div> 313 + </div> 314 + </div> 315 + )} 316 + 125 317 <form onSubmit={handleSubmit} class="space-y-6"> 126 318 {error && ( 127 - <div class="bg-red-50 dark:bg-red-900 rounded-lg"> 319 + <div class="bg-red-50 dark:bg-red-900 rounded-lg "> 128 320 <p class="text-red-800 dark:text-red-200 flex items-center"> 129 - <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 130 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"></path> 321 + <svg 322 + class="w-5 h-5 mr-2" 323 + fill="none" 324 + stroke="currentColor" 325 + viewBox="0 0 24 24" 326 + > 327 + <path 328 + stroke-linecap="round" 329 + stroke-linejoin="round" 330 + stroke-width="2" 331 + 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" 332 + > 333 + </path> 131 334 </svg> 132 335 {error} 133 336 </p> ··· 138 341 <div> 139 342 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 140 343 Destination Server 141 - <span class="text-xs text-gray-500 ml-1">(Final Destination)</span> 344 + <span class="text-xs text-gray-500 ml-1"> 345 + (Final Destination) 346 + </span> 142 347 </label> 143 348 <div class="relative"> 144 349 <input ··· 151 356 class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white disabled:opacity-50 disabled:cursor-not-allowed pl-10" 152 357 /> 153 358 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> 154 - <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 155 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path> 359 + <svg 360 + class="h-5 w-5 text-gray-400" 361 + fill="none" 362 + stroke="currentColor" 363 + viewBox="0 0 24 24" 364 + > 365 + <path 366 + stroke-linecap="round" 367 + stroke-linejoin="round" 368 + stroke-width="2" 369 + d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" 370 + > 371 + </path> 156 372 </svg> 157 373 </div> 158 374 </div> 159 375 {isLoading && ( 160 376 <p class="mt-1 text-sm text-gray-500 dark:text-gray-400 flex items-center"> 161 - <svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24"> 162 - <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 163 - <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> 377 + <svg 378 + class="animate-spin -ml-1 mr-2 h-4 w-4 text-blue-500" 379 + fill="none" 380 + viewBox="0 0 24 24" 381 + > 382 + <circle 383 + class="opacity-25" 384 + cx="12" 385 + cy="12" 386 + r="10" 387 + stroke="currentColor" 388 + stroke-width="4" 389 + > 390 + </circle> 391 + <path 392 + class="opacity-75" 393 + fill="currentColor" 394 + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" 395 + > 396 + </path> 164 397 </svg> 165 398 Verifying destination server... 166 399 </p> ··· 171 404 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 172 405 New Account Handle 173 406 <span class="text-xs text-gray-500 ml-1">(Passport ID)</span> 407 + <div class="inline-block relative group ml-2"> 408 + <svg 409 + class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-help" 410 + fill="currentColor" 411 + viewBox="0 0 20 20" 412 + > 413 + <path 414 + fill-rule="evenodd" 415 + d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z" 416 + clip-rule="evenodd" 417 + /> 418 + </svg> 419 + <div class="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10"> 420 + You can change your handle to a custom domain later 421 + <div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"> 422 + </div> 423 + </div> 424 + </div> 174 425 </label> 175 426 <div class="mt-1 relative w-full"> 176 427 <div class="flex rounded-md shadow-sm w-full"> ··· 182 433 placeholder="username" 183 434 required 184 435 class="w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10 pr-32" 185 - style={{ fontFamily: 'inherit' }} 436 + style={{ fontFamily: "inherit" }} 186 437 /> 187 438 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> 188 - <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 189 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path> 439 + <svg 440 + class="h-5 w-5 text-gray-400" 441 + fill="none" 442 + stroke="currentColor" 443 + viewBox="0 0 24 24" 444 + > 445 + <path 446 + stroke-linecap="round" 447 + stroke-linejoin="round" 448 + stroke-width="2" 449 + d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" 450 + > 451 + </path> 190 452 </svg> 191 453 </div> 192 454 {/* Suffix for domain ending */} 193 - {availableDomains.length > 0 ? ( 194 - availableDomains.length === 1 ? ( 455 + {availableDomains.length > 0 456 + ? ( 457 + availableDomains.length === 1 458 + ? ( 459 + <span class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 select-none pointer-events-none font-mono text-base"> 460 + {availableDomains[0]} 461 + </span> 462 + ) 463 + : ( 464 + <span class="absolute inset-y-0 right-0 flex items-center pr-1"> 465 + <select 466 + value={selectedDomain} 467 + onChange={(e) => 468 + setSelectedDomain(e.currentTarget.value)} 469 + class="bg-transparent text-gray-400 font-mono text-base focus:outline-none focus:ring-0 border-0 pr-2" 470 + style={{ appearance: "none" }} 471 + > 472 + {availableDomains.map((domain) => ( 473 + <option key={domain} value={domain}> 474 + {domain} 475 + </option> 476 + ))} 477 + </select> 478 + </span> 479 + ) 480 + ) 481 + : ( 195 482 <span class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 select-none pointer-events-none font-mono text-base"> 196 - {availableDomains[0]} 197 - </span> 198 - ) : ( 199 - <span class="absolute inset-y-0 right-0 flex items-center pr-1"> 200 - <select 201 - value={selectedDomain} 202 - onChange={(e) => setSelectedDomain(e.currentTarget.value)} 203 - class="bg-transparent text-gray-400 font-mono text-base focus:outline-none focus:ring-0 border-0 pr-2" 204 - style={{ appearance: 'none' }} 205 - > 206 - {availableDomains.map((domain) => ( 207 - <option key={domain} value={domain}>{domain}</option> 208 - ))} 209 - </select> 483 + .example.com 210 484 </span> 211 - ) 212 - ) : ( 213 - <span class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 select-none pointer-events-none font-mono text-base"> 214 - .example.com 215 - </span> 216 - )} 485 + )} 217 486 </div> 218 487 </div> 219 488 </div> ··· 221 490 222 491 <div> 223 492 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 224 - Contact Email 225 - <span class="text-xs text-gray-500 ml-1">(Emergency Contact)</span> 493 + Email 494 + <span class="text-xs text-gray-500 ml-1"> 495 + (Emergency Contact) 496 + </span> 226 497 </label> 227 498 <div class="relative"> 228 499 <input ··· 233 504 class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10" 234 505 /> 235 506 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> 236 - <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 237 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path> 507 + <svg 508 + class="h-5 w-5 text-gray-400" 509 + fill="none" 510 + stroke="currentColor" 511 + viewBox="0 0 24 24" 512 + > 513 + <path 514 + stroke-linecap="round" 515 + stroke-linejoin="round" 516 + stroke-width="2" 517 + d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" 518 + > 519 + </path> 238 520 </svg> 239 521 </div> 240 522 </div> ··· 243 525 <div> 244 526 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 245 527 New Account Password 246 - <span class="text-xs text-gray-500 ml-1">(Security Clearance)</span> 528 + <span class="text-xs text-gray-500 ml-1"> 529 + (Security Clearance) 530 + </span> 247 531 </label> 248 532 <div class="relative"> 249 533 <input ··· 254 538 class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10" 255 539 /> 256 540 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> 257 - <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 258 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path> 541 + <svg 542 + class="h-5 w-5 text-gray-400" 543 + fill="none" 544 + stroke="currentColor" 545 + viewBox="0 0 24 24" 546 + > 547 + <path 548 + stroke-linecap="round" 549 + stroke-linejoin="round" 550 + stroke-width="2" 551 + d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" 552 + > 553 + </path> 259 554 </svg> 260 555 </div> 261 556 </div> ··· 276 571 class="mt-1 block w-full rounded-md bg-white dark:bg-gray-700 shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 dark:text-white pl-10" 277 572 /> 278 573 <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> 279 - <svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 280 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path> 574 + <svg 575 + class="h-5 w-5 text-gray-400" 576 + fill="none" 577 + stroke="currentColor" 578 + viewBox="0 0 24 24" 579 + > 580 + <path 581 + stroke-linecap="round" 582 + stroke-linejoin="round" 583 + stroke-width="2" 584 + d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" 585 + > 586 + </path> 281 587 </svg> 282 588 </div> 283 589 </div> ··· 287 593 288 594 <button 289 595 type="submit" 290 - disabled={isLoading} 596 + disabled={isLoading || 597 + Boolean(migrationState && !migrationState.allowMigration)} 291 598 class="w-full flex justify-center items-center py-3 px-4 rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" 292 599 > 293 - <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 294 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> 600 + <svg 601 + class="w-5 h-5 mr-2" 602 + fill="none" 603 + stroke="currentColor" 604 + viewBox="0 0 24 24" 605 + > 606 + <path 607 + stroke-linecap="round" 608 + stroke-linejoin="round" 609 + stroke-width="2" 610 + d="M5 13l4 4L19 7" 611 + > 612 + </path> 295 613 </svg> 296 614 Proceed to Check-in 297 615 </button> ··· 301 619 <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"> 302 620 <div 303 621 class="bg-white dark:bg-gray-800 rounded-xl p-8 max-w-md w-full shadow-2xl border-0 relative animate-popin" 304 - style={{ boxShadow: '0 8px 32px 0 rgba(255, 0, 0, 0.15), 0 1.5px 8px 0 rgba(0,0,0,0.10)' }} 622 + style={{ 623 + boxShadow: 624 + "0 8px 32px 0 rgba(255, 0, 0, 0.15), 0 1.5px 8px 0 rgba(0,0,0,0.10)", 625 + }} 305 626 > 306 627 <div class="absolute -top-8 left-1/2 -translate-x-1/2"> 307 628 <div class="bg-red-500 rounded-full p-3 shadow-lg animate-bounce-short"> 308 - <svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 309 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" /> 629 + <svg 630 + class="w-8 h-8 text-white" 631 + fill="none" 632 + stroke="currentColor" 633 + viewBox="0 0 24 24" 634 + > 635 + <path 636 + stroke-linecap="round" 637 + stroke-linejoin="round" 638 + stroke-width="2" 639 + 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" 640 + /> 310 641 </svg> 311 642 </div> 312 643 </div> 313 644 <div class="text-center mb-4 mt-6"> 314 - <h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide">Final Boarding Call</h3> 645 + <h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide"> 646 + Final Boarding Call 647 + </h3> 315 648 <p class="text-gray-700 dark:text-gray-300 mb-2 text-base"> 316 - <span class="font-semibold text-red-500">Warning:</span> This migration process can be <strong>irreversible</strong>.<br />Airport is in <strong>alpha</strong> currently, and we don't recommend it for main accounts. 649 + <span class="font-semibold text-red-500">Warning:</span>{" "} 650 + This migration is <strong>irreversible</strong>{" "} 651 + if coming from Bluesky servers.<br />Bluesky does not recommend 652 + it for main accounts. Migrate at your own risk. We reccomend 653 + backing up your data before proceeding. 317 654 </p> 318 655 <p class="text-gray-700 dark:text-gray-300 mb-4 text-base"> 319 - Please type <span class="font-mono font-bold text-blue-600">MIGRATE</span> below to confirm and proceed. 656 + Please type{" "} 657 + <span class="font-mono font-bold text-blue-600">MIGRATE</span> 658 + {" "} 659 + below to confirm and proceed. 320 660 </p> 321 661 </div> 322 662 <div class="relative"> ··· 335 675 class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md flex items-center transition" 336 676 type="button" 337 677 > 338 - <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 339 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path> 678 + <svg 679 + class="w-5 h-5 mr-2" 680 + fill="none" 681 + stroke="currentColor" 682 + viewBox="0 0 24 24" 683 + > 684 + <path 685 + stroke-linecap="round" 686 + stroke-linejoin="round" 687 + stroke-width="2" 688 + d="M6 18L18 6M6 6l12 12" 689 + > 690 + </path> 340 691 </svg> 341 692 Cancel 342 693 </button> 343 694 <button 344 695 onClick={handleConfirmation} 345 - class={`px-4 py-2 rounded-md flex items-center transition font-semibold ${confirmationText.trim().toLowerCase() === 'migrate' ? 'bg-red-600 text-white hover:bg-red-700 cursor-pointer' : 'bg-red-300 text-white cursor-not-allowed'}`} 696 + class={`px-4 py-2 rounded-md flex items-center transition font-semibold ${ 697 + confirmationText.trim().toLowerCase() === "migrate" 698 + ? "bg-red-600 text-white hover:bg-red-700 cursor-pointer" 699 + : "bg-red-300 text-white cursor-not-allowed" 700 + }`} 346 701 type="button" 347 - disabled={confirmationText.trim().toLowerCase() !== 'migrate'} 702 + disabled={confirmationText.trim().toLowerCase() !== "migrate"} 348 703 > 349 - <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 350 - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path> 704 + <svg 705 + class="w-5 h-5 mr-2" 706 + fill="none" 707 + stroke="currentColor" 708 + viewBox="0 0 24 24" 709 + > 710 + <path 711 + stroke-linecap="round" 712 + stroke-linejoin="round" 713 + stroke-width="2" 714 + d="M5 13l4 4L19 7" 715 + > 716 + </path> 351 717 </svg> 352 718 Confirm Migration 353 719 </button>
+10
islands/OAuthCallback.tsx
··· 1 1 import { useEffect, useState } from "preact/hooks"; 2 2 import { IS_BROWSER } from "fresh/runtime"; 3 3 4 + /** 5 + * The OAuth callback props. 6 + * @type {OAuthCallbackProps} 7 + */ 4 8 interface OAuthCallbackProps { 5 9 error?: string; 6 10 } 7 11 12 + /** 13 + * The OAuth callback component. 14 + * @param props - The OAuth callback props 15 + * @returns The OAuth callback component 16 + * @component 17 + */ 8 18 export default function OAuthCallback( 9 19 { error: initialError }: OAuthCallbackProps, 10 20 ) {
+17 -8
islands/SocialLinks.tsx
··· 1 1 import { useEffect, useState } from "preact/hooks"; 2 - import * as Icon from 'npm:preact-feather'; 2 + import * as Icon from "npm:preact-feather"; 3 3 4 + /** 5 + * The GitHub repository. 6 + * @type {GitHubRepo} 7 + */ 4 8 interface GitHubRepo { 5 9 stargazers_count: number; 6 10 } 7 11 12 + /** 13 + * The social links component. 14 + * @returns The social links component 15 + * @component 16 + */ 8 17 export default function SocialLinks() { 9 18 const [starCount, setStarCount] = useState<number | null>(null); 10 19 11 20 useEffect(() => { 12 - const CACHE_KEY = 'github_stars'; 21 + const CACHE_KEY = "github_stars"; 13 22 const CACHE_DURATION = 15 * 60 * 1000; // 15 minutes in milliseconds 14 23 15 24 const fetchRepoInfo = async () => { 16 25 try { 17 - const response = await fetch("https://api.github.com/repos/knotbin/airport"); 26 + const response = await fetch( 27 + "https://api.github.com/repos/knotbin/airport", 28 + ); 18 29 const data: GitHubRepo = await response.json(); 19 30 const cacheData = { 20 31 count: data.stargazers_count, 21 - timestamp: Date.now() 32 + timestamp: Date.now(), 22 33 }; 23 34 localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData)); 24 35 setStarCount(data.stargazers_count); ··· 69 80 stroke-linejoin="round" 70 81 xmlns="http://www.w3.org/2000/svg" 71 82 > 72 - <path 73 - d="M55.491 15.172c29.35 22.035 60.917 66.712 72.509 90.686 11.592-23.974 43.159-68.651 72.509-90.686C221.686-.727 256-13.028 256 26.116c0 7.818-4.482 65.674-7.111 75.068-9.138 32.654-42.436 40.983-72.057 35.942 51.775 8.812 64.946 38 36.501 67.187-54.021 55.433-77.644-13.908-83.696-31.676-1.11-3.257-1.63-4.78-1.637-3.485-.008-1.296-.527.228-1.637 3.485-6.052 17.768-29.675 87.11-83.696 31.676-28.445-29.187-15.274-58.375 36.5-67.187-29.62 5.041-62.918-3.288-72.056-35.942C4.482 91.79 0 33.934 0 26.116 0-13.028 34.314-.727 55.491 15.172Z" 74 - /> 83 + <path d="M55.491 15.172c29.35 22.035 60.917 66.712 72.509 90.686 11.592-23.974 43.159-68.651 72.509-90.686C221.686-.727 256-13.028 256 26.116c0 7.818-4.482 65.674-7.111 75.068-9.138 32.654-42.436 40.983-72.057 35.942 51.775 8.812 64.946 38 36.501 67.187-54.021 55.433-77.644-13.908-83.696-31.676-1.11-3.257-1.63-4.78-1.637-3.485-.008-1.296-.527.228-1.637 3.485-6.052 17.768-29.675 87.11-83.696 31.676-28.445-29.187-15.274-58.375 36.5-67.187-29.62 5.041-62.918-3.288-72.056-35.942C4.482 91.79 0 33.934 0 26.116 0-13.028 34.314-.727 55.491 15.172Z" /> 75 84 </svg> 76 85 </a> 77 86 <a ··· 93 102 </a> 94 103 </div> 95 104 ); 96 - } 105 + }
+20 -3
islands/Ticket.tsx
··· 1 1 import { useEffect, useState } from "preact/hooks"; 2 2 import { IS_BROWSER } from "fresh/runtime"; 3 + import { Link } from "../components/Link.tsx"; 3 4 5 + /** 6 + * The user interface for the ticket component. 7 + * @type {User} 8 + */ 4 9 interface User { 5 10 did: string; 6 11 handle?: string; 7 12 } 8 13 14 + /** 15 + * The ticket component for the landing page. 16 + * @returns The ticket component 17 + * @component 18 + */ 9 19 export default function Ticket() { 10 20 const [user, setUser] = useState<User | null>(null); 11 21 ··· 63 73 </p> 64 74 <p> 65 75 Think you might need to migrate in the future but your PDS might be 66 - hostile or offline? No worries! Soon you'll be able to go to the 67 - ticket booth and get a PLC key to use for account recovery in the 68 - future. You can also go to baggage claim (take the air shuttle to 76 + hostile or offline? No worries! You can go to the{" "} 77 + <Link 78 + href="/ticket-booth" 79 + isExternal 80 + class="text-blue-600 dark:text-blue-400" 81 + > 82 + ticket booth 83 + </Link>{" "} 84 + and get a PLC key to use for account recovery in the future. Soon 85 + you'll also be able to go to baggage claim (take the air shuttle to 69 86 terminal four) and get a downloadable backup of all your current PDS 70 87 data in case that were to happen. 71 88 </p>
+151
islands/migration-steps/AccountCreationStep.tsx
··· 1 + import { useEffect, useState } from "preact/hooks"; 2 + import { MigrationStep } from "../../components/MigrationStep.tsx"; 3 + import { 4 + parseApiResponse, 5 + StepCommonProps, 6 + verifyMigrationStep, 7 + } from "../../lib/migration-types.ts"; 8 + 9 + interface AccountCreationStepProps extends StepCommonProps { 10 + isActive: boolean; 11 + } 12 + 13 + export default function AccountCreationStep({ 14 + credentials, 15 + onStepComplete, 16 + onStepError, 17 + isActive, 18 + }: AccountCreationStepProps) { 19 + const [status, setStatus] = useState< 20 + "pending" | "in-progress" | "verifying" | "completed" | "error" 21 + >("pending"); 22 + const [error, setError] = useState<string>(); 23 + const [retryCount, setRetryCount] = useState(0); 24 + const [showContinueAnyway, setShowContinueAnyway] = useState(false); 25 + 26 + useEffect(() => { 27 + if (isActive && status === "pending") { 28 + startAccountCreation(); 29 + } 30 + }, [isActive]); 31 + 32 + const startAccountCreation = async () => { 33 + setStatus("in-progress"); 34 + setError(undefined); 35 + 36 + try { 37 + const createRes = await fetch("/api/migrate/create", { 38 + method: "POST", 39 + headers: { "Content-Type": "application/json" }, 40 + body: JSON.stringify({ 41 + service: credentials.service, 42 + handle: credentials.handle, 43 + password: credentials.password, 44 + email: credentials.email, 45 + ...(credentials.invite ? { invite: credentials.invite } : {}), 46 + }), 47 + }); 48 + 49 + const responseText = await createRes.text(); 50 + 51 + if (!createRes.ok) { 52 + const parsed = parseApiResponse(responseText); 53 + throw new Error(parsed.message || "Failed to create account"); 54 + } 55 + 56 + const parsed = parseApiResponse(responseText); 57 + if (!parsed.success) { 58 + throw new Error(parsed.message || "Account creation failed"); 59 + } 60 + 61 + // Verify the account creation 62 + await verifyAccountCreation(); 63 + } catch (error) { 64 + const errorMessage = error instanceof Error 65 + ? error.message 66 + : String(error); 67 + setError(errorMessage); 68 + setStatus("error"); 69 + onStepError(errorMessage); 70 + } 71 + }; 72 + 73 + const verifyAccountCreation = async () => { 74 + setStatus("verifying"); 75 + 76 + try { 77 + const result = await verifyMigrationStep(1); 78 + 79 + if (result.ready) { 80 + setStatus("completed"); 81 + setRetryCount(0); 82 + setShowContinueAnyway(false); 83 + onStepComplete(); 84 + } else { 85 + const statusDetails = { 86 + activated: result.activated, 87 + validDid: result.validDid, 88 + }; 89 + const errorMessage = `${ 90 + result.reason || "Verification failed" 91 + }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 92 + 93 + setRetryCount((prev) => prev + 1); 94 + if (retryCount >= 1) { 95 + setShowContinueAnyway(true); 96 + } 97 + 98 + setError(errorMessage); 99 + setStatus("error"); 100 + onStepError(errorMessage, true); 101 + } 102 + } catch (error) { 103 + const errorMessage = error instanceof Error 104 + ? error.message 105 + : String(error); 106 + setRetryCount((prev) => prev + 1); 107 + if (retryCount >= 1) { 108 + setShowContinueAnyway(true); 109 + } 110 + 111 + setError(errorMessage); 112 + setStatus("error"); 113 + onStepError(errorMessage, true); 114 + } 115 + }; 116 + 117 + const retryVerification = async () => { 118 + await verifyAccountCreation(); 119 + }; 120 + 121 + const continueAnyway = () => { 122 + setStatus("completed"); 123 + setShowContinueAnyway(false); 124 + onStepComplete(); 125 + }; 126 + 127 + return ( 128 + <MigrationStep 129 + name="Create Account" 130 + status={status} 131 + error={error} 132 + isVerificationError={status === "error" && 133 + error?.includes("Verification failed")} 134 + index={0} 135 + onRetryVerification={retryVerification} 136 + > 137 + {status === "error" && showContinueAnyway && ( 138 + <div class="flex space-x-2 mt-2"> 139 + <button 140 + type="button" 141 + onClick={continueAnyway} 142 + class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200 143 + dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700" 144 + > 145 + Continue Anyway 146 + </button> 147 + </div> 148 + )} 149 + </MigrationStep> 150 + ); 151 + }
+172
islands/migration-steps/DataMigrationStep.tsx
··· 1 + import { useEffect, useState } from "preact/hooks"; 2 + import { MigrationStep } from "../../components/MigrationStep.tsx"; 3 + import { 4 + parseApiResponse, 5 + StepCommonProps, 6 + verifyMigrationStep, 7 + } from "../../lib/migration-types.ts"; 8 + 9 + interface DataMigrationStepProps extends StepCommonProps { 10 + isActive: boolean; 11 + } 12 + 13 + export default function DataMigrationStep({ 14 + credentials: _credentials, 15 + onStepComplete, 16 + onStepError, 17 + isActive, 18 + }: DataMigrationStepProps) { 19 + const [status, setStatus] = useState< 20 + "pending" | "in-progress" | "verifying" | "completed" | "error" 21 + >("pending"); 22 + const [error, setError] = useState<string>(); 23 + const [retryCount, setRetryCount] = useState(0); 24 + const [showContinueAnyway, setShowContinueAnyway] = useState(false); 25 + 26 + useEffect(() => { 27 + if (isActive && status === "pending") { 28 + startDataMigration(); 29 + } 30 + }, [isActive]); 31 + 32 + const startDataMigration = async () => { 33 + setStatus("in-progress"); 34 + setError(undefined); 35 + 36 + try { 37 + // Step 1: Migrate Repo 38 + const repoRes = await fetch("/api/migrate/data/repo", { 39 + method: "POST", 40 + headers: { "Content-Type": "application/json" }, 41 + }); 42 + 43 + const repoText = await repoRes.text(); 44 + 45 + if (!repoRes.ok) { 46 + const parsed = parseApiResponse(repoText); 47 + throw new Error(parsed.message || "Failed to migrate repo"); 48 + } 49 + 50 + // Step 2: Migrate Blobs 51 + const blobsRes = await fetch("/api/migrate/data/blobs", { 52 + method: "POST", 53 + headers: { "Content-Type": "application/json" }, 54 + }); 55 + 56 + const blobsText = await blobsRes.text(); 57 + 58 + if (!blobsRes.ok) { 59 + const parsed = parseApiResponse(blobsText); 60 + throw new Error(parsed.message || "Failed to migrate blobs"); 61 + } 62 + 63 + // Step 3: Migrate Preferences 64 + const prefsRes = await fetch("/api/migrate/data/prefs", { 65 + method: "POST", 66 + headers: { "Content-Type": "application/json" }, 67 + }); 68 + 69 + const prefsText = await prefsRes.text(); 70 + 71 + if (!prefsRes.ok) { 72 + const parsed = parseApiResponse(prefsText); 73 + throw new Error(parsed.message || "Failed to migrate preferences"); 74 + } 75 + 76 + // Verify the data migration 77 + await verifyDataMigration(); 78 + } catch (error) { 79 + const errorMessage = error instanceof Error 80 + ? error.message 81 + : String(error); 82 + setError(errorMessage); 83 + setStatus("error"); 84 + onStepError(errorMessage); 85 + } 86 + }; 87 + 88 + const verifyDataMigration = async () => { 89 + setStatus("verifying"); 90 + 91 + try { 92 + const result = await verifyMigrationStep(2); 93 + 94 + if (result.ready) { 95 + setStatus("completed"); 96 + setRetryCount(0); 97 + setShowContinueAnyway(false); 98 + onStepComplete(); 99 + } else { 100 + const statusDetails = { 101 + repoCommit: result.repoCommit, 102 + repoRev: result.repoRev, 103 + repoBlocks: result.repoBlocks, 104 + expectedRecords: result.expectedRecords, 105 + indexedRecords: result.indexedRecords, 106 + privateStateValues: result.privateStateValues, 107 + expectedBlobs: result.expectedBlobs, 108 + importedBlobs: result.importedBlobs, 109 + }; 110 + const errorMessage = `${ 111 + result.reason || "Verification failed" 112 + }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 113 + 114 + setRetryCount((prev) => prev + 1); 115 + if (retryCount >= 1) { 116 + setShowContinueAnyway(true); 117 + } 118 + 119 + setError(errorMessage); 120 + setStatus("error"); 121 + onStepError(errorMessage, true); 122 + } 123 + } catch (error) { 124 + const errorMessage = error instanceof Error 125 + ? error.message 126 + : String(error); 127 + setRetryCount((prev) => prev + 1); 128 + if (retryCount >= 1) { 129 + setShowContinueAnyway(true); 130 + } 131 + 132 + setError(errorMessage); 133 + setStatus("error"); 134 + onStepError(errorMessage, true); 135 + } 136 + }; 137 + 138 + const retryVerification = async () => { 139 + await verifyDataMigration(); 140 + }; 141 + 142 + const continueAnyway = () => { 143 + setStatus("completed"); 144 + setShowContinueAnyway(false); 145 + onStepComplete(); 146 + }; 147 + 148 + return ( 149 + <MigrationStep 150 + name="Migrate Data" 151 + status={status} 152 + error={error} 153 + isVerificationError={status === "error" && 154 + error?.includes("Verification failed")} 155 + index={1} 156 + onRetryVerification={retryVerification} 157 + > 158 + {status === "error" && showContinueAnyway && ( 159 + <div class="flex space-x-2 mt-2"> 160 + <button 161 + type="button" 162 + onClick={continueAnyway} 163 + class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200 164 + dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700" 165 + > 166 + Continue Anyway 167 + </button> 168 + </div> 169 + )} 170 + </MigrationStep> 171 + ); 172 + }
+143
islands/migration-steps/FinalizationStep.tsx
··· 1 + import { useEffect, useState } from "preact/hooks"; 2 + import { MigrationStep } from "../../components/MigrationStep.tsx"; 3 + import { 4 + parseApiResponse, 5 + StepCommonProps, 6 + verifyMigrationStep, 7 + } from "../../lib/migration-types.ts"; 8 + 9 + interface FinalizationStepProps extends StepCommonProps { 10 + isActive: boolean; 11 + } 12 + 13 + export default function FinalizationStep({ 14 + credentials: _credentials, 15 + onStepComplete, 16 + onStepError, 17 + isActive, 18 + }: FinalizationStepProps) { 19 + const [status, setStatus] = useState< 20 + "pending" | "in-progress" | "verifying" | "completed" | "error" 21 + >("pending"); 22 + const [error, setError] = useState<string>(); 23 + const [retryCount, setRetryCount] = useState(0); 24 + const [showContinueAnyway, setShowContinueAnyway] = useState(false); 25 + 26 + useEffect(() => { 27 + if (isActive && status === "pending") { 28 + startFinalization(); 29 + } 30 + }, [isActive]); 31 + 32 + const startFinalization = async () => { 33 + setStatus("in-progress"); 34 + setError(undefined); 35 + 36 + try { 37 + const finalizeRes = await fetch("/api/migrate/finalize", { 38 + method: "POST", 39 + headers: { "Content-Type": "application/json" }, 40 + }); 41 + 42 + const finalizeData = await finalizeRes.text(); 43 + if (!finalizeRes.ok) { 44 + const parsed = parseApiResponse(finalizeData); 45 + throw new Error(parsed.message || "Failed to finalize migration"); 46 + } 47 + 48 + const parsed = parseApiResponse(finalizeData); 49 + if (!parsed.success) { 50 + throw new Error(parsed.message || "Finalization failed"); 51 + } 52 + 53 + // Verify the finalization 54 + await verifyFinalization(); 55 + } catch (error) { 56 + const errorMessage = error instanceof Error 57 + ? error.message 58 + : String(error); 59 + setError(errorMessage); 60 + setStatus("error"); 61 + onStepError(errorMessage); 62 + } 63 + }; 64 + 65 + const verifyFinalization = async () => { 66 + setStatus("verifying"); 67 + 68 + try { 69 + const result = await verifyMigrationStep(4); 70 + 71 + if (result.ready) { 72 + setStatus("completed"); 73 + setRetryCount(0); 74 + setShowContinueAnyway(false); 75 + onStepComplete(); 76 + } else { 77 + const statusDetails = { 78 + activated: result.activated, 79 + validDid: result.validDid, 80 + }; 81 + const errorMessage = `${ 82 + result.reason || "Verification failed" 83 + }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 84 + 85 + setRetryCount((prev) => prev + 1); 86 + if (retryCount >= 1) { 87 + setShowContinueAnyway(true); 88 + } 89 + 90 + setError(errorMessage); 91 + setStatus("error"); 92 + onStepError(errorMessage, true); 93 + } 94 + } catch (error) { 95 + const errorMessage = error instanceof Error 96 + ? error.message 97 + : String(error); 98 + setRetryCount((prev) => prev + 1); 99 + if (retryCount >= 1) { 100 + setShowContinueAnyway(true); 101 + } 102 + 103 + setError(errorMessage); 104 + setStatus("error"); 105 + onStepError(errorMessage, true); 106 + } 107 + }; 108 + 109 + const retryVerification = async () => { 110 + await verifyFinalization(); 111 + }; 112 + 113 + const continueAnyway = () => { 114 + setStatus("completed"); 115 + setShowContinueAnyway(false); 116 + onStepComplete(); 117 + }; 118 + 119 + return ( 120 + <MigrationStep 121 + name="Finalize Migration" 122 + status={status} 123 + error={error} 124 + isVerificationError={status === "error" && 125 + error?.includes("Verification failed")} 126 + index={3} 127 + onRetryVerification={retryVerification} 128 + > 129 + {status === "error" && showContinueAnyway && ( 130 + <div class="flex space-x-2 mt-2"> 131 + <button 132 + type="button" 133 + onClick={continueAnyway} 134 + class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200 135 + dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700" 136 + > 137 + Continue Anyway 138 + </button> 139 + </div> 140 + )} 141 + </MigrationStep> 142 + ); 143 + }
+294
islands/migration-steps/IdentityMigrationStep.tsx
··· 1 + import { useEffect, useRef, useState } from "preact/hooks"; 2 + import { MigrationStep } from "../../components/MigrationStep.tsx"; 3 + import { 4 + parseApiResponse, 5 + StepCommonProps, 6 + verifyMigrationStep, 7 + } from "../../lib/migration-types.ts"; 8 + 9 + interface IdentityMigrationStepProps extends StepCommonProps { 10 + isActive: boolean; 11 + } 12 + 13 + export default function IdentityMigrationStep({ 14 + credentials: _credentials, 15 + onStepComplete, 16 + onStepError, 17 + isActive, 18 + }: IdentityMigrationStepProps) { 19 + const [status, setStatus] = useState< 20 + "pending" | "in-progress" | "verifying" | "completed" | "error" 21 + >("pending"); 22 + const [error, setError] = useState<string>(); 23 + const [retryCount, setRetryCount] = useState(0); 24 + const [showContinueAnyway, setShowContinueAnyway] = useState(false); 25 + const [token, setToken] = useState(""); 26 + const [identityRequestSent, setIdentityRequestSent] = useState(false); 27 + const [identityRequestCooldown, setIdentityRequestCooldown] = useState(0); 28 + const [cooldownInterval, setCooldownInterval] = useState<number | null>(null); 29 + const [stepName, setStepName] = useState("Migrate Identity"); 30 + const identityRequestInProgressRef = useRef(false); 31 + 32 + // Clean up interval on unmount 33 + useEffect(() => { 34 + return () => { 35 + if (cooldownInterval !== null) { 36 + clearInterval(cooldownInterval); 37 + } 38 + }; 39 + }, [cooldownInterval]); 40 + 41 + useEffect(() => { 42 + if (isActive && status === "pending") { 43 + startIdentityMigration(); 44 + } 45 + }, [isActive]); 46 + 47 + const startIdentityMigration = async () => { 48 + // Prevent multiple concurrent calls 49 + if (identityRequestInProgressRef.current) { 50 + return; 51 + } 52 + 53 + identityRequestInProgressRef.current = true; 54 + setStatus("in-progress"); 55 + setError(undefined); 56 + 57 + // Don't send duplicate requests 58 + if (identityRequestSent) { 59 + setStepName( 60 + "Enter the token sent to your email to complete identity migration", 61 + ); 62 + setTimeout(() => { 63 + identityRequestInProgressRef.current = false; 64 + }, 1000); 65 + return; 66 + } 67 + 68 + try { 69 + const requestRes = await fetch("/api/migrate/identity/request", { 70 + method: "POST", 71 + headers: { "Content-Type": "application/json" }, 72 + }); 73 + 74 + const requestText = await requestRes.text(); 75 + 76 + if (!requestRes.ok) { 77 + const parsed = parseApiResponse(requestText); 78 + throw new Error( 79 + parsed.message || "Failed to request identity migration", 80 + ); 81 + } 82 + 83 + const parsed = parseApiResponse(requestText); 84 + if (!parsed.success) { 85 + throw new Error(parsed.message || "Identity migration request failed"); 86 + } 87 + 88 + // Mark request as sent 89 + setIdentityRequestSent(true); 90 + 91 + // Handle rate limiting 92 + const jsonData = JSON.parse(requestText); 93 + if (jsonData.rateLimited && jsonData.cooldownRemaining) { 94 + setIdentityRequestCooldown(jsonData.cooldownRemaining); 95 + 96 + // Clear any existing interval 97 + if (cooldownInterval !== null) { 98 + clearInterval(cooldownInterval); 99 + } 100 + 101 + // Set up countdown timer 102 + const intervalId = setInterval(() => { 103 + setIdentityRequestCooldown((prev) => { 104 + if (prev <= 1) { 105 + clearInterval(intervalId); 106 + setCooldownInterval(null); 107 + return 0; 108 + } 109 + return prev - 1; 110 + }); 111 + }, 1000); 112 + 113 + setCooldownInterval(intervalId); 114 + } 115 + 116 + // Update step name to prompt for token 117 + setStepName( 118 + identityRequestCooldown > 0 119 + ? `Please wait ${identityRequestCooldown}s before requesting another code` 120 + : "Enter the token sent to your email to complete identity migration", 121 + ); 122 + } catch (error) { 123 + const errorMessage = error instanceof Error 124 + ? error.message 125 + : String(error); 126 + // Don't mark as error if it was due to rate limiting 127 + if (identityRequestCooldown > 0) { 128 + setStatus("in-progress"); 129 + } else { 130 + setError(errorMessage); 131 + setStatus("error"); 132 + onStepError(errorMessage); 133 + } 134 + } finally { 135 + setTimeout(() => { 136 + identityRequestInProgressRef.current = false; 137 + }, 1000); 138 + } 139 + }; 140 + 141 + const handleIdentityMigration = async () => { 142 + if (!token) return; 143 + 144 + try { 145 + const identityRes = await fetch( 146 + `/api/migrate/identity/sign?token=${encodeURIComponent(token)}`, 147 + { 148 + method: "POST", 149 + headers: { "Content-Type": "application/json" }, 150 + }, 151 + ); 152 + 153 + const identityData = await identityRes.text(); 154 + if (!identityRes.ok) { 155 + const parsed = parseApiResponse(identityData); 156 + throw new Error( 157 + parsed.message || "Failed to complete identity migration", 158 + ); 159 + } 160 + 161 + const parsed = parseApiResponse(identityData); 162 + if (!parsed.success) { 163 + throw new Error(parsed.message || "Identity migration failed"); 164 + } 165 + 166 + // Verify the identity migration 167 + await verifyIdentityMigration(); 168 + } catch (error) { 169 + const errorMessage = error instanceof Error 170 + ? error.message 171 + : String(error); 172 + setError(errorMessage); 173 + setStatus("error"); 174 + onStepError(errorMessage); 175 + } 176 + }; 177 + 178 + const verifyIdentityMigration = async () => { 179 + setStatus("verifying"); 180 + 181 + try { 182 + const result = await verifyMigrationStep(3); 183 + 184 + if (result.ready) { 185 + setStatus("completed"); 186 + setRetryCount(0); 187 + setShowContinueAnyway(false); 188 + onStepComplete(); 189 + } else { 190 + const statusDetails = { 191 + activated: result.activated, 192 + validDid: result.validDid, 193 + }; 194 + const errorMessage = `${ 195 + result.reason || "Verification failed" 196 + }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 197 + 198 + setRetryCount((prev) => prev + 1); 199 + if (retryCount >= 1) { 200 + setShowContinueAnyway(true); 201 + } 202 + 203 + setError(errorMessage); 204 + setStatus("error"); 205 + onStepError(errorMessage, true); 206 + } 207 + } catch (error) { 208 + const errorMessage = error instanceof Error 209 + ? error.message 210 + : String(error); 211 + setRetryCount((prev) => prev + 1); 212 + if (retryCount >= 1) { 213 + setShowContinueAnyway(true); 214 + } 215 + 216 + setError(errorMessage); 217 + setStatus("error"); 218 + onStepError(errorMessage, true); 219 + } 220 + }; 221 + 222 + const retryVerification = async () => { 223 + await verifyIdentityMigration(); 224 + }; 225 + 226 + const continueAnyway = () => { 227 + setStatus("completed"); 228 + setShowContinueAnyway(false); 229 + onStepComplete(); 230 + }; 231 + 232 + return ( 233 + <MigrationStep 234 + name={stepName} 235 + status={status} 236 + error={error} 237 + isVerificationError={status === "error" && 238 + error?.includes("Verification failed")} 239 + index={2} 240 + onRetryVerification={retryVerification} 241 + > 242 + {status === "error" && showContinueAnyway && ( 243 + <div class="flex space-x-2 mt-2"> 244 + <button 245 + type="button" 246 + onClick={continueAnyway} 247 + class="px-3 py-1 text-xs bg-white border border-gray-300 text-gray-700 hover:bg-gray-100 rounded transition-colors duration-200 248 + dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700" 249 + > 250 + Continue Anyway 251 + </button> 252 + </div> 253 + )} 254 + 255 + {(status === "in-progress" || identityRequestSent) && 256 + stepName.includes("Enter the token sent to your email") && 257 + (identityRequestCooldown > 0 258 + ? ( 259 + <div class="mt-4"> 260 + <p class="text-sm text-amber-600 dark:text-amber-400"> 261 + <span class="font-medium">Rate limit:</span> Please wait{" "} 262 + {identityRequestCooldown}{" "} 263 + seconds before requesting another code. Check your email inbox 264 + and spam folder for a previously sent code. 265 + </p> 266 + </div> 267 + ) 268 + : ( 269 + <div class="mt-4 space-y-4"> 270 + <p class="text-sm text-blue-800 dark:text-blue-200"> 271 + Please check your email for the migration token and enter it 272 + below: 273 + </p> 274 + <div class="flex space-x-2"> 275 + <input 276 + type="text" 277 + value={token} 278 + onChange={(e) => setToken(e.currentTarget.value)} 279 + placeholder="Enter token" 280 + class="flex-1 rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-blue-400 dark:focus:ring-blue-400" 281 + /> 282 + <button 283 + type="button" 284 + onClick={handleIdentityMigration} 285 + class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-200" 286 + > 287 + Submit Token 288 + </button> 289 + </div> 290 + </div> 291 + ))} 292 + </MigrationStep> 293 + ); 294 + }
+7
lib/check-dids.ts
··· 1 + import { getSession } from "./sessions.ts"; 2 + 3 + export async function checkDidsMatch(req: Request): Promise<boolean> { 4 + const oldSession = await getSession(req, undefined, false); 5 + const newSession = await getSession(req, undefined, true); 6 + return oldSession.did === newSession.did; 7 + }
+54 -28
lib/cred/sessions.ts
··· 1 1 import { Agent } from "npm:@atproto/api"; 2 2 import { getIronSession, SessionOptions } from "npm:iron-session"; 3 - import { CredentialSession, createSessionOptions } from "../types.ts"; 3 + import { createSessionOptions, CredentialSession } from "../types.ts"; 4 4 5 5 let migrationSessionOptions: SessionOptions; 6 6 let credentialSessionOptions: SessionOptions; 7 7 8 + /** 9 + * Get the session options for the given request. 10 + * @param isMigration - Whether to get the migration session options 11 + * @returns The session options 12 + */ 8 13 async function getOptions(isMigration: boolean) { 9 14 if (isMigration) { 10 15 if (!migrationSessionOptions) { ··· 12 17 } 13 18 return migrationSessionOptions; 14 19 } 15 - 20 + 16 21 if (!credentialSessionOptions) { 17 22 credentialSessionOptions = await createSessionOptions("cred_sid"); 18 23 } 19 24 return credentialSessionOptions; 20 25 } 21 26 27 + /** 28 + * Get the credential session for the given request. 29 + * @param req - The request object 30 + * @param res - The response object 31 + * @param isMigration - Whether to get the migration session 32 + * @returns The credential session 33 + */ 22 34 export async function getCredentialSession( 23 35 req: Request, 24 36 res: Response = new Response(), 25 - isMigration: boolean = false 37 + isMigration: boolean = false, 26 38 ) { 27 39 const options = await getOptions(isMigration); 28 - return getIronSession<CredentialSession>( 29 - req, 30 - res, 31 - options, 32 - ); 40 + return getIronSession<CredentialSession>(req, res, options); 33 41 } 34 42 43 + /** 44 + * Get the credential agent for the given request. 45 + * @param req - The request object 46 + * @param res - The response object 47 + * @param isMigration - Whether to get the migration session 48 + * @returns The credential agent 49 + */ 35 50 export async function getCredentialAgent( 36 51 req: Request, 37 52 res: Response = new Response(), 38 53 isMigration: boolean = false, 39 54 ) { 40 - const session = await getCredentialSession( 41 - req, 42 - res, 43 - isMigration 44 - ); 45 - if (!session.did || !session.service || !session.handle || !session.password) { 55 + const session = await getCredentialSession(req, res, isMigration); 56 + if ( 57 + !session.did || 58 + !session.service || 59 + !session.handle || 60 + !session.password 61 + ) { 46 62 return null; 47 63 } 48 64 ··· 76 92 } 77 93 } 78 94 95 + /** 96 + * Set the credential session for the given request. 97 + * @param req - The request object 98 + * @param res - The response object 99 + * @param data - The credential session data 100 + * @param isMigration - Whether to set the migration session 101 + * @returns The credential session 102 + */ 79 103 export async function setCredentialSession( 80 104 req: Request, 81 105 res: Response, 82 106 data: CredentialSession, 83 107 isMigration: boolean = false, 84 108 ) { 85 - const session = await getCredentialSession( 86 - req, 87 - res, 88 - isMigration 89 - ); 109 + const session = await getCredentialSession(req, res, isMigration); 90 110 session.did = data.did; 91 111 session.handle = data.handle; 92 112 session.service = data.service; ··· 95 115 return session; 96 116 } 97 117 118 + /** 119 + * Get the credential session agent for the given request. 120 + * @param req - The request object 121 + * @param res - The response object 122 + * @param isMigration - Whether to get the migration session 123 + * @returns The credential session agent 124 + */ 98 125 export async function getCredentialSessionAgent( 99 126 req: Request, 100 127 res: Response = new Response(), 101 128 isMigration: boolean = false, 102 129 ) { 103 - const session = await getCredentialSession( 104 - req, 105 - res, 106 - isMigration 107 - ); 130 + const session = await getCredentialSession(req, res, isMigration); 108 131 109 132 console.log("Session state:", { 110 133 hasDid: !!session.did, ··· 113 136 hasPassword: !!session.password, 114 137 hasAccessJwt: !!session.accessJwt, 115 138 service: session.service, 116 - handle: session.handle 139 + handle: session.handle, 117 140 }); 118 141 119 142 if ( 120 - !session.did || !session.service || !session.handle || !session.password 143 + !session.did || 144 + !session.service || 145 + !session.handle || 146 + !session.password 121 147 ) { 122 148 console.log("Missing required session fields"); 123 149 return null; ··· 136 162 const sessionInfo = await agent.com.atproto.server.getSession(); 137 163 console.log("Stored JWT is valid, session info:", { 138 164 did: sessionInfo.data.did, 139 - handle: sessionInfo.data.handle 165 + handle: sessionInfo.data.handle, 140 166 }); 141 167 return agent; 142 168 } catch (err) { ··· 156 182 console.log("Session created successfully:", { 157 183 did: sessionRes.data.did, 158 184 handle: sessionRes.data.handle, 159 - hasAccessJwt: !!sessionRes.data.accessJwt 185 + hasAccessJwt: !!sessionRes.data.accessJwt, 160 186 }); 161 187 162 188 // Store the new token
+35 -2
lib/id-resolver.ts
··· 8 8 pds: string; 9 9 } 10 10 11 + interface DidService { 12 + id: string; 13 + type: string; 14 + serviceEndpoint: string; 15 + } 16 + 17 + /** 18 + * ID resolver instance. 19 + */ 11 20 const idResolver = createIdResolver(); 12 21 export const resolver = createBidirectionalResolver(idResolver); 13 22 23 + /** 24 + * Create the ID resolver. 25 + * @returns The ID resolver 26 + */ 14 27 export function createIdResolver() { 15 28 return new IdResolver(); 16 29 } 17 30 31 + /** 32 + * The bidirectional resolver. 33 + * @interface 34 + */ 18 35 export interface BidirectionalResolver { 19 36 resolveDidToHandle(did: string): Promise<string>; 20 37 resolveDidsToHandles(dids: string[]): Promise<Record<string, string>>; 21 38 resolveDidToPdsUrl(did: string): Promise<string | undefined>; 22 39 } 23 40 41 + /** 42 + * Create the bidirectional resolver. 43 + * @param resolver - The ID resolver 44 + * @returns The bidirectional resolver 45 + */ 24 46 export function createBidirectionalResolver(resolver: IdResolver) { 25 47 return { 26 48 async resolveDidToHandle(did: string): Promise<string> { ··· 33 55 }, 34 56 35 57 async resolveHandleToDid(handle: string) { 36 - return await resolver.handle.resolve(handle) as Did 58 + return await resolver.handle.resolve(handle) as Did; 37 59 }, 38 60 39 61 async resolveDidToPdsUrl(did: string): Promise<string | undefined> { 40 62 try { 63 + // First try the standard resolution 41 64 const didDoc = await resolver.did.resolveAtprotoData( 42 65 did, 43 66 ) as AtprotoData; 44 - return didDoc.pds; 67 + if (didDoc.pds) { 68 + return didDoc.pds; 69 + } else { 70 + const forcedDidDoc = await resolver.did.resolveAtprotoData( 71 + did, 72 + true, 73 + ); 74 + if (forcedDidDoc.pds) { 75 + return forcedDidDoc.pds; 76 + } 77 + } 45 78 } catch (err) { 46 79 console.error("Error resolving PDS URL:", err); 47 80 return undefined;
+73
lib/migration-state.ts
··· 1 + /** 2 + * Migration state types and utilities for controlling migration availability. 3 + */ 4 + 5 + export type MigrationState = "up" | "issue" | "maintenance"; 6 + 7 + export interface MigrationStateInfo { 8 + state: MigrationState; 9 + message: string; 10 + allowMigration: boolean; 11 + } 12 + 13 + /** 14 + * Get the current migration state from environment variables. 15 + * @returns The migration state information 16 + */ 17 + export function getMigrationState(): MigrationStateInfo { 18 + const state = (Deno.env.get("MIGRATION_STATE") || "up") 19 + .toLowerCase() as MigrationState; 20 + 21 + switch (state) { 22 + case "issue": 23 + return { 24 + state: "issue", 25 + message: 26 + "Migration services are temporarily unavailable as we investigate an issue. Please try again later.", 27 + allowMigration: false, 28 + }; 29 + 30 + case "maintenance": 31 + return { 32 + state: "maintenance", 33 + message: 34 + "Migration services are temporarily unavailable for maintenance. Please try again later.", 35 + allowMigration: false, 36 + }; 37 + 38 + case "up": 39 + default: 40 + return { 41 + state: "up", 42 + message: "Migration services are operational.", 43 + allowMigration: true, 44 + }; 45 + } 46 + } 47 + 48 + /** 49 + * Check if migrations are currently allowed. 50 + * @returns True if migrations are allowed, false otherwise 51 + */ 52 + export function isMigrationAllowed(): boolean { 53 + return getMigrationState().allowMigration; 54 + } 55 + 56 + /** 57 + * Get a user-friendly message for the current migration state. 58 + * @returns The message to display to users 59 + */ 60 + export function getMigrationStateMessage(): string { 61 + return getMigrationState().message; 62 + } 63 + 64 + /** 65 + * Throw an error if migrations are not allowed. 66 + * Used in API endpoints to prevent migration operations when disabled. 67 + */ 68 + export function assertMigrationAllowed(): void { 69 + const stateInfo = getMigrationState(); 70 + if (!stateInfo.allowMigration) { 71 + throw new Error(stateInfo.message); 72 + } 73 + }
+63
lib/migration-types.ts
··· 1 + /** 2 + * Shared types for migration components 3 + */ 4 + 5 + export interface MigrationStateInfo { 6 + state: "up" | "issue" | "maintenance"; 7 + message: string; 8 + allowMigration: boolean; 9 + } 10 + 11 + export interface MigrationCredentials { 12 + service: string; 13 + handle: string; 14 + email: string; 15 + password: string; 16 + invite?: string; 17 + } 18 + 19 + export interface StepCommonProps { 20 + credentials: MigrationCredentials; 21 + onStepComplete: () => void; 22 + onStepError: (error: string, isVerificationError?: boolean) => void; 23 + } 24 + 25 + export interface VerificationResult { 26 + ready: boolean; 27 + reason?: string; 28 + activated?: boolean; 29 + validDid?: boolean; 30 + repoCommit?: boolean; 31 + repoRev?: boolean; 32 + repoBlocks?: number; 33 + expectedRecords?: number; 34 + indexedRecords?: number; 35 + privateStateValues?: number; 36 + expectedBlobs?: number; 37 + importedBlobs?: number; 38 + } 39 + 40 + /** 41 + * Helper function to verify a migration step 42 + */ 43 + export async function verifyMigrationStep( 44 + stepNum: number, 45 + ): Promise<VerificationResult> { 46 + const res = await fetch(`/api/migrate/status?step=${stepNum}`); 47 + const data = await res.json(); 48 + return data; 49 + } 50 + 51 + /** 52 + * Helper function to handle API responses with proper error parsing 53 + */ 54 + export function parseApiResponse( 55 + responseText: string, 56 + ): { success: boolean; message?: string } { 57 + try { 58 + const json = JSON.parse(responseText); 59 + return { success: json.success !== false, message: json.message }; 60 + } catch { 61 + return { success: responseText.trim() !== "", message: responseText }; 62 + } 63 + }
+5
lib/oauth/client.ts
··· 1 1 import { AtprotoOAuthClient } from "@bigmoves/atproto-oauth-client"; 2 2 import { SessionStore, StateStore } from "../storage.ts"; 3 3 4 + /** 5 + * Create the OAuth client. 6 + * @param db - The Deno KV instance for the database 7 + * @returns The OAuth client 8 + */ 4 9 export const createClient = (db: Deno.Kv) => { 5 10 if (Deno.env.get("NODE_ENV") == "production" && !Deno.env.get("PUBLIC_URL")) { 6 11 throw new Error("PUBLIC_URL is not set");
+17 -2
lib/oauth/sessions.ts
··· 1 1 import { Agent } from "npm:@atproto/api"; 2 2 import { getIronSession, SessionOptions } from "npm:iron-session"; 3 3 import { oauthClient } from "./client.ts"; 4 - import { OauthSession, createSessionOptions } from "../types.ts"; 4 + import { createSessionOptions, OauthSession } from "../types.ts"; 5 5 6 6 let oauthSessionOptions: SessionOptions; 7 7 8 + /** 9 + * Get the OAuth session options. 10 + * @returns The OAuth session options 11 + */ 8 12 async function getOptions() { 9 13 if (!oauthSessionOptions) { 10 14 oauthSessionOptions = await createSessionOptions("oauth_sid"); ··· 12 16 return oauthSessionOptions; 13 17 } 14 18 19 + /** 20 + * Get the OAuth session agent for the given request. 21 + * @param req - The request object 22 + * @returns The OAuth session agent 23 + */ 15 24 export async function getOauthSessionAgent( 16 - req: Request 25 + req: Request, 17 26 ) { 18 27 try { 19 28 console.log("Getting OAuth session..."); ··· 47 56 } 48 57 } 49 58 59 + /** 60 + * Get the OAuth session for the given request. 61 + * @param req - The request object 62 + * @param res - The response object 63 + * @returns The OAuth session 64 + */ 50 65 export async function getOauthSession( 51 66 req: Request, 52 67 res: Response = new Response(),
+49 -10
lib/sessions.ts
··· 1 1 import { Agent } from "npm:@atproto/api"; 2 - import { OauthSession, CredentialSession } from "./types.ts"; 3 - import { getCredentialSession, getCredentialSessionAgent } from "./cred/sessions.ts"; 2 + import { CredentialSession, OauthSession } from "./types.ts"; 3 + import { 4 + getCredentialSession, 5 + getCredentialSessionAgent, 6 + } from "./cred/sessions.ts"; 4 7 import { getOauthSession, getOauthSessionAgent } from "./oauth/sessions.ts"; 5 8 import { IronSession } from "npm:iron-session"; 6 9 10 + /** 11 + * Get the session for the given request. 12 + * @param req - The request object 13 + * @param res - The response object 14 + * @param isMigration - Whether to get the migration session 15 + * @returns The session 16 + */ 7 17 export async function getSession( 8 18 req: Request, 9 19 res: Response = new Response(), 10 - isMigration: boolean = false 20 + isMigration: boolean = false, 11 21 ): Promise<IronSession<OauthSession | CredentialSession>> { 12 22 if (isMigration) { 13 23 return await getCredentialSession(req, res, true); ··· 16 26 const credentialSession = await getCredentialSession(req, res); 17 27 18 28 if (oauthSession.did) { 19 - console.log("Oauth session found") 29 + console.log("Oauth session found"); 20 30 return oauthSession; 21 31 } 22 32 if (credentialSession.did) { ··· 26 36 throw new Error("No session found"); 27 37 } 28 38 39 + /** 40 + * Get the session agent for the given request. 41 + * @param req - The request object 42 + * @param res - The response object 43 + * @param isMigration - Whether to get the migration session 44 + * @returns The session agent 45 + */ 29 46 export async function getSessionAgent( 30 47 req: Request, 31 48 res: Response = new Response(), 32 - isMigration: boolean = false 49 + isMigration: boolean = false, 33 50 ): Promise<Agent | null> { 34 51 if (isMigration) { 35 52 return await getCredentialSessionAgent(req, res, isMigration); 36 53 } 37 54 38 55 const oauthAgent = await getOauthSessionAgent(req); 39 - const credentialAgent = await getCredentialSessionAgent(req, res, isMigration); 56 + const credentialAgent = await getCredentialSessionAgent( 57 + req, 58 + res, 59 + isMigration, 60 + ); 40 61 41 62 if (oauthAgent) { 42 63 return oauthAgent; ··· 49 70 return null; 50 71 } 51 72 52 - export async function destroyAllSessions(req: Request) { 53 - const oauthSession = await getOauthSession(req); 54 - const credentialSession = await getCredentialSession(req); 55 - const migrationSession = await getCredentialSession(req, new Response(), true); 73 + /** 74 + * Destroy all sessions for the given request. 75 + * @param req - The request object 76 + * @param res - The response object 77 + */ 78 + export async function destroyAllSessions( 79 + req: Request, 80 + res?: Response, 81 + ): Promise<Response> { 82 + const response = res || new Response(); 83 + const oauthSession = await getOauthSession(req, response); 84 + const credentialSession = await getCredentialSession(req, res); 85 + const migrationSession = await getCredentialSession( 86 + req, 87 + res, 88 + true, 89 + ); 56 90 57 91 if (oauthSession.did) { 58 92 oauthSession.destroy(); ··· 61 95 credentialSession.destroy(); 62 96 } 63 97 if (migrationSession.did) { 98 + console.log("DESTROYING MIGRATION SESSION", migrationSession); 64 99 migrationSession.destroy(); 100 + } else { 101 + console.log("MIGRATION SESSION NOT FOUND", migrationSession); 65 102 } 103 + 104 + return response; 66 105 }
+9 -1
lib/storage.ts
··· 3 3 NodeSavedSessionStore, 4 4 NodeSavedState, 5 5 NodeSavedStateStore, 6 - } from "jsr:@bigmoves/atproto-oauth-client"; 6 + } from "@bigmoves/atproto-oauth-client"; 7 7 8 + /** 9 + * The state store for sessions. 10 + * @implements {NodeSavedStateStore} 11 + */ 8 12 export class StateStore implements NodeSavedStateStore { 9 13 constructor(private db: Deno.Kv) {} 10 14 async get(key: string): Promise<NodeSavedState | undefined> { ··· 19 23 } 20 24 } 21 25 26 + /** 27 + * The session store for sessions. 28 + * @implements {NodeSavedSessionStore} 29 + */ 22 30 export class SessionStore implements NodeSavedSessionStore { 23 31 constructor(private db: Deno.Kv) {} 24 32 async get(key: string): Promise<NodeSavedSession | undefined> {
+56 -27
lib/types.ts
··· 1 1 import { SessionOptions as BaseSessionOptions } from "npm:iron-session"; 2 2 3 + /** 4 + * The session options. 5 + * @type {SessionOptions} 6 + * @implements {BaseSessionOptions} 7 + */ 3 8 interface SessionOptions extends BaseSessionOptions { 4 9 lockFn?: (key: string) => Promise<() => Promise<void>>; 5 10 } 6 11 7 - // Helper function to create a lock using Deno KV 8 - async function createLock(key: string, db: Deno.Kv): Promise<() => Promise<void>> { 12 + /** 13 + * Create a lock using Deno KV. 14 + * @param key - The key to lock 15 + * @param db - The Deno KV instance for the database 16 + * @returns The unlock function 17 + */ 18 + async function createLock( 19 + key: string, 20 + db: Deno.Kv, 21 + ): Promise<() => Promise<void>> { 9 22 const lockKey = ["session_lock", key]; 10 23 const lockValue = Date.now(); 11 - 24 + 12 25 // Try to acquire lock 13 26 const result = await db.atomic() 14 - .check({ key: lockKey, versionstamp: null }) // Only if key doesn't exist 15 - .set(lockKey, lockValue, { expireIn: 5000 }) // 5 second TTL 27 + .check({ key: lockKey, versionstamp: null }) // Only if key doesn't exist 28 + .set(lockKey, lockValue, { expireIn: 5000 }) // 5 second TTL 16 29 .commit(); 17 30 18 31 if (!result.ok) { ··· 25 38 }; 26 39 } 27 40 41 + /** 42 + * The OAuth session. 43 + * @type {OauthSession} 44 + */ 28 45 export interface OauthSession { 29 - did: string 46 + did: string; 30 47 } 31 48 49 + /** 50 + * The credential session. 51 + * @type {CredentialSession} 52 + */ 32 53 export interface CredentialSession { 33 54 did: string; 34 55 handle: string; ··· 45 66 46 67 let db: Deno.Kv; 47 68 48 - export const createSessionOptions = async (cookieName: string): Promise<SessionOptions> => { 49 - const cookieSecret = Deno.env.get("COOKIE_SECRET"); 50 - if (!cookieSecret) { 51 - throw new Error("COOKIE_SECRET is not set"); 52 - } 69 + /** 70 + * Create the session options. 71 + * @param cookieName - The name of the iron session cookie 72 + * @returns The session options for iron session 73 + */ 74 + export const createSessionOptions = async ( 75 + cookieName: string, 76 + ): Promise<SessionOptions> => { 77 + const cookieSecret = Deno.env.get("COOKIE_SECRET"); 78 + if (!cookieSecret) { 79 + throw new Error("COOKIE_SECRET is not set"); 80 + } 53 81 54 - if (!db) { 55 - db = await Deno.openKv(); 56 - } 82 + if (!db) { 83 + db = await Deno.openKv(); 84 + } 57 85 58 - return { 59 - cookieName: cookieName, 60 - password: cookieSecret, 61 - cookieOptions: { 62 - secure: Deno.env.get("NODE_ENV") === "production" || Deno.env.get("NODE_ENV") === "staging", 63 - httpOnly: true, 64 - sameSite: "lax", 65 - path: "/", 66 - domain: undefined, 67 - }, 68 - lockFn: (key: string) => createLock(key, db) 69 - } 70 - }; 86 + return { 87 + cookieName: cookieName, 88 + password: cookieSecret, 89 + cookieOptions: { 90 + secure: Deno.env.get("NODE_ENV") === "production" || 91 + Deno.env.get("NODE_ENV") === "staging", 92 + httpOnly: true, 93 + sameSite: "lax", 94 + path: "/", 95 + domain: undefined, 96 + }, 97 + lockFn: (key: string) => createLock(key, db), 98 + }; 99 + };
+5 -5
routes/_error.tsx
··· 1 - import { PageProps, HttpError } from "fresh"; 1 + import { HttpError, PageProps } from "fresh"; 2 2 import posthog from "posthog-js"; 3 3 4 4 export default function ErrorPage(props: PageProps) { 5 5 const error = props.error; // Contains the thrown Error or HTTPError 6 6 if (error instanceof HttpError) { 7 - posthog.default.capture('error', { 7 + posthog.default.capture("error", { 8 8 error: error.message, 9 9 status: error.status, 10 10 }); ··· 32 32 FLIGHT NOT FOUND 33 33 </p> 34 34 <p class="text-lg sm:text-xl text-slate-600 dark:text-white/70 max-w-2xl"> 35 - We couldn't locate the destination you're looking for. Please 36 - check your flight number and try again. 35 + We couldn't locate the destination you're looking for. 36 + Please check your flight number and try again. 37 37 </p> 38 38 <div class="mt-8"> 39 39 <a ··· 48 48 </div> 49 49 </div> 50 50 </> 51 - ) 51 + ); 52 52 } 53 53 } 54 54
+134
routes/about.tsx
··· 1 + import { Button } from "../components/Button.tsx"; 2 + 3 + export default function About() { 4 + return ( 5 + <> 6 + <div class="px-2 sm:px-4 py-4 sm:py-8 mx-auto"> 7 + <div class="max-w-screen-lg mx-auto flex flex-col items-center justify-center"> 8 + <div class="prose dark:prose-invert max-w-none w-full mb-0"> 9 + <h1 class="text-3xl font-bold text-center mb-8"> 10 + About AT Protocol 11 + </h1> 12 + 13 + <div class="space-y-6"> 14 + <section> 15 + <h2 class="text-2xl font-semibold mb-4"> 16 + What is AT Protocol? 17 + </h2> 18 + <p class="text-gray-600 dark:text-gray-300"> 19 + AT Protocol (Authenticated Transfer Protocol) is the 20 + foundation of Bluesky and other social apps like 21 + <a href="https://tangled.sh">Tangled</a>, 22 + <a href="https://spark.com">Spark</a>, and more. Unlike 23 + traditional social platforms that lock your data and identity 24 + to a single service, AT Protocol gives you complete control 25 + over your digital presence. Think of it as an open standard 26 + for social networking, similar to how email works across 27 + different providers. 28 + </p> 29 + </section> 30 + 31 + <section> 32 + <h2 class="text-2xl font-semibold mb-4">Key Features</h2> 33 + <ul class="list-disc pl-6 space-y-4 text-gray-600 dark:text-gray-300"> 34 + <li> 35 + <strong>PDS Servers:</strong>{" "} 36 + PDS servers are where your data is stored. They can be run 37 + by anyone, and they are very lightweight, allowing you to 38 + choose which one to use or run your own. PDS servers just 39 + store your data, meaning you don't have to switch PDS 40 + servers to use a different app or service. You can have one 41 + PDS while using many different apps and services with the 42 + same account. 43 + </li> 44 + <li> 45 + <strong>Decentralized Identity:</strong>{" "} 46 + Your account is tied to a DID (Decentralized Identifier) 47 + rather than your handle/username. This means you can move 48 + your entire account, including your followers and content, 49 + to any PDS by changing where your DID points. It's also the 50 + reason you can use any domain as your handle, because your 51 + identity is not tied to your handle. Your handle can change, 52 + but your DID will always remain the same. 53 + </li> 54 + <li> 55 + <strong>Portable Content:</strong>{" "} 56 + All your posts, likes, and other social data are stored in 57 + your Personal Data Server (PDS). You can switch PDS 58 + providers without losing any content or connections. 59 + </li> 60 + <li> 61 + <strong>Architecture:</strong>{" "} 62 + The protocol uses a three-tier architecture: Personal Data 63 + Servers (PDS) store your content, relays broadcast a stream 64 + of all events on all PDSes, and AppViews process and serve 65 + that stream into content for users. This means when you make 66 + a post, the content is stored on your PDS, picked up by 67 + relays, and AppViews listen to those relays to deliver that 68 + post to all users. 69 + </li> 70 + <li> 71 + <strong>Algorithmic Choice:</strong>{" "} 72 + You're not locked into a single algorithm for your feed. 73 + Different services can offer different ways of curating 74 + content, and you can choose which one you prefer. Bluesky 75 + offers a way to make custom feeds, but even if it didn't, 76 + different apps could still offer their own algorithms for 77 + curating content. 78 + </li> 79 + </ul> 80 + </section> 81 + 82 + <section> 83 + <h2 class="text-2xl font-semibold mb-4">Learn More</h2> 84 + <div class="space-y-4"> 85 + <p class="text-gray-600 dark:text-gray-300"> 86 + Want to dive deeper into AT Protocol? Check out these 87 + resources: 88 + </p> 89 + <ul class="list-none space-y-2"> 90 + <li> 91 + <a 92 + href="https://atproto.com" 93 + class="text-blue-500 hover:underline" 94 + > 95 + Official AT Protocol Docs 96 + </a>{" "} 97 + - The main source for protocol specs and information 98 + </li> 99 + <li> 100 + <a 101 + href="https://github.com/bluesky-social/atproto" 102 + class="text-blue-500 hover:underline" 103 + > 104 + GitHub Repository 105 + </a>{" "} 106 + - View the protocol implementation 107 + </li> 108 + <li> 109 + <a 110 + href="https://atproto.wiki" 111 + class="text-blue-500 hover:underline" 112 + > 113 + AT Protocol Wiki 114 + </a>{" "} 115 + - Community-driven documentation and resources 116 + </li> 117 + </ul> 118 + </div> 119 + </section> 120 + </div> 121 + 122 + <div class="mt-8 text-center"> 123 + <Button 124 + href="/" 125 + color="blue" 126 + label="Back to Home" 127 + /> 128 + </div> 129 + </div> 130 + </div> 131 + </div> 132 + </> 133 + ); 134 + }
+66 -41
routes/api/cred/login.ts
··· 3 3 import { define } from "../../../utils.ts"; 4 4 import { Agent } from "npm:@atproto/api"; 5 5 6 + /** 7 + * Handle credential login 8 + * Save iron session to cookies 9 + * Save credential session state to database 10 + * @param ctx - The context object containing the request and response 11 + * @returns A response object with the login result 12 + */ 6 13 export const handler = define.handlers({ 7 14 async POST(ctx) { 8 15 try { ··· 10 17 const { handle, password } = body; 11 18 12 19 if (!handle || !password) { 13 - return new Response(JSON.stringify({ 14 - success: false, 15 - message: "Handle and password are required" 16 - }), { 17 - status: 400, 18 - headers: { "Content-Type": "application/json" } 19 - }); 20 + return new Response( 21 + JSON.stringify({ 22 + success: false, 23 + message: "Handle and password are required", 24 + }), 25 + { 26 + status: 400, 27 + headers: { "Content-Type": "application/json" }, 28 + } 29 + ); 20 30 } 21 31 22 32 console.log("Resolving handle:", handle); 23 - const did = await resolver.resolveHandleToDid(handle) 24 - const service = await resolver.resolveDidToPdsUrl(did) 33 + const did = 34 + typeof handle == "string" && handle.startsWith("did:") 35 + ? handle 36 + : await resolver.resolveHandleToDid(handle); 37 + const service = await resolver.resolveDidToPdsUrl(did); 25 38 console.log("Resolved service:", service); 26 39 27 40 if (!service) { 28 - return new Response(JSON.stringify({ 29 - success: false, 30 - message: "Invalid handle" 31 - }), { 32 - status: 400, 33 - }) 41 + return new Response( 42 + JSON.stringify({ 43 + success: false, 44 + message: "Invalid handle", 45 + }), 46 + { 47 + status: 400, 48 + } 49 + ); 34 50 } 35 51 36 52 try { ··· 44 60 console.log("Created ATProto session:", { 45 61 did: sessionRes.data.did, 46 62 handle: sessionRes.data.handle, 47 - hasAccessJwt: !!sessionRes.data.accessJwt 63 + hasAccessJwt: !!sessionRes.data.accessJwt, 48 64 }); 49 65 50 66 // Create response for setting cookies 51 - const response = new Response(JSON.stringify({ 52 - success: true, 53 - did, 54 - handle 55 - }), { 56 - status: 200, 57 - headers: { "Content-Type": "application/json" } 58 - }); 67 + const response = new Response( 68 + JSON.stringify({ 69 + success: true, 70 + did, 71 + handle, 72 + }), 73 + { 74 + status: 200, 75 + headers: { "Content-Type": "application/json" }, 76 + } 77 + ); 59 78 60 79 // Create and save our client session with tokens 61 80 await setCredentialSession(ctx.req, response, { ··· 63 82 service, 64 83 password, 65 84 handle, 66 - accessJwt: sessionRes.data.accessJwt 85 + accessJwt: sessionRes.data.accessJwt, 67 86 }); 68 87 69 88 // Log the response headers 70 89 console.log("Response headers:", { 71 90 cookies: response.headers.get("Set-Cookie"), 72 - allHeaders: Object.fromEntries(response.headers.entries()) 91 + allHeaders: Object.fromEntries(response.headers.entries()), 73 92 }); 74 93 75 94 return response; 76 95 } catch (err) { 77 96 const message = err instanceof Error ? err.message : String(err); 78 97 console.error("Login failed:", message); 79 - return new Response(JSON.stringify({ 80 - success: false, 81 - message: "Invalid credentials" 82 - }), { 83 - status: 401, 84 - headers: { "Content-Type": "application/json" } 85 - }); 98 + return new Response( 99 + JSON.stringify({ 100 + success: false, 101 + message: "Invalid credentials", 102 + }), 103 + { 104 + status: 401, 105 + headers: { "Content-Type": "application/json" }, 106 + } 107 + ); 86 108 } 87 109 } catch (error) { 88 110 const message = error instanceof Error ? error.message : String(error); 89 111 console.error("Login error:", message); 90 - return new Response(JSON.stringify({ 91 - success: false, 92 - message: error instanceof Error ? error.message : "An error occurred" 93 - }), { 94 - status: 500, 95 - headers: { "Content-Type": "application/json" } 96 - }); 112 + return new Response( 113 + JSON.stringify({ 114 + success: false, 115 + message: error instanceof Error ? error.message : "An error occurred", 116 + }), 117 + { 118 + status: 500, 119 + headers: { "Content-Type": "application/json" }, 120 + } 121 + ); 97 122 } 98 - } 123 + }, 99 124 });
+6 -3
routes/api/logout.ts
··· 1 - import { getSession } from "../../lib/sessions.ts"; 1 + import { destroyAllSessions, getSession } from "../../lib/sessions.ts"; 2 2 import { oauthClient } from "../../lib/oauth/client.ts"; 3 3 import { define } from "../../utils.ts"; 4 4 ··· 13 13 if (session.did) { 14 14 // Try to revoke both types of sessions - the one that doesn't exist will just no-op 15 15 await Promise.all([ 16 - oauthClient.revoke(session.did).catch(console.error) 16 + oauthClient.revoke(session.did).catch(console.error), 17 17 ]); 18 18 // Then destroy the iron session 19 19 session.destroy(); 20 20 } 21 21 22 - return response; 22 + // Destroy all sessions including migration session 23 + const result = await destroyAllSessions(req, response); 24 + 25 + return result; 23 26 } catch (error: unknown) { 24 27 const err = error instanceof Error ? error : new Error(String(error)); 25 28 console.error("Logout failed:", err.message);
+12 -9
routes/api/me.ts
··· 8 8 const res = new Response(); 9 9 10 10 try { 11 - console.log("[/api/me] Request headers:", Object.fromEntries(req.headers.entries())); 11 + console.log( 12 + "[/api/me] Request headers:", 13 + Object.fromEntries(req.headers.entries()), 14 + ); 12 15 13 16 const agent = await getSessionAgent(req, res); 14 17 if (!agent) { ··· 17 20 status: 200, 18 21 headers: { 19 22 "Content-Type": "application/json", 20 - "X-Response-Type": "null" 21 - } 23 + "X-Response-Type": "null", 24 + }, 22 25 }); 23 26 } 24 27 ··· 28 31 29 32 const responseData = { 30 33 did: session.data.did, 31 - handle 34 + handle, 32 35 }; 33 36 34 37 return new Response(JSON.stringify(responseData), { 35 38 status: 200, 36 39 headers: { 37 40 "Content-Type": "application/json", 38 - "X-Response-Type": "user" 39 - } 41 + "X-Response-Type": "user", 42 + }, 40 43 }); 41 44 } catch (err) { 42 45 const message = err instanceof Error ? err.message : String(err); ··· 45 48 stack: err instanceof Error ? err.stack : undefined, 46 49 url: req.url, 47 50 method: req.method, 48 - headers: Object.fromEntries(req.headers.entries()) 51 + headers: Object.fromEntries(req.headers.entries()), 49 52 }); 50 53 51 54 return new Response(JSON.stringify(null), { ··· 53 56 headers: { 54 57 "Content-Type": "application/json", 55 58 "X-Response-Type": "error", 56 - "X-Error-Message": encodeURIComponent(message) 57 - } 59 + "X-Error-Message": encodeURIComponent(message), 60 + }, 58 61 }); 59 62 } 60 63 },
+18 -2
routes/api/migrate/create.ts
··· 2 2 import { setCredentialSession } from "../../../lib/cred/sessions.ts"; 3 3 import { Agent } from "@atproto/api"; 4 4 import { define } from "../../../utils.ts"; 5 + import { assertMigrationAllowed } from "../../../lib/migration-state.ts"; 5 6 7 + /** 8 + * Handle account creation 9 + * First step of the migration process 10 + * Body must contain: 11 + * - service: The service URL of the new account 12 + * - handle: The handle of the new account 13 + * - password: The password of the new account 14 + * - email: The email of the new account 15 + * - invite: The invite code of the new account (optional depending on the PDS) 16 + * @param ctx - The context object containing the request and response 17 + * @returns A response object with the creation result 18 + */ 6 19 export const handler = define.handlers({ 7 20 async POST(ctx) { 8 21 const res = new Response(); 9 22 try { 23 + // Check if migrations are currently allowed 24 + assertMigrationAllowed(); 25 + 10 26 const body = await ctx.req.json(); 11 27 const serviceUrl = body.service; 12 28 const newHandle = body.handle; ··· 29 45 return new Response("Could not create new agent", { status: 400 }); 30 46 } 31 47 32 - console.log("getting did") 48 + console.log("getting did"); 33 49 const session = await oldAgent.com.atproto.server.getSession(); 34 50 const accountDid = session.data.did; 35 - console.log("got did") 51 + console.log("got did"); 36 52 const describeRes = await newAgent.com.atproto.server.describeServer(); 37 53 const newServerDid = describeRes.data.did; 38 54 const inviteRequired = describeRes.data.inviteCodeRequired ?? false;
+359
routes/api/migrate/data/blobs.ts
··· 1 + import { getSessionAgent } from "../../../../lib/sessions.ts"; 2 + import { checkDidsMatch } from "../../../../lib/check-dids.ts"; 3 + import { define } from "../../../../utils.ts"; 4 + import { assertMigrationAllowed } from "../../../../lib/migration-state.ts"; 5 + 6 + export const handler = define.handlers({ 7 + async POST(ctx) { 8 + const res = new Response(); 9 + try { 10 + // Check if migrations are currently allowed 11 + assertMigrationAllowed(); 12 + 13 + console.log("Blob migration: Starting session retrieval"); 14 + const oldAgent = await getSessionAgent(ctx.req); 15 + console.log("Blob migration: Got old agent:", !!oldAgent); 16 + 17 + const newAgent = await getSessionAgent(ctx.req, res, true); 18 + console.log("Blob migration: Got new agent:", !!newAgent); 19 + 20 + if (!oldAgent) { 21 + return new Response( 22 + JSON.stringify({ 23 + success: false, 24 + message: "Unauthorized", 25 + }), 26 + { 27 + status: 401, 28 + headers: { "Content-Type": "application/json" }, 29 + }, 30 + ); 31 + } 32 + if (!newAgent) { 33 + return new Response( 34 + JSON.stringify({ 35 + success: false, 36 + message: "Migration session not found or invalid", 37 + }), 38 + { 39 + status: 400, 40 + headers: { "Content-Type": "application/json" }, 41 + }, 42 + ); 43 + } 44 + 45 + // Verify DIDs match between sessions 46 + const didsMatch = await checkDidsMatch(ctx.req); 47 + if (!didsMatch) { 48 + return new Response( 49 + JSON.stringify({ 50 + success: false, 51 + message: "Invalid state, original and target DIDs do not match", 52 + }), 53 + { 54 + status: 400, 55 + headers: { "Content-Type": "application/json" }, 56 + }, 57 + ); 58 + } 59 + 60 + // Migrate blobs 61 + const migrationLogs: string[] = []; 62 + const migratedBlobs: string[] = []; 63 + const failedBlobs: string[] = []; 64 + let pageCount = 0; 65 + let blobCursor: string | undefined = undefined; 66 + let totalBlobs = 0; 67 + let processedBlobs = 0; 68 + 69 + const startTime = Date.now(); 70 + console.log(`[${new Date().toISOString()}] Starting blob migration...`); 71 + migrationLogs.push( 72 + `[${new Date().toISOString()}] Starting blob migration...`, 73 + ); 74 + 75 + // First count total blobs 76 + console.log(`[${new Date().toISOString()}] Starting blob count...`); 77 + migrationLogs.push( 78 + `[${new Date().toISOString()}] Starting blob count...`, 79 + ); 80 + 81 + const session = await oldAgent.com.atproto.server.getSession(); 82 + const accountDid = session.data.did; 83 + 84 + do { 85 + const pageStartTime = Date.now(); 86 + console.log( 87 + `[${new Date().toISOString()}] Counting blobs on page ${ 88 + pageCount + 1 89 + }...`, 90 + ); 91 + migrationLogs.push( 92 + `[${new Date().toISOString()}] Counting blobs on page ${ 93 + pageCount + 1 94 + }...`, 95 + ); 96 + const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({ 97 + did: accountDid, 98 + cursor: blobCursor, 99 + }); 100 + 101 + const newBlobs = listedBlobs.data.cids.length; 102 + totalBlobs += newBlobs; 103 + const pageTime = Date.now() - pageStartTime; 104 + 105 + console.log( 106 + `[${new Date().toISOString()}] Found ${newBlobs} blobs on page ${ 107 + pageCount + 1 108 + } in ${pageTime / 1000} seconds, total so far: ${totalBlobs}`, 109 + ); 110 + migrationLogs.push( 111 + `[${new Date().toISOString()}] Found ${newBlobs} blobs on page ${ 112 + pageCount + 1 113 + } in ${pageTime / 1000} seconds, total so far: ${totalBlobs}`, 114 + ); 115 + 116 + pageCount++; 117 + blobCursor = listedBlobs.data.cursor; 118 + } while (blobCursor); 119 + 120 + console.log( 121 + `[${new Date().toISOString()}] Total blobs to migrate: ${totalBlobs}`, 122 + ); 123 + migrationLogs.push( 124 + `[${new Date().toISOString()}] Total blobs to migrate: ${totalBlobs}`, 125 + ); 126 + 127 + // Reset cursor for actual migration 128 + blobCursor = undefined; 129 + pageCount = 0; 130 + processedBlobs = 0; 131 + 132 + do { 133 + const pageStartTime = Date.now(); 134 + console.log( 135 + `[${new Date().toISOString()}] Fetching blob list page ${ 136 + pageCount + 1 137 + }...`, 138 + ); 139 + migrationLogs.push( 140 + `[${new Date().toISOString()}] Fetching blob list page ${ 141 + pageCount + 1 142 + }...`, 143 + ); 144 + 145 + const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({ 146 + did: accountDid, 147 + cursor: blobCursor, 148 + }); 149 + 150 + const pageTime = Date.now() - pageStartTime; 151 + console.log( 152 + `[${ 153 + new Date().toISOString() 154 + }] Found ${listedBlobs.data.cids.length} blobs on page ${ 155 + pageCount + 1 156 + } in ${pageTime / 1000} seconds`, 157 + ); 158 + migrationLogs.push( 159 + `[${ 160 + new Date().toISOString() 161 + }] Found ${listedBlobs.data.cids.length} blobs on page ${ 162 + pageCount + 1 163 + } in ${pageTime / 1000} seconds`, 164 + ); 165 + 166 + blobCursor = listedBlobs.data.cursor; 167 + 168 + for (const cid of listedBlobs.data.cids) { 169 + try { 170 + const blobStartTime = Date.now(); 171 + console.log( 172 + `[${ 173 + new Date().toISOString() 174 + }] Starting migration for blob ${cid} (${ 175 + processedBlobs + 1 176 + } of ${totalBlobs})...`, 177 + ); 178 + migrationLogs.push( 179 + `[${ 180 + new Date().toISOString() 181 + }] Starting migration for blob ${cid} (${ 182 + processedBlobs + 1 183 + } of ${totalBlobs})...`, 184 + ); 185 + 186 + const blobRes = await oldAgent.com.atproto.sync.getBlob({ 187 + did: accountDid, 188 + cid, 189 + }); 190 + 191 + const contentLength = blobRes.headers["content-length"]; 192 + if (!contentLength) { 193 + throw new Error(`Blob ${cid} has no content length`); 194 + } 195 + 196 + const size = parseInt(contentLength, 10); 197 + if (isNaN(size)) { 198 + throw new Error( 199 + `Blob ${cid} has invalid content length: ${contentLength}`, 200 + ); 201 + } 202 + 203 + const MAX_SIZE = 200 * 1024 * 1024; // 200MB 204 + if (size > MAX_SIZE) { 205 + throw new Error( 206 + `Blob ${cid} exceeds maximum size limit (${size} bytes)`, 207 + ); 208 + } 209 + 210 + console.log( 211 + `[${ 212 + new Date().toISOString() 213 + }] Downloading blob ${cid} (${size} bytes)...`, 214 + ); 215 + migrationLogs.push( 216 + `[${ 217 + new Date().toISOString() 218 + }] Downloading blob ${cid} (${size} bytes)...`, 219 + ); 220 + 221 + if (!blobRes.data) { 222 + throw new Error( 223 + `Failed to download blob ${cid}: No data received`, 224 + ); 225 + } 226 + 227 + console.log( 228 + `[${ 229 + new Date().toISOString() 230 + }] Uploading blob ${cid} to new account...`, 231 + ); 232 + migrationLogs.push( 233 + `[${ 234 + new Date().toISOString() 235 + }] Uploading blob ${cid} to new account...`, 236 + ); 237 + 238 + try { 239 + await newAgent.com.atproto.repo.uploadBlob(blobRes.data); 240 + const blobTime = Date.now() - blobStartTime; 241 + console.log( 242 + `[${ 243 + new Date().toISOString() 244 + }] Successfully migrated blob ${cid} in ${ 245 + blobTime / 1000 246 + } seconds`, 247 + ); 248 + migrationLogs.push( 249 + `[${ 250 + new Date().toISOString() 251 + }] Successfully migrated blob ${cid} in ${ 252 + blobTime / 1000 253 + } seconds`, 254 + ); 255 + migratedBlobs.push(cid); 256 + } catch (uploadError) { 257 + console.error( 258 + `[${new Date().toISOString()}] Failed to upload blob ${cid}:`, 259 + uploadError, 260 + ); 261 + throw new Error( 262 + `Upload failed: ${ 263 + uploadError instanceof Error 264 + ? uploadError.message 265 + : String(uploadError) 266 + }`, 267 + ); 268 + } 269 + } catch (error) { 270 + const errorMessage = error instanceof Error 271 + ? error.message 272 + : String(error); 273 + const detailedError = `[${ 274 + new Date().toISOString() 275 + }] Failed to migrate blob ${cid}: ${errorMessage}`; 276 + console.error(detailedError); 277 + console.error("Full error details:", error); 278 + migrationLogs.push(detailedError); 279 + failedBlobs.push(cid); 280 + } 281 + 282 + processedBlobs++; 283 + const progressLog = `[${ 284 + new Date().toISOString() 285 + }] Progress: ${processedBlobs}/${totalBlobs} blobs processed (${ 286 + Math.round((processedBlobs / totalBlobs) * 100) 287 + }%)`; 288 + console.log(progressLog); 289 + migrationLogs.push(progressLog); 290 + } 291 + pageCount++; 292 + } while (blobCursor); 293 + 294 + const totalTime = Date.now() - startTime; 295 + const completionMessage = `[${ 296 + new Date().toISOString() 297 + }] Blob migration completed in ${ 298 + totalTime / 1000 299 + } seconds: ${migratedBlobs.length} blobs migrated${ 300 + failedBlobs.length > 0 ? `, ${failedBlobs.length} failed` : "" 301 + } (${pageCount} pages processed)`; 302 + console.log(completionMessage); 303 + migrationLogs.push(completionMessage); 304 + 305 + return new Response( 306 + JSON.stringify({ 307 + success: true, 308 + message: failedBlobs.length > 0 309 + ? `Blob migration completed with ${failedBlobs.length} failed blobs` 310 + : "Blob migration completed successfully", 311 + migratedBlobs, 312 + failedBlobs, 313 + totalMigrated: migratedBlobs.length, 314 + totalFailed: failedBlobs.length, 315 + totalProcessed: processedBlobs, 316 + totalBlobs, 317 + logs: migrationLogs, 318 + timing: { 319 + totalTime: totalTime / 1000, 320 + }, 321 + }), 322 + { 323 + status: 200, 324 + headers: { 325 + "Content-Type": "application/json", 326 + ...Object.fromEntries(res.headers), 327 + }, 328 + }, 329 + ); 330 + } catch (error) { 331 + const message = error instanceof Error ? error.message : String(error); 332 + console.error( 333 + `[${new Date().toISOString()}] Blob migration error:`, 334 + message, 335 + ); 336 + console.error("Full error details:", error); 337 + return new Response( 338 + JSON.stringify({ 339 + success: false, 340 + message: `Blob migration failed: ${message}`, 341 + error: error instanceof Error 342 + ? { 343 + name: error.name, 344 + message: error.message, 345 + stack: error.stack, 346 + } 347 + : String(error), 348 + }), 349 + { 350 + status: 500, 351 + headers: { 352 + "Content-Type": "application/json", 353 + ...Object.fromEntries(res.headers), 354 + }, 355 + }, 356 + ); 357 + } 358 + }, 359 + });
+163
routes/api/migrate/data/prefs.ts
··· 1 + import { getSessionAgent } from "../../../../lib/sessions.ts"; 2 + import { checkDidsMatch } from "../../../../lib/check-dids.ts"; 3 + import { define } from "../../../../utils.ts"; 4 + import { assertMigrationAllowed } from "../../../../lib/migration-state.ts"; 5 + 6 + export const handler = define.handlers({ 7 + async POST(ctx) { 8 + const res = new Response(); 9 + try { 10 + // Check if migrations are currently allowed 11 + assertMigrationAllowed(); 12 + 13 + console.log("Preferences migration: Starting session retrieval"); 14 + const oldAgent = await getSessionAgent(ctx.req); 15 + console.log("Preferences migration: Got old agent:", !!oldAgent); 16 + 17 + const newAgent = await getSessionAgent(ctx.req, res, true); 18 + console.log("Preferences migration: Got new agent:", !!newAgent); 19 + 20 + if (!oldAgent || !newAgent) { 21 + return new Response( 22 + JSON.stringify({ 23 + success: false, 24 + message: "Not authenticated", 25 + }), 26 + { 27 + status: 401, 28 + headers: { "Content-Type": "application/json" }, 29 + }, 30 + ); 31 + } 32 + 33 + // Verify DIDs match between sessions 34 + const didsMatch = await checkDidsMatch(ctx.req); 35 + if (!didsMatch) { 36 + return new Response( 37 + JSON.stringify({ 38 + success: false, 39 + message: "Invalid state, original and target DIDs do not match", 40 + }), 41 + { 42 + status: 400, 43 + headers: { "Content-Type": "application/json" }, 44 + }, 45 + ); 46 + } 47 + 48 + // Migrate preferences 49 + const migrationLogs: string[] = []; 50 + const startTime = Date.now(); 51 + console.log( 52 + `[${new Date().toISOString()}] Starting preferences migration...`, 53 + ); 54 + migrationLogs.push( 55 + `[${new Date().toISOString()}] Starting preferences migration...`, 56 + ); 57 + 58 + // Fetch preferences 59 + console.log( 60 + `[${ 61 + new Date().toISOString() 62 + }] Fetching preferences from old account...`, 63 + ); 64 + migrationLogs.push( 65 + `[${ 66 + new Date().toISOString() 67 + }] Fetching preferences from old account...`, 68 + ); 69 + 70 + const fetchStartTime = Date.now(); 71 + const prefs = await oldAgent.app.bsky.actor.getPreferences(); 72 + const fetchTime = Date.now() - fetchStartTime; 73 + 74 + console.log( 75 + `[${new Date().toISOString()}] Preferences fetched in ${ 76 + fetchTime / 1000 77 + } seconds`, 78 + ); 79 + migrationLogs.push( 80 + `[${new Date().toISOString()}] Preferences fetched in ${ 81 + fetchTime / 1000 82 + } seconds`, 83 + ); 84 + 85 + // Update preferences 86 + console.log( 87 + `[${new Date().toISOString()}] Updating preferences on new account...`, 88 + ); 89 + migrationLogs.push( 90 + `[${new Date().toISOString()}] Updating preferences on new account...`, 91 + ); 92 + 93 + const updateStartTime = Date.now(); 94 + await newAgent.app.bsky.actor.putPreferences(prefs.data); 95 + const updateTime = Date.now() - updateStartTime; 96 + 97 + console.log( 98 + `[${new Date().toISOString()}] Preferences updated in ${ 99 + updateTime / 1000 100 + } seconds`, 101 + ); 102 + migrationLogs.push( 103 + `[${new Date().toISOString()}] Preferences updated in ${ 104 + updateTime / 1000 105 + } seconds`, 106 + ); 107 + 108 + const totalTime = Date.now() - startTime; 109 + const completionMessage = `[${ 110 + new Date().toISOString() 111 + }] Preferences migration completed in ${totalTime / 1000} seconds total`; 112 + console.log(completionMessage); 113 + migrationLogs.push(completionMessage); 114 + 115 + return new Response( 116 + JSON.stringify({ 117 + success: true, 118 + message: "Preferences migration completed successfully", 119 + logs: migrationLogs, 120 + timing: { 121 + fetchTime: fetchTime / 1000, 122 + updateTime: updateTime / 1000, 123 + totalTime: totalTime / 1000, 124 + }, 125 + }), 126 + { 127 + status: 200, 128 + headers: { 129 + "Content-Type": "application/json", 130 + ...Object.fromEntries(res.headers), 131 + }, 132 + }, 133 + ); 134 + } catch (error) { 135 + const message = error instanceof Error ? error.message : String(error); 136 + console.error( 137 + `[${new Date().toISOString()}] Preferences migration error:`, 138 + message, 139 + ); 140 + console.error("Full error details:", error); 141 + return new Response( 142 + JSON.stringify({ 143 + success: false, 144 + message: `Preferences migration failed: ${message}`, 145 + error: error instanceof Error 146 + ? { 147 + name: error.name, 148 + message: error.message, 149 + stack: error.stack, 150 + } 151 + : String(error), 152 + }), 153 + { 154 + status: 500, 155 + headers: { 156 + "Content-Type": "application/json", 157 + ...Object.fromEntries(res.headers), 158 + }, 159 + }, 160 + ); 161 + } 162 + }, 163 + });
+163
routes/api/migrate/data/repo.ts
··· 1 + import { getSessionAgent } from "../../../../lib/sessions.ts"; 2 + import { checkDidsMatch } from "../../../../lib/check-dids.ts"; 3 + import { define } from "../../../../utils.ts"; 4 + import { assertMigrationAllowed } from "../../../../lib/migration-state.ts"; 5 + 6 + export const handler = define.handlers({ 7 + async POST(ctx) { 8 + const res = new Response(); 9 + try { 10 + // Check if migrations are currently allowed 11 + assertMigrationAllowed(); 12 + 13 + console.log("Repo migration: Starting session retrieval"); 14 + const oldAgent = await getSessionAgent(ctx.req); 15 + console.log("Repo migration: Got old agent:", !!oldAgent); 16 + 17 + const newAgent = await getSessionAgent(ctx.req, res, true); 18 + console.log("Repo migration: Got new agent:", !!newAgent); 19 + 20 + if (!oldAgent || !newAgent) { 21 + return new Response( 22 + JSON.stringify({ 23 + success: false, 24 + message: "Not authenticated", 25 + }), 26 + { 27 + status: 401, 28 + headers: { "Content-Type": "application/json" }, 29 + }, 30 + ); 31 + } 32 + 33 + // Verify DIDs match between sessions 34 + const didsMatch = await checkDidsMatch(ctx.req); 35 + if (!didsMatch) { 36 + return new Response( 37 + JSON.stringify({ 38 + success: false, 39 + message: "Invalid state, original and target DIDs do not match", 40 + }), 41 + { 42 + status: 400, 43 + headers: { "Content-Type": "application/json" }, 44 + }, 45 + ); 46 + } 47 + 48 + const session = await oldAgent.com.atproto.server.getSession(); 49 + const accountDid = session.data.did; 50 + // Migrate repo data 51 + const migrationLogs: string[] = []; 52 + const startTime = Date.now(); 53 + console.log(`[${new Date().toISOString()}] Starting repo migration...`); 54 + migrationLogs.push( 55 + `[${new Date().toISOString()}] Starting repo migration...`, 56 + ); 57 + 58 + // Get repo data from old account 59 + console.log( 60 + `[${new Date().toISOString()}] Fetching repo data from old account...`, 61 + ); 62 + migrationLogs.push( 63 + `[${new Date().toISOString()}] Fetching repo data from old account...`, 64 + ); 65 + 66 + const fetchStartTime = Date.now(); 67 + const repoData = await oldAgent.com.atproto.sync.getRepo({ 68 + did: accountDid, 69 + }); 70 + const fetchTime = Date.now() - fetchStartTime; 71 + 72 + console.log( 73 + `[${new Date().toISOString()}] Repo data fetched in ${ 74 + fetchTime / 1000 75 + } seconds`, 76 + ); 77 + migrationLogs.push( 78 + `[${new Date().toISOString()}] Repo data fetched in ${ 79 + fetchTime / 1000 80 + } seconds`, 81 + ); 82 + 83 + console.log( 84 + `[${new Date().toISOString()}] Importing repo data to new account...`, 85 + ); 86 + migrationLogs.push( 87 + `[${new Date().toISOString()}] Importing repo data to new account...`, 88 + ); 89 + 90 + // Import repo data to new account 91 + const importStartTime = Date.now(); 92 + await newAgent.com.atproto.repo.importRepo(repoData.data, { 93 + encoding: "application/vnd.ipld.car", 94 + }); 95 + const importTime = Date.now() - importStartTime; 96 + 97 + console.log( 98 + `[${new Date().toISOString()}] Repo data imported in ${ 99 + importTime / 1000 100 + } seconds`, 101 + ); 102 + migrationLogs.push( 103 + `[${new Date().toISOString()}] Repo data imported in ${ 104 + importTime / 1000 105 + } seconds`, 106 + ); 107 + 108 + const totalTime = Date.now() - startTime; 109 + const completionMessage = `[${ 110 + new Date().toISOString() 111 + }] Repo migration completed in ${totalTime / 1000} seconds total`; 112 + console.log(completionMessage); 113 + migrationLogs.push(completionMessage); 114 + 115 + return new Response( 116 + JSON.stringify({ 117 + success: true, 118 + message: "Repo migration completed successfully", 119 + logs: migrationLogs, 120 + timing: { 121 + fetchTime: fetchTime / 1000, 122 + importTime: importTime / 1000, 123 + totalTime: totalTime / 1000, 124 + }, 125 + }), 126 + { 127 + status: 200, 128 + headers: { 129 + "Content-Type": "application/json", 130 + ...Object.fromEntries(res.headers), 131 + }, 132 + }, 133 + ); 134 + } catch (error) { 135 + const message = error instanceof Error ? error.message : String(error); 136 + console.error( 137 + `[${new Date().toISOString()}] Repo migration error:`, 138 + message, 139 + ); 140 + console.error("Full error details:", error); 141 + return new Response( 142 + JSON.stringify({ 143 + success: false, 144 + message: `Repo migration failed: ${message}`, 145 + error: error instanceof Error 146 + ? { 147 + name: error.name, 148 + message: error.message, 149 + stack: error.stack, 150 + } 151 + : String(error), 152 + }), 153 + { 154 + status: 500, 155 + headers: { 156 + "Content-Type": "application/json", 157 + ...Object.fromEntries(res.headers), 158 + }, 159 + }, 160 + ); 161 + } 162 + }, 163 + });
-273
routes/api/migrate/data.ts
··· 1 - import { define } from "../../../utils.ts"; 2 - import { 3 - getSessionAgent, 4 - } from "../../../lib/sessions.ts"; 5 - import { Agent, ComAtprotoSyncGetBlob } from "npm:@atproto/api"; 6 - 7 - // Retry configuration 8 - const MAX_RETRIES = 3; 9 - const INITIAL_RETRY_DELAY = 1000; // 1 second 10 - 11 - interface RetryOptions { 12 - maxRetries?: number; 13 - initialDelay?: number; 14 - onRetry?: (attempt: number, error: Error) => void; 15 - } 16 - 17 - async function withRetry<T>( 18 - operation: () => Promise<T>, 19 - options: RetryOptions = {}, 20 - ): Promise<T> { 21 - const maxRetries = options.maxRetries ?? MAX_RETRIES; 22 - const initialDelay = options.initialDelay ?? INITIAL_RETRY_DELAY; 23 - 24 - let lastError: Error | null = null; 25 - for (let attempt = 0; attempt < maxRetries; attempt++) { 26 - try { 27 - return await operation(); 28 - } catch (error) { 29 - lastError = error instanceof Error ? error : new Error(String(error)); 30 - 31 - // Don't retry on certain errors 32 - if (error instanceof Error) { 33 - // Don't retry on permanent errors like authentication 34 - if (error.message.includes("Unauthorized") || error.message.includes("Invalid token")) { 35 - throw error; 36 - } 37 - } 38 - 39 - if (attempt < maxRetries - 1) { 40 - const delay = initialDelay * Math.pow(2, attempt); 41 - console.log(`Retry attempt ${attempt + 1}/${maxRetries} after ${delay}ms:`, lastError.message); 42 - if (options.onRetry) { 43 - options.onRetry(attempt + 1, lastError); 44 - } 45 - await new Promise(resolve => setTimeout(resolve, delay)); 46 - } 47 - } 48 - } 49 - throw lastError ?? new Error("Operation failed after retries"); 50 - } 51 - 52 - async function handleBlobUpload( 53 - newAgent: Agent, 54 - blobRes: ComAtprotoSyncGetBlob.Response, 55 - cid: string 56 - ) { 57 - try { 58 - const contentLength = parseInt(blobRes.headers["content-length"] || "0", 10); 59 - const contentType = blobRes.headers["content-type"]; 60 - 61 - // Check file size before attempting upload 62 - const MAX_SIZE = 95 * 1024 * 1024; // 95MB to be safe 63 - if (contentLength > MAX_SIZE) { 64 - throw new Error(`Blob ${cid} exceeds maximum size limit (${contentLength} bytes)`); 65 - } 66 - 67 - await withRetry( 68 - () => newAgent.com.atproto.repo.uploadBlob(blobRes.data, { 69 - encoding: contentType, 70 - }), 71 - { 72 - maxRetries: 5, 73 - onRetry: (attempt, error) => { 74 - console.log(`Retrying blob upload for ${cid} (attempt ${attempt}):`, error.message); 75 - }, 76 - } 77 - ); 78 - } catch (error) { 79 - console.error(`Failed to upload blob ${cid}:`, error); 80 - throw error; 81 - } 82 - } 83 - 84 - export const handler = define.handlers({ 85 - async POST(ctx) { 86 - const res = new Response(); 87 - try { 88 - console.log("Data migration: Starting session retrieval"); 89 - const oldAgent = await getSessionAgent(ctx.req); 90 - console.log("Data migration: Got old agent:", !!oldAgent); 91 - 92 - // Log cookie information 93 - const cookies = ctx.req.headers.get("cookie"); 94 - console.log("Data migration: Cookies present:", !!cookies); 95 - console.log("Data migration: Cookie header:", cookies); 96 - 97 - const newAgent = await getSessionAgent(ctx.req, res, true); 98 - console.log("Data migration: Got new agent:", !!newAgent); 99 - 100 - if (!oldAgent) { 101 - return new Response( 102 - JSON.stringify({ 103 - success: false, 104 - message: "Unauthorized", 105 - }), 106 - { 107 - status: 401, 108 - headers: { "Content-Type": "application/json" }, 109 - }, 110 - ); 111 - } 112 - if (!newAgent) { 113 - return new Response( 114 - JSON.stringify({ 115 - success: false, 116 - message: "Migration session not found or invalid", 117 - }), 118 - { 119 - status: 400, 120 - headers: { "Content-Type": "application/json" }, 121 - }, 122 - ); 123 - } 124 - 125 - const session = await oldAgent.com.atproto.server.getSession(); 126 - const accountDid = session.data.did; 127 - 128 - // Migrate repo data with retries 129 - const repoRes = await withRetry( 130 - () => oldAgent.com.atproto.sync.getRepo({ 131 - did: accountDid, 132 - }), 133 - { 134 - maxRetries: 5, 135 - onRetry: (attempt, error) => { 136 - console.log(`Retrying repo fetch (attempt ${attempt}):`, error.message); 137 - }, 138 - } 139 - ); 140 - 141 - await withRetry( 142 - () => newAgent.com.atproto.repo.importRepo(repoRes.data, { 143 - encoding: "application/vnd.ipld.car", 144 - }), 145 - { 146 - maxRetries: 5, 147 - onRetry: (attempt, error) => { 148 - console.log(`Retrying repo import (attempt ${attempt}):`, error.message); 149 - }, 150 - } 151 - ); 152 - 153 - // Migrate blobs with enhanced error handling 154 - let blobCursor: string | undefined = undefined; 155 - const migratedBlobs: string[] = []; 156 - const failedBlobs: Array<{ cid: string; error: string }> = []; 157 - 158 - do { 159 - try { 160 - const listedBlobs = await withRetry( 161 - () => oldAgent.com.atproto.sync.listBlobs({ 162 - did: accountDid, 163 - cursor: blobCursor, 164 - }), 165 - { 166 - maxRetries: 5, 167 - onRetry: (attempt, error) => { 168 - console.log(`Retrying blob list fetch (attempt ${attempt}):`, error.message); 169 - }, 170 - } 171 - ); 172 - 173 - for (const cid of listedBlobs.data.cids) { 174 - try { 175 - const blobRes = await withRetry( 176 - () => oldAgent.com.atproto.sync.getBlob({ 177 - did: accountDid, 178 - cid, 179 - }), 180 - { 181 - maxRetries: 5, 182 - onRetry: (attempt, error) => { 183 - console.log(`Retrying blob download for ${cid} (attempt ${attempt}):`, error.message); 184 - }, 185 - } 186 - ); 187 - 188 - await handleBlobUpload(newAgent, blobRes, cid); 189 - migratedBlobs.push(cid); 190 - console.log(`Successfully migrated blob: ${cid}`); 191 - } catch (error) { 192 - console.error(`Failed to migrate blob ${cid}:`, error); 193 - failedBlobs.push({ 194 - cid, 195 - error: error instanceof Error ? error.message : String(error), 196 - }); 197 - } 198 - } 199 - blobCursor = listedBlobs.data.cursor; 200 - } catch (error) { 201 - console.error("Error during blob migration batch:", error); 202 - // If we hit a critical error during blob listing, break the loop 203 - if (error instanceof Error && 204 - (error.message.includes("Unauthorized") || 205 - error.message.includes("Invalid token"))) { 206 - throw error; 207 - } 208 - break; 209 - } 210 - } while (blobCursor); 211 - 212 - // Migrate preferences with retry 213 - const prefs = await withRetry( 214 - () => oldAgent.app.bsky.actor.getPreferences(), 215 - { 216 - maxRetries: 3, 217 - onRetry: (attempt, error) => { 218 - console.log(`Retrying preferences fetch (attempt ${attempt}):`, error.message); 219 - }, 220 - } 221 - ); 222 - 223 - await withRetry( 224 - () => newAgent.app.bsky.actor.putPreferences(prefs.data), 225 - { 226 - maxRetries: 3, 227 - onRetry: (attempt, error) => { 228 - console.log(`Retrying preferences update (attempt ${attempt}):`, error.message); 229 - }, 230 - } 231 - ); 232 - 233 - return new Response( 234 - JSON.stringify({ 235 - success: true, 236 - message: failedBlobs.length > 0 237 - ? `Data migration completed with ${failedBlobs.length} failed blobs` 238 - : "Data migration completed successfully", 239 - migratedBlobs, 240 - failedBlobs, 241 - totalMigrated: migratedBlobs.length, 242 - totalFailed: failedBlobs.length, 243 - }), 244 - { 245 - status: failedBlobs.length > 0 ? 207 : 200, // Use 207 Multi-Status if some blobs failed 246 - headers: { 247 - "Content-Type": "application/json", 248 - ...Object.fromEntries(res.headers), // Include session cookie headers 249 - }, 250 - }, 251 - ); 252 - } catch (error) { 253 - console.error("Data migration error:", error); 254 - return new Response( 255 - JSON.stringify({ 256 - success: false, 257 - message: error instanceof Error 258 - ? error.message 259 - : "Failed to migrate data", 260 - error: error instanceof Error ? { 261 - name: error.name, 262 - message: error.message, 263 - stack: error.stack, 264 - } : String(error), 265 - }), 266 - { 267 - status: 400, 268 - headers: { "Content-Type": "application/json" }, 269 - }, 270 - ); 271 - } 272 - }, 273 - });
+17
routes/api/migrate/finalize.ts
··· 1 1 import { getSessionAgent } from "../../../lib/sessions.ts"; 2 + import { checkDidsMatch } from "../../../lib/check-dids.ts"; 2 3 import { define } from "../../../utils.ts"; 4 + import { assertMigrationAllowed } from "../../../lib/migration-state.ts"; 3 5 4 6 export const handler = define.handlers({ 5 7 async POST(ctx) { 6 8 const res = new Response(); 7 9 try { 10 + // Check if migrations are currently allowed 11 + assertMigrationAllowed(); 12 + 8 13 const oldAgent = await getSessionAgent(ctx.req); 9 14 const newAgent = await getSessionAgent(ctx.req, res, true); 10 15 ··· 13 18 return new Response("Migration session not found or invalid", { 14 19 status: 400, 15 20 }); 21 + } 22 + 23 + // Verify DIDs match between sessions 24 + const didsMatch = await checkDidsMatch(ctx.req); 25 + if (!didsMatch) { 26 + return new Response( 27 + JSON.stringify({ 28 + success: false, 29 + message: "Invalid state, original and target DIDs do not match", 30 + }), 31 + { status: 400, headers: { "Content-Type": "application/json" } }, 32 + ); 16 33 } 17 34 18 35 // Activate new account and deactivate old account
+81 -4
routes/api/migrate/identity/request.ts
··· 1 - import { 2 - getSessionAgent, 3 - } from "../../../../lib/sessions.ts"; 1 + import { getSessionAgent } from "../../../../lib/sessions.ts"; 2 + import { checkDidsMatch } from "../../../../lib/check-dids.ts"; 4 3 import { define } from "../../../../utils.ts"; 4 + import { assertMigrationAllowed } from "../../../../lib/migration-state.ts"; 5 + 6 + // Simple in-memory cache for rate limiting 7 + // In a production environment, you might want to use Redis or another shared cache 8 + const requestCache = new Map<string, number>(); 9 + const COOLDOWN_PERIOD_MS = 60000; // 1 minute cooldown 5 10 11 + /** 12 + * Handle identity migration request 13 + * Sends a PLC operation signature request to the old account's email 14 + * Should be called after all data is migrated to the new account 15 + * @param ctx - The context object containing the request and response 16 + * @returns A response object with the migration result 17 + */ 6 18 export const handler = define.handlers({ 7 19 async POST(ctx) { 8 20 const res = new Response(); 9 21 try { 22 + // Check if migrations are currently allowed 23 + assertMigrationAllowed(); 24 + 10 25 console.log("Starting identity migration request..."); 11 26 const oldAgent = await getSessionAgent(ctx.req); 12 27 console.log("Got old agent:", { ··· 45 60 ); 46 61 } 47 62 63 + // Verify DIDs match between sessions 64 + const didsMatch = await checkDidsMatch(ctx.req); 65 + if (!didsMatch) { 66 + return new Response( 67 + JSON.stringify({ 68 + success: false, 69 + message: "Invalid state, original and target DIDs do not match", 70 + }), 71 + { 72 + status: 400, 73 + headers: { "Content-Type": "application/json" }, 74 + }, 75 + ); 76 + } 77 + 78 + // Check if we've recently sent a request for this DID 79 + const did = oldAgent.did || ""; 80 + const now = Date.now(); 81 + const lastRequestTime = requestCache.get(did); 82 + 83 + if (lastRequestTime && now - lastRequestTime < COOLDOWN_PERIOD_MS) { 84 + console.log( 85 + `Rate limiting PLC request for ${did}, last request was ${ 86 + (now - lastRequestTime) / 1000 87 + } seconds ago`, 88 + ); 89 + return new Response( 90 + JSON.stringify({ 91 + success: true, 92 + message: 93 + "A PLC code was already sent to your email. Please check your inbox and spam folder.", 94 + rateLimited: true, 95 + cooldownRemaining: Math.ceil( 96 + (COOLDOWN_PERIOD_MS - (now - lastRequestTime)) / 1000, 97 + ), 98 + }), 99 + { 100 + status: 200, 101 + headers: { 102 + "Content-Type": "application/json", 103 + ...Object.fromEntries(res.headers), 104 + }, 105 + }, 106 + ); 107 + } 108 + 48 109 // Request the signature 49 110 console.log("Requesting PLC operation signature..."); 50 111 try { 51 112 await oldAgent.com.atproto.identity.requestPlcOperationSignature(); 52 113 console.log("Successfully requested PLC operation signature"); 114 + 115 + // Store the request time 116 + if (did) { 117 + requestCache.set(did, now); 118 + 119 + // Optionally, set up cache cleanup for DIDs that haven't been used in a while 120 + setTimeout(() => { 121 + if ( 122 + did && 123 + requestCache.has(did) && 124 + Date.now() - requestCache.get(did)! > COOLDOWN_PERIOD_MS * 2 125 + ) { 126 + requestCache.delete(did); 127 + } 128 + }, COOLDOWN_PERIOD_MS * 2); 129 + } 53 130 } catch (error) { 54 131 console.error("Error requesting PLC operation signature:", { 55 132 name: error instanceof Error ? error.name : "Unknown", 56 133 message: error instanceof Error ? error.message : String(error), 57 - status: 400 134 + status: 400, 58 135 }); 59 136 throw error; 60 137 }
+27 -3
routes/api/migrate/identity/sign.ts
··· 1 - import { 2 - getSessionAgent, 3 - } from "../../../../lib/sessions.ts"; 1 + import { getSessionAgent } from "../../../../lib/sessions.ts"; 2 + import { checkDidsMatch } from "../../../../lib/check-dids.ts"; 4 3 import { Secp256k1Keypair } from "npm:@atproto/crypto"; 5 4 import * as ui8 from "npm:uint8arrays"; 6 5 import { define } from "../../../../utils.ts"; 6 + import { assertMigrationAllowed } from "../../../../lib/migration-state.ts"; 7 7 8 + /** 9 + * Handle identity migration sign 10 + * Should be called after user receives the migration token via email 11 + * URL params must contain the token 12 + * @param ctx - The context object containing the request with the token in the URL params 13 + * @returns A response object with the migration result 14 + */ 8 15 export const handler = define.handlers({ 9 16 async POST(ctx) { 10 17 const res = new Response(); 11 18 try { 19 + // Check if migrations are currently allowed 20 + assertMigrationAllowed(); 12 21 const url = new URL(ctx.req.url); 13 22 const token = url.searchParams.get("token"); 14 23 ··· 45 54 JSON.stringify({ 46 55 success: false, 47 56 message: "Migration session not found or invalid", 57 + }), 58 + { 59 + status: 400, 60 + headers: { "Content-Type": "application/json" }, 61 + }, 62 + ); 63 + } 64 + 65 + // Verify DIDs match between sessions 66 + const didsMatch = await checkDidsMatch(ctx.req); 67 + if (!didsMatch) { 68 + return new Response( 69 + JSON.stringify({ 70 + success: false, 71 + message: "Invalid state, original and target DIDs do not match", 48 72 }), 49 73 { 50 74 status: 400,
+44 -37
routes/api/migrate/next-step.ts
··· 2 2 import { define } from "../../../utils.ts"; 3 3 4 4 export const handler = define.handlers({ 5 - async GET(ctx) { 6 - let nextStep = null; 7 - const oldAgent = await getSessionAgent(ctx.req); 8 - const newAgent = await getSessionAgent(ctx.req, new Response(), true); 5 + async GET(ctx) { 6 + let nextStep = null; 7 + const oldAgent = await getSessionAgent(ctx.req); 8 + const newAgent = await getSessionAgent(ctx.req, new Response(), true); 9 9 10 - if (!newAgent) return Response.json({ nextStep: 1, completed: false }); 11 - if (!oldAgent) return new Response("Unauthorized", { status: 401 }); 10 + if (!newAgent) return Response.json({ nextStep: 1, completed: false }); 11 + if (!oldAgent) return new Response("Unauthorized", { status: 401 }); 12 12 13 - const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus(); 14 - const newStatus = await newAgent.com.atproto.server.checkAccountStatus(); 15 - if (!oldStatus.data || !newStatus.data) return new Response("Could not verify status", { status: 500 }); 13 + const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus(); 14 + const newStatus = await newAgent.com.atproto.server.checkAccountStatus(); 15 + if (!oldStatus.data || !newStatus.data) { 16 + return new Response("Could not verify status", { status: 500 }); 17 + } 16 18 17 - // Check conditions in sequence to determine the next step 18 - if (!newStatus.data) { 19 - nextStep = 1; 20 - } else if (!(newStatus.data.repoCommit && 21 - newStatus.data.indexedRecords === oldStatus.data.indexedRecords && 22 - newStatus.data.privateStateValues === oldStatus.data.privateStateValues && 23 - newStatus.data.expectedBlobs === newStatus.data.importedBlobs && 24 - newStatus.data.importedBlobs === oldStatus.data.importedBlobs)) { 25 - nextStep = 2; 26 - } else if (!newStatus.data.validDid) { 27 - nextStep = 3; 28 - } else if (!(newStatus.data.activated === true && oldStatus.data.activated === false)) { 29 - nextStep = 4; 30 - } 19 + // Check conditions in sequence to determine the next step 20 + if (!newStatus.data) { 21 + nextStep = 1; 22 + } else if ( 23 + !(newStatus.data.repoCommit && 24 + newStatus.data.indexedRecords === oldStatus.data.indexedRecords && 25 + newStatus.data.privateStateValues === 26 + oldStatus.data.privateStateValues && 27 + newStatus.data.expectedBlobs === newStatus.data.importedBlobs && 28 + newStatus.data.importedBlobs === oldStatus.data.importedBlobs) 29 + ) { 30 + nextStep = 2; 31 + } else if (!newStatus.data.validDid) { 32 + nextStep = 3; 33 + } else if ( 34 + !(newStatus.data.activated === true && oldStatus.data.activated === false) 35 + ) { 36 + nextStep = 4; 37 + } 31 38 32 - return Response.json({ 33 - nextStep, 34 - completed: nextStep === null, 35 - currentStatus: { 36 - activated: newStatus.data.activated, 37 - validDid: newStatus.data.validDid, 38 - repoCommit: newStatus.data.repoCommit, 39 - indexedRecords: newStatus.data.indexedRecords, 40 - privateStateValues: newStatus.data.privateStateValues, 41 - importedBlobs: newStatus.data.importedBlobs 42 - } 43 - }); 44 - } 45 - }) 39 + return Response.json({ 40 + nextStep, 41 + completed: nextStep === null, 42 + currentStatus: { 43 + activated: newStatus.data.activated, 44 + validDid: newStatus.data.validDid, 45 + repoCommit: newStatus.data.repoCommit, 46 + indexedRecords: newStatus.data.indexedRecords, 47 + privateStateValues: newStatus.data.privateStateValues, 48 + importedBlobs: newStatus.data.importedBlobs, 49 + }, 50 + }); 51 + }, 52 + });
+135 -71
routes/api/migrate/status.ts
··· 1 + import { checkDidsMatch } from "../../../lib/check-dids.ts"; 1 2 import { getSessionAgent } from "../../../lib/sessions.ts"; 2 3 import { define } from "../../../utils.ts"; 3 4 4 5 export const handler = define.handlers({ 5 - async GET(ctx) { 6 - const url = new URL(ctx.req.url); 7 - const params = new URLSearchParams(url.search); 8 - const step = params.get("step"); 9 - const oldAgent = await getSessionAgent(ctx.req); 10 - const newAgent = await getSessionAgent(ctx.req, new Response(), true); 11 - 12 - if (!oldAgent || !newAgent) return new Response("Unauthorized", { status: 401 }); 6 + async GET(ctx) { 7 + console.log("Status check: Starting"); 8 + const url = new URL(ctx.req.url); 9 + const params = new URLSearchParams(url.search); 10 + const step = params.get("step"); 11 + console.log("Status check: Step", step); 13 12 14 - const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus(); 15 - const newStatus = await newAgent.com.atproto.server.checkAccountStatus(); 16 - if (!oldStatus.data || !newStatus.data) return new Response("Could not verify status", { status: 500 }); 13 + console.log("Status check: Getting agents"); 14 + const oldAgent = await getSessionAgent(ctx.req); 15 + const newAgent = await getSessionAgent(ctx.req, new Response(), true); 17 16 18 - const readyToContinue = () => { 19 - if (step) { 20 - switch (step) { 21 - case "1": { 22 - if (newStatus.data) { 23 - return { ready: true }; 24 - } 25 - return { ready: false, reason: "New account status not available" }; 26 - } 27 - case "2": { 28 - if (newStatus.data.repoCommit && 29 - newStatus.data.indexedRecords === oldStatus.data.indexedRecords && 30 - newStatus.data.privateStateValues === oldStatus.data.privateStateValues && 31 - newStatus.data.expectedBlobs === newStatus.data.importedBlobs && 32 - newStatus.data.importedBlobs === oldStatus.data.importedBlobs) { 33 - return { ready: true }; 34 - } 35 - const reasons = []; 36 - if (!newStatus.data.repoCommit) reasons.push("Repository not imported."); 37 - if (newStatus.data.indexedRecords < oldStatus.data.indexedRecords) 38 - reasons.push("Not all records imported."); 39 - if (newStatus.data.privateStateValues < oldStatus.data.privateStateValues) 40 - reasons.push("Not all private state values imported."); 41 - if (newStatus.data.expectedBlobs !== newStatus.data.importedBlobs) 42 - reasons.push("Expected blobs not fully imported."); 43 - if (newStatus.data.importedBlobs < oldStatus.data.importedBlobs) 44 - reasons.push("Not all blobs imported."); 45 - return { ready: false, reason: reasons.join(", ") }; 46 - } 47 - case "3": { 48 - if (newStatus.data.validDid) { 49 - return { ready: true }; 50 - } 51 - return { ready: false, reason: "DID not valid" }; 52 - } 53 - case "4": { 54 - if (newStatus.data.activated === true && oldStatus.data.activated === false) { 55 - return { ready: true }; 56 - } 57 - return { ready: false, reason: "Account not activated" }; 58 - } 59 - } 60 - } else { 61 - return { ready: true }; 17 + if (!oldAgent || !newAgent) { 18 + console.log("Status check: Unauthorized - missing agents", { 19 + hasOldAgent: !!oldAgent, 20 + hasNewAgent: !!newAgent, 21 + }); 22 + return new Response("Unauthorized", { status: 401 }); 23 + } 24 + 25 + const didsMatch = await checkDidsMatch(ctx.req); 26 + 27 + console.log("Status check: Fetching account statuses"); 28 + const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus(); 29 + const newStatus = await newAgent.com.atproto.server.checkAccountStatus(); 30 + 31 + if (!oldStatus.data || !newStatus.data) { 32 + console.error("Status check: Failed to verify status", { 33 + hasOldStatus: !!oldStatus.data, 34 + hasNewStatus: !!newStatus.data, 35 + }); 36 + return new Response("Could not verify status", { status: 500 }); 37 + } 38 + 39 + console.log("Status check: Account statuses", { 40 + old: oldStatus.data, 41 + new: newStatus.data, 42 + }); 43 + 44 + const readyToContinue = () => { 45 + if (!didsMatch) { 46 + return { 47 + ready: false, 48 + reason: "Invalid state, original and target DIDs do not match", 49 + }; 50 + } 51 + if (step) { 52 + console.log("Status check: Evaluating step", step); 53 + switch (step) { 54 + case "1": { 55 + if (newStatus.data) { 56 + console.log("Status check: Step 1 ready"); 57 + return { ready: true }; 62 58 } 59 + console.log( 60 + "Status check: Step 1 not ready - new account status not available", 61 + ); 62 + return { ready: false, reason: "New account status not available" }; 63 + } 64 + case "2": { 65 + const isReady = newStatus.data.repoCommit && 66 + newStatus.data.indexedRecords === oldStatus.data.indexedRecords && 67 + newStatus.data.privateStateValues === 68 + oldStatus.data.privateStateValues && 69 + newStatus.data.expectedBlobs === newStatus.data.importedBlobs && 70 + newStatus.data.importedBlobs === oldStatus.data.importedBlobs; 71 + 72 + if (isReady) { 73 + console.log("Status check: Step 2 ready"); 74 + return { ready: true }; 75 + } 76 + 77 + const reasons = []; 78 + if (!newStatus.data.repoCommit) { 79 + reasons.push("Repository not imported."); 80 + } 81 + if (newStatus.data.indexedRecords < oldStatus.data.indexedRecords) { 82 + reasons.push("Not all records imported."); 83 + } 84 + if ( 85 + newStatus.data.privateStateValues < 86 + oldStatus.data.privateStateValues 87 + ) { 88 + reasons.push("Not all private state values imported."); 89 + } 90 + if (newStatus.data.expectedBlobs !== newStatus.data.importedBlobs) { 91 + reasons.push("Expected blobs not fully imported."); 92 + } 93 + if (newStatus.data.importedBlobs < oldStatus.data.importedBlobs) { 94 + reasons.push("Not all blobs imported."); 95 + } 96 + 97 + console.log("Status check: Step 2 not ready", { reasons }); 98 + return { ready: false, reason: reasons.join(", ") }; 99 + } 100 + case "3": { 101 + if (newStatus.data.validDid) { 102 + console.log("Status check: Step 3 ready"); 103 + return { ready: true }; 104 + } 105 + console.log("Status check: Step 3 not ready - DID not valid"); 106 + return { ready: false, reason: "DID not valid" }; 107 + } 108 + case "4": { 109 + if ( 110 + newStatus.data.activated === true && 111 + oldStatus.data.activated === false 112 + ) { 113 + console.log("Status check: Step 4 ready"); 114 + return { ready: true }; 115 + } 116 + console.log( 117 + "Status check: Step 4 not ready - Account not activated", 118 + ); 119 + return { ready: false, reason: "Account not activated" }; 120 + } 63 121 } 122 + } else { 123 + console.log("Status check: No step specified, returning ready"); 124 + return { ready: true }; 125 + } 126 + }; 64 127 65 - const status = { 66 - activated: newStatus.data.activated, 67 - validDid: newStatus.data.validDid, 68 - repoCommit: newStatus.data.repoCommit, 69 - repoRev: newStatus.data.repoRev, 70 - repoBlocks: newStatus.data.repoBlocks, 71 - expectedRecords: oldStatus.data.indexedRecords, 72 - indexedRecords: newStatus.data.indexedRecords, 73 - privateStateValues: newStatus.data.privateStateValues, 74 - expectedBlobs: newStatus.data.expectedBlobs, 75 - importedBlobs: newStatus.data.importedBlobs, 76 - ...readyToContinue() 77 - } 128 + const status = { 129 + activated: newStatus.data.activated, 130 + validDid: newStatus.data.validDid, 131 + repoCommit: newStatus.data.repoCommit, 132 + repoRev: newStatus.data.repoRev, 133 + repoBlocks: newStatus.data.repoBlocks, 134 + expectedRecords: oldStatus.data.indexedRecords, 135 + indexedRecords: newStatus.data.indexedRecords, 136 + privateStateValues: newStatus.data.privateStateValues, 137 + expectedBlobs: newStatus.data.expectedBlobs, 138 + importedBlobs: newStatus.data.importedBlobs, 139 + ...readyToContinue(), 140 + }; 78 141 79 - return Response.json(status); 80 - } 81 - }) 142 + console.log("Status check: Complete", status); 143 + return Response.json(status); 144 + }, 145 + });
+45
routes/api/migration-state.ts
··· 1 + import { getMigrationState } from "../../lib/migration-state.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 + try { 11 + const stateInfo = getMigrationState(); 12 + 13 + return new Response( 14 + JSON.stringify({ 15 + state: stateInfo.state, 16 + message: stateInfo.message, 17 + allowMigration: stateInfo.allowMigration, 18 + }), 19 + { 20 + status: 200, 21 + headers: { 22 + "Content-Type": "application/json", 23 + }, 24 + }, 25 + ); 26 + } catch (error) { 27 + console.error("Error checking migration state:", error); 28 + 29 + return new Response( 30 + JSON.stringify({ 31 + state: "issue", 32 + message: 33 + "Unable to determine migration state. Please try again later.", 34 + allowMigration: false, 35 + }), 36 + { 37 + status: 500, 38 + headers: { 39 + "Content-Type": "application/json", 40 + }, 41 + }, 42 + ); 43 + } 44 + }, 45 + });
+13 -13
routes/api/oauth/initiate.ts
··· 1 - import { isValidHandle } from 'npm:@atproto/syntax' 1 + import { isValidHandle } from "npm:@atproto/syntax"; 2 2 import { oauthClient } from "../../../lib/oauth/client.ts"; 3 3 import { define } from "../../../utils.ts"; 4 4 5 5 function isValidUrl(url: string): boolean { 6 6 try { 7 - const urlp = new URL(url) 7 + const urlp = new URL(url); 8 8 // http or https 9 - return urlp.protocol === 'http:' || urlp.protocol === 'https:' 9 + return urlp.protocol === "http:" || urlp.protocol === "https:"; 10 10 } catch { 11 - return false 11 + return false; 12 12 } 13 13 } 14 14 15 15 export const handler = define.handlers({ 16 16 async POST(ctx) { 17 - const data = await ctx.req.json() 18 - const handle = data.handle 17 + const data = await ctx.req.json(); 18 + const handle = data.handle; 19 19 if ( 20 - typeof handle !== 'string' || 20 + typeof handle !== "string" || 21 21 !(isValidHandle(handle) || isValidUrl(handle)) 22 22 ) { 23 - return new Response("Invalid Handle", {status: 400}) 23 + return new Response("Invalid Handle", { status: 400 }); 24 24 } 25 25 26 26 // Initiate the OAuth flow 27 27 try { 28 28 const url = await oauthClient.authorize(handle, { 29 - scope: 'atproto transition:generic transition:chat.bsky', 30 - }) 31 - return Response.json({ redirectUrl: url.toString() }) 29 + scope: "atproto transition:generic transition:chat.bsky", 30 + }); 31 + return Response.json({ redirectUrl: url.toString() }); 32 32 } catch (err) { 33 - console.error({ err }, 'oauth authorize failed') 34 - return new Response("Couldn't initiate login", {status: 500}) 33 + console.error({ err }, "oauth authorize failed"); 34 + return new Response("Couldn't initiate login", { status: 500 }); 35 35 } 36 36 }, 37 37 });
+42
routes/api/plc/keys.ts
··· 1 + import { Secp256k1Keypair } from "@atproto/crypto"; 2 + import { getSessionAgent } from "../../../lib/sessions.ts"; 3 + import { define } from "../../../utils.ts"; 4 + import * as ui8 from "npm:uint8arrays"; 5 + 6 + /** 7 + * Generate and return PLC keys for the authenticated user 8 + */ 9 + export const handler = define.handlers({ 10 + async GET(ctx) { 11 + const agent = await getSessionAgent(ctx.req); 12 + if (!agent) { 13 + return new Response("Unauthorized", { status: 401 }); 14 + } 15 + 16 + // Create a new keypair 17 + const keypair = await Secp256k1Keypair.create({ exportable: true }); 18 + 19 + // Export private key bytes 20 + const privateKeyBytes = await keypair.export(); 21 + const privateKeyHex = ui8.toString(privateKeyBytes, "hex"); 22 + 23 + // Get public key as DID 24 + const publicKeyDid = keypair.did(); 25 + 26 + // Convert private key to multikey format (base58btc) 27 + const privateKeyMultikey = ui8.toString(privateKeyBytes, "base58btc"); 28 + 29 + // Return the key information 30 + return new Response( 31 + JSON.stringify({ 32 + keyType: "secp256k1", 33 + publicKeyDid: publicKeyDid, 34 + privateKeyHex: privateKeyHex, 35 + privateKeyMultikey: privateKeyMultikey, 36 + }), 37 + { 38 + headers: { "Content-Type": "application/json" }, 39 + }, 40 + ); 41 + }, 42 + });
+61
routes/api/plc/token.ts
··· 1 + import { getSessionAgent } from "../../../lib/sessions.ts"; 2 + import { define } from "../../../utils.ts"; 3 + 4 + /** 5 + * Handle account creation 6 + * First step of the migration process 7 + * Body must contain: 8 + * - service: The service URL of the new account 9 + * - handle: The handle of the new account 10 + * - password: The password of the new account 11 + * - email: The email of the new account 12 + * - invite: The invite code of the new account (optional depending on the PDS) 13 + * @param ctx - The context object containing the request and response 14 + * @returns A response object with the creation result 15 + */ 16 + export const handler = define.handlers({ 17 + async GET(ctx) { 18 + const res = new Response(); 19 + try { 20 + const agent = await getSessionAgent(ctx.req, res); 21 + 22 + if (!agent) return new Response("Unauthorized", { status: 401 }); 23 + 24 + // console.log("getting did"); 25 + // const session = await agent.com.atproto.server.getSession(); 26 + // const accountDid = session.data.did; 27 + // console.log("got did"); 28 + 29 + await agent.com.atproto.identity.requestPlcOperationSignature(); 30 + 31 + return new Response( 32 + JSON.stringify({ 33 + success: true, 34 + message: 35 + "We've requested a token to update your identity, it should be sent to your account's email address.", 36 + }), 37 + { 38 + status: 200, 39 + headers: { 40 + "Content-Type": "application/json", 41 + ...Object.fromEntries(res.headers), // Include session cookie headers 42 + }, 43 + }, 44 + ); 45 + } catch (error) { 46 + console.error("PLC signature request error:", error); 47 + return new Response( 48 + JSON.stringify({ 49 + success: false, 50 + message: error instanceof Error 51 + ? error.message 52 + : "Failed to get PLC operation signature (sending confirmation email)", 53 + }), 54 + { 55 + status: 400, 56 + headers: { "Content-Type": "application/json" }, 57 + }, 58 + ); 59 + } 60 + }, 61 + });
+92
routes/api/plc/update/complete.ts
··· 1 + import { getSessionAgent } from "../../../../lib/sessions.ts"; 2 + import { define } from "../../../../utils.ts"; 3 + 4 + /** 5 + * Complete PLC update using email token 6 + */ 7 + export const handler = define.handlers({ 8 + async POST(ctx) { 9 + const res = new Response(); 10 + try { 11 + const url = new URL(ctx.req.url); 12 + const token = url.searchParams.get("token"); 13 + 14 + if (!token) { 15 + return new Response( 16 + JSON.stringify({ 17 + success: false, 18 + message: "Missing token parameter", 19 + }), 20 + { 21 + status: 400, 22 + headers: { "Content-Type": "application/json" }, 23 + }, 24 + ); 25 + } 26 + 27 + const agent = await getSessionAgent(ctx.req, res, true); 28 + if (!agent) { 29 + return new Response( 30 + JSON.stringify({ 31 + success: false, 32 + message: "Unauthorized", 33 + }), 34 + { 35 + status: 401, 36 + headers: { "Content-Type": "application/json" }, 37 + }, 38 + ); 39 + } 40 + 41 + const did = agent.did; 42 + if (!did) { 43 + return new Response( 44 + JSON.stringify({ 45 + success: false, 46 + message: "No DID found in session", 47 + }), 48 + { 49 + status: 400, 50 + headers: { "Content-Type": "application/json" }, 51 + }, 52 + ); 53 + } 54 + 55 + // Submit the PLC operation with the token 56 + await agent!.com.atproto.identity.submitPlcOperation({ 57 + operation: { token: token }, 58 + }); 59 + 60 + return new Response( 61 + JSON.stringify({ 62 + success: true, 63 + message: "PLC update completed successfully", 64 + did, 65 + }), 66 + { 67 + status: 200, 68 + headers: { 69 + "Content-Type": "application/json", 70 + ...Object.fromEntries(res.headers), // Include session cookie headers 71 + }, 72 + }, 73 + ); 74 + } catch (error) { 75 + console.error("PLC update completion error:", error); 76 + const message = error instanceof Error 77 + ? error.message 78 + : "Unknown error occurred"; 79 + 80 + return new Response( 81 + JSON.stringify({ 82 + success: false, 83 + message: `Failed to complete PLC update: ${message}`, 84 + }), 85 + { 86 + status: 500, 87 + headers: { "Content-Type": "application/json" }, 88 + }, 89 + ); 90 + } 91 + }, 92 + });
+155
routes/api/plc/update.ts
··· 1 + import { getSessionAgent } from "../../../lib/sessions.ts"; 2 + import { define } from "../../../utils.ts"; 3 + import * as plc from "@did-plc/lib"; 4 + 5 + /** 6 + * Handle PLC update operation 7 + * Body must contain: 8 + * - key: The new rotation key to add 9 + * - token: The email token received from requestPlcOperationSignature 10 + * @param ctx - The context object containing the request and response 11 + * @returns A response object with the update result 12 + */ 13 + export const handler = define.handlers({ 14 + async POST(ctx) { 15 + const res = new Response(); 16 + try { 17 + console.log("=== PLC Update Debug ==="); 18 + const body = await ctx.req.json(); 19 + const { key: newKey, token } = body; 20 + console.log("Request body:", { newKey, hasToken: !!token }); 21 + 22 + if (!newKey) { 23 + console.log("Missing key in request"); 24 + return new Response("Missing param key in request body", { 25 + status: 400, 26 + }); 27 + } 28 + 29 + if (!token) { 30 + console.log("Missing token in request"); 31 + return new Response("Missing param token in request body", { 32 + status: 400, 33 + }); 34 + } 35 + 36 + const agent = await getSessionAgent(ctx.req, res); 37 + if (!agent) { 38 + console.log("No agent found"); 39 + return new Response("Unauthorized", { status: 401 }); 40 + } 41 + 42 + const session = await agent.com.atproto.server.getSession(); 43 + const did = session.data.did; 44 + if (!did) { 45 + console.log("No DID found in session"); 46 + return new Response( 47 + JSON.stringify({ 48 + success: false, 49 + message: "No DID found in your session", 50 + }), 51 + { 52 + status: 400, 53 + headers: { "Content-Type": "application/json" }, 54 + }, 55 + ); 56 + } 57 + console.log("Using agent DID:", did); 58 + 59 + // Get recommended credentials first 60 + console.log("Getting did:plc document..."); 61 + const plcClient = new plc.Client("https://plc.directory"); 62 + const didDoc = await plcClient.getDocumentData(did); 63 + if (!didDoc) { 64 + console.log("No DID document found for agent DID"); 65 + return new Response( 66 + JSON.stringify({ 67 + success: false, 68 + message: "No DID document found for your account", 69 + }), 70 + { 71 + status: 400, 72 + headers: { "Content-Type": "application/json" }, 73 + }, 74 + ); 75 + } 76 + console.log("Got DID document:", didDoc); 77 + 78 + const rotationKeys = didDoc.rotationKeys ?? []; 79 + if (!rotationKeys.length) { 80 + console.log("No existing rotation keys found"); 81 + throw new Error("No rotation keys provided in recommended credentials"); 82 + } 83 + 84 + // Check if the key is already in rotation keys 85 + if (rotationKeys.includes(newKey)) { 86 + console.log("Key already exists in rotation keys"); 87 + return new Response( 88 + JSON.stringify({ 89 + success: false, 90 + message: "This key is already in your rotation keys", 91 + }), 92 + { 93 + status: 400, 94 + headers: { "Content-Type": "application/json" }, 95 + }, 96 + ); 97 + } 98 + 99 + // Perform the actual PLC update with the provided token 100 + console.log("Signing PLC operation..."); 101 + const plcOp = await agent.com.atproto.identity.signPlcOperation({ 102 + token, 103 + rotationKeys: [newKey, ...rotationKeys], 104 + }); 105 + console.log("PLC operation signed successfully:", plcOp.data); 106 + 107 + console.log("Submitting PLC operation..."); 108 + const plcSubmit = await agent.com.atproto.identity.submitPlcOperation({ 109 + operation: plcOp.data.operation, 110 + }); 111 + console.log("PLC operation submitted successfully:", plcSubmit); 112 + 113 + return new Response( 114 + JSON.stringify({ 115 + success: true, 116 + message: "PLC update completed successfully", 117 + did: plcOp.data, 118 + newKey, 119 + rotationKeys: [newKey, ...rotationKeys], 120 + }), 121 + { 122 + status: 200, 123 + headers: { 124 + "Content-Type": "application/json", 125 + ...Object.fromEntries(res.headers), // Include session cookie headers 126 + }, 127 + }, 128 + ); 129 + } catch (error) { 130 + console.error("PLC update error:", error); 131 + const errorMessage = error instanceof Error 132 + ? error.message 133 + : "Failed to update your PLC"; 134 + console.log("Sending error response:", errorMessage); 135 + 136 + return new Response( 137 + JSON.stringify({ 138 + success: false, 139 + message: errorMessage, 140 + error: error instanceof Error 141 + ? { 142 + name: error.name, 143 + message: error.message, 144 + stack: error.stack, 145 + } 146 + : String(error), 147 + }), 148 + { 149 + status: 400, 150 + headers: { "Content-Type": "application/json" }, 151 + }, 152 + ); 153 + } 154 + }, 155 + });
+129
routes/api/plc/verify.ts
··· 1 + import { getSessionAgent } from "../../../lib/sessions.ts"; 2 + import { define } from "../../../utils.ts"; 3 + import * as plc from "@did-plc/lib"; 4 + 5 + /** 6 + * Verify if a rotation key exists in the PLC document 7 + * Body must contain: 8 + * - key: The rotation key to verify 9 + * @param ctx - The context object containing the request and response 10 + * @returns A response object with the verification result 11 + */ 12 + export const handler = define.handlers({ 13 + async POST(ctx) { 14 + const res = new Response(); 15 + try { 16 + const body = await ctx.req.json(); 17 + const { key: newKey } = body; 18 + console.log("Request body:", { newKey }); 19 + 20 + if (!newKey) { 21 + console.log("Missing key in request"); 22 + return new Response("Missing param key in request body", { 23 + status: 400, 24 + }); 25 + } 26 + 27 + const agent = await getSessionAgent(ctx.req, res); 28 + if (!agent) { 29 + console.log("No agent found"); 30 + return new Response("Unauthorized", { status: 401 }); 31 + } 32 + 33 + const session = await agent.com.atproto.server.getSession(); 34 + const did = session.data.did; 35 + if (!did) { 36 + console.log("No DID found in session"); 37 + return new Response( 38 + JSON.stringify({ 39 + success: false, 40 + message: "No DID found in your session", 41 + }), 42 + { 43 + status: 400, 44 + headers: { "Content-Type": "application/json" }, 45 + }, 46 + ); 47 + } 48 + console.log("Using agent DID:", did); 49 + 50 + // Fetch the PLC document to check rotation keys 51 + console.log("Getting did:plc document..."); 52 + const plcClient = new plc.Client("https://plc.directory"); 53 + const didDoc = await plcClient.getDocumentData(did); 54 + if (!didDoc) { 55 + console.log("No DID document found for agent DID"); 56 + return new Response( 57 + JSON.stringify({ 58 + success: false, 59 + message: "No DID document found for your account", 60 + }), 61 + { 62 + status: 400, 63 + headers: { "Content-Type": "application/json" }, 64 + }, 65 + ); 66 + } 67 + console.log("Got DID document:", didDoc); 68 + 69 + const rotationKeys = didDoc.rotationKeys ?? []; 70 + if (!rotationKeys.length) { 71 + console.log("No existing rotation keys found"); 72 + throw new Error("No rotation keys found in did:plc document"); 73 + } 74 + 75 + // Check if the key exists in rotation keys 76 + if (rotationKeys.includes(newKey)) { 77 + return new Response( 78 + JSON.stringify({ 79 + success: true, 80 + message: "Rotation key exists in PLC document", 81 + }), 82 + { 83 + status: 200, 84 + headers: { 85 + "Content-Type": "application/json", 86 + ...Object.fromEntries(res.headers), // Include session cookie headers 87 + }, 88 + }, 89 + ); 90 + } 91 + 92 + // If we get here, the key was not found 93 + return new Response( 94 + JSON.stringify({ 95 + success: false, 96 + message: "Rotation key not found in PLC document", 97 + }), 98 + { 99 + status: 404, 100 + headers: { "Content-Type": "application/json" }, 101 + }, 102 + ); 103 + } catch (error) { 104 + console.error("PLC verification error:", error); 105 + const errorMessage = error instanceof Error 106 + ? error.message 107 + : "Failed to verify rotation key"; 108 + console.log("Sending error response:", errorMessage); 109 + 110 + return new Response( 111 + JSON.stringify({ 112 + success: false, 113 + message: errorMessage, 114 + error: error instanceof Error 115 + ? { 116 + name: error.name, 117 + message: error.message, 118 + stack: error.stack, 119 + } 120 + : String(error), 121 + }), 122 + { 123 + status: 400, 124 + headers: { "Content-Type": "application/json" }, 125 + }, 126 + ); 127 + } 128 + }, 129 + });
+33
routes/api/resolve-pds.ts
··· 1 + import { resolver } from "../../lib/id-resolver.ts"; 2 + import { define } from "../../utils.ts"; 3 + 4 + export const handler = define.handlers({ 5 + async GET(ctx) { 6 + const url = new URL(ctx.req.url); 7 + const did = url.searchParams.get("did"); 8 + 9 + if (!did) { 10 + return new Response( 11 + JSON.stringify({ error: "DID parameter is required" }), 12 + { 13 + status: 400, 14 + headers: { "Content-Type": "application/json" }, 15 + }, 16 + ); 17 + } 18 + 19 + try { 20 + const pds = await resolver.resolveDidToPdsUrl(did); 21 + return new Response(JSON.stringify({ pds }), { 22 + status: 200, 23 + headers: { "Content-Type": "application/json" }, 24 + }); 25 + } catch (error) { 26 + console.error("Failed to resolve PDS:", error); 27 + return new Response(JSON.stringify({ error: "Failed to resolve PDS" }), { 28 + status: 500, 29 + headers: { "Content-Type": "application/json" }, 30 + }); 31 + } 32 + }, 33 + });
+1 -2
routes/api/server/describe.ts
··· 1 - 2 1 import { Agent } from "@atproto/api"; 3 2 import { getSessionAgent } from "../../../lib/sessions.ts"; 4 3 import { define } from "../../../utils.ts"; ··· 21 20 } 22 21 const result = await agent.com.atproto.server.describeServer(); 23 22 return Response.json(result); 24 - } 23 + }, 25 24 });
+26 -18
routes/index.tsx
··· 1 1 import Ticket from "../islands/Ticket.tsx"; 2 2 import AirportSign from "../components/AirportSign.tsx"; 3 3 import SocialLinks from "../islands/SocialLinks.tsx"; 4 - import { Button } from "../components/Button.tsx"; 4 + import LoginButton from "../islands/LoginButton.tsx"; 5 5 6 6 export default function Home() { 7 7 return ( ··· 14 14 <p class="font-mono text-lg sm:text-xl font-bold mb-4 sm:mb-6 mt-0 text-center text-gray-600 dark:text-gray-300"> 15 15 Your terminal for seamless AT Protocol PDS migration and backup. 16 16 </p> 17 - <p class="font-mono mb-4 sm:mb-6 mt-0 text-center text-gray-600 dark:text-gray-300"> 18 - Airport is in <strong>alpha</strong> currently, and we don't recommend it for main accounts. <br/> Please use its migration tools at your own risk. 19 - </p> 20 17 21 18 <Ticket /> 22 19 23 - <div class="mt-6 sm:mt-8 text-center w-fit mx-auto"> 24 - <Button 25 - href="/login" 26 - color="blue" 27 - label="MOBILE NOT SUPPORTED" 28 - className="opacity-50 cursor-not-allowed sm:opacity-100 sm:cursor-pointer" 29 - onClick={(e: MouseEvent) => { 30 - if (globalThis.innerWidth < 640) { 31 - e.preventDefault(); 32 - } 33 - }} 34 - /> 35 - </div> 20 + <LoginButton /> 36 21 <p class="font-mono text-lg sm:text-xl mb-4 mt-4 sm:mb-6 text-center text-gray-600 dark:text-gray-300"> 37 - Airport is made with love by <a class="text-blue-500 hover:underline" href="https://bsky.app/profile/knotbin.com">Roscoe</a> for <a class="text-blue-500 hover:underline" href="https://sprk.so">Spark</a>, a new short-video platform for AT Protocol. 22 + Airport is made with love by{" "} 23 + <a 24 + class="text-blue-500 hover:underline" 25 + href="https://bsky.app/profile/knotbin.com" 26 + > 27 + Roscoe 28 + </a>{" "} 29 + for{" "} 30 + <a class="text-blue-500 hover:underline" href="https://sprk.so"> 31 + Spark 32 + </a>, a new short-video platform for AT Protocol. 38 33 </p> 34 + <div class="text-center mb-4"> 35 + <a 36 + href="/about" 37 + class="inline-flex items-center text-blue-500 hover:text-blue-600 transition-colors" 38 + > 39 + <img 40 + src="/icons/info_bold.svg" 41 + alt="Info" 42 + class="w-5 h-5 mr-2" 43 + /> 44 + <span class="font-mono">Learn more about AT Protocol</span> 45 + </a> 46 + </div> 39 47 <SocialLinks /> 40 48 </div> 41 49 </div>
+1 -1
routes/login/index.tsx
··· 1 1 import { PageProps } from "fresh"; 2 - import LoginSelector from "../../islands/LoginSelector.tsx" 2 + import LoginSelector from "../../islands/LoginSelector.tsx"; 3 3 4 4 export async function submitHandle(handle: string) { 5 5 const response = await fetch("/api/oauth/initiate", {
+2 -2
routes/migrate/progress.tsx
··· 10 10 11 11 if (!service || !handle || !email || !password) { 12 12 return ( 13 - <div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-4"> 13 + <div class="bg-gray-50 dark:bg-gray-900 p-4"> 14 14 <div class="max-w-2xl mx-auto"> 15 15 <div class="bg-red-50 dark:bg-red-900 p-4 rounded-lg"> 16 16 <p class="text-red-800 dark:text-red-200"> ··· 24 24 } 25 25 26 26 return ( 27 - <div class="min-h-screen bg-gray-50 dark:bg-gray-900 p-4"> 27 + <div class="bg-gray-50 dark:bg-gray-900 p-4"> 28 28 <div class="max-w-2xl mx-auto"> 29 29 <h1 class="font-mono text-3xl font-bold text-gray-900 dark:text-white mb-8"> 30 30 Migration Progress
+14
routes/ticket-booth/index.tsx
··· 1 + import DidPlcProgress from "../../islands/DidPlcProgress.tsx"; 2 + 3 + export default function TicketBooth() { 4 + return ( 5 + <div class=" bg-gray-50 dark:bg-gray-900 p-4"> 6 + <div class="max-w-2xl mx-auto"> 7 + <h1 class="font-mono text-3xl font-bold text-gray-900 dark:text-white mb-8"> 8 + Ticket Booth Self-Service Kiosk 9 + </h1> 10 + <DidPlcProgress /> 11 + </div> 12 + </div> 13 + ); 14 + }
+52 -9
static/favicon.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="768px" height="768px"><svg width="768px" height="768px" viewBox="0 0 768 768" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + version="1.1" 4 + xmlns:xlink="http://www.w3.org/1999/xlink" 5 + width="768px" 6 + height="768px" 7 + > 8 + <svg 9 + width="768px" 10 + height="768px" 11 + viewBox="0 0 768 768" 12 + version="1.1" 13 + xmlns="http://www.w3.org/2000/svg" 14 + xmlns:xlink="http://www.w3.org/1999/xlink" 15 + > 2 16 <title>Artboard</title> 3 - <g id="SvgjsG1018" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> 4 - <path d="M258.744161,673.532662 C220.757598,658.556745 187.761971,637.340862 159.757278,609.885015 C131.731315,582.675001 109.640415,549.95668 94.87512,513.789548 C79.62504,476.904419 72,436.829975 72,393.566215 C72,341.982502 79.9023142,296.222756 95.7069425,256.286977 C111.788845,216.351199 133.97078,182.794052 162.252746,155.615536 C190.638366,128.268343 224.327386,107.031355 261.239629,93.2158823 C298.948918,79.0719608 339.292311,72 382.269809,72 C434.951904,72 481.118055,80.1812879 520.768263,96.5438638 C560.418471,112.90644 593.414098,135.092983 619.755146,163.103494 C645.823967,190.667688 665.804365,223.408333 678.398635,259.198961 C691.153247,294.974763 696.976005,332.414555 695.866908,371.518338 C694.480537,425.320706 674.410807,486.092796 651.875912,513.807986 C627.109489,544.267686 603.766423,555.08515 561.350365,555.08515 C539.879106,555.08515 518.908303,544.647246 498.437956,523.771439 L520.642336,488.757018 L553.379562,469.399451 C566.854015,479.647575 578.145985,482.494276 587.255474,477.939554 C600.919708,471.107472 612.130106,436.413978 615.180122,422.270056 C618.73393,406.280102 620.684502,389.975506 621.002879,373.598326 C621.834702,330.611898 615.457396,294.420099 601.870961,265.022929 C588.284526,235.625759 569.845793,211.91389 546.554762,193.887324 C524.028964,175.836615 498.164782,162.407064 470.442999,154.367543 C442.715581,146.047589 415.1268,141.887612 387.676656,141.887612 C348.85827,141.887612 314.337635,148.127577 284.114749,160.607508 C253.891863,172.810107 228.382638,190.143344 207.587075,212.60722 C187.068785,234.793763 171.541431,261.140284 161.005012,291.646781 C150.745868,321.875947 145.893569,355.155762 146.448118,391.486227 C147.557214,427.53936 154.073158,459.98718 165.995948,488.829687 C177.28032,516.727243 194.28181,541.951359 215.9053,562.877276 C237.610095,583.620856 263.386636,599.628298 291.601152,609.885015 C320.714941,620.700955 352.601472,626.108925 387.260744,626.108925 C406.669937,626.108925 425.940493,623.89027 445.072411,619.452962 C464.481604,615.292985 482.227152,609.330351 498.309054,601.565061 L522.015997,666.460701 C500.665885,676.444645 478.206676,683.793938 454.63837,688.508578 C431.262297,693.509483 407.422332,696.019474 383.517543,696 C338.321851,696 296.730724,688.508578 258.744161,673.532662 Z" id="SvgjsPath1017" fill="#0083FF" fill-rule="nonzero"></path> 5 - <g id="SvgjsG1016" transform="translate(115.0733, 79.4049)" fill="#0083FF"> 6 - <path d="M329.828907,231.383849 L260.699401,110.602459 C251.63529,94.765511 234.795945,84.9961472 216.561535,84.9961472 L202.32856,84.9961472 C197.759942,84.9961472 194.291354,89.1141864 195.064554,93.6217527 L218.683383,231.383849 L146.044594,231.383849 L117.317307,191.126992 C112.543308,184.436292 104.83484,180.465281 96.6208666,180.465281 L77.9170684,180.465281 C74.6441326,180.465281 72.2041032,183.486025 72.8889188,186.689439 L90.5192033,269.169882 C90.542094,269.250079 90.5821529,269.322638 90.6349289,269.382467 C90.634293,269.446115 90.634293,269.509764 90.634293,269.572775 L90.6349289,269.760538 C90.5821529,269.821003 90.542094,269.892926 90.5192033,269.973759 L72.8889188,352.453566 C72.2041032,355.657617 74.6441326,358.677724 77.9170684,358.677724 L96.6208666,358.677724 C104.83484,358.677724 112.543308,354.706712 117.317307,348.016012 L146.042051,307.761702 L218.684019,307.761702 L195.064554,445.52889 C194.291354,450.036456 197.759942,454.154495 202.32856,454.154495 L216.561535,454.154495 C234.795945,454.154495 251.63529,444.385132 260.699401,428.548184 L329.83145,307.761702 L399.512242,307.761702 C415.470292,307.761702 431.243943,304.348885 445.777042,297.751748 L448.41584,296.553888 C458.994558,291.751631 465.788667,281.20003 465.788667,269.572775 C465.788667,257.946157 458.994558,247.394556 448.41584,242.592299 L445.777042,241.39444 C431.243943,234.797303 415.470292,231.383849 399.512242,231.383849 L329.828907,231.383849 Z" id="SvgjsPath1015" transform="translate(269.2809, 269.5753) rotate(-134) translate(-269.2809, -269.5753)"></path> 7 - </g> 17 + <g 18 + id="SvgjsG1018" 19 + stroke="none" 20 + stroke-width="1" 21 + fill="none" 22 + fill-rule="evenodd" 23 + > 24 + <path 25 + d="M258.744161,673.532662 C220.757598,658.556745 187.761971,637.340862 159.757278,609.885015 C131.731315,582.675001 109.640415,549.95668 94.87512,513.789548 C79.62504,476.904419 72,436.829975 72,393.566215 C72,341.982502 79.9023142,296.222756 95.7069425,256.286977 C111.788845,216.351199 133.97078,182.794052 162.252746,155.615536 C190.638366,128.268343 224.327386,107.031355 261.239629,93.2158823 C298.948918,79.0719608 339.292311,72 382.269809,72 C434.951904,72 481.118055,80.1812879 520.768263,96.5438638 C560.418471,112.90644 593.414098,135.092983 619.755146,163.103494 C645.823967,190.667688 665.804365,223.408333 678.398635,259.198961 C691.153247,294.974763 696.976005,332.414555 695.866908,371.518338 C694.480537,425.320706 674.410807,486.092796 651.875912,513.807986 C627.109489,544.267686 603.766423,555.08515 561.350365,555.08515 C539.879106,555.08515 518.908303,544.647246 498.437956,523.771439 L520.642336,488.757018 L553.379562,469.399451 C566.854015,479.647575 578.145985,482.494276 587.255474,477.939554 C600.919708,471.107472 612.130106,436.413978 615.180122,422.270056 C618.73393,406.280102 620.684502,389.975506 621.002879,373.598326 C621.834702,330.611898 615.457396,294.420099 601.870961,265.022929 C588.284526,235.625759 569.845793,211.91389 546.554762,193.887324 C524.028964,175.836615 498.164782,162.407064 470.442999,154.367543 C442.715581,146.047589 415.1268,141.887612 387.676656,141.887612 C348.85827,141.887612 314.337635,148.127577 284.114749,160.607508 C253.891863,172.810107 228.382638,190.143344 207.587075,212.60722 C187.068785,234.793763 171.541431,261.140284 161.005012,291.646781 C150.745868,321.875947 145.893569,355.155762 146.448118,391.486227 C147.557214,427.53936 154.073158,459.98718 165.995948,488.829687 C177.28032,516.727243 194.28181,541.951359 215.9053,562.877276 C237.610095,583.620856 263.386636,599.628298 291.601152,609.885015 C320.714941,620.700955 352.601472,626.108925 387.260744,626.108925 C406.669937,626.108925 425.940493,623.89027 445.072411,619.452962 C464.481604,615.292985 482.227152,609.330351 498.309054,601.565061 L522.015997,666.460701 C500.665885,676.444645 478.206676,683.793938 454.63837,688.508578 C431.262297,693.509483 407.422332,696.019474 383.517543,696 C338.321851,696 296.730724,688.508578 258.744161,673.532662 Z" 26 + id="SvgjsPath1017" 27 + fill="#0083FF" 28 + fill-rule="nonzero" 29 + ></path> 30 + <g 31 + id="SvgjsG1016" 32 + transform="translate(115.0733, 79.4049)" 33 + fill="#0083FF" 34 + > 35 + <path 36 + d="M329.828907,231.383849 L260.699401,110.602459 C251.63529,94.765511 234.795945,84.9961472 216.561535,84.9961472 L202.32856,84.9961472 C197.759942,84.9961472 194.291354,89.1141864 195.064554,93.6217527 L218.683383,231.383849 L146.044594,231.383849 L117.317307,191.126992 C112.543308,184.436292 104.83484,180.465281 96.6208666,180.465281 L77.9170684,180.465281 C74.6441326,180.465281 72.2041032,183.486025 72.8889188,186.689439 L90.5192033,269.169882 C90.542094,269.250079 90.5821529,269.322638 90.6349289,269.382467 C90.634293,269.446115 90.634293,269.509764 90.634293,269.572775 L90.6349289,269.760538 C90.5821529,269.821003 90.542094,269.892926 90.5192033,269.973759 L72.8889188,352.453566 C72.2041032,355.657617 74.6441326,358.677724 77.9170684,358.677724 L96.6208666,358.677724 C104.83484,358.677724 112.543308,354.706712 117.317307,348.016012 L146.042051,307.761702 L218.684019,307.761702 L195.064554,445.52889 C194.291354,450.036456 197.759942,454.154495 202.32856,454.154495 L216.561535,454.154495 C234.795945,454.154495 251.63529,444.385132 260.699401,428.548184 L329.83145,307.761702 L399.512242,307.761702 C415.470292,307.761702 431.243943,304.348885 445.777042,297.751748 L448.41584,296.553888 C458.994558,291.751631 465.788667,281.20003 465.788667,269.572775 C465.788667,257.946157 458.994558,247.394556 448.41584,242.592299 L445.777042,241.39444 C431.243943,234.797303 415.470292,231.383849 399.512242,231.383849 L329.828907,231.383849 Z" 37 + id="SvgjsPath1015" 38 + transform="translate(269.2809, 269.5753) rotate(-134) translate(-269.2809, -269.5753)" 39 + ></path> 40 + </g> 8 41 </g> 9 - </svg><style>@media (prefers-color-scheme: light) { :root { filter: none; } } 10 - @media (prefers-color-scheme: dark) { :root { filter: none; } } 11 - </style></svg> 42 + </svg><style> 43 + @media (prefers-color-scheme: light) { 44 + :root { 45 + filter: none; 46 + } 47 + } 48 + @media (prefers-color-scheme: dark) { 49 + :root { 50 + filter: none; 51 + } 52 + } 53 + </style> 54 + </svg>
+12
static/icons/account.svg
··· 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + height="24px" 4 + viewBox="0 0 24 24" 5 + width="24px" 6 + fill="#e3e3e3" 7 + > 8 + <path d="M0 0h24v24H0z" fill="none" /> 9 + <path 10 + d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" 11 + /> 12 + </svg>
+9 -3
static/icons/bluesky.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="-20 -20 296 266" fill="none"> 2 - <path 1 + <svg 2 + xmlns="http://www.w3.org/2000/svg" 3 + width="24" 4 + height="24" 5 + viewBox="-20 -20 296 266" 6 + fill="none" 7 + > 8 + <path 3 9 d="M55.491 15.172c29.35 22.035 60.917 66.712 72.509 90.686 11.592-23.974 43.159-68.651 72.509-90.686C221.686-.727 256-13.028 256 26.116c0 7.818-4.482 65.674-7.111 75.068-9.138 32.654-42.436 40.983-72.057 35.942 51.775 8.812 64.946 38 36.501 67.187-54.021 55.433-77.644-13.908-83.696-31.676-1.11-3.257-1.63-4.78-1.637-3.485-.008-1.296-.527.228-1.637 3.485-6.052 17.768-29.675 87.11-83.696 31.676-28.445-29.187-15.274-58.375 36.5-67.187-29.62 5.041-62.918-3.288-72.056-35.942C4.482 91.79 0 33.934 0 26.116 0-13.028 34.314-.727 55.491 15.172Z" 4 10 stroke="currentColor" 5 11 stroke-width="25" 6 12 fill="none" 7 13 stroke-linejoin="round" 8 14 /> 9 - </svg> 15 + </svg>
+30
static/icons/info_bold.svg
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <svg 3 + width="24" 4 + height="24" 5 + viewBox="0 0 24 24" 6 + fill="none" 7 + xmlns="http://www.w3.org/2000/svg" 8 + > 9 + <path 10 + d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" 11 + stroke="currentColor" 12 + stroke-width="2" 13 + stroke-linecap="round" 14 + stroke-linejoin="round" 15 + /> 16 + <path 17 + d="M12 16V12" 18 + stroke="currentColor" 19 + stroke-width="2" 20 + stroke-linecap="round" 21 + stroke-linejoin="round" 22 + /> 23 + <path 24 + d="M12 8H12.01" 25 + stroke="currentColor" 26 + stroke-width="2" 27 + stroke-linecap="round" 28 + stroke-linejoin="round" 29 + /> 30 + </svg>
+21 -4
static/icons/plane-departure_bold.svg
··· 1 - <svg width="80" height="80" viewBox="4 20 70 36" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 - <path fill-rule="evenodd" clip-rule="evenodd" d="M50.8224 33.2841L31.008 22.743C29.2561 21.8109 27.1909 21.6663 25.3262 22.345L19.7985 24.3569C19.3667 24.5141 19.2513 25.0706 19.5849 25.3865L34.2839 39.3037L18.5107 45.0447L13.6805 42.0448C12.6378 41.3972 11.3555 41.2642 10.202 41.684L5.45108 43.4133C4.99314 43.5799 4.83855 44.15 5.14954 44.5252L13.9345 55.124C13.9589 55.1535 13.9934 55.1711 14.0299 55.1746C14.0582 55.1928 14.0913 55.2035 14.1264 55.2046L22.9607 55.4824C27.707 55.6317 32.4381 54.874 36.9004 53.2498L63.8001 43.4591C66.6965 42.405 69.3245 40.7247 71.4968 38.5381L72.708 37.3189C73.9986 36.0199 74.4226 34.0923 73.7963 32.3716C73.1701 30.6509 71.6062 29.4468 69.7826 29.2812L68.071 29.1259C65.0014 28.8472 61.9082 29.2493 59.0119 30.3034L50.8224 33.2841Z" fill="#FFFFFF" /> 3 - <path d="M7 64H75" stroke="#C2CCDE" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" /> 4 - </svg> 1 + <svg 2 + width="80" 3 + height="80" 4 + viewBox="4 20 70 36" 5 + fill="none" 6 + xmlns="http://www.w3.org/2000/svg" 7 + > 8 + <path 9 + fill-rule="evenodd" 10 + clip-rule="evenodd" 11 + d="M50.8224 33.2841L31.008 22.743C29.2561 21.8109 27.1909 21.6663 25.3262 22.345L19.7985 24.3569C19.3667 24.5141 19.2513 25.0706 19.5849 25.3865L34.2839 39.3037L18.5107 45.0447L13.6805 42.0448C12.6378 41.3972 11.3555 41.2642 10.202 41.684L5.45108 43.4133C4.99314 43.5799 4.83855 44.15 5.14954 44.5252L13.9345 55.124C13.9589 55.1535 13.9934 55.1711 14.0299 55.1746C14.0582 55.1928 14.0913 55.2035 14.1264 55.2046L22.9607 55.4824C27.707 55.6317 32.4381 54.874 36.9004 53.2498L63.8001 43.4591C66.6965 42.405 69.3245 40.7247 71.4968 38.5381L72.708 37.3189C73.9986 36.0199 74.4226 34.0923 73.7963 32.3716C73.1701 30.6509 71.6062 29.4468 69.7826 29.2812L68.071 29.1259C65.0014 28.8472 61.9082 29.2493 59.0119 30.3034L50.8224 33.2841Z" 12 + fill="#FFFFFF" 13 + /> 14 + <path 15 + d="M7 64H75" 16 + stroke="#C2CCDE" 17 + stroke-width="6" 18 + stroke-linecap="round" 19 + stroke-linejoin="round" 20 + /> 21 + </svg>
+14 -3
static/icons/plane_bold.svg
··· 1 - <svg width="80" height="80" viewBox="8 10 64 60" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 - <path fill-rule="evenodd" clip-rule="evenodd" d="M49.4268 34L38.5549 15.0236C37.1294 12.5354 34.4811 11.0005 31.6134 11.0005H29.375C28.6565 11.0005 28.111 11.6475 28.2326 12.3557L31.9471 34H20.5233L16.0054 27.6751C15.2546 26.6239 14.0423 26 12.7505 26H9.80898C9.29425 26 8.91051 26.4746 9.01821 26.9779L11.7909 39.9367C11.7945 39.9493 11.8008 39.9607 11.8091 39.9701C11.809 39.9801 11.809 39.9901 11.809 40L11.8091 40.0295C11.8008 40.039 11.7945 40.0503 11.7909 40.063L9.01821 53.0217C8.91051 53.5251 9.29425 53.9996 9.80898 53.9996H12.7505C14.0423 53.9996 15.2546 53.3757 16.0054 52.3245L20.5229 46H31.9472L28.2326 67.6451C28.111 68.3533 28.6565 69.0003 29.375 69.0003H31.6134C34.4811 69.0003 37.1294 67.4654 38.5549 64.9772L49.4272 46H60.3858C62.8955 46 65.3762 45.4638 67.6618 44.4273L68.0768 44.2391C69.7405 43.4846 70.809 41.8268 70.809 40C70.809 38.1733 69.7405 36.5155 68.0768 35.761L67.6618 35.5728C65.3762 34.5363 62.8955 34 60.3858 34H49.4268Z" fill="#C2CCDE" /> 3 - </svg> 1 + <svg 2 + width="80" 3 + height="80" 4 + viewBox="8 10 64 60" 5 + fill="none" 6 + xmlns="http://www.w3.org/2000/svg" 7 + > 8 + <path 9 + fill-rule="evenodd" 10 + clip-rule="evenodd" 11 + d="M49.4268 34L38.5549 15.0236C37.1294 12.5354 34.4811 11.0005 31.6134 11.0005H29.375C28.6565 11.0005 28.111 11.6475 28.2326 12.3557L31.9471 34H20.5233L16.0054 27.6751C15.2546 26.6239 14.0423 26 12.7505 26H9.80898C9.29425 26 8.91051 26.4746 9.01821 26.9779L11.7909 39.9367C11.7945 39.9493 11.8008 39.9607 11.8091 39.9701C11.809 39.9801 11.809 39.9901 11.809 40L11.8091 40.0295C11.8008 40.039 11.7945 40.0503 11.7909 40.063L9.01821 53.0217C8.91051 53.5251 9.29425 53.9996 9.80898 53.9996H12.7505C14.0423 53.9996 15.2546 53.3757 16.0054 52.3245L20.5229 46H31.9472L28.2326 67.6451C28.111 68.3533 28.6565 69.0003 29.375 69.0003H31.6134C34.4811 69.0003 37.1294 67.4654 38.5549 64.9772L49.4272 46H60.3858C62.8955 46 65.3762 45.4638 67.6618 44.4273L68.0768 44.2391C69.7405 43.4846 70.809 41.8268 70.809 40C70.809 38.1733 69.7405 36.5155 68.0768 35.761L67.6618 35.5728C65.3762 34.5363 62.8955 34 60.3858 34H49.4268Z" 12 + fill="#C2CCDE" 13 + /> 14 + </svg>
+18 -4
static/icons/ticket_bold.svg
··· 1 - <svg width="80" height="80" viewBox="6 18 70 44" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 - <path fill-rule="evenodd" clip-rule="evenodd" d="M8 24C8 21.7909 9.79086 20 12 20H68C70.2091 20 72 21.7909 72 24V33.6052C71.6647 33.6578 71.3304 33.7373 71 33.8446C68.3333 34.7111 66.5279 37.1961 66.5279 40C66.5279 42.8039 68.3333 45.2889 71 46.1554C71.3304 46.2627 71.6647 46.3422 72 46.3948V56C72 58.2091 70.2091 60 68 60H12C9.79086 60 8 58.2091 8 56V46.4726C8.66685 46.4727 9.3412 46.3694 10 46.1554C12.6667 45.2889 14.4721 42.8039 14.4721 40C14.4721 37.1961 12.6667 34.7111 10 33.8446C9.3412 33.6306 8.66685 33.5273 8 33.5274V24Z" fill="#FFFFFF" /> 3 - <path d="M72 33.6052L72.3097 35.5811C73.2828 35.4286 74 34.5903 74 33.6052H72ZM71 33.8446L70.382 31.9425L70.382 31.9425L71 33.8446ZM71 46.1554L70.382 48.0575H70.382L71 46.1554ZM72 46.3948H74C74 45.4097 73.2828 44.5714 72.3097 44.4189L72 46.3948ZM8 46.4726L8.00027 44.4726C7.46979 44.4725 6.96101 44.6832 6.58588 45.0583C6.21075 45.4333 6 45.9421 6 46.4726H8ZM10 46.1554L10.618 48.0575H10.618L10 46.1554ZM14.4721 40H12.4721H14.4721ZM10 33.8446L10.618 31.9425H10.618L10 33.8446ZM8 33.5274H6C6 34.0579 6.21075 34.5667 6.58588 34.9417C6.96101 35.3168 7.46979 35.5275 8.00027 35.5274L8 33.5274ZM12 18C8.68629 18 6 20.6863 6 24H10C10 22.8954 10.8954 22 12 22V18ZM68 18H12V22H68V18ZM74 24C74 20.6863 71.3137 18 68 18V22C69.1046 22 70 22.8954 70 24H74ZM74 33.6052V24H70V33.6052H74ZM71.6903 31.6294C71.2511 31.6982 70.8136 31.8023 70.382 31.9425L71.618 35.7467C71.8472 35.6723 72.0783 35.6174 72.3097 35.5811L71.6903 31.6294ZM70.382 31.9425C66.8913 33.0767 64.5279 36.3296 64.5279 40H68.5279C68.5279 38.0626 69.7754 36.3454 71.618 35.7467L70.382 31.9425ZM64.5279 40C64.5279 43.6704 66.8913 46.9233 70.382 48.0575L71.618 44.2533C69.7754 43.6546 68.5279 41.9374 68.5279 40H64.5279ZM70.382 48.0575C70.8136 48.1977 71.2511 48.3018 71.6903 48.3706L72.3097 44.4189C72.0783 44.3826 71.8472 44.3277 71.618 44.2533L70.382 48.0575ZM74 56V46.3948H70V56H74ZM68 62C71.3137 62 74 59.3137 74 56H70C70 57.1046 69.1046 58 68 58V62ZM12 62H68V58H12V62ZM6 56C6 59.3137 8.68629 62 12 62V58C10.8954 58 10 57.1046 10 56H6ZM6 46.4726V56H10V46.4726H6ZM9.38197 44.2533C8.92559 44.4015 8.46006 44.4726 8.00027 44.4726L7.99973 48.4726C8.87364 48.4727 9.7568 48.3373 10.618 48.0575L9.38197 44.2533ZM12.4721 40C12.4721 41.9374 11.2246 43.6545 9.38197 44.2533L10.618 48.0575C14.1087 46.9233 16.4721 43.6704 16.4721 40H12.4721ZM9.38197 35.7467C11.2246 36.3454 12.4721 38.0626 12.4721 40H16.4721C16.4721 36.3296 14.1087 33.0767 10.618 31.9425L9.38197 35.7467ZM8.00027 35.5274C8.46006 35.5274 8.92559 35.5985 9.38197 35.7467L10.618 31.9425C9.7568 31.6627 8.87364 31.5273 7.99973 31.5274L8.00027 35.5274ZM6 24V33.5274H10V24H6Z" fill="#FFFFFF" /> 4 - </svg> 1 + <svg 2 + width="80" 3 + height="80" 4 + viewBox="6 18 70 44" 5 + fill="none" 6 + xmlns="http://www.w3.org/2000/svg" 7 + > 8 + <path 9 + fill-rule="evenodd" 10 + clip-rule="evenodd" 11 + d="M8 24C8 21.7909 9.79086 20 12 20H68C70.2091 20 72 21.7909 72 24V33.6052C71.6647 33.6578 71.3304 33.7373 71 33.8446C68.3333 34.7111 66.5279 37.1961 66.5279 40C66.5279 42.8039 68.3333 45.2889 71 46.1554C71.3304 46.2627 71.6647 46.3422 72 46.3948V56C72 58.2091 70.2091 60 68 60H12C9.79086 60 8 58.2091 8 56V46.4726C8.66685 46.4727 9.3412 46.3694 10 46.1554C12.6667 45.2889 14.4721 42.8039 14.4721 40C14.4721 37.1961 12.6667 34.7111 10 33.8446C9.3412 33.6306 8.66685 33.5273 8 33.5274V24Z" 12 + fill="#FFFFFF" 13 + /> 14 + <path 15 + d="M72 33.6052L72.3097 35.5811C73.2828 35.4286 74 34.5903 74 33.6052H72ZM71 33.8446L70.382 31.9425L70.382 31.9425L71 33.8446ZM71 46.1554L70.382 48.0575H70.382L71 46.1554ZM72 46.3948H74C74 45.4097 73.2828 44.5714 72.3097 44.4189L72 46.3948ZM8 46.4726L8.00027 44.4726C7.46979 44.4725 6.96101 44.6832 6.58588 45.0583C6.21075 45.4333 6 45.9421 6 46.4726H8ZM10 46.1554L10.618 48.0575H10.618L10 46.1554ZM14.4721 40H12.4721H14.4721ZM10 33.8446L10.618 31.9425H10.618L10 33.8446ZM8 33.5274H6C6 34.0579 6.21075 34.5667 6.58588 34.9417C6.96101 35.3168 7.46979 35.5275 8.00027 35.5274L8 33.5274ZM12 18C8.68629 18 6 20.6863 6 24H10C10 22.8954 10.8954 22 12 22V18ZM68 18H12V22H68V18ZM74 24C74 20.6863 71.3137 18 68 18V22C69.1046 22 70 22.8954 70 24H74ZM74 33.6052V24H70V33.6052H74ZM71.6903 31.6294C71.2511 31.6982 70.8136 31.8023 70.382 31.9425L71.618 35.7467C71.8472 35.6723 72.0783 35.6174 72.3097 35.5811L71.6903 31.6294ZM70.382 31.9425C66.8913 33.0767 64.5279 36.3296 64.5279 40H68.5279C68.5279 38.0626 69.7754 36.3454 71.618 35.7467L70.382 31.9425ZM64.5279 40C64.5279 43.6704 66.8913 46.9233 70.382 48.0575L71.618 44.2533C69.7754 43.6546 68.5279 41.9374 68.5279 40H64.5279ZM70.382 48.0575C70.8136 48.1977 71.2511 48.3018 71.6903 48.3706L72.3097 44.4189C72.0783 44.3826 71.8472 44.3277 71.618 44.2533L70.382 48.0575ZM74 56V46.3948H70V56H74ZM68 62C71.3137 62 74 59.3137 74 56H70C70 57.1046 69.1046 58 68 58V62ZM12 62H68V58H12V62ZM6 56C6 59.3137 8.68629 62 12 62V58C10.8954 58 10 57.1046 10 56H6ZM6 46.4726V56H10V46.4726H6ZM9.38197 44.2533C8.92559 44.4015 8.46006 44.4726 8.00027 44.4726L7.99973 48.4726C8.87364 48.4727 9.7568 48.3373 10.618 48.0575L9.38197 44.2533ZM12.4721 40C12.4721 41.9374 11.2246 43.6545 9.38197 44.2533L10.618 48.0575C14.1087 46.9233 16.4721 43.6704 16.4721 40H12.4721ZM9.38197 35.7467C11.2246 36.3454 12.4721 38.0626 12.4721 40H16.4721C16.4721 36.3296 14.1087 33.0767 10.618 31.9425L9.38197 35.7467ZM8.00027 35.5274C8.46006 35.5274 8.92559 35.5985 9.38197 35.7467L10.618 31.9425C9.7568 31.6627 8.87364 31.5273 7.99973 31.5274L8.00027 35.5274ZM6 24V33.5274H10V24H6Z" 16 + fill="#FFFFFF" 17 + /> 18 + </svg>
+125 -116
static/styles.css
··· 2 2 @import url("https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap"); 3 3 4 4 @font-face { 5 - font-family: "Skyfont"; 6 - src: url("fonts/skyfont.regular.otf") format("opentype"); 7 - font-weight: normal; 8 - font-style: normal; 5 + font-family: "Skyfont"; 6 + src: url("fonts/skyfont.regular.otf") format("opentype"); 7 + font-weight: normal; 8 + font-style: normal; 9 9 } 10 10 11 11 @font-face { 12 - font-family: "F25_Bank_Printer"; 13 - src: url("fonts/F25_Bank_Printer.ttf") format("truetype"); 14 - font-weight: normal; 15 - font-style: normal; 12 + font-family: "F25_Bank_Printer"; 13 + src: url("fonts/F25_Bank_Printer.ttf") format("truetype"); 14 + font-weight: normal; 15 + font-style: normal; 16 16 } 17 17 18 18 @tailwind base; ··· 20 20 @tailwind utilities; 21 21 22 22 @keyframes fadeOut { 23 - 0% { 24 - opacity: 1; 25 - } 26 - 75% { 27 - opacity: 1; 28 - } /* Hold full opacity for most of the animation */ 29 - 100% { 30 - opacity: 0; 31 - } 23 + 0% { 24 + opacity: 1; 25 + } 26 + 75% { 27 + opacity: 1; 28 + } /* Hold full opacity for most of the animation */ 29 + 100% { 30 + opacity: 0; 31 + } 32 32 } 33 33 34 34 .status-message-fade { 35 - animation: fadeOut 2s forwards; 35 + animation: fadeOut 2s forwards; 36 36 } 37 37 38 38 .font-spectral { 39 - font-family: "Spectral", serif; 39 + font-family: "Spectral", serif; 40 40 } 41 41 42 42 .grow-wrap { 43 - /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ 44 - display: grid; 43 + /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ 44 + display: grid; 45 45 } 46 46 .grow-wrap::after { 47 - /* Note the weird space! Needed to preventy jumpy behavior */ 48 - content: attr(data-replicated-value) " "; 47 + /* Note the weird space! Needed to preventy jumpy behavior */ 48 + content: attr(data-replicated-value) " "; 49 49 50 - /* This is how textarea text behaves */ 51 - white-space: pre-wrap; 50 + /* This is how textarea text behaves */ 51 + white-space: pre-wrap; 52 52 53 - /* Hidden from view, clicks, and screen readers */ 54 - visibility: hidden; 53 + /* Hidden from view, clicks, and screen readers */ 54 + visibility: hidden; 55 55 } 56 56 .grow-wrap > textarea { 57 - /* You could leave this, but after a user resizes, then it ruins the auto sizing */ 58 - resize: none; 57 + /* You could leave this, but after a user resizes, then it ruins the auto sizing */ 58 + resize: none; 59 59 60 - /* Firefox shows scrollbar on growth, you can hide like this. */ 61 - overflow: hidden; 60 + /* Firefox shows scrollbar on growth, you can hide like this. */ 61 + overflow: hidden; 62 62 } 63 63 .grow-wrap > textarea, 64 64 .grow-wrap::after { 65 - /* Identical styling required!! */ 66 - font: inherit; 65 + /* Identical styling required!! */ 66 + font: inherit; 67 67 68 - /* Place on top of each other */ 69 - grid-area: 1 / 1 / 2 / 2; 68 + /* Place on top of each other */ 69 + grid-area: 1 / 1 / 2 / 2; 70 70 } 71 71 72 72 /* Base styling */ 73 73 @layer base { 74 - body { 75 - @apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100; 76 - font-family: Space Mono; 77 - } 78 - button { 79 - @apply rounded-xl; 80 - } 74 + body { 75 + @apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100; 76 + font-family: Space Mono; 77 + } 78 + button { 79 + @apply rounded-xl; 80 + } 81 81 82 - input { 83 - @apply px-4 py-2; 84 - } 82 + input { 83 + @apply px-4 py-2; 84 + } 85 85 86 - h1, 87 - h2, 88 - h3, 89 - h4, 90 - h5 { 91 - font-family: 92 - Share Tech Mono, 93 - monospace; 94 - } 86 + h1, 87 + h2, 88 + h3, 89 + h4, 90 + h5 { 91 + font-family: Share Tech Mono, monospace; 92 + } 95 93 } 96 94 97 95 .ticket { 98 - font-family: F25_Bank_Printer, monospace; 99 - @apply bg-white dark:bg-gray-800 p-8 relative overflow-hidden; 100 - position: relative; 101 - /* Angled corners */ 102 - clip-path: polygon( 103 - 20px 0, 104 - /* Top left corner */ calc(100% - 20px) 0, 105 - /* Top right corner */ 100% 20px, 106 - /* Top right */ 100% calc(100% - 20px), 107 - /* Bottom right */ calc(100% - 20px) 100%, 108 - /* Bottom right corner */ 20px 100%, 109 - /* Bottom left corner */ 0 calc(100% - 20px), 110 - /* Bottom left */ 0 20px /* Back to top left */ 111 - ); 96 + font-family: F25_Bank_Printer, monospace; 97 + @apply bg-white dark:bg-gray-800 p-8 relative overflow-hidden; 98 + position: relative; 99 + /* Angled corners */ 100 + clip-path: polygon( 101 + 20px 0, 102 + /* Top left corner */ calc(100% - 20px) 0, 103 + /* Top right corner */ 100% 20px, 104 + /* Top right */ 100% calc(100% - 20px), 105 + /* Bottom right */ calc(100% - 20px) 100%, 106 + /* Bottom right corner */ 20px 100%, 107 + /* Bottom left corner */ 0 calc(100% - 20px), 108 + /* Bottom left */ 0 20px /* Back to top left */ 109 + ); 112 110 } 113 111 114 112 /* Create side perforations using pseudo-elements */ 115 113 .ticket::before, 116 114 .ticket::after { 117 - content: ""; 118 - position: absolute; 119 - top: 30px; 120 - bottom: 30px; 121 - width: 1px; 122 - background-image: linear-gradient( 123 - to bottom, 124 - transparent 0%, 125 - transparent 40%, 126 - currentColor 40%, 127 - currentColor 60%, 128 - transparent 60%, 129 - transparent 100% 130 - ); 131 - background-size: 100% 20px; 132 - background-repeat: repeat-y; 133 - opacity: 0.2; 115 + content: ""; 116 + position: absolute; 117 + top: 30px; 118 + bottom: 30px; 119 + width: 1px; 120 + background-image: linear-gradient( 121 + to bottom, 122 + transparent 0%, 123 + transparent 40%, 124 + currentColor 40%, 125 + currentColor 60%, 126 + transparent 60%, 127 + transparent 100% 128 + ); 129 + background-size: 100% 20px; 130 + background-repeat: repeat-y; 131 + opacity: 0.2; 134 132 } 135 133 136 134 .ticket::before { 137 - left: 8px; 135 + left: 8px; 138 136 } 139 137 140 138 .ticket::after { 141 - right: 8px; 139 + right: 8px; 142 140 } 143 141 144 142 .dark .ticket { 145 - background-image: 146 - radial-gradient( 147 - circle at 10px center, 148 - rgb(17 24 39) 4px, 149 - transparent 4px 150 - ), 151 - radial-gradient( 152 - circle at calc(100% - 10px) center, 153 - rgb(17 24 39) 4px, 154 - transparent 4px 155 - ); 143 + background-image: 144 + radial-gradient( 145 + circle at 10px center, 146 + rgb(17 24 39) 4px, 147 + transparent 4px 148 + ), 149 + radial-gradient( 150 + circle at calc(100% - 10px) center, 151 + rgb(17 24 39) 4px, 152 + transparent 4px 153 + ); 156 154 } 157 155 158 156 /* Remove the previous background images and corner cuts */ 159 157 .ticket::before, 160 158 .ticket::after { 161 - display: none; 159 + display: none; 162 160 } 163 161 164 162 .boarding-label { 165 - @apply absolute top-2 right-2 bg-blue-100 dark:bg-blue-900 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider; 166 - transform: rotate(2deg); 163 + @apply absolute top-2 right-2 bg-blue-100 dark:bg-blue-900 px-3 py-1 164 + rounded-full text-xs font-bold uppercase tracking-wider; 165 + transform: rotate(2deg); 167 166 } 168 167 169 168 .flight-info { 170 - @apply flex justify-between items-center mt-4 pt-4 border-t border-dashed; 169 + @apply flex justify-between items-center mt-4 pt-4 border-t border-dashed; 171 170 } 172 171 173 172 .passenger-info { 174 - @apply text-sm text-gray-600 dark:text-gray-400 mt-2; 173 + @apply text-sm text-gray-600 dark:text-gray-400 mt-2; 175 174 } 176 175 177 176 /* Modern Airport Sign Styles */ 178 177 .airport-sign { 179 - position: relative; 180 - transform-origin: top; 181 - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); 182 - border-radius: 0.5rem; 183 - backdrop-filter: blur(8px); 178 + position: relative; 179 + transform-origin: top; 180 + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); 181 + border-radius: 0.5rem; 182 + backdrop-filter: blur(8px); 184 183 } 185 184 186 185 /* Dropdown panel styles */ 187 186 .airport-sign + div { 188 - border-radius: 0.5rem; 189 - backdrop-filter: blur(8px); 187 + border-radius: 0.5rem; 188 + backdrop-filter: blur(8px); 190 189 } 191 190 192 191 /* Remove old texture styles */ 193 192 .airport-sign, 194 193 .airport-sign + div { 195 - background-blend-mode: overlay; 194 + background-blend-mode: overlay; 196 195 } 197 196 198 197 @keyframes popin { 199 - 0% { opacity: 0; transform: scale(0.95); } 200 - 100% { opacity: 1; transform: scale(1); } 198 + 0% { 199 + opacity: 0; 200 + transform: scale(0.95); 201 + } 202 + 100% { 203 + opacity: 1; 204 + transform: scale(1); 205 + } 201 206 } 202 207 .animate-popin { 203 - animation: popin 0.25s cubic-bezier(0.4,0,0.2,1); 208 + animation: popin 0.25s cubic-bezier(0.4, 0, 0.2, 1); 204 209 } 205 210 @keyframes bounce-short { 206 - 0%, 100% { transform: translateY(0); } 207 - 50% { transform: translateY(-8px); } 211 + 0%, 100% { 212 + transform: translateY(0); 213 + } 214 + 50% { 215 + transform: translateY(-8px); 216 + } 208 217 } 209 218 .animate-bounce-short { 210 219 animation: bounce-short 0.5s;