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
+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 }
+7 -16
README.md
··· 4 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 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. 8 - 9 7 ## Features 10 8 11 9 - PDS migration between servers ··· 13 11 - User-friendly interface 14 12 - Coming soon: PLC Key retrieval, data backup 15 13 16 - ## Technology Stack 14 + ## Tech Stack 17 15 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 16 + - [Fresh](https://fresh.deno.dev/) - Web Framework 17 + - [Deno](https://deno.com/) - Runtime 18 + - [Tailwind](https://tailwindcss.com/) - Styling 22 19 23 - ## Getting Started 24 - 25 - ### Prerequisites 20 + ## Development 26 21 27 - Make sure to install Deno: 22 + Make sure you have Deno installed: 28 23 https://docs.deno.com/runtime/getting_started/installation 29 24 30 - ### Development 31 - 32 25 Start the project in development mode: 33 26 34 - ``` 27 + ```shell 35 28 deno task dev 36 29 ``` 37 - 38 - This will watch the project directory and restart as necessary. 39 30 40 31 ## About 41 32
+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">
+10
components/Button.tsx
··· 12 12 type ButtonProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLButtonElement>, keyof ButtonBaseProps>; 13 13 type AnchorProps = ButtonBaseProps & Omit<JSX.HTMLAttributes<HTMLAnchorElement>, keyof ButtonBaseProps> & { href: string }; 14 14 15 + /** 16 + * The button props or anchor props for a button or link. 17 + * @type {Props} 18 + */ 15 19 type Props = ButtonProps | AnchorProps; 16 20 21 + /** 22 + * Styled button component. 23 + * @param props - The button props 24 + * @returns The button component 25 + * @component 26 + */ 17 27 export function Button(props: Props) { 18 28 const { color = "blue", icon, iconAlt, label, className = "", condensed = false, ...rest } = props; 19 29 const isAnchor = 'href' in props;
+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 + }
+32 -8
deno.json
··· 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"] 34 - } 54 + "unstable": [ 55 + "kv", 56 + "otel" 57 + ] 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("");
+1136
islands/DidPlcProgress.tsx
··· 1 + import { useState, useEffect } 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 + // Content chunks for the description 11 + const contentChunks = [ 12 + { 13 + title: "Welcome to Key Management", 14 + subtitle: "BOARDING PASS - SECTION A", 15 + content: ( 16 + <> 17 + <div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4"> 18 + GATE: KEY-01 โ€ข SEAT: DID-1A 19 + </div> 20 + <p class="text-slate-700 dark:text-slate-300 mb-4"> 21 + This tool helps you add a new rotation key to your{" "} 22 + <Link 23 + href="https://web.plc.directory/" 24 + isExternal 25 + class="text-blue-600 dark:text-blue-400" 26 + > 27 + PLC (Public Ledger of Credentials) 28 + </Link> 29 + . Having control of a rotation key gives you sovereignty over your DID 30 + (Decentralized Identifier). 31 + </p> 32 + </> 33 + ), 34 + }, 35 + { 36 + title: "Key Benefits", 37 + subtitle: "BOARDING PASS - SECTION B", 38 + content: ( 39 + <> 40 + <div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4"> 41 + GATE: KEY-02 โ€ข SEAT: DID-1B 42 + </div> 43 + <div class="space-y-4"> 44 + <div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700"> 45 + <h4 class="font-mono font-bold text-amber-500 dark:text-amber-400 mb-2"> 46 + PROVIDER MOBILITY โœˆ๏ธ 47 + </h4> 48 + <p class="text-slate-700 dark:text-slate-300"> 49 + Change your PDS without losing your identity, protecting you if 50 + your provider becomes hostile. 51 + </p> 52 + </div> 53 + <div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700"> 54 + <h4 class="font-mono font-bold text-amber-500 dark:text-amber-400 mb-2"> 55 + IDENTITY CONTROL โœจ 56 + </h4> 57 + <p class="text-slate-700 dark:text-slate-300"> 58 + Modify your DID document independently of your provider. 59 + </p> 60 + </div> 61 + <div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700"> 62 + <p class="text-slate-700 dark:text-slate-300"> 63 + ๐Ÿ’ก It's good practice to have a rotation key so you can move to a 64 + different provider if you need to. 65 + </p> 66 + </div> 67 + </div> 68 + </> 69 + ), 70 + }, 71 + { 72 + title: "โš ๏ธ CRITICAL SECURITY WARNING", 73 + subtitle: "BOARDING PASS - SECTION C", 74 + content: ( 75 + <> 76 + <div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4"> 77 + GATE: KEY-03 โ€ข SEAT: DID-1C 78 + </div> 79 + <div class="p-4 bg-red-50 dark:bg-red-900 rounded-lg border-2 border-red-500 dark:border-red-700 mb-4"> 80 + <div class="flex items-center mb-3"> 81 + <span class="text-2xl mr-2">โš ๏ธ</span> 82 + <h4 class="font-mono font-bold text-red-700 dark:text-red-400 text-lg"> 83 + NON-REVOCABLE KEY WARNING 84 + </h4> 85 + </div> 86 + <div class="space-y-3 text-red-700 dark:text-red-300"> 87 + <p class="font-bold"> 88 + This rotation key CANNOT BE DISABLED OR DELETED once added: 89 + </p> 90 + <ul class="list-disc pl-5 space-y-2"> 91 + <li> 92 + If compromised, the attacker can take complete control of your 93 + account and identity 94 + </li> 95 + <li> 96 + Malicious actors with this key have COMPLETE CONTROL of your 97 + account and identity 98 + </li> 99 + <li> 100 + Store securely, like a password (e.g. <strong>DO NOT</strong>{" "} 101 + keep it in Notes or any easily accessible app on an unlocked 102 + device). 103 + </li> 104 + </ul> 105 + </div> 106 + </div> 107 + <div class="p-3 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700"> 108 + <p class="text-slate-700 dark:text-slate-300"> 109 + ๐Ÿ’ก We recommend adding a custom rotation key but recommend{" "} 110 + <strong class="italic">against</strong> having more than one custom 111 + rotation key, as more than one increases risk. 112 + </p> 113 + </div> 114 + </> 115 + ), 116 + }, 117 + { 118 + title: "Technical Overview", 119 + subtitle: "BOARDING PASS - SECTION C", 120 + content: ( 121 + <> 122 + <div class="passenger-info text-slate-600 dark:text-slate-300 font-mono text-sm mb-4"> 123 + GATE: KEY-03 โ€ข SEAT: DID-1C 124 + </div> 125 + <div class="p-4 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700"> 126 + <div class="flex items-center mb-3"> 127 + <span class="text-lg mr-2">๐Ÿ“</span> 128 + <h4 class="font-mono font-bold text-amber-500 dark:text-amber-400"> 129 + TECHNICAL DETAILS 130 + </h4> 131 + </div> 132 + <p class="text-slate-700 dark:text-slate-300"> 133 + The rotation key is a did:key that will be added to your PLC 134 + document's rotationKeys array. This process uses the AT Protocol's 135 + PLC operations to update your DID document. 136 + <Link 137 + href="https://web.plc.directory/" 138 + class="block ml-1 text-blue-600 dark:text-blue-400" 139 + isExternal 140 + > 141 + Learn more about did:plc 142 + </Link> 143 + </p> 144 + </div> 145 + </> 146 + ), 147 + }, 148 + ]; 149 + 150 + export default function PlcUpdateProgress() { 151 + const [hasStarted, setHasStarted] = useState(false); 152 + const [currentChunkIndex, setCurrentChunkIndex] = useState(0); 153 + const [steps, setSteps] = useState<PlcUpdateStep[]>([ 154 + { name: "Generate Rotation Key", status: "pending" }, 155 + { name: "Start PLC update", status: "pending" }, 156 + { name: "Complete PLC update", status: "pending" }, 157 + ]); 158 + const [generatedKey, setGeneratedKey] = useState<string>(""); 159 + const [keyJson, setKeyJson] = useState<any>(null); 160 + const [emailToken, setEmailToken] = useState<string>(""); 161 + const [updateResult, setUpdateResult] = useState<string>(""); 162 + const [showDownload, setShowDownload] = useState(false); 163 + const [showKeyInfo, setShowKeyInfo] = useState(false); 164 + const [hasDownloadedKey, setHasDownloadedKey] = useState(false); 165 + const [downloadedKeyId, setDownloadedKeyId] = useState<string | null>(null); 166 + 167 + const updateStepStatus = ( 168 + index: number, 169 + status: PlcUpdateStep["status"], 170 + error?: string 171 + ) => { 172 + console.log( 173 + `Updating step ${index} to ${status}${ 174 + error ? ` with error: ${error}` : "" 175 + }` 176 + ); 177 + setSteps((prevSteps) => 178 + prevSteps.map((step, i) => 179 + i === index 180 + ? { ...step, status, error } 181 + : i > index 182 + ? { ...step, status: "pending", error: undefined } 183 + : step 184 + ) 185 + ); 186 + }; 187 + 188 + const handleStart = () => { 189 + setHasStarted(true); 190 + // Automatically start the first step 191 + setTimeout(() => { 192 + handleGenerateKey(); 193 + }, 100); 194 + }; 195 + 196 + const getStepDisplayName = (step: PlcUpdateStep, index: number) => { 197 + if (step.status === "completed") { 198 + switch (index) { 199 + case 0: 200 + return "Rotation Key Generated"; 201 + case 1: 202 + return "PLC Operation Requested"; 203 + case 2: 204 + return "PLC Update Completed"; 205 + } 206 + } 207 + 208 + if (step.status === "in-progress") { 209 + switch (index) { 210 + case 0: 211 + return "Generating Rotation Key..."; 212 + case 1: 213 + return "Requesting PLC Operation Token..."; 214 + case 2: 215 + return step.name === 216 + "Enter the code sent to your email to complete PLC update" 217 + ? step.name 218 + : "Completing PLC Update..."; 219 + } 220 + } 221 + 222 + if (step.status === "verifying") { 223 + switch (index) { 224 + case 0: 225 + return "Verifying Rotation Key Generation..."; 226 + case 1: 227 + return "Verifying PLC Operation Token Request..."; 228 + case 2: 229 + return "Verifying PLC Update Completion..."; 230 + } 231 + } 232 + 233 + return step.name; 234 + }; 235 + 236 + const handleStartPlcUpdate = async (keyToUse?: string) => { 237 + const key = keyToUse || generatedKey; 238 + 239 + // Debug logging 240 + console.log("=== PLC Update Debug ==="); 241 + console.log("Current state:", { 242 + keyToUse, 243 + generatedKey, 244 + key, 245 + hasKeyJson: !!keyJson, 246 + keyJsonId: keyJson?.publicKeyDid, 247 + hasDownloadedKey, 248 + downloadedKeyId, 249 + steps: steps.map((s) => ({ name: s.name, status: s.status })), 250 + }); 251 + 252 + if (!key) { 253 + console.log("No key generated yet"); 254 + updateStepStatus(1, "error", "No key generated yet"); 255 + return; 256 + } 257 + 258 + if (!keyJson || keyJson.publicKeyDid !== key) { 259 + console.log("Key mismatch or missing:", { 260 + hasKeyJson: !!keyJson, 261 + keyJsonId: keyJson?.publicKeyDid, 262 + expectedKey: key, 263 + }); 264 + updateStepStatus( 265 + 1, 266 + "error", 267 + "Please ensure you have the correct key loaded" 268 + ); 269 + return; 270 + } 271 + 272 + updateStepStatus(1, "in-progress"); 273 + try { 274 + // First request the token 275 + console.log("Requesting PLC token..."); 276 + const tokenRes = await fetch("/api/plc/token", { 277 + method: "GET", 278 + }); 279 + const tokenText = await tokenRes.text(); 280 + console.log("Token response:", tokenText); 281 + 282 + if (!tokenRes.ok) { 283 + try { 284 + const json = JSON.parse(tokenText); 285 + throw new Error(json.message || "Failed to request PLC token"); 286 + } catch { 287 + throw new Error(tokenText || "Failed to request PLC token"); 288 + } 289 + } 290 + 291 + let data; 292 + try { 293 + data = JSON.parse(tokenText); 294 + if (!data.success) { 295 + throw new Error(data.message || "Failed to request token"); 296 + } 297 + } catch (error) { 298 + throw new Error("Invalid response from server"); 299 + } 300 + 301 + console.log("Token request successful, updating UI..."); 302 + // Update step name to prompt for token 303 + setSteps((prevSteps) => 304 + prevSteps.map((step, i) => 305 + i === 1 306 + ? { 307 + ...step, 308 + name: "Enter the code sent to your email to complete PLC update", 309 + status: "in-progress", 310 + } 311 + : step 312 + ) 313 + ); 314 + } catch (error) { 315 + console.error("Token request failed:", error); 316 + updateStepStatus( 317 + 1, 318 + "error", 319 + error instanceof Error ? error.message : String(error) 320 + ); 321 + } 322 + }; 323 + 324 + const handleTokenSubmit = async () => { 325 + console.log("=== Token Submit Debug ==="); 326 + console.log("Current state:", { 327 + emailToken, 328 + generatedKey, 329 + keyJsonId: keyJson?.publicKeyDid, 330 + steps: steps.map((s) => ({ name: s.name, status: s.status })), 331 + }); 332 + 333 + if (!emailToken) { 334 + console.log("No token provided"); 335 + updateStepStatus(1, "error", "Please enter the email token"); 336 + return; 337 + } 338 + 339 + if (!keyJson || !keyJson.publicKeyDid) { 340 + console.log("Missing key data"); 341 + updateStepStatus(1, "error", "Key data is missing, please try again"); 342 + return; 343 + } 344 + 345 + // Prevent duplicate submissions 346 + if (steps[1].status === "completed" || steps[2].status === "completed") { 347 + console.log("Update already completed, preventing duplicate submission"); 348 + return; 349 + } 350 + 351 + updateStepStatus(1, "completed"); 352 + try { 353 + updateStepStatus(2, "in-progress"); 354 + console.log("Submitting update request with token..."); 355 + // Send the update request with both key and token 356 + const res = await fetch("/api/plc/update", { 357 + method: "POST", 358 + headers: { "Content-Type": "application/json" }, 359 + body: JSON.stringify({ 360 + key: keyJson.publicKeyDid, 361 + token: emailToken, 362 + }), 363 + }); 364 + const text = await res.text(); 365 + console.log("Update response:", text); 366 + 367 + let data; 368 + try { 369 + data = JSON.parse(text); 370 + } catch { 371 + throw new Error("Invalid response from server"); 372 + } 373 + 374 + // Check for error responses 375 + if (!res.ok || !data.success) { 376 + const errorMessage = data.message || "Failed to complete PLC update"; 377 + console.error("Update failed:", errorMessage); 378 + throw new Error(errorMessage); 379 + } 380 + 381 + // Only proceed if we have a successful response 382 + console.log("Update completed successfully!"); 383 + setUpdateResult("PLC update completed successfully!"); 384 + 385 + // Add a delay before marking steps as completed for better UX 386 + updateStepStatus(2, "verifying"); 387 + 388 + const verifyRes = await fetch("/api/plc/verify", { 389 + method: "POST", 390 + headers: { "Content-Type": "application/json" }, 391 + body: JSON.stringify({ 392 + key: keyJson.publicKeyDid, 393 + }), 394 + }); 395 + 396 + const verifyText = await verifyRes.text(); 397 + console.log("Verification response:", verifyText); 398 + 399 + let verifyData; 400 + try { 401 + verifyData = JSON.parse(verifyText); 402 + } catch { 403 + throw new Error("Invalid verification response from server"); 404 + } 405 + 406 + if (!verifyRes.ok || !verifyData.success) { 407 + const errorMessage = 408 + verifyData.message || "Failed to verify PLC update"; 409 + console.error("Verification failed:", errorMessage); 410 + throw new Error(errorMessage); 411 + } 412 + 413 + console.log("Verification successful, marking steps as completed"); 414 + updateStepStatus(2, "completed"); 415 + } catch (error) { 416 + console.error("Update failed:", error); 417 + // Reset the steps to error state 418 + updateStepStatus( 419 + 1, 420 + "error", 421 + error instanceof Error ? error.message : String(error) 422 + ); 423 + updateStepStatus(2, "pending"); // Reset the final step 424 + setUpdateResult(error instanceof Error ? error.message : String(error)); 425 + 426 + // If token is invalid, we should clear it so user can try again 427 + if ( 428 + error instanceof Error && 429 + error.message.toLowerCase().includes("token is invalid") 430 + ) { 431 + setEmailToken(""); 432 + } 433 + } 434 + }; 435 + 436 + const handleCompletePlcUpdate = async () => { 437 + // This function is no longer needed as we handle everything in handleTokenSubmit 438 + return; 439 + }; 440 + 441 + const handleDownload = () => { 442 + console.log("=== Download Debug ==="); 443 + console.log("Download started with:", { 444 + hasKeyJson: !!keyJson, 445 + keyJsonId: keyJson?.publicKeyDid, 446 + }); 447 + 448 + if (!keyJson) { 449 + console.error("No key JSON to download"); 450 + return; 451 + } 452 + 453 + try { 454 + const jsonString = JSON.stringify(keyJson, null, 2); 455 + const blob = new Blob([jsonString], { 456 + type: "application/json", 457 + }); 458 + const url = URL.createObjectURL(blob); 459 + const a = document.createElement("a"); 460 + a.href = url; 461 + a.download = `plc-key-${keyJson.publicKeyDid || "unknown"}.json`; 462 + a.style.display = "none"; 463 + document.body.appendChild(a); 464 + a.click(); 465 + document.body.removeChild(a); 466 + URL.revokeObjectURL(url); 467 + 468 + console.log("Download completed, proceeding to next step..."); 469 + setHasDownloadedKey(true); 470 + setDownloadedKeyId(keyJson.publicKeyDid); 471 + 472 + // Automatically proceed to the next step after successful download 473 + setTimeout(() => { 474 + console.log("Auto-proceeding with key:", keyJson.publicKeyDid); 475 + handleStartPlcUpdate(keyJson.publicKeyDid); 476 + }, 1000); 477 + } catch (error) { 478 + console.error("Download failed:", error); 479 + } 480 + }; 481 + 482 + const handleGenerateKey = async () => { 483 + console.log("=== Generate Key Debug ==="); 484 + updateStepStatus(0, "in-progress"); 485 + setShowDownload(false); 486 + setKeyJson(null); 487 + setGeneratedKey(""); 488 + setHasDownloadedKey(false); 489 + setDownloadedKeyId(null); 490 + 491 + try { 492 + console.log("Requesting new key..."); 493 + const res = await fetch("/api/plc/keys"); 494 + const text = await res.text(); 495 + console.log("Key generation response:", text); 496 + 497 + if (!res.ok) { 498 + try { 499 + const json = JSON.parse(text); 500 + throw new Error(json.message || "Failed to generate key"); 501 + } catch { 502 + throw new Error(text || "Failed to generate key"); 503 + } 504 + } 505 + 506 + let data; 507 + try { 508 + data = JSON.parse(text); 509 + } catch { 510 + throw new Error("Invalid response from /api/plc/keys"); 511 + } 512 + 513 + if (!data.publicKeyDid || !data.privateKeyHex) { 514 + throw new Error("Key generation failed: missing key data"); 515 + } 516 + 517 + console.log("Key generated successfully:", { 518 + keyId: data.publicKeyDid, 519 + }); 520 + 521 + setGeneratedKey(data.publicKeyDid); 522 + setKeyJson(data); 523 + setShowDownload(true); 524 + updateStepStatus(0, "completed"); 525 + } catch (error) { 526 + console.error("Key generation failed:", error); 527 + updateStepStatus( 528 + 0, 529 + "error", 530 + error instanceof Error ? error.message : String(error) 531 + ); 532 + } 533 + }; 534 + 535 + const getStepIcon = (status: PlcUpdateStep["status"]) => { 536 + switch (status) { 537 + case "pending": 538 + return ( 539 + <div class="w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center"> 540 + <div class="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" /> 541 + </div> 542 + ); 543 + case "in-progress": 544 + return ( 545 + <div class="w-8 h-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin flex items-center justify-center"> 546 + <div class="w-3 h-3 rounded-full bg-blue-500" /> 547 + </div> 548 + ); 549 + case "verifying": 550 + return ( 551 + <div class="w-8 h-8 rounded-full border-2 border-yellow-500 border-t-transparent animate-spin flex items-center justify-center"> 552 + <div class="w-3 h-3 rounded-full bg-yellow-500" /> 553 + </div> 554 + ); 555 + case "completed": 556 + return ( 557 + <div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center"> 558 + <svg 559 + class="w-5 h-5 text-white" 560 + fill="none" 561 + stroke="currentColor" 562 + viewBox="0 0 24 24" 563 + > 564 + <path 565 + stroke-linecap="round" 566 + stroke-linejoin="round" 567 + stroke-width="2" 568 + d="M5 13l4 4L19 7" 569 + /> 570 + </svg> 571 + </div> 572 + ); 573 + case "error": 574 + return ( 575 + <div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center"> 576 + <svg 577 + class="w-5 h-5 text-white" 578 + fill="none" 579 + stroke="currentColor" 580 + viewBox="0 0 24 24" 581 + > 582 + <path 583 + stroke-linecap="round" 584 + stroke-linejoin="round" 585 + stroke-width="2" 586 + d="M6 18L18 6M6 6l12 12" 587 + /> 588 + </svg> 589 + </div> 590 + ); 591 + } 592 + }; 593 + 594 + const getStepClasses = (status: PlcUpdateStep["status"]) => { 595 + const baseClasses = 596 + "flex items-center space-x-3 p-4 rounded-lg transition-colors duration-200"; 597 + switch (status) { 598 + case "pending": 599 + return `${baseClasses} bg-gray-50 dark:bg-gray-800`; 600 + case "in-progress": 601 + return `${baseClasses} bg-blue-50 dark:bg-blue-900`; 602 + case "verifying": 603 + return `${baseClasses} bg-yellow-50 dark:bg-yellow-900`; 604 + case "completed": 605 + return `${baseClasses} bg-green-50 dark:bg-green-900`; 606 + case "error": 607 + return `${baseClasses} bg-red-50 dark:bg-red-900`; 608 + } 609 + }; 610 + 611 + const requestNewToken = async () => { 612 + try { 613 + console.log("Requesting new token..."); 614 + const res = await fetch("/api/plc/token", { 615 + method: "GET", 616 + }); 617 + const text = await res.text(); 618 + console.log("Token request response:", text); 619 + 620 + if (!res.ok) { 621 + throw new Error(text || "Failed to request new token"); 622 + } 623 + 624 + let data; 625 + try { 626 + data = JSON.parse(text); 627 + if (!data.success) { 628 + throw new Error(data.message || "Failed to request token"); 629 + } 630 + } catch { 631 + throw new Error("Invalid response from server"); 632 + } 633 + 634 + // Clear any existing error and token 635 + setEmailToken(""); 636 + updateStepStatus(1, "in-progress"); 637 + updateStepStatus(2, "pending"); 638 + } catch (error) { 639 + console.error("Failed to request new token:", error); 640 + updateStepStatus( 641 + 1, 642 + "error", 643 + error instanceof Error ? error.message : String(error) 644 + ); 645 + } 646 + }; 647 + 648 + if (!hasStarted) { 649 + return ( 650 + <div class="space-y-6"> 651 + <div class="ticket bg-white dark:bg-slate-800 p-6 relative"> 652 + <div class="boarding-label text-amber-500 dark:text-amber-400 font-mono font-bold tracking-wider text-sm mb-2"> 653 + {contentChunks[currentChunkIndex].subtitle} 654 + </div> 655 + 656 + <div class="flex justify-between items-start mb-4"> 657 + <h3 class="text-2xl font-mono text-slate-800 dark:text-slate-200"> 658 + {contentChunks[currentChunkIndex].title} 659 + </h3> 660 + </div> 661 + 662 + {/* Main Description */} 663 + <div class="mb-6">{contentChunks[currentChunkIndex].content}</div> 664 + 665 + {/* Navigation */} 666 + <div class="mt-8 border-t border-dashed border-slate-200 dark:border-slate-700 pt-4"> 667 + <div class="flex justify-between items-center"> 668 + <button 669 + onClick={() => 670 + setCurrentChunkIndex((prev) => Math.max(0, prev - 1)) 671 + } 672 + 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 ${ 673 + currentChunkIndex === 0 ? "invisible" : "" 674 + }`} 675 + > 676 + <svg 677 + class="w-5 h-5 rotate-180" 678 + fill="none" 679 + stroke="currentColor" 680 + viewBox="0 0 24 24" 681 + > 682 + <path 683 + stroke-linecap="round" 684 + stroke-linejoin="round" 685 + stroke-width="2" 686 + d="M9 5l7 7-7 7" 687 + /> 688 + </svg> 689 + <span>Previous Gate</span> 690 + </button> 691 + 692 + {currentChunkIndex === contentChunks.length - 1 ? ( 693 + <button 694 + onClick={handleStart} 695 + 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" 696 + > 697 + <span>Begin Key Generation</span> 698 + <svg 699 + class="w-5 h-5" 700 + fill="none" 701 + stroke="currentColor" 702 + viewBox="0 0 24 24" 703 + > 704 + <path 705 + stroke-linecap="round" 706 + stroke-linejoin="round" 707 + stroke-width="2" 708 + d="M9 5l7 7-7 7" 709 + /> 710 + </svg> 711 + </button> 712 + ) : ( 713 + <button 714 + onClick={() => 715 + setCurrentChunkIndex((prev) => 716 + Math.min(contentChunks.length - 1, prev + 1) 717 + ) 718 + } 719 + 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" 720 + > 721 + <span>Next Gate</span> 722 + <svg 723 + class="w-5 h-5" 724 + fill="none" 725 + stroke="currentColor" 726 + viewBox="0 0 24 24" 727 + > 728 + <path 729 + stroke-linecap="round" 730 + stroke-linejoin="round" 731 + stroke-width="2" 732 + d="M9 5l7 7-7 7" 733 + /> 734 + </svg> 735 + </button> 736 + )} 737 + </div> 738 + 739 + {/* Progress Dots */} 740 + <div class="flex justify-center space-x-3 mt-4"> 741 + {contentChunks.map((_, index) => ( 742 + <div 743 + key={index} 744 + class={`h-1.5 w-8 rounded-full transition-colors duration-200 ${ 745 + index === currentChunkIndex 746 + ? "bg-amber-500" 747 + : "bg-slate-200 dark:bg-slate-700" 748 + }`} 749 + /> 750 + ))} 751 + </div> 752 + </div> 753 + </div> 754 + </div> 755 + ); 756 + } 757 + 758 + return ( 759 + <div class="space-y-8"> 760 + {/* Progress Steps */} 761 + <div class="space-y-4"> 762 + <div class="flex items-center justify-between"> 763 + <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100"> 764 + Key Generation Progress 765 + </h3> 766 + {/* Add a help tooltip */} 767 + <div class="relative group"> 768 + <button class="text-gray-400 hover:text-gray-500"> 769 + <svg 770 + class="w-5 h-5" 771 + fill="none" 772 + stroke="currentColor" 773 + viewBox="0 0 24 24" 774 + > 775 + <path 776 + stroke-linecap="round" 777 + stroke-linejoin="round" 778 + stroke-width="2" 779 + d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" 780 + /> 781 + </svg> 782 + </button> 783 + <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"> 784 + <p class="text-gray-600 dark:text-gray-400"> 785 + Follow these steps to securely add a new rotation key to your 786 + PLC record. Each step requires completion before proceeding. 787 + </p> 788 + </div> 789 + </div> 790 + </div> 791 + 792 + {/* Steps with enhanced visual hierarchy */} 793 + {steps.map((step, index) => ( 794 + <div 795 + key={step.name} 796 + class={`${getStepClasses(step.status)} ${ 797 + step.status === "in-progress" 798 + ? "ring-2 ring-blue-500 ring-opacity-50" 799 + : "" 800 + }`} 801 + > 802 + <div class="flex-shrink-0">{getStepIcon(step.status)}</div> 803 + <div class="flex-1 min-w-0"> 804 + <div class="flex items-center justify-between"> 805 + <p 806 + class={`font-medium ${ 807 + step.status === "error" 808 + ? "text-red-900 dark:text-red-200" 809 + : step.status === "completed" 810 + ? "text-green-900 dark:text-green-200" 811 + : step.status === "in-progress" 812 + ? "text-blue-900 dark:text-blue-200" 813 + : "text-gray-900 dark:text-gray-200" 814 + }`} 815 + > 816 + {getStepDisplayName(step, index)} 817 + </p> 818 + {/* Add step number */} 819 + <span class="text-sm text-gray-500 dark:text-gray-400"> 820 + Step {index + 1} of {steps.length} 821 + </span> 822 + </div> 823 + 824 + {step.error && ( 825 + <div class="mt-2 p-2 bg-red-50 dark:bg-red-900/50 rounded-md"> 826 + <p class="text-sm text-red-600 dark:text-red-400 flex items-center"> 827 + <svg 828 + class="w-4 h-4 mr-1" 829 + fill="none" 830 + stroke="currentColor" 831 + viewBox="0 0 24 24" 832 + > 833 + <path 834 + stroke-linecap="round" 835 + stroke-linejoin="round" 836 + stroke-width="2" 837 + d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" 838 + /> 839 + </svg> 840 + {(() => { 841 + try { 842 + const err = JSON.parse(step.error); 843 + return err.message || step.error; 844 + } catch { 845 + return step.error; 846 + } 847 + })()} 848 + </p> 849 + </div> 850 + )} 851 + 852 + {/* Key Download Warning */} 853 + {index === 0 && 854 + step.status === "completed" && 855 + !hasDownloadedKey && ( 856 + <div class="mt-4 space-y-4"> 857 + <div class="bg-yellow-50 dark:bg-yellow-900/50 p-4 rounded-lg border border-yellow-200 dark:border-yellow-800"> 858 + <div class="flex items-start"> 859 + <div class="flex-shrink-0"> 860 + <svg 861 + class="h-5 w-5 text-yellow-400" 862 + viewBox="0 0 20 20" 863 + fill="currentColor" 864 + > 865 + <path 866 + fill-rule="evenodd" 867 + 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" 868 + clip-rule="evenodd" 869 + /> 870 + </svg> 871 + </div> 872 + <div class="ml-3"> 873 + <h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200"> 874 + Critical Security Step 875 + </h3> 876 + <div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300"> 877 + <p class="mb-2"> 878 + Your rotation key grants control over your 879 + identity: 880 + </p> 881 + <ul class="list-disc pl-5 space-y-2"> 882 + <li> 883 + <strong>Store Securely:</strong> Use a password 884 + manager 885 + </li> 886 + <li> 887 + <strong>Keep Private:</strong> Never share with 888 + anyone 889 + </li> 890 + <li> 891 + <strong>Backup:</strong> Keep a secure backup 892 + copy 893 + </li> 894 + <li> 895 + <strong>Required:</strong> Needed for future DID 896 + modifications 897 + </li> 898 + </ul> 899 + </div> 900 + </div> 901 + </div> 902 + </div> 903 + 904 + <div class="flex items-center justify-between"> 905 + <button 906 + onClick={handleDownload} 907 + 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" 908 + > 909 + <svg 910 + class="w-5 h-5" 911 + fill="none" 912 + stroke="currentColor" 913 + viewBox="0 0 24 24" 914 + > 915 + <path 916 + stroke-linecap="round" 917 + stroke-linejoin="round" 918 + stroke-width="2" 919 + d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" 920 + /> 921 + </svg> 922 + <span>Download Key</span> 923 + </button> 924 + 925 + <div class="flex items-center text-sm text-red-600 dark:text-red-400"> 926 + <svg 927 + class="w-4 h-4 mr-1" 928 + fill="none" 929 + stroke="currentColor" 930 + viewBox="0 0 24 24" 931 + > 932 + <path 933 + stroke-linecap="round" 934 + stroke-linejoin="round" 935 + stroke-width="2" 936 + 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" 937 + /> 938 + </svg> 939 + Download required to proceed 940 + </div> 941 + </div> 942 + </div> 943 + )} 944 + 945 + {/* Email Code Input */} 946 + {index === 1 && 947 + (step.status === "in-progress" || 948 + step.status === "verifying") && 949 + step.name === 950 + "Enter the code sent to your email to complete PLC update" && ( 951 + <div class="mt-4 space-y-4"> 952 + <div class="bg-blue-50 dark:bg-blue-900/50 p-4 rounded-lg"> 953 + <p class="text-sm text-blue-800 dark:text-blue-200 mb-3"> 954 + Check your email for the verification code to complete 955 + the PLC update: 956 + </p> 957 + <div class="flex space-x-2"> 958 + <div class="flex-1 relative"> 959 + <input 960 + type="text" 961 + value={emailToken} 962 + onChange={(e) => 963 + setEmailToken(e.currentTarget.value) 964 + } 965 + placeholder="Enter verification code" 966 + 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" 967 + /> 968 + </div> 969 + <button 970 + type="button" 971 + onClick={handleTokenSubmit} 972 + disabled={!emailToken || step.status === "verifying"} 973 + 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" 974 + > 975 + <span> 976 + {step.status === "verifying" 977 + ? "Verifying..." 978 + : "Verify"} 979 + </span> 980 + <svg 981 + class="w-4 h-4" 982 + fill="none" 983 + stroke="currentColor" 984 + viewBox="0 0 24 24" 985 + > 986 + <path 987 + stroke-linecap="round" 988 + stroke-linejoin="round" 989 + stroke-width="2" 990 + d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" 991 + /> 992 + </svg> 993 + </button> 994 + </div> 995 + {step.error && ( 996 + <div class="mt-2 p-2 bg-red-50 dark:bg-red-900/50 rounded-md"> 997 + <p class="text-sm text-red-600 dark:text-red-400 flex items-center"> 998 + <svg 999 + class="w-4 h-4 mr-1" 1000 + fill="none" 1001 + stroke="currentColor" 1002 + viewBox="0 0 24 24" 1003 + > 1004 + <path 1005 + stroke-linecap="round" 1006 + stroke-linejoin="round" 1007 + stroke-width="2" 1008 + d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" 1009 + /> 1010 + </svg> 1011 + {step.error} 1012 + </p> 1013 + {step.error 1014 + .toLowerCase() 1015 + .includes("token is invalid") && ( 1016 + <div class="mt-2"> 1017 + <p class="text-sm text-red-500 dark:text-red-300 mb-2"> 1018 + The verification code may have expired. Request 1019 + a new code to try again. 1020 + </p> 1021 + <button 1022 + onClick={requestNewToken} 1023 + 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" 1024 + > 1025 + <svg 1026 + class="w-4 h-4" 1027 + fill="none" 1028 + stroke="currentColor" 1029 + viewBox="0 0 24 24" 1030 + > 1031 + <path 1032 + stroke-linecap="round" 1033 + stroke-linejoin="round" 1034 + stroke-width="2" 1035 + 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" 1036 + /> 1037 + </svg> 1038 + <span>Request New Code</span> 1039 + </button> 1040 + </div> 1041 + )} 1042 + </div> 1043 + )} 1044 + </div> 1045 + </div> 1046 + )} 1047 + </div> 1048 + </div> 1049 + ))} 1050 + </div> 1051 + 1052 + {/* Success Message */} 1053 + {steps[2].status === "completed" && ( 1054 + <div class="p-4 bg-green-50 dark:bg-green-900/50 rounded-lg border-2 border-green-200 dark:border-green-800"> 1055 + <div class="flex items-center space-x-3 mb-4"> 1056 + <svg 1057 + class="w-6 h-6 text-green-500" 1058 + fill="none" 1059 + stroke="currentColor" 1060 + viewBox="0 0 24 24" 1061 + > 1062 + <path 1063 + stroke-linecap="round" 1064 + stroke-linejoin="round" 1065 + stroke-width="2" 1066 + d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" 1067 + /> 1068 + </svg> 1069 + <h4 class="text-lg font-medium text-green-800 dark:text-green-200"> 1070 + PLC Update Successful! 1071 + </h4> 1072 + </div> 1073 + <p class="text-sm text-green-700 dark:text-green-300 mb-4"> 1074 + Your rotation key has been successfully added to your PLC record. 1075 + You can now use this key for future DID modifications. 1076 + </p> 1077 + <div class="flex space-x-4"> 1078 + <button 1079 + type="button" 1080 + onClick={async () => { 1081 + try { 1082 + const response = await fetch("/api/logout", { 1083 + method: "POST", 1084 + credentials: "include", 1085 + }); 1086 + if (!response.ok) { 1087 + throw new Error("Logout failed"); 1088 + } 1089 + globalThis.location.href = "/"; 1090 + } catch (error) { 1091 + console.error("Failed to logout:", error); 1092 + } 1093 + }} 1094 + 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" 1095 + > 1096 + <svg 1097 + class="w-5 h-5" 1098 + fill="none" 1099 + stroke="currentColor" 1100 + viewBox="0 0 24 24" 1101 + > 1102 + <path 1103 + stroke-linecap="round" 1104 + stroke-linejoin="round" 1105 + stroke-width="2" 1106 + 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" 1107 + /> 1108 + </svg> 1109 + <span>Sign Out</span> 1110 + </button> 1111 + <a 1112 + href="https://ko-fi.com/knotbin" 1113 + target="_blank" 1114 + 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" 1115 + > 1116 + <svg 1117 + class="w-5 h-5" 1118 + fill="none" 1119 + stroke="currentColor" 1120 + viewBox="0 0 24 24" 1121 + > 1122 + <path 1123 + stroke-linecap="round" 1124 + stroke-linejoin="round" 1125 + stroke-width="2" 1126 + 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" 1127 + /> 1128 + </svg> 1129 + <span>Support Us</span> 1130 + </a> 1131 + </div> 1132 + </div> 1133 + )} 1134 + </div> 1135 + ); 1136 + }
+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);
+30 -6
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); ··· 36 51 setUser( 37 52 userData 38 53 ? { 39 - did: userData.did, 40 - handle: userData.handle, 41 - } 42 - : null, 54 + did: userData.did, 55 + handle: userData.handle, 56 + } 57 + : null 43 58 ); 44 59 } catch (error) { 45 60 console.error("Failed to fetch user:", error); ··· 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" ··· 97 121 <div className="relative"> 98 122 <Button 99 123 color="amber" 100 - icon="/icons/ticket_bold.svg" 124 + icon="/icons/account.svg" 101 125 iconAlt="Check-in" 102 126 label="CHECKED IN" 103 127 onClick={() => setShowDropdown(!showDropdown)} ··· 126 150 <Button 127 151 href="/login" 128 152 color="amber" 129 - icon="/icons/ticket_bold.svg" 153 + icon="/icons/account.svg" 130 154 iconAlt="Check-in" 131 155 label="CHECK-IN" 132 156 />
+35
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 ? "opacity-50 cursor-not-allowed" : "opacity-100 cursor-pointer"} 27 + onClick={(e: MouseEvent) => { 28 + if (isMobile) { 29 + e.preventDefault(); 30 + } 31 + }} 32 + /> 33 + </div> 34 + ); 35 + }
+5
islands/LoginSelector.tsx
··· 2 2 import HandleInput from "./HandleInput.tsx" 3 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 11 const [loginMethod, setLoginMethod] = useState<'oauth' | 'password'>('password') 7 12
+568 -199
islands/MigrationProgress.tsx
··· 1 1 import { useEffect, useState } from "preact/hooks"; 2 2 3 + /** 4 + * The migration state info. 5 + * @type {MigrationStateInfo} 6 + */ 7 + interface MigrationStateInfo { 8 + state: "up" | "issue" | "maintenance"; 9 + message: string; 10 + allowMigration: boolean; 11 + } 12 + 13 + /** 14 + * The migration progress props. 15 + * @type {MigrationProgressProps} 16 + */ 3 17 interface MigrationProgressProps { 4 18 service: string; 5 19 handle: string; ··· 8 22 invite?: string; 9 23 } 10 24 25 + /** 26 + * The migration step. 27 + * @type {MigrationStep} 28 + */ 11 29 interface MigrationStep { 12 30 name: string; 13 31 status: "pending" | "in-progress" | "verifying" | "completed" | "error"; 14 32 error?: string; 33 + isVerificationError?: boolean; 15 34 } 16 35 36 + /** 37 + * The migration progress component. 38 + * @param props - The migration progress props 39 + * @returns The migration progress component 40 + * @component 41 + */ 17 42 export default function MigrationProgress(props: MigrationProgressProps) { 18 43 const [token, setToken] = useState(""); 44 + const [migrationState, setMigrationState] = useState< 45 + MigrationStateInfo | null 46 + >(null); 47 + const [retryAttempts, setRetryAttempts] = useState<Record<number, number>>( 48 + {}, 49 + ); 50 + const [showContinueAnyway, setShowContinueAnyway] = useState< 51 + Record<number, boolean> 52 + >({}); 19 53 20 54 const [steps, setSteps] = useState<MigrationStep[]>([ 21 55 { name: "Create Account", status: "pending" }, ··· 28 62 index: number, 29 63 status: MigrationStep["status"], 30 64 error?: string, 65 + isVerificationError?: boolean, 31 66 ) => { 32 67 console.log( 33 68 `Updating step ${index} to ${status}${ ··· 37 72 setSteps((prevSteps) => 38 73 prevSteps.map((step, i) => 39 74 i === index 40 - ? { ...step, status, error } 75 + ? { ...step, status, error, isVerificationError } 41 76 : i > index 42 - ? { ...step, status: "pending", error: undefined } 77 + ? { 78 + ...step, 79 + status: "pending", 80 + error: undefined, 81 + isVerificationError: undefined, 82 + } 43 83 : step 44 84 ) 45 85 ); ··· 74 114 invite: props.invite, 75 115 }); 76 116 77 - if (!validateParams()) { 78 - console.log("Parameter validation failed"); 79 - return; 80 - } 117 + // Check migration state first 118 + const checkMigrationState = async () => { 119 + try { 120 + const migrationResponse = await fetch("/api/migration-state"); 121 + if (migrationResponse.ok) { 122 + const migrationData = await migrationResponse.json(); 123 + setMigrationState(migrationData); 81 124 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 - }); 125 + if (!migrationData.allowMigration) { 126 + updateStepStatus(0, "error", migrationData.message); 127 + return; 128 + } 129 + } 130 + } catch (error) { 131 + console.error("Failed to check migration state:", error); 132 + updateStepStatus(0, "error", "Unable to verify migration availability"); 133 + return; 134 + } 135 + 136 + if (!validateParams()) { 137 + console.log("Parameter validation failed"); 138 + return; 139 + } 140 + 141 + startMigration().catch((error) => { 142 + console.error("Unhandled migration error:", error); 143 + updateStepStatus( 144 + 0, 145 + "error", 146 + error.message || "Unknown error occurred", 147 + ); 148 + }); 149 + }; 150 + 151 + checkMigrationState(); 90 152 }, []); 91 153 92 154 const getStepDisplayName = (step: MigrationStep, index: number) => { 93 155 if (step.status === "completed") { 94 156 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"; 157 + case 0: 158 + return "Account Created"; 159 + case 1: 160 + return "Data Migrated"; 161 + case 2: 162 + return "Identity Migrated"; 163 + case 3: 164 + return "Migration Finalized"; 99 165 } 100 166 } 101 - 167 + 102 168 if (step.status === "in-progress") { 103 169 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..."; 170 + case 0: 171 + return "Creating your new account..."; 172 + case 1: 173 + return "Migrating your data..."; 174 + case 2: 175 + return step.name === 176 + "Enter the token sent to your email to complete identity migration" 177 + ? step.name 178 + : "Migrating your identity..."; 179 + case 3: 180 + return "Finalizing migration..."; 110 181 } 111 182 } 112 183 113 184 if (step.status === "verifying") { 114 185 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..."; 186 + case 0: 187 + return "Verifying account creation..."; 188 + case 1: 189 + return "Verifying data migration..."; 190 + case 2: 191 + return "Verifying identity migration..."; 192 + case 3: 193 + return "Verifying migration completion..."; 119 194 } 120 195 } 121 - 196 + 122 197 return step.name; 123 198 }; 124 199 ··· 166 241 updateStepStatus(0, "verifying"); 167 242 const verified = await verifyStep(0); 168 243 if (!verified) { 169 - throw new Error("Account creation verification failed"); 170 - } 171 - } catch (error) { 172 - updateStepStatus( 173 - 0, 174 - "error", 175 - error instanceof Error ? error.message : String(error), 176 - ); 177 - throw error; 178 - } 179 - 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; 226 - } 227 - 228 - // Step 3: Request Identity Migration 229 - updateStepStatus(2, "in-progress"); 230 - console.log("Requesting identity migration..."); 231 - 232 - try { 233 - const requestRes = await fetch("/api/migrate/identity/request", { 234 - method: "POST", 235 - headers: { "Content-Type": "application/json" }, 236 - }); 237 - 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 - } 250 - 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 - ) 244 + console.log( 245 + "Account creation: Verification failed, waiting for user action", 267 246 ); 268 - // Don't continue with migration - wait for token input 269 247 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 248 } 249 + 250 + // If verification succeeds, continue to data migration 251 + await startDataMigration(); 276 252 } catch (error) { 277 253 updateStepStatus( 278 - 2, 254 + 0, 279 255 "error", 280 256 error instanceof Error ? error.message : String(error), 281 257 ); ··· 302 278 if (!identityRes.ok) { 303 279 try { 304 280 const json = JSON.parse(identityData); 305 - throw new Error(json.message || "Failed to complete identity migration"); 281 + throw new Error( 282 + json.message || "Failed to complete identity migration", 283 + ); 306 284 } catch { 307 - throw new Error(identityData || "Failed to complete identity migration"); 285 + throw new Error( 286 + identityData || "Failed to complete identity migration", 287 + ); 308 288 } 309 289 } 310 290 ··· 318 298 throw new Error("Invalid response from server"); 319 299 } 320 300 321 - 322 301 updateStepStatus(2, "verifying"); 323 302 const verified = await verifyStep(2); 324 303 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), 304 + console.log( 305 + "Identity migration: Verification failed, waiting for user action", 365 306 ); 366 - throw error; 307 + return; 367 308 } 309 + 310 + // If verification succeeds, continue to finalization 311 + await startFinalization(); 368 312 } catch (error) { 369 313 console.error("Identity migration error:", error); 370 314 updateStepStatus( ··· 453 397 454 398 // Helper to verify a step after completion 455 399 const verifyStep = async (stepNum: number) => { 400 + console.log(`Verification: Starting step ${stepNum + 1}`); 456 401 updateStepStatus(stepNum, "verifying"); 457 402 try { 403 + console.log(`Verification: Fetching status for step ${stepNum + 1}`); 458 404 const res = await fetch(`/api/migrate/status?step=${stepNum + 1}`); 405 + console.log(`Verification: Status response status:`, res.status); 459 406 const data = await res.json(); 407 + console.log(`Verification: Status data for step ${stepNum + 1}:`, data); 408 + 460 409 if (data.ready) { 410 + console.log(`Verification: Step ${stepNum + 1} is ready`); 461 411 updateStepStatus(stepNum, "completed"); 412 + // Reset retry state on success 413 + setRetryAttempts((prev) => ({ ...prev, [stepNum]: 0 })); 414 + setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false })); 415 + 416 + // Continue to next step if not the last one 417 + if (stepNum < 3) { 418 + setTimeout(() => continueToNextStep(stepNum + 1), 500); 419 + } 420 + 462 421 return true; 463 422 } else { 464 - updateStepStatus(stepNum, "error", data.reason || "Verification failed"); 423 + console.log( 424 + `Verification: Step ${stepNum + 1} is not ready:`, 425 + data.reason, 426 + ); 427 + const statusDetails = { 428 + activated: data.activated, 429 + validDid: data.validDid, 430 + repoCommit: data.repoCommit, 431 + repoRev: data.repoRev, 432 + repoBlocks: data.repoBlocks, 433 + expectedRecords: data.expectedRecords, 434 + indexedRecords: data.indexedRecords, 435 + privateStateValues: data.privateStateValues, 436 + expectedBlobs: data.expectedBlobs, 437 + importedBlobs: data.importedBlobs, 438 + }; 439 + console.log( 440 + `Verification: Step ${stepNum + 1} status details:`, 441 + statusDetails, 442 + ); 443 + const errorMessage = `${ 444 + data.reason || "Verification failed" 445 + }\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 446 + 447 + // Track retry attempts 448 + const currentAttempts = retryAttempts[stepNum] || 0; 449 + setRetryAttempts((prev) => ({ 450 + ...prev, 451 + [stepNum]: currentAttempts + 1, 452 + })); 453 + 454 + // Show continue anyway option if this is the second failure 455 + if (currentAttempts >= 1) { 456 + setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true })); 457 + } 458 + 459 + updateStepStatus(stepNum, "error", errorMessage, true); 465 460 return false; 466 461 } 467 462 } catch (e) { 468 - updateStepStatus(stepNum, "error", e instanceof Error ? e.message : String(e)); 463 + console.error(`Verification: Error in step ${stepNum + 1}:`, e); 464 + const currentAttempts = retryAttempts[stepNum] || 0; 465 + setRetryAttempts((prev) => ({ ...prev, [stepNum]: currentAttempts + 1 })); 466 + 467 + // Show continue anyway option if this is the second failure 468 + if (currentAttempts >= 1) { 469 + setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: true })); 470 + } 471 + 472 + updateStepStatus( 473 + stepNum, 474 + "error", 475 + e instanceof Error ? e.message : String(e), 476 + true, 477 + ); 469 478 return false; 470 479 } 471 480 }; 472 481 482 + const retryVerification = async (stepNum: number) => { 483 + console.log(`Retrying verification for step ${stepNum + 1}`); 484 + await verifyStep(stepNum); 485 + }; 486 + 487 + const continueAnyway = (stepNum: number) => { 488 + console.log(`Continuing anyway for step ${stepNum + 1}`); 489 + updateStepStatus(stepNum, "completed"); 490 + setShowContinueAnyway((prev) => ({ ...prev, [stepNum]: false })); 491 + 492 + // Continue with next step if not the last one 493 + if (stepNum < 3) { 494 + continueToNextStep(stepNum + 1); 495 + } 496 + }; 497 + 498 + const continueToNextStep = async (stepNum: number) => { 499 + switch (stepNum) { 500 + case 1: 501 + // Continue to data migration 502 + await startDataMigration(); 503 + break; 504 + case 2: 505 + // Continue to identity migration 506 + await startIdentityMigration(); 507 + break; 508 + case 3: 509 + // Continue to finalization 510 + await startFinalization(); 511 + break; 512 + } 513 + }; 514 + 515 + const startDataMigration = async () => { 516 + // Step 2: Migrate Data 517 + updateStepStatus(1, "in-progress"); 518 + console.log("Starting data migration..."); 519 + 520 + try { 521 + // Step 2.1: Migrate Repo 522 + console.log("Data migration: Starting repo migration"); 523 + const repoRes = await fetch("/api/migrate/data/repo", { 524 + method: "POST", 525 + headers: { "Content-Type": "application/json" }, 526 + }); 527 + 528 + console.log("Repo migration: Response status:", repoRes.status); 529 + const repoText = await repoRes.text(); 530 + console.log("Repo migration: Raw response:", repoText); 531 + 532 + if (!repoRes.ok) { 533 + try { 534 + const json = JSON.parse(repoText); 535 + console.error("Repo migration: Error response:", json); 536 + throw new Error(json.message || "Failed to migrate repo"); 537 + } catch { 538 + console.error("Repo migration: Non-JSON error response:", repoText); 539 + throw new Error(repoText || "Failed to migrate repo"); 540 + } 541 + } 542 + 543 + // Step 2.2: Migrate Blobs 544 + console.log("Data migration: Starting blob migration"); 545 + const blobsRes = await fetch("/api/migrate/data/blobs", { 546 + method: "POST", 547 + headers: { "Content-Type": "application/json" }, 548 + }); 549 + 550 + console.log("Blob migration: Response status:", blobsRes.status); 551 + const blobsText = await blobsRes.text(); 552 + console.log("Blob migration: Raw response:", blobsText); 553 + 554 + if (!blobsRes.ok) { 555 + try { 556 + const json = JSON.parse(blobsText); 557 + console.error("Blob migration: Error response:", json); 558 + throw new Error(json.message || "Failed to migrate blobs"); 559 + } catch { 560 + console.error( 561 + "Blob migration: Non-JSON error response:", 562 + blobsText, 563 + ); 564 + throw new Error(blobsText || "Failed to migrate blobs"); 565 + } 566 + } 567 + 568 + // Step 2.3: Migrate Preferences 569 + console.log("Data migration: Starting preferences migration"); 570 + const prefsRes = await fetch("/api/migrate/data/prefs", { 571 + method: "POST", 572 + headers: { "Content-Type": "application/json" }, 573 + }); 574 + 575 + console.log("Preferences migration: Response status:", prefsRes.status); 576 + const prefsText = await prefsRes.text(); 577 + console.log("Preferences migration: Raw response:", prefsText); 578 + 579 + if (!prefsRes.ok) { 580 + try { 581 + const json = JSON.parse(prefsText); 582 + console.error("Preferences migration: Error response:", json); 583 + throw new Error(json.message || "Failed to migrate preferences"); 584 + } catch { 585 + console.error( 586 + "Preferences migration: Non-JSON error response:", 587 + prefsText, 588 + ); 589 + throw new Error(prefsText || "Failed to migrate preferences"); 590 + } 591 + } 592 + 593 + console.log("Data migration: Starting verification"); 594 + updateStepStatus(1, "verifying"); 595 + const verified = await verifyStep(1); 596 + console.log("Data migration: Verification result:", verified); 597 + if (!verified) { 598 + console.log( 599 + "Data migration: Verification failed, waiting for user action", 600 + ); 601 + return; 602 + } 603 + 604 + // If verification succeeds, continue to next step 605 + await startIdentityMigration(); 606 + } catch (error) { 607 + console.error("Data migration: Error caught:", error); 608 + updateStepStatus( 609 + 1, 610 + "error", 611 + error instanceof Error ? error.message : String(error), 612 + ); 613 + throw error; 614 + } 615 + }; 616 + 617 + const startIdentityMigration = async () => { 618 + // Step 3: Request Identity Migration 619 + updateStepStatus(2, "in-progress"); 620 + console.log("Requesting identity migration..."); 621 + 622 + try { 623 + const requestRes = await fetch("/api/migrate/identity/request", { 624 + method: "POST", 625 + headers: { "Content-Type": "application/json" }, 626 + }); 627 + 628 + console.log("Identity request response status:", requestRes.status); 629 + const requestText = await requestRes.text(); 630 + console.log("Identity request response:", requestText); 631 + 632 + if (!requestRes.ok) { 633 + try { 634 + const json = JSON.parse(requestText); 635 + throw new Error( 636 + json.message || "Failed to request identity migration", 637 + ); 638 + } catch { 639 + throw new Error( 640 + requestText || "Failed to request identity migration", 641 + ); 642 + } 643 + } 644 + 645 + try { 646 + const jsonData = JSON.parse(requestText); 647 + if (!jsonData.success) { 648 + throw new Error( 649 + jsonData.message || "Identity migration request failed", 650 + ); 651 + } 652 + console.log("Identity migration requested successfully"); 653 + 654 + // Update step name to prompt for token 655 + setSteps((prevSteps) => 656 + prevSteps.map((step, i) => 657 + i === 2 658 + ? { 659 + ...step, 660 + name: 661 + "Enter the token sent to your email to complete identity migration", 662 + } 663 + : step 664 + ) 665 + ); 666 + // Don't continue with migration - wait for token input 667 + return; 668 + } catch (e) { 669 + console.error("Failed to parse identity request response:", e); 670 + throw new Error( 671 + "Invalid response from server during identity request", 672 + ); 673 + } 674 + } catch (error) { 675 + updateStepStatus( 676 + 2, 677 + "error", 678 + error instanceof Error ? error.message : String(error), 679 + ); 680 + throw error; 681 + } 682 + }; 683 + 684 + const startFinalization = async () => { 685 + // Step 4: Finalize Migration 686 + updateStepStatus(3, "in-progress"); 687 + try { 688 + const finalizeRes = await fetch("/api/migrate/finalize", { 689 + method: "POST", 690 + headers: { "Content-Type": "application/json" }, 691 + }); 692 + 693 + const finalizeData = await finalizeRes.text(); 694 + if (!finalizeRes.ok) { 695 + try { 696 + const json = JSON.parse(finalizeData); 697 + throw new Error(json.message || "Failed to finalize migration"); 698 + } catch { 699 + throw new Error(finalizeData || "Failed to finalize migration"); 700 + } 701 + } 702 + 703 + try { 704 + const jsonData = JSON.parse(finalizeData); 705 + if (!jsonData.success) { 706 + throw new Error(jsonData.message || "Finalization failed"); 707 + } 708 + } catch { 709 + throw new Error("Invalid response from server during finalization"); 710 + } 711 + 712 + updateStepStatus(3, "verifying"); 713 + const verified = await verifyStep(3); 714 + if (!verified) { 715 + console.log( 716 + "Finalization: Verification failed, waiting for user action", 717 + ); 718 + return; 719 + } 720 + } catch (error) { 721 + updateStepStatus( 722 + 3, 723 + "error", 724 + error instanceof Error ? error.message : String(error), 725 + ); 726 + throw error; 727 + } 728 + }; 729 + 473 730 return ( 474 731 <div class="space-y-8"> 732 + {/* Migration state alert */} 733 + {migrationState && !migrationState.allowMigration && ( 734 + <div 735 + class={`p-4 rounded-lg border ${ 736 + migrationState.state === "maintenance" 737 + ? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200" 738 + : "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200" 739 + }`} 740 + > 741 + <div class="flex items-center"> 742 + <div 743 + class={`mr-3 ${ 744 + migrationState.state === "maintenance" 745 + ? "text-yellow-600 dark:text-yellow-400" 746 + : "text-red-600 dark:text-red-400" 747 + }`} 748 + > 749 + {migrationState.state === "maintenance" ? "โš ๏ธ" : "๐Ÿšซ"} 750 + </div> 751 + <div> 752 + <h3 class="font-semibold mb-1"> 753 + {migrationState.state === "maintenance" 754 + ? "Maintenance Mode" 755 + : "Service Unavailable"} 756 + </h3> 757 + <p class="text-sm">{migrationState.message}</p> 758 + </div> 759 + </div> 760 + </div> 761 + )} 762 + 475 763 <div class="space-y-4"> 476 764 {steps.map((step, index) => ( 477 765 <div key={step.name} class={getStepClasses(step.status)}> ··· 491 779 {getStepDisplayName(step, index)} 492 780 </p> 493 781 {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> 782 + <div class="mt-1"> 783 + <p class="text-sm text-red-600 dark:text-red-400"> 784 + {(() => { 785 + try { 786 + const err = JSON.parse(step.error); 787 + return err.message || step.error; 788 + } catch { 789 + return step.error; 790 + } 791 + })()} 792 + </p> 793 + {step.isVerificationError && ( 794 + <div class="flex space-x-2 mt-2"> 795 + <button 796 + type="button" 797 + onClick={() => retryVerification(index)} 798 + 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" 799 + > 800 + Retry Verification 801 + </button> 802 + {showContinueAnyway[index] && ( 803 + <button 804 + type="button" 805 + onClick={() => continueAnyway(index)} 806 + 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 807 + dark:bg-gray-800 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700" 808 + > 809 + Continue Anyway 810 + </button> 811 + )} 812 + </div> 813 + )} 814 + </div> 504 815 )} 505 816 {index === 2 && step.status === "in-progress" && 506 - step.name === "Enter the token sent to your email to complete identity migration" && ( 817 + step.name === 818 + "Enter the token sent to your email to complete identity migration" && 819 + ( 507 820 <div class="mt-4 space-y-4"> 508 821 <p class="text-sm text-blue-800 dark:text-blue-200"> 509 - Please check your email for the migration token and enter it below: 822 + Please check your email for the migration token and enter 823 + it below: 510 824 </p> 511 825 <div class="flex space-x-2"> 512 826 <input ··· 525 839 </button> 526 840 </div> 527 841 </div> 528 - ) 529 - } 842 + )} 530 843 </div> 531 844 </div> 532 845 ))} 533 846 </div> 534 847 535 - 536 - 537 848 {steps[3].status === "completed" && ( 538 849 <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. 850 + <p class="text-sm text-green-800 dark:text-green-200 pb-2"> 851 + Migration completed successfully! Sign out to finish the process and 852 + return home.<br /> 853 + Please consider donating to Airport to support server and 854 + development costs. 541 855 </p> 856 + <div class="flex space-x-4"> 857 + <button 858 + type="button" 859 + onClick={async () => { 860 + try { 861 + const response = await fetch("/api/logout", { 862 + method: "POST", 863 + credentials: "include", 864 + }); 865 + if (!response.ok) { 866 + throw new Error("Logout failed"); 867 + } 868 + globalThis.location.href = "/"; 869 + } catch (error) { 870 + console.error("Failed to logout:", error); 871 + } 872 + }} 873 + 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" 874 + > 875 + <svg 876 + class="w-5 h-5" 877 + fill="none" 878 + stroke="currentColor" 879 + viewBox="0 0 24 24" 880 + > 881 + <path 882 + stroke-linecap="round" 883 + stroke-linejoin="round" 884 + stroke-width="2" 885 + 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" 886 + /> 887 + </svg> 888 + <span>Sign Out</span> 889 + </button> 890 + <a 891 + href="https://ko-fi.com/knotbin" 892 + target="_blank" 893 + 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" 894 + > 895 + <svg 896 + class="w-5 h-5" 897 + fill="none" 898 + stroke="currentColor" 899 + viewBox="0 0 24 24" 900 + > 901 + <path 902 + stroke-linecap="round" 903 + stroke-linejoin="round" 904 + stroke-width="2" 905 + 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" 906 + /> 907 + </svg> 908 + <span>Support Us</span> 909 + </a> 910 + </div> 542 911 </div> 543 912 )} 544 913 </div>
+173 -8
islands/MigrationSetup.tsx
··· 1 - import { useState } from "preact/hooks"; 1 + import { useState, useEffect } 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<MigrationStateInfo | null>(null); 68 + 69 + const ensureServiceUrl = (url: string): string => { 70 + if (!url) return url; 71 + try { 72 + // If it already has a protocol, return as is 73 + new URL(url); 74 + return url; 75 + } catch { 76 + // If no protocol, add https:// 77 + return `https://${url}`; 78 + } 79 + }; 80 + 81 + useEffect(() => { 82 + if (!IS_BROWSER) return; 83 + 84 + const fetchInitialData = async () => { 85 + try { 86 + // Check migration state first 87 + const migrationResponse = await fetch("/api/migration-state"); 88 + if (migrationResponse.ok) { 89 + const migrationData = await migrationResponse.json(); 90 + setMigrationState(migrationData); 91 + } 92 + 93 + // Fetch user passport 94 + const response = await fetch("/api/me", { 95 + credentials: "include", 96 + }); 97 + if (!response.ok) { 98 + throw new Error("Failed to fetch user profile"); 99 + } 100 + const userData = await response.json(); 101 + if (userData) { 102 + // Get PDS URL from the current service 103 + const pdsResponse = await fetch(`/api/resolve-pds?did=${userData.did}`); 104 + const pdsData = await pdsResponse.json(); 105 + 106 + setPassport({ 107 + did: userData.did, 108 + handle: userData.handle, 109 + pds: pdsData.pds || "Unknown", 110 + createdAt: new Date().toISOString() // TODO: Get actual creation date from API 111 + }); 112 + } 113 + } catch (error) { 114 + console.error("Failed to fetch initial data:", error); 115 + } 116 + }; 117 + 118 + fetchInitialData(); 119 + }, []); 30 120 31 121 const checkServerDescription = async (serviceUrl: string) => { 32 122 try { ··· 59 149 }; 60 150 61 151 const handleServiceChange = (value: string) => { 62 - setService(value); 152 + const urlWithProtocol = ensureServiceUrl(value); 153 + setService(urlWithProtocol); 63 154 setError(""); 64 - if (value) { 65 - checkServerDescription(value); 155 + if (urlWithProtocol) { 156 + checkServerDescription(urlWithProtocol); 66 157 } else { 67 158 setAvailableDomains([]); 68 159 setSelectedDomain(""); ··· 72 163 const handleSubmit = (e: Event) => { 73 164 e.preventDefault(); 74 165 166 + // Check migration state first 167 + if (migrationState && !migrationState.allowMigration) { 168 + setError(migrationState.message); 169 + return; 170 + } 171 + 75 172 if (!service || !handlePrefix || !email || !password) { 76 173 setError("Please fill in all required fields"); 77 174 return; ··· 86 183 }; 87 184 88 185 const handleConfirmation = () => { 186 + // Double-check migration state before proceeding 187 + if (migrationState && !migrationState.allowMigration) { 188 + setError(migrationState.message); 189 + return; 190 + } 191 + 89 192 if (confirmationText !== "MIGRATE") { 90 193 setError("Please type 'MIGRATE' to confirm"); 91 194 return; ··· 117 220 <div class="absolute top-2 left-4 text-blue-500 text-sm font-mono">TERMINAL 1</div> 118 221 <div class="absolute top-2 right-4 text-blue-500 text-sm font-mono">GATE M1</div> 119 222 223 + {/* Migration state alert */} 224 + {migrationState && !migrationState.allowMigration && ( 225 + <div class={`mb-6 mt-4 p-4 rounded-lg border ${ 226 + migrationState.state === "maintenance" 227 + ? "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200" 228 + : "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200" 229 + }`}> 230 + <div class="flex items-center"> 231 + <div class={`mr-3 ${ 232 + migrationState.state === "maintenance" ? "text-yellow-600 dark:text-yellow-400" : "text-red-600 dark:text-red-400" 233 + }`}> 234 + {migrationState.state === "maintenance" ? "โš ๏ธ" : "๐Ÿšซ"} 235 + </div> 236 + <div> 237 + <h3 class="font-semibold mb-1"> 238 + {migrationState.state === "maintenance" ? "Maintenance Mode" : "Service Unavailable"} 239 + </h3> 240 + <p class="text-sm">{migrationState.message}</p> 241 + </div> 242 + </div> 243 + </div> 244 + )} 245 + 120 246 <div class="text-center mb-8 relative"> 121 247 <p class="text-gray-600 dark:text-gray-400 mt-4">Please complete your migration check-in</p> 122 248 <div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono">FLIGHT: MIG-2024</div> 123 249 </div> 124 250 251 + {/* Passport Section */} 252 + {passport && ( 253 + <div class="mb-8 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 border border-gray-200 dark:border-gray-700"> 254 + <div class="flex items-center justify-between mb-4"> 255 + <h3 class="text-lg font-semibold text-gray-900 dark:text-white">Current Passport</h3> 256 + <div class="text-xs text-gray-500 dark:text-gray-400 font-mono">ISSUED: {new Date().toLocaleDateString()}</div> 257 + </div> 258 + <div class="grid grid-cols-2 gap-4 text-sm"> 259 + <div> 260 + <div class="text-gray-500 dark:text-gray-400 mb-1">Handle</div> 261 + <div class="font-mono text-gray-900 dark:text-white">{passport.handle}</div> 262 + </div> 263 + <div> 264 + <div class="text-gray-500 dark:text-gray-400 mb-1">DID</div> 265 + <div class="font-mono text-gray-900 dark:text-white break-all">{passport.did}</div> 266 + </div> 267 + <div> 268 + <div class="text-gray-500 dark:text-gray-400 mb-1">Citizen of PDS</div> 269 + <div class="font-mono text-gray-900 dark:text-white break-all">{passport.pds}</div> 270 + </div> 271 + <div> 272 + <div class="text-gray-500 dark:text-gray-400 mb-1">Account Age</div> 273 + <div class="font-mono text-gray-900 dark:text-white"> 274 + {passport.createdAt ? new Date(passport.createdAt).toLocaleDateString() : "Unknown"} 275 + </div> 276 + </div> 277 + </div> 278 + </div> 279 + )} 280 + 125 281 <form onSubmit={handleSubmit} class="space-y-6"> 126 282 {error && ( 127 - <div class="bg-red-50 dark:bg-red-900 rounded-lg"> 283 + <div class="bg-red-50 dark:bg-red-900 rounded-lg "> 128 284 <p class="text-red-800 dark:text-red-200 flex items-center"> 129 285 <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 130 286 <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> ··· 171 327 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 172 328 New Account Handle 173 329 <span class="text-xs text-gray-500 ml-1">(Passport ID)</span> 330 + <div class="inline-block relative group ml-2"> 331 + <svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 cursor-help" fill="currentColor" viewBox="0 0 20 20"> 332 + <path fill-rule="evenodd" 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" clip-rule="evenodd" /> 333 + </svg> 334 + <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"> 335 + You can change your handle to a custom domain later 336 + <div class="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900"></div> 337 + </div> 338 + </div> 174 339 </label> 175 340 <div class="mt-1 relative w-full"> 176 341 <div class="flex rounded-md shadow-sm w-full"> ··· 221 386 222 387 <div> 223 388 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 224 - Contact Email 389 + Email 225 390 <span class="text-xs text-gray-500 ml-1">(Emergency Contact)</span> 226 391 </label> 227 392 <div class="relative"> ··· 287 452 288 453 <button 289 454 type="submit" 290 - disabled={isLoading} 455 + disabled={isLoading || Boolean(migrationState && !migrationState.allowMigration)} 291 456 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 457 > 293 458 <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> ··· 313 478 <div class="text-center mb-4 mt-6"> 314 479 <h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide">Final Boarding Call</h3> 315 480 <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. 481 + <span class="font-semibold text-red-500">Warning:</span> This migration is <strong>irreversible</strong> if coming from Bluesky servers.<br />Bluesky does not recommend it for main accounts. Migrate at your own risk. We reccomend backing up your data before proceeding. 317 482 </p> 318 483 <p class="text-gray-700 dark:text-gray-300 mb-4 text-base"> 319 484 Please type <span class="font-mono font-bold text-blue-600">MIGRATE</span> below to confirm and proceed.
+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 ) {
+9
islands/SocialLinks.tsx
··· 1 1 import { useEffect, useState } from "preact/hooks"; 2 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
+24 -7
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 ··· 24 34 setUser( 25 35 userData 26 36 ? { 27 - did: userData.did, 28 - handle: userData.handle, 29 - } 30 - : null, 37 + did: userData.did, 38 + handle: userData.handle, 39 + } 40 + : null 31 41 ); 32 42 } catch (error) { 33 43 console.error("Failed to fetch user:", error); ··· 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>
+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 + }
+55 -29
lib/cred/sessions.ts
··· 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 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 - isMigration: boolean = false, 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 - isMigration: boolean = false, 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 - isMigration: boolean = false, 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
+34 -1
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> { ··· 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;
+70
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").toLowerCase() as MigrationState; 19 + 20 + switch (state) { 21 + case "issue": 22 + return { 23 + state: "issue", 24 + message: "Migration services are temporarily unavailable as we investigate an issue. Please try again later.", 25 + allowMigration: false, 26 + }; 27 + 28 + case "maintenance": 29 + return { 30 + state: "maintenance", 31 + message: "Migration services are temporarily unavailable for maintenance. Please try again later.", 32 + allowMigration: false, 33 + }; 34 + 35 + case "up": 36 + default: 37 + return { 38 + state: "up", 39 + message: "Migration services are operational.", 40 + allowMigration: true, 41 + }; 42 + } 43 + } 44 + 45 + /** 46 + * Check if migrations are currently allowed. 47 + * @returns True if migrations are allowed, false otherwise 48 + */ 49 + export function isMigrationAllowed(): boolean { 50 + return getMigrationState().allowMigration; 51 + } 52 + 53 + /** 54 + * Get a user-friendly message for the current migration state. 55 + * @returns The message to display to users 56 + */ 57 + export function getMigrationStateMessage(): string { 58 + return getMigrationState().message; 59 + } 60 + 61 + /** 62 + * Throw an error if migrations are not allowed. 63 + * Used in API endpoints to prevent migration operations when disabled. 64 + */ 65 + export function assertMigrationAllowed(): void { 66 + const stateInfo = getMigrationState(); 67 + if (!stateInfo.allowMigration) { 68 + throw new Error(stateInfo.message); 69 + } 70 + }
+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");
+15
lib/oauth/sessions.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 25 req: Request 17 26 ) { ··· 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> {
+24 -1
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 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 + */ 8 18 async function createLock(key: string, db: Deno.Kv): Promise<() => Promise<void>> { 9 19 const lockKey = ["session_lock", key]; 10 20 const lockValue = Date.now(); ··· 25 35 }; 26 36 } 27 37 38 + /** 39 + * The OAuth session. 40 + * @type {OauthSession} 41 + */ 28 42 export interface OauthSession { 29 43 did: string 30 44 } 31 45 46 + /** 47 + * The credential session. 48 + * @type {CredentialSession} 49 + */ 32 50 export interface CredentialSession { 33 51 did: string; 34 52 handle: string; ··· 45 63 46 64 let db: Deno.Kv; 47 65 66 + /** 67 + * Create the session options. 68 + * @param cookieName - The name of the iron session cookie 69 + * @returns The session options for iron session 70 + */ 48 71 export const createSessionOptions = async (cookieName: string): Promise<SessionOptions> => { 49 72 const cookieSecret = Deno.env.get("COOKIE_SECRET"); 50 73 if (!cookieSecret) {
+104
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">About AT Protocol</h1> 10 + 11 + <div class="space-y-6"> 12 + <section> 13 + <h2 class="text-2xl font-semibold mb-4">What is AT Protocol?</h2> 14 + <p class="text-gray-600 dark:text-gray-300"> 15 + AT Protocol (Authenticated Transfer Protocol) is the 16 + foundation of Bluesky and other social apps like 17 + <a href="https://tangled.sh">Tangled</a>, 18 + <a href="https://spark.com">Spark</a>, and more. 19 + Unlike traditional social platforms that lock your 20 + data and identity to a single service, AT Protocol 21 + gives you complete control over your digital presence. 22 + Think of it as an open standard for social networking, 23 + similar to how email works across different providers. 24 + </p> 25 + </section> 26 + 27 + <section> 28 + <h2 class="text-2xl font-semibold mb-4">Key Features</h2> 29 + <ul class="list-disc pl-6 space-y-4 text-gray-600 dark:text-gray-300"> 30 + <li> 31 + <strong>PDS Servers:</strong> PDS servers are where your data is stored. 32 + They can be run by anyone, and they are very lightweight, allowing you to 33 + choose which one to use or run your own. PDS servers just store your data, 34 + meaning you don't have to switch PDS servers to use a different app or service. 35 + You can have one PDS while using many different apps and services with the 36 + same account. 37 + </li> 38 + <li> 39 + <strong>Decentralized Identity:</strong> Your account is tied to a DID 40 + (Decentralized Identifier) rather than your handle/username. 41 + This means you can move your entire account, including your followers 42 + and content, to any PDS by changing where your DID points. 43 + It's also the reason you can use any domain as your handle, because 44 + your identity is not tied to your handle. Your handle can change, 45 + but your DID will always remain the same. 46 + </li> 47 + <li> 48 + <strong>Portable Content:</strong> All your posts, likes, and other social 49 + data are stored in your Personal Data Server (PDS). 50 + You can switch PDS providers without losing any content or connections. 51 + </li> 52 + <li> 53 + <strong>Architecture:</strong> The protocol uses a three-tier architecture: 54 + Personal Data Servers (PDS) store your content, 55 + relays broadcast a stream of all events on all PDSes, 56 + and AppViews process and serve that stream into content for users. 57 + This means when you make a post, the content is stored on your PDS, 58 + picked up by relays, and AppViews listen to those relays to deliver 59 + that post to all users. 60 + </li> 61 + <li> 62 + <strong>Algorithmic Choice:</strong> You're not locked into a single algorithm 63 + for your feed. Different services can offer different ways of curating content, 64 + and you can choose which one you prefer. Bluesky offers a way to make custom 65 + feeds, but even if it didn't, different apps could still offer their own 66 + algorithms for curating content. 67 + </li> 68 + </ul> 69 + </section> 70 + 71 + <section> 72 + <h2 class="text-2xl font-semibold mb-4">Learn More</h2> 73 + <div class="space-y-4"> 74 + <p class="text-gray-600 dark:text-gray-300"> 75 + Want to dive deeper into AT Protocol? Check out these resources: 76 + </p> 77 + <ul class="list-none space-y-2"> 78 + <li> 79 + <a href="https://atproto.com" class="text-blue-500 hover:underline">Official AT Protocol Docs</a> - The main source for protocol specs and information 80 + </li> 81 + <li> 82 + <a href="https://github.com/bluesky-social/atproto" class="text-blue-500 hover:underline">GitHub Repository</a> - View the protocol implementation 83 + </li> 84 + <li> 85 + <a href="https://atproto.wiki" class="text-blue-500 hover:underline">AT Protocol Wiki</a> - Community-driven documentation and resources 86 + </li> 87 + </ul> 88 + </div> 89 + </section> 90 + </div> 91 + 92 + <div class="mt-8 text-center"> 93 + <Button 94 + href="/" 95 + color="blue" 96 + label="Back to Home" 97 + /> 98 + </div> 99 + </div> 100 + </div> 101 + </div> 102 + </> 103 + ); 104 + }
+7
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 {
+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);
+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
+29 -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 5 6 + /** 7 + * Handle identity migration request 8 + * Sends a PLC operation signature request to the old account's email 9 + * Should be called after all data is migrated to the new account 10 + * @param ctx - The context object containing the request and response 11 + * @returns A response object with the migration result 12 + */ 6 13 export const handler = define.handlers({ 7 14 async POST(ctx) { 8 15 const res = new Response(); 9 16 try { 17 + // Check if migrations are currently allowed 18 + assertMigrationAllowed(); 19 + 10 20 console.log("Starting identity migration request..."); 11 21 const oldAgent = await getSessionAgent(ctx.req); 12 22 console.log("Got old agent:", { ··· 45 55 ); 46 56 } 47 57 58 + // Verify DIDs match between sessions 59 + const didsMatch = await checkDidsMatch(ctx.req); 60 + if (!didsMatch) { 61 + return new Response( 62 + JSON.stringify({ 63 + success: false, 64 + message: "Invalid state, original and target DIDs do not match", 65 + }), 66 + { 67 + status: 400, 68 + headers: { "Content-Type": "application/json" }, 69 + }, 70 + ); 71 + } 72 + 48 73 // Request the signature 49 74 console.log("Requesting PLC operation signature..."); 50 75 try { ··· 54 79 console.error("Error requesting PLC operation signature:", { 55 80 name: error instanceof Error ? error.name : "Unknown", 56 81 message: error instanceof Error ? error.message : String(error), 57 - status: 400 82 + status: 400, 58 83 }); 59 84 throw error; 60 85 }
+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,
+2 -2
routes/api/migrate/next-step.ts
··· 17 17 // Check conditions in sequence to determine the next step 18 18 if (!newStatus.data) { 19 19 nextStep = 1; 20 - } else if (!(newStatus.data.repoCommit && 20 + } else if (!(newStatus.data.repoCommit && 21 21 newStatus.data.indexedRecords === oldStatus.data.indexedRecords && 22 22 newStatus.data.privateStateValues === oldStatus.data.privateStateValues && 23 23 newStatus.data.expectedBlobs === newStatus.data.importedBlobs && ··· 42 42 } 43 43 }); 44 44 } 45 - }) 45 + })
+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 + });
+44
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: "Unable to determine migration state. Please try again later.", 33 + allowMigration: false, 34 + }), 35 + { 36 + status: 500, 37 + headers: { 38 + "Content-Type": "application/json", 39 + }, 40 + } 41 + ); 42 + } 43 + }, 44 + });
+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 + });
+64
routes/api/plc/token.ts
··· 1 + import { getSessionAgent } from "../../../lib/sessions.ts"; 2 + import { setCredentialSession } from "../../../lib/cred/sessions.ts"; 3 + import { Agent } from "@atproto/api"; 4 + import { define } from "../../../utils.ts"; 5 + 6 + /** 7 + * Handle account creation 8 + * First step of the migration process 9 + * Body must contain: 10 + * - service: The service URL of the new account 11 + * - handle: The handle of the new account 12 + * - password: The password of the new account 13 + * - email: The email of the new account 14 + * - invite: The invite code of the new account (optional depending on the PDS) 15 + * @param ctx - The context object containing the request and response 16 + * @returns A response object with the creation result 17 + */ 18 + export const handler = define.handlers({ 19 + async GET(ctx) { 20 + const res = new Response(); 21 + try { 22 + const agent = await getSessionAgent(ctx.req, res); 23 + 24 + if (!agent) return new Response("Unauthorized", { status: 401 }); 25 + 26 + // console.log("getting did"); 27 + // const session = await agent.com.atproto.server.getSession(); 28 + // const accountDid = session.data.did; 29 + // console.log("got did"); 30 + 31 + await agent.com.atproto.identity.requestPlcOperationSignature(); 32 + 33 + return new Response( 34 + JSON.stringify({ 35 + success: true, 36 + message: 37 + "We've requested a token to update your identity, it should be sent to your account's email address.", 38 + }), 39 + { 40 + status: 200, 41 + headers: { 42 + "Content-Type": "application/json", 43 + ...Object.fromEntries(res.headers), // Include session cookie headers 44 + }, 45 + } 46 + ); 47 + } catch (error) { 48 + console.error("PLC signature request error:", error); 49 + return new Response( 50 + JSON.stringify({ 51 + success: false, 52 + message: 53 + error instanceof Error 54 + ? error.message 55 + : "Failed to get PLC operation signature (sending confirmation email)", 56 + }), 57 + { 58 + status: 400, 59 + headers: { "Content-Type": "application/json" }, 60 + } 61 + ); 62 + } 63 + }, 64 + });
+92
routes/api/plc/update/complete.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { getSessionAgent } from "../../../../lib/sessions.ts"; 3 + import { define } from "../../../../utils.ts"; 4 + 5 + /** 6 + * Complete PLC update using email token 7 + */ 8 + export const handler = define.handlers({ 9 + async POST(ctx) { 10 + const res = new Response(); 11 + try { 12 + const url = new URL(ctx.req.url); 13 + const token = url.searchParams.get("token"); 14 + 15 + if (!token) { 16 + return new Response( 17 + JSON.stringify({ 18 + success: false, 19 + message: "Missing token parameter", 20 + }), 21 + { 22 + status: 400, 23 + headers: { "Content-Type": "application/json" }, 24 + } 25 + ); 26 + } 27 + 28 + const agent = await getSessionAgent(ctx.req, res, true); 29 + if (!agent) { 30 + return new Response( 31 + JSON.stringify({ 32 + success: false, 33 + message: "Unauthorized", 34 + }), 35 + { 36 + status: 401, 37 + headers: { "Content-Type": "application/json" }, 38 + } 39 + ); 40 + } 41 + 42 + const did = agent.did; 43 + if (!did) { 44 + return new Response( 45 + JSON.stringify({ 46 + success: false, 47 + message: "No DID found in session", 48 + }), 49 + { 50 + status: 400, 51 + headers: { "Content-Type": "application/json" }, 52 + } 53 + ); 54 + } 55 + 56 + // Submit the PLC operation with the token 57 + await agent!.com.atproto.identity.submitPlcOperation({ 58 + operation: { token: token }, 59 + }); 60 + 61 + return new Response( 62 + JSON.stringify({ 63 + success: true, 64 + message: "PLC update completed successfully", 65 + did, 66 + }), 67 + { 68 + status: 200, 69 + headers: { 70 + "Content-Type": "application/json", 71 + ...Object.fromEntries(res.headers), // Include session cookie headers 72 + }, 73 + } 74 + ); 75 + } catch (error) { 76 + console.error("PLC update completion error:", error); 77 + const message = 78 + error instanceof Error ? error.message : "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 = 132 + error instanceof Error ? error.message : "Failed to update your PLC"; 133 + console.log("Sending error response:", errorMessage); 134 + 135 + return new Response( 136 + JSON.stringify({ 137 + success: false, 138 + message: errorMessage, 139 + error: 140 + 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 + });
+131
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 = 106 + error instanceof Error 107 + ? error.message 108 + : "Failed to verify rotation key"; 109 + console.log("Sending error response:", errorMessage); 110 + 111 + return new Response( 112 + JSON.stringify({ 113 + success: false, 114 + message: errorMessage, 115 + error: 116 + error instanceof Error 117 + ? { 118 + name: error.name, 119 + message: error.message, 120 + stack: error.stack, 121 + } 122 + : String(error), 123 + }), 124 + { 125 + status: 400, 126 + headers: { "Content-Type": "application/json" }, 127 + } 128 + ); 129 + } 130 + }, 131 + });
+30
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(JSON.stringify({ error: "DID parameter is required" }), { 11 + status: 400, 12 + headers: { "Content-Type": "application/json" } 13 + }); 14 + } 15 + 16 + try { 17 + const pds = await resolver.resolveDidToPdsUrl(did); 18 + return new Response(JSON.stringify({ pds }), { 19 + status: 200, 20 + headers: { "Content-Type": "application/json" } 21 + }); 22 + } catch (error) { 23 + console.error("Failed to resolve PDS:", error); 24 + return new Response(JSON.stringify({ error: "Failed to resolve PDS" }), { 25 + status: 500, 26 + headers: { "Content-Type": "application/json" } 27 + }); 28 + } 29 + } 30 + });
+8 -17
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 22 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. 38 23 </p> 24 + <div class="text-center mb-4"> 25 + <a href="/about" class="inline-flex items-center text-blue-500 hover:text-blue-600 transition-colors"> 26 + <img src="/icons/info_bold.svg" alt="Info" class="w-5 h-5 mr-2" /> 27 + <span class="font-mono">Learn more about AT Protocol</span> 28 + </a> 29 + </div> 39 30 <SocialLinks /> 40 31 </div> 41 32 </div>
+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
+19
routes/ticket-booth/index.tsx
··· 1 + import { PageProps } from "fresh"; 2 + import MigrationSetup from "../../islands/MigrationSetup.tsx"; 3 + import DidPlcProgress from "../../islands/DidPlcProgress.tsx"; 4 + 5 + export default function TicketBooth(props: PageProps) { 6 + const service = props.url.searchParams.get("service"); 7 + const handle = props.url.searchParams.get("handle"); 8 + 9 + return ( 10 + <div class=" bg-gray-50 dark:bg-gray-900 p-4"> 11 + <div class="max-w-2xl mx-auto"> 12 + <h1 class="font-mono text-3xl font-bold text-gray-900 dark:text-white mb-8"> 13 + Ticket Booth Self-Service Kiosk 14 + </h1> 15 + <DidPlcProgress /> 16 + </div> 17 + </div> 18 + ); 19 + }
+4
static/icons/account.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#e3e3e3"> 2 + <path d="M0 0h24v24H0z" fill="none"/> 3 + <path 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"/> 4 + </svg>
+6
static/icons/info_bold.svg
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 3 + <path 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" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 4 + <path d="M12 16V12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 5 + <path d="M12 8H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 6 + </svg>