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
+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",
+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 + }
+15 -6
islands/Header.tsx
··· 51 51 setUser( 52 52 userData 53 53 ? { 54 - did: userData.did, 55 - handle: userData.handle, 56 - } 57 - : null, 54 + did: userData.did, 55 + handle: userData.handle, 56 + } 57 + : null 58 58 ); 59 59 } catch (error) { 60 60 console.error("Failed to fetch user:", error); ··· 97 97 /> 98 98 99 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 + 100 109 {/* Departures (Migration) */} 101 110 <Button 102 111 href="/migrate" ··· 112 121 <div className="relative"> 113 122 <Button 114 123 color="amber" 115 - icon="/icons/ticket_bold.svg" 124 + icon="/icons/account.svg" 116 125 iconAlt="Check-in" 117 126 label="CHECKED IN" 118 127 onClick={() => setShowDropdown(!showDropdown)} ··· 141 150 <Button 142 151 href="/login" 143 152 color="amber" 144 - icon="/icons/ticket_bold.svg" 153 + icon="/icons/account.svg" 145 154 iconAlt="Check-in" 146 155 label="CHECK-IN" 147 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 + }
+536 -264
islands/MigrationProgress.tsx
··· 1 1 import { useEffect, useState } from "preact/hooks"; 2 2 3 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 + /** 4 14 * The migration progress props. 5 15 * @type {MigrationProgressProps} 6 16 */ ··· 20 30 name: string; 21 31 status: "pending" | "in-progress" | "verifying" | "completed" | "error"; 22 32 error?: string; 33 + isVerificationError?: boolean; 23 34 } 24 35 25 36 /** ··· 30 41 */ 31 42 export default function MigrationProgress(props: MigrationProgressProps) { 32 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 + >({}); 33 53 34 54 const [steps, setSteps] = useState<MigrationStep[]>([ 35 55 { name: "Create Account", status: "pending" }, ··· 42 62 index: number, 43 63 status: MigrationStep["status"], 44 64 error?: string, 65 + isVerificationError?: boolean, 45 66 ) => { 46 67 console.log( 47 68 `Updating step ${index} to ${status}${ ··· 51 72 setSteps((prevSteps) => 52 73 prevSteps.map((step, i) => 53 74 i === index 54 - ? { ...step, status, error } 75 + ? { ...step, status, error, isVerificationError } 55 76 : i > index 56 - ? { ...step, status: "pending", error: undefined } 77 + ? { 78 + ...step, 79 + status: "pending", 80 + error: undefined, 81 + isVerificationError: undefined, 82 + } 57 83 : step 58 84 ) 59 85 ); ··· 88 114 invite: props.invite, 89 115 }); 90 116 91 - if (!validateParams()) { 92 - console.log("Parameter validation failed"); 93 - return; 94 - } 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); 95 124 96 - startMigration().catch((error) => { 97 - console.error("Unhandled migration error:", error); 98 - updateStepStatus( 99 - 0, 100 - "error", 101 - error instanceof Error ? error.message : String(error), 102 - ); 103 - }); 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(); 104 152 }, []); 105 153 106 154 const getStepDisplayName = (step: MigrationStep, index: number) => { 107 155 if (step.status === "completed") { 108 156 switch (index) { 109 - case 0: return "Account Created"; 110 - case 1: return "Data Migrated"; 111 - case 2: return "Identity Migrated"; 112 - 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"; 113 165 } 114 166 } 115 - 167 + 116 168 if (step.status === "in-progress") { 117 169 switch (index) { 118 - case 0: return "Creating your new account..."; 119 - case 1: return "Migrating your data..."; 120 - case 2: return step.name === "Enter the token sent to your email to complete identity migration" 121 - ? step.name 122 - : "Migrating your identity..."; 123 - 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..."; 124 181 } 125 182 } 126 183 127 184 if (step.status === "verifying") { 128 185 switch (index) { 129 - case 0: return "Verifying account creation..."; 130 - case 1: return "Verifying data migration..."; 131 - case 2: return "Verifying identity migration..."; 132 - 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..."; 133 194 } 134 195 } 135 - 196 + 136 197 return step.name; 137 198 }; 138 199 ··· 180 241 updateStepStatus(0, "verifying"); 181 242 const verified = await verifyStep(0); 182 243 if (!verified) { 183 - throw new Error("Account creation verification failed"); 184 - } 185 - } catch (error) { 186 - updateStepStatus( 187 - 0, 188 - "error", 189 - error instanceof Error ? error.message : String(error), 190 - ); 191 - throw error; 192 - } 193 - 194 - // Step 2: Migrate Data 195 - updateStepStatus(1, "in-progress"); 196 - console.log("Starting data migration..."); 197 - 198 - try { 199 - // Step 2.1: Migrate Repo 200 - console.log("Data migration: Starting repo migration"); 201 - const repoRes = await fetch("/api/migrate/data/repo", { 202 - method: "POST", 203 - headers: { "Content-Type": "application/json" }, 204 - }); 205 - 206 - console.log("Repo migration: Response status:", repoRes.status); 207 - const repoText = await repoRes.text(); 208 - console.log("Repo migration: Raw response:", repoText); 209 - 210 - if (!repoRes.ok) { 211 - try { 212 - const json = JSON.parse(repoText); 213 - console.error("Repo migration: Error response:", json); 214 - throw new Error(json.message || "Failed to migrate repo"); 215 - } catch { 216 - console.error("Repo migration: Non-JSON error response:", repoText); 217 - throw new Error(repoText || "Failed to migrate repo"); 218 - } 219 - } 220 - 221 - // Step 2.2: Migrate Blobs 222 - console.log("Data migration: Starting blob migration"); 223 - const blobsRes = await fetch("/api/migrate/data/blobs", { 224 - method: "POST", 225 - headers: { "Content-Type": "application/json" }, 226 - }); 227 - 228 - console.log("Blob migration: Response status:", blobsRes.status); 229 - const blobsText = await blobsRes.text(); 230 - console.log("Blob migration: Raw response:", blobsText); 231 - 232 - if (!blobsRes.ok) { 233 - try { 234 - const json = JSON.parse(blobsText); 235 - console.error("Blob migration: Error response:", json); 236 - throw new Error(json.message || "Failed to migrate blobs"); 237 - } catch { 238 - console.error("Blob migration: Non-JSON error response:", blobsText); 239 - throw new Error(blobsText || "Failed to migrate blobs"); 240 - } 241 - } 242 - 243 - // Step 2.3: Migrate Preferences 244 - console.log("Data migration: Starting preferences migration"); 245 - const prefsRes = await fetch("/api/migrate/data/prefs", { 246 - method: "POST", 247 - headers: { "Content-Type": "application/json" }, 248 - }); 249 - 250 - console.log("Preferences migration: Response status:", prefsRes.status); 251 - const prefsText = await prefsRes.text(); 252 - console.log("Preferences migration: Raw response:", prefsText); 253 - 254 - if (!prefsRes.ok) { 255 - try { 256 - const json = JSON.parse(prefsText); 257 - console.error("Preferences migration: Error response:", json); 258 - throw new Error(json.message || "Failed to migrate preferences"); 259 - } catch { 260 - console.error("Preferences migration: Non-JSON error response:", prefsText); 261 - throw new Error(prefsText || "Failed to migrate preferences"); 262 - } 263 - } 264 - 265 - console.log("Data migration: Starting verification"); 266 - updateStepStatus(1, "verifying"); 267 - const verified = await verifyStep(1); 268 - console.log("Data migration: Verification result:", verified); 269 - if (!verified) { 270 - throw new Error("Data migration verification failed"); 271 - } 272 - } catch (error) { 273 - console.error("Data migration: Error caught:", error); 274 - updateStepStatus( 275 - 1, 276 - "error", 277 - error instanceof Error ? error.message : String(error), 278 - ); 279 - throw error; 280 - } 281 - 282 - // Step 3: Request Identity Migration 283 - updateStepStatus(2, "in-progress"); 284 - console.log("Requesting identity migration..."); 285 - 286 - try { 287 - const requestRes = await fetch("/api/migrate/identity/request", { 288 - method: "POST", 289 - headers: { "Content-Type": "application/json" }, 290 - }); 291 - 292 - console.log("Identity request response status:", requestRes.status); 293 - const requestText = await requestRes.text(); 294 - console.log("Identity request response:", requestText); 295 - 296 - if (!requestRes.ok) { 297 - try { 298 - const json = JSON.parse(requestText); 299 - throw new Error(json.message || "Failed to request identity migration"); 300 - } catch { 301 - throw new Error(requestText || "Failed to request identity migration"); 302 - } 303 - } 304 - 305 - try { 306 - const jsonData = JSON.parse(requestText); 307 - if (!jsonData.success) { 308 - throw new Error( 309 - jsonData.message || "Identity migration request failed", 310 - ); 311 - } 312 - console.log("Identity migration requested successfully"); 313 - 314 - // Update step name to prompt for token 315 - setSteps(prevSteps => 316 - prevSteps.map((step, i) => 317 - i === 2 318 - ? { ...step, name: "Enter the token sent to your email to complete identity migration" } 319 - : step 320 - ) 244 + console.log( 245 + "Account creation: Verification failed, waiting for user action", 321 246 ); 322 - // Don't continue with migration - wait for token input 323 247 return; 324 - } catch (e) { 325 - console.error("Failed to parse identity request response:", e); 326 - throw new Error( 327 - "Invalid response from server during identity request", 328 - ); 329 248 } 249 + 250 + // If verification succeeds, continue to data migration 251 + await startDataMigration(); 330 252 } catch (error) { 331 253 updateStepStatus( 332 - 2, 254 + 0, 333 255 "error", 334 256 error instanceof Error ? error.message : String(error), 335 257 ); ··· 356 278 if (!identityRes.ok) { 357 279 try { 358 280 const json = JSON.parse(identityData); 359 - throw new Error(json.message || "Failed to complete identity migration"); 281 + throw new Error( 282 + json.message || "Failed to complete identity migration", 283 + ); 360 284 } catch { 361 - throw new Error(identityData || "Failed to complete identity migration"); 285 + throw new Error( 286 + identityData || "Failed to complete identity migration", 287 + ); 362 288 } 363 289 } 364 290 ··· 372 298 throw new Error("Invalid response from server"); 373 299 } 374 300 375 - 376 301 updateStepStatus(2, "verifying"); 377 302 const verified = await verifyStep(2); 378 303 if (!verified) { 379 - throw new Error("Identity migration verification failed"); 380 - } 381 - 382 - // Step 4: Finalize Migration 383 - updateStepStatus(3, "in-progress"); 384 - try { 385 - const finalizeRes = await fetch("/api/migrate/finalize", { 386 - method: "POST", 387 - headers: { "Content-Type": "application/json" }, 388 - }); 389 - 390 - const finalizeData = await finalizeRes.text(); 391 - if (!finalizeRes.ok) { 392 - try { 393 - const json = JSON.parse(finalizeData); 394 - throw new Error(json.message || "Failed to finalize migration"); 395 - } catch { 396 - throw new Error(finalizeData || "Failed to finalize migration"); 397 - } 398 - } 399 - 400 - try { 401 - const jsonData = JSON.parse(finalizeData); 402 - if (!jsonData.success) { 403 - throw new Error(jsonData.message || "Finalization failed"); 404 - } 405 - } catch { 406 - throw new Error("Invalid response from server during finalization"); 407 - } 408 - 409 - updateStepStatus(3, "verifying"); 410 - const verified = await verifyStep(3); 411 - if (!verified) { 412 - throw new Error("Migration finalization verification failed"); 413 - } 414 - } catch (error) { 415 - updateStepStatus( 416 - 3, 417 - "error", 418 - error instanceof Error ? error.message : String(error), 304 + console.log( 305 + "Identity migration: Verification failed, waiting for user action", 419 306 ); 420 - throw error; 307 + return; 421 308 } 309 + 310 + // If verification succeeds, continue to finalization 311 + await startFinalization(); 422 312 } catch (error) { 423 313 console.error("Identity migration error:", error); 424 314 updateStepStatus( ··· 515 405 console.log(`Verification: Status response status:`, res.status); 516 406 const data = await res.json(); 517 407 console.log(`Verification: Status data for step ${stepNum + 1}:`, data); 518 - 408 + 519 409 if (data.ready) { 520 410 console.log(`Verification: Step ${stepNum + 1} is ready`); 521 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 + 522 421 return true; 523 422 } else { 524 - console.log(`Verification: Step ${stepNum + 1} is not ready:`, data.reason); 423 + console.log( 424 + `Verification: Step ${stepNum + 1} is not ready:`, 425 + data.reason, 426 + ); 525 427 const statusDetails = { 526 428 activated: data.activated, 527 429 validDid: data.validDid, ··· 532 434 indexedRecords: data.indexedRecords, 533 435 privateStateValues: data.privateStateValues, 534 436 expectedBlobs: data.expectedBlobs, 535 - importedBlobs: data.importedBlobs 437 + importedBlobs: data.importedBlobs, 536 438 }; 537 - console.log(`Verification: Step ${stepNum + 1} status details:`, statusDetails); 538 - const errorMessage = `${data.reason || "Verification failed"}\nStatus details: ${JSON.stringify(statusDetails, null, 2)}`; 539 - updateStepStatus(stepNum, "error", errorMessage); 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); 540 460 return false; 541 461 } 542 462 } catch (e) { 543 463 console.error(`Verification: Error in step ${stepNum + 1}:`, e); 544 - updateStepStatus(stepNum, "error", e instanceof Error ? e.message : String(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 + ); 545 478 return false; 546 479 } 547 480 }; 548 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 + 549 730 return ( 550 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 + 551 763 <div class="space-y-4"> 552 764 {steps.map((step, index) => ( 553 765 <div key={step.name} class={getStepClasses(step.status)}> ··· 567 779 {getStepDisplayName(step, index)} 568 780 </p> 569 781 {step.error && ( 570 - <p class="text-sm text-red-600 dark:text-red-400 mt-1"> 571 - {(() => { 572 - try { 573 - const err = JSON.parse(step.error); 574 - return err.message || step.error; 575 - } catch { 576 - return step.error; 577 - } 578 - })()} 579 - </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> 580 815 )} 581 816 {index === 2 && step.status === "in-progress" && 582 - 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 + ( 583 820 <div class="mt-4 space-y-4"> 584 821 <p class="text-sm text-blue-800 dark:text-blue-200"> 585 - 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: 586 824 </p> 587 825 <div class="flex space-x-2"> 588 826 <input ··· 601 839 </button> 602 840 </div> 603 841 </div> 604 - ) 605 - } 842 + )} 606 843 </div> 607 844 </div> 608 845 ))} ··· 610 847 611 848 {steps[3].status === "completed" && ( 612 849 <div class="p-4 bg-green-50 dark:bg-green-900 rounded-lg border-2 border-green-200 dark:border-green-800"> 613 - <p class="text-sm text-green-800 dark:text-green-200"> 614 - 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. 615 855 </p> 616 - <button 617 - type="button" 618 - onClick={async () => { 619 - try { 620 - const response = await fetch("/api/logout", { 621 - method: "POST", 622 - credentials: "include", 623 - }); 624 - if (!response.ok) { 625 - throw new Error("Logout failed"); 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); 626 871 } 627 - globalThis.location.href = "/"; 628 - } catch (error) { 629 - console.error("Failed to logout:", error); 630 - } 631 - }} 632 - class="mt-4 mr-4 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200" 633 - > 634 - Sign Out 635 - </button> 636 - <a href="https://ko-fi.com/knotbin" target="_blank" class="mt-4 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors duration-200"> 637 - Donate 638 - </a> 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> 639 911 </div> 640 912 )} 641 913 </div>
+70 -7
islands/MigrationSetup.tsx
··· 33 33 } 34 34 35 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 + /** 36 46 * The migration setup component. 37 47 * @param props - The migration setup props 38 48 * @returns The migration setup component ··· 54 64 const [showConfirmation, setShowConfirmation] = useState(false); 55 65 const [confirmationText, setConfirmationText] = useState(""); 56 66 const [passport, setPassport] = useState<UserPassport | null>(null); 67 + const [migrationState, setMigrationState] = useState<MigrationStateInfo | null>(null); 57 68 58 69 const ensureServiceUrl = (url: string): string => { 59 70 if (!url) return url; ··· 70 81 useEffect(() => { 71 82 if (!IS_BROWSER) return; 72 83 73 - const fetchPassport = async () => { 84 + const fetchInitialData = async () => { 74 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 75 94 const response = await fetch("/api/me", { 76 95 credentials: "include", 77 96 }); ··· 83 102 // Get PDS URL from the current service 84 103 const pdsResponse = await fetch(`/api/resolve-pds?did=${userData.did}`); 85 104 const pdsData = await pdsResponse.json(); 86 - 105 + 87 106 setPassport({ 88 107 did: userData.did, 89 108 handle: userData.handle, ··· 92 111 }); 93 112 } 94 113 } catch (error) { 95 - console.error("Failed to fetch passport:", error); 114 + console.error("Failed to fetch initial data:", error); 96 115 } 97 116 }; 98 117 99 - fetchPassport(); 118 + fetchInitialData(); 100 119 }, []); 101 120 102 121 const checkServerDescription = async (serviceUrl: string) => { ··· 144 163 const handleSubmit = (e: Event) => { 145 164 e.preventDefault(); 146 165 166 + // Check migration state first 167 + if (migrationState && !migrationState.allowMigration) { 168 + setError(migrationState.message); 169 + return; 170 + } 171 + 147 172 if (!service || !handlePrefix || !email || !password) { 148 173 setError("Please fill in all required fields"); 149 174 return; ··· 158 183 }; 159 184 160 185 const handleConfirmation = () => { 186 + // Double-check migration state before proceeding 187 + if (migrationState && !migrationState.allowMigration) { 188 + setError(migrationState.message); 189 + return; 190 + } 191 + 161 192 if (confirmationText !== "MIGRATE") { 162 193 setError("Please type 'MIGRATE' to confirm"); 163 194 return; ··· 189 220 <div class="absolute top-2 left-4 text-blue-500 text-sm font-mono">TERMINAL 1</div> 190 221 <div class="absolute top-2 right-4 text-blue-500 text-sm font-mono">GATE M1</div> 191 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 + 192 246 <div class="text-center mb-8 relative"> 193 247 <p class="text-gray-600 dark:text-gray-400 mt-4">Please complete your migration check-in</p> 194 248 <div class="mt-2 text-sm text-gray-500 dark:text-gray-400 font-mono">FLIGHT: MIG-2024</div> ··· 226 280 227 281 <form onSubmit={handleSubmit} class="space-y-6"> 228 282 {error && ( 229 - <div class="bg-red-50 dark:bg-red-900 rounded-lg"> 283 + <div class="bg-red-50 dark:bg-red-900 rounded-lg "> 230 284 <p class="text-red-800 dark:text-red-200 flex items-center"> 231 285 <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 232 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> ··· 273 327 <label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> 274 328 New Account Handle 275 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> 276 339 </label> 277 340 <div class="mt-1 relative w-full"> 278 341 <div class="flex rounded-md shadow-sm w-full"> ··· 389 452 390 453 <button 391 454 type="submit" 392 - disabled={isLoading} 455 + disabled={isLoading || Boolean(migrationState && !migrationState.allowMigration)} 393 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" 394 457 > 395 458 <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> ··· 415 478 <div class="text-center mb-4 mt-6"> 416 479 <h3 class="text-2xl font-bold text-red-600 mb-2 tracking-wide">Final Boarding Call</h3> 417 480 <p class="text-gray-700 dark:text-gray-300 mb-2 text-base"> 418 - <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. Migrate at your own risk. We reccomend backing up your data before proceeding. 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. 419 482 </p> 420 483 <p class="text-gray-700 dark:text-gray-300 mb-4 text-base"> 421 484 Please type <span class="font-mono font-bold text-blue-600">MIGRATE</span> below to confirm and proceed.
+15 -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 4 5 /** 5 6 * The user interface for the ticket component. ··· 33 34 setUser( 34 35 userData 35 36 ? { 36 - did: userData.did, 37 - handle: userData.handle, 38 - } 39 - : null, 37 + did: userData.did, 38 + handle: userData.handle, 39 + } 40 + : null 40 41 ); 41 42 } catch (error) { 42 43 console.error("Failed to fetch user:", error); ··· 72 73 </p> 73 74 <p> 74 75 Think you might need to migrate in the future but your PDS might be 75 - hostile or offline? No worries! Soon you'll be able to go to the 76 - ticket booth and get a PLC key to use for account recovery in the 77 - 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 78 86 terminal four) and get a downloadable backup of all your current PDS 79 87 data in case that were to happen. 80 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 + }
+21 -29
lib/cred/sessions.ts
··· 17 17 } 18 18 return migrationSessionOptions; 19 19 } 20 - 20 + 21 21 if (!credentialSessionOptions) { 22 22 credentialSessionOptions = await createSessionOptions("cred_sid"); 23 23 } ··· 37 37 isMigration: boolean = false 38 38 ) { 39 39 const options = await getOptions(isMigration); 40 - return getIronSession<CredentialSession>( 41 - req, 42 - res, 43 - options, 44 - ); 40 + return getIronSession<CredentialSession>(req, res, options); 45 41 } 46 42 47 43 /** ··· 54 50 export async function getCredentialAgent( 55 51 req: Request, 56 52 res: Response = new Response(), 57 - isMigration: boolean = false, 53 + isMigration: boolean = false 58 54 ) { 59 - const session = await getCredentialSession( 60 - req, 61 - res, 62 - isMigration 63 - ); 64 - 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 + ) { 65 62 return null; 66 63 } 67 64 ··· 107 104 req: Request, 108 105 res: Response, 109 106 data: CredentialSession, 110 - isMigration: boolean = false, 107 + isMigration: boolean = false 111 108 ) { 112 - const session = await getCredentialSession( 113 - req, 114 - res, 115 - isMigration 116 - ); 109 + const session = await getCredentialSession(req, res, isMigration); 117 110 session.did = data.did; 118 111 session.handle = data.handle; 119 112 session.service = data.service; ··· 132 125 export async function getCredentialSessionAgent( 133 126 req: Request, 134 127 res: Response = new Response(), 135 - isMigration: boolean = false, 128 + isMigration: boolean = false 136 129 ) { 137 - const session = await getCredentialSession( 138 - req, 139 - res, 140 - isMigration 141 - ); 130 + const session = await getCredentialSession(req, res, isMigration); 142 131 143 132 console.log("Session state:", { 144 133 hasDid: !!session.did, ··· 147 136 hasPassword: !!session.password, 148 137 hasAccessJwt: !!session.accessJwt, 149 138 service: session.service, 150 - handle: session.handle 139 + handle: session.handle, 151 140 }); 152 141 153 142 if ( 154 - !session.did || !session.service || !session.handle || !session.password 143 + !session.did || 144 + !session.service || 145 + !session.handle || 146 + !session.password 155 147 ) { 156 148 console.log("Missing required session fields"); 157 149 return null; ··· 170 162 const sessionInfo = await agent.com.atproto.server.getSession(); 171 163 console.log("Stored JWT is valid, session info:", { 172 164 did: sessionInfo.data.did, 173 - handle: sessionInfo.data.handle 165 + handle: sessionInfo.data.handle, 174 166 }); 175 167 return agent; 176 168 } catch (err) { ··· 190 182 console.log("Session created successfully:", { 191 183 did: sessionRes.data.did, 192 184 handle: sessionRes.data.handle, 193 - hasAccessJwt: !!sessionRes.data.accessJwt 185 + hasAccessJwt: !!sessionRes.data.accessJwt, 194 186 }); 195 187 196 188 // Store the new token
+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 + }
+31 -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 ··· 14 17 export async function getSession( 15 18 req: Request, 16 19 res: Response = new Response(), 17 - isMigration: boolean = false 20 + isMigration: boolean = false, 18 21 ): Promise<IronSession<OauthSession | CredentialSession>> { 19 22 if (isMigration) { 20 23 return await getCredentialSession(req, res, true); ··· 23 26 const credentialSession = await getCredentialSession(req, res); 24 27 25 28 if (oauthSession.did) { 26 - console.log("Oauth session found") 29 + console.log("Oauth session found"); 27 30 return oauthSession; 28 31 } 29 32 if (credentialSession.did) { ··· 43 46 export async function getSessionAgent( 44 47 req: Request, 45 48 res: Response = new Response(), 46 - isMigration: boolean = false 49 + isMigration: boolean = false, 47 50 ): Promise<Agent | null> { 48 51 if (isMigration) { 49 52 return await getCredentialSessionAgent(req, res, isMigration); 50 53 } 51 54 52 55 const oauthAgent = await getOauthSessionAgent(req); 53 - const credentialAgent = await getCredentialSessionAgent(req, res, isMigration); 56 + const credentialAgent = await getCredentialSessionAgent( 57 + req, 58 + res, 59 + isMigration, 60 + ); 54 61 55 62 if (oauthAgent) { 56 63 return oauthAgent; ··· 66 73 /** 67 74 * Destroy all sessions for the given request. 68 75 * @param req - The request object 76 + * @param res - The response object 69 77 */ 70 - export async function destroyAllSessions(req: Request) { 71 - const oauthSession = await getOauthSession(req); 72 - const credentialSession = await getCredentialSession(req); 73 - const migrationSession = await getCredentialSession(req, new Response(), true); 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 + ); 74 90 75 91 if (oauthSession.did) { 76 92 oauthSession.destroy(); ··· 79 95 credentialSession.destroy(); 80 96 } 81 97 if (migrationSession.did) { 98 + console.log("DESTROYING MIGRATION SESSION", migrationSession); 82 99 migrationSession.destroy(); 100 + } else { 101 + console.log("MIGRATION SESSION NOT FOUND", migrationSession); 83 102 } 103 + 104 + return response; 84 105 }
+1 -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 8 /** 9 9 * The state store for sessions.
routes/.DS_Store

This is a binary file and will not be displayed.

routes/api/.DS_Store

This is a binary file and will not be displayed.

+4 -4
routes/api/logout.ts
··· 1 - import { getSession, destroyAllSessions } 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 22 // Destroy all sessions including migration session 23 - await destroyAllSessions(req); 23 + const result = await destroyAllSessions(req, response); 24 24 25 - return response; 25 + return result; 26 26 } catch (error: unknown) { 27 27 const err = error instanceof Error ? error : new Error(String(error)); 28 28 console.error("Logout failed:", err.message);
+6 -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 6 7 /** 7 8 * Handle account creation ··· 19 20 async POST(ctx) { 20 21 const res = new Response(); 21 22 try { 23 + // Check if migrations are currently allowed 24 + assertMigrationAllowed(); 25 + 22 26 const body = await ctx.req.json(); 23 27 const serviceUrl = body.service; 24 28 const newHandle = body.handle; ··· 41 45 return new Response("Could not create new agent", { status: 400 }); 42 46 } 43 47 44 - console.log("getting did") 48 + console.log("getting did"); 45 49 const session = await oldAgent.com.atproto.server.getSession(); 46 50 const accountDid = session.data.did; 47 - console.log("got did") 51 + console.log("got did"); 48 52 const describeRes = await newAgent.com.atproto.server.describeServer(); 49 53 const newServerDid = describeRes.data.did; 50 54 const inviteRequired = describeRes.data.inviteCodeRequired ?? false;
+193 -51
routes/api/migrate/data/blobs.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 console.log("Blob migration: Starting session retrieval"); 9 14 const oldAgent = await getSessionAgent(ctx.req); 10 15 console.log("Blob migration: Got old agent:", !!oldAgent); ··· 37 42 ); 38 43 } 39 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 + 40 60 // Migrate blobs 41 61 const migrationLogs: string[] = []; 42 62 const migratedBlobs: string[] = []; ··· 48 68 49 69 const startTime = Date.now(); 50 70 console.log(`[${new Date().toISOString()}] Starting blob migration...`); 51 - migrationLogs.push(`[${new Date().toISOString()}] Starting blob migration...`); 71 + migrationLogs.push( 72 + `[${new Date().toISOString()}] Starting blob migration...`, 73 + ); 52 74 53 75 // First count total blobs 54 76 console.log(`[${new Date().toISOString()}] Starting blob count...`); 55 - migrationLogs.push(`[${new Date().toISOString()}] Starting blob count...`); 77 + migrationLogs.push( 78 + `[${new Date().toISOString()}] Starting blob count...`, 79 + ); 56 80 57 81 const session = await oldAgent.com.atproto.server.getSession(); 58 82 const accountDid = session.data.did; 59 - 83 + 60 84 do { 61 85 const pageStartTime = Date.now(); 62 - console.log(`[${new Date().toISOString()}] Counting blobs on page ${pageCount + 1}...`); 63 - migrationLogs.push(`[${new Date().toISOString()}] Counting blobs on page ${pageCount + 1}...`); 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 + ); 64 96 const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({ 65 97 did: accountDid, 66 98 cursor: blobCursor, ··· 69 101 const newBlobs = listedBlobs.data.cids.length; 70 102 totalBlobs += newBlobs; 71 103 const pageTime = Date.now() - pageStartTime; 72 - 73 - console.log(`[${new Date().toISOString()}] Found ${newBlobs} blobs on page ${pageCount + 1} in ${pageTime/1000} seconds, total so far: ${totalBlobs}`); 74 - migrationLogs.push(`[${new Date().toISOString()}] Found ${newBlobs} blobs on page ${pageCount + 1} in ${pageTime/1000} seconds, total so far: ${totalBlobs}`); 75 - 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 + 76 116 pageCount++; 77 117 blobCursor = listedBlobs.data.cursor; 78 118 } while (blobCursor); 79 119 80 - console.log(`[${new Date().toISOString()}] Total blobs to migrate: ${totalBlobs}`); 81 - migrationLogs.push(`[${new Date().toISOString()}] Total blobs to migrate: ${totalBlobs}`); 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 + ); 82 126 83 127 // Reset cursor for actual migration 84 128 blobCursor = undefined; ··· 87 131 88 132 do { 89 133 const pageStartTime = Date.now(); 90 - console.log(`[${new Date().toISOString()}] Fetching blob list page ${pageCount + 1}...`); 91 - migrationLogs.push(`[${new Date().toISOString()}] Fetching blob list page ${pageCount + 1}...`); 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 + ); 92 144 93 145 const listedBlobs = await oldAgent.com.atproto.sync.listBlobs({ 94 146 did: accountDid, ··· 96 148 }); 97 149 98 150 const pageTime = Date.now() - pageStartTime; 99 - console.log(`[${new Date().toISOString()}] Found ${listedBlobs.data.cids.length} blobs on page ${pageCount + 1} in ${pageTime/1000} seconds`); 100 - migrationLogs.push(`[${new Date().toISOString()}] Found ${listedBlobs.data.cids.length} blobs on page ${pageCount + 1} in ${pageTime/1000} seconds`); 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 + ); 101 165 102 166 blobCursor = listedBlobs.data.cursor; 103 167 104 168 for (const cid of listedBlobs.data.cids) { 105 169 try { 106 170 const blobStartTime = Date.now(); 107 - console.log(`[${new Date().toISOString()}] Starting migration for blob ${cid} (${processedBlobs + 1} of ${totalBlobs})...`); 108 - migrationLogs.push(`[${new Date().toISOString()}] Starting migration for blob ${cid} (${processedBlobs + 1} of ${totalBlobs})...`); 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 + ); 109 185 110 186 const blobRes = await oldAgent.com.atproto.sync.getBlob({ 111 187 did: accountDid, ··· 119 195 120 196 const size = parseInt(contentLength, 10); 121 197 if (isNaN(size)) { 122 - throw new Error(`Blob ${cid} has invalid content length: ${contentLength}`); 198 + throw new Error( 199 + `Blob ${cid} has invalid content length: ${contentLength}`, 200 + ); 123 201 } 124 202 125 203 const MAX_SIZE = 200 * 1024 * 1024; // 200MB 126 204 if (size > MAX_SIZE) { 127 - throw new Error(`Blob ${cid} exceeds maximum size limit (${size} bytes)`); 205 + throw new Error( 206 + `Blob ${cid} exceeds maximum size limit (${size} bytes)`, 207 + ); 128 208 } 129 209 130 - console.log(`[${new Date().toISOString()}] Downloading blob ${cid} (${size} bytes)...`); 131 - migrationLogs.push(`[${new Date().toISOString()}] Downloading blob ${cid} (${size} bytes)...`); 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 + ); 132 220 133 221 if (!blobRes.data) { 134 - throw new Error(`Failed to download blob ${cid}: No data received`); 222 + throw new Error( 223 + `Failed to download blob ${cid}: No data received`, 224 + ); 135 225 } 136 226 137 - console.log(`[${new Date().toISOString()}] Uploading blob ${cid} to new account...`); 138 - migrationLogs.push(`[${new Date().toISOString()}] Uploading blob ${cid} to new account...`); 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 + ); 139 237 140 - await newAgent.com.atproto.repo.uploadBlob(blobRes.data); 141 - 142 - const blobTime = Date.now() - blobStartTime; 143 - console.log(`[${new Date().toISOString()}] Successfully migrated blob ${cid} in ${blobTime/1000} seconds`); 144 - migrationLogs.push(`[${new Date().toISOString()}] Successfully migrated blob ${cid} in ${blobTime/1000} seconds`); 145 - migratedBlobs.push(cid); 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 + } 146 269 } catch (error) { 147 - const errorMessage = error instanceof Error ? error.message : String(error); 148 - const detailedError = `[${new Date().toISOString()}] Failed to migrate blob ${cid}: ${errorMessage}`; 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}`; 149 276 console.error(detailedError); 150 - console.error('Full error details:', error); 277 + console.error("Full error details:", error); 151 278 migrationLogs.push(detailedError); 152 279 failedBlobs.push(cid); 153 280 } 154 281 155 282 processedBlobs++; 156 - const progressLog = `[${new Date().toISOString()}] Progress: ${processedBlobs}/${totalBlobs} blobs processed (${Math.round((processedBlobs/totalBlobs)*100)}%)`; 283 + const progressLog = `[${ 284 + new Date().toISOString() 285 + }] Progress: ${processedBlobs}/${totalBlobs} blobs processed (${ 286 + Math.round((processedBlobs / totalBlobs) * 100) 287 + }%)`; 157 288 console.log(progressLog); 158 289 migrationLogs.push(progressLog); 159 290 } ··· 161 292 } while (blobCursor); 162 293 163 294 const totalTime = Date.now() - startTime; 164 - const completionMessage = `[${new Date().toISOString()}] Blob migration completed in ${totalTime/1000} seconds: ${migratedBlobs.length} blobs migrated${failedBlobs.length > 0 ? `, ${failedBlobs.length} failed` : ''} (${pageCount} pages processed)`; 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)`; 165 302 console.log(completionMessage); 166 303 migrationLogs.push(completionMessage); 167 304 168 305 return new Response( 169 306 JSON.stringify({ 170 307 success: true, 171 - message: failedBlobs.length > 0 308 + message: failedBlobs.length > 0 172 309 ? `Blob migration completed with ${failedBlobs.length} failed blobs` 173 310 : "Blob migration completed successfully", 174 311 migratedBlobs, ··· 179 316 totalBlobs, 180 317 logs: migrationLogs, 181 318 timing: { 182 - totalTime: totalTime/1000 183 - } 319 + totalTime: totalTime / 1000, 320 + }, 184 321 }), 185 322 { 186 323 status: 200, 187 324 headers: { 188 325 "Content-Type": "application/json", 189 326 ...Object.fromEntries(res.headers), 190 - } 191 - } 327 + }, 328 + }, 192 329 ); 193 330 } catch (error) { 194 331 const message = error instanceof Error ? error.message : String(error); 195 - console.error(`[${new Date().toISOString()}] Blob migration error:`, message); 196 - console.error('Full error details:', error); 332 + console.error( 333 + `[${new Date().toISOString()}] Blob migration error:`, 334 + message, 335 + ); 336 + console.error("Full error details:", error); 197 337 return new Response( 198 338 JSON.stringify({ 199 339 success: false, 200 340 message: `Blob migration failed: ${message}`, 201 - error: error instanceof Error ? { 202 - name: error.name, 203 - message: error.message, 204 - stack: error.stack, 205 - } : String(error) 341 + error: error instanceof Error 342 + ? { 343 + name: error.name, 344 + message: error.message, 345 + stack: error.stack, 346 + } 347 + : String(error), 206 348 }), 207 349 { 208 350 status: 500, 209 351 headers: { 210 352 "Content-Type": "application/json", 211 353 ...Object.fromEntries(res.headers), 212 - } 213 - } 354 + }, 355 + }, 214 356 ); 215 357 } 216 - } 217 - }); 358 + }, 359 + });
+99 -37
routes/api/migrate/data/prefs.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 console.log("Preferences migration: Starting session retrieval"); 9 14 const oldAgent = await getSessionAgent(ctx.req); 10 15 console.log("Preferences migration: Got old agent:", !!oldAgent); ··· 13 18 console.log("Preferences migration: Got new agent:", !!newAgent); 14 19 15 20 if (!oldAgent || !newAgent) { 16 - return new Response(JSON.stringify({ 17 - success: false, 18 - message: "Not authenticated" 19 - }), { 20 - status: 401, 21 - headers: { "Content-Type": "application/json" } 22 - }); 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 + ); 23 46 } 24 47 25 48 // Migrate preferences 26 49 const migrationLogs: string[] = []; 27 50 const startTime = Date.now(); 28 - console.log(`[${new Date().toISOString()}] Starting preferences migration...`); 29 - migrationLogs.push(`[${new Date().toISOString()}] Starting preferences migration...`); 51 + console.log( 52 + `[${new Date().toISOString()}] Starting preferences migration...`, 53 + ); 54 + migrationLogs.push( 55 + `[${new Date().toISOString()}] Starting preferences migration...`, 56 + ); 30 57 31 58 // Fetch preferences 32 - console.log(`[${new Date().toISOString()}] Fetching preferences from old account...`); 33 - migrationLogs.push(`[${new Date().toISOString()}] Fetching preferences from old account...`); 34 - 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 + 35 70 const fetchStartTime = Date.now(); 36 71 const prefs = await oldAgent.app.bsky.actor.getPreferences(); 37 72 const fetchTime = Date.now() - fetchStartTime; 38 - 39 - console.log(`[${new Date().toISOString()}] Preferences fetched in ${fetchTime/1000} seconds`); 40 - migrationLogs.push(`[${new Date().toISOString()}] Preferences fetched in ${fetchTime/1000} seconds`); 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 + ); 41 84 42 85 // Update preferences 43 - console.log(`[${new Date().toISOString()}] Updating preferences on new account...`); 44 - migrationLogs.push(`[${new Date().toISOString()}] Updating preferences on new account...`); 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 + ); 45 92 46 93 const updateStartTime = Date.now(); 47 94 await newAgent.app.bsky.actor.putPreferences(prefs.data); 48 95 const updateTime = Date.now() - updateStartTime; 49 96 50 - console.log(`[${new Date().toISOString()}] Preferences updated in ${updateTime/1000} seconds`); 51 - migrationLogs.push(`[${new Date().toISOString()}] Preferences updated in ${updateTime/1000} seconds`); 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 + ); 52 107 53 108 const totalTime = Date.now() - startTime; 54 - const completionMessage = `[${new Date().toISOString()}] Preferences migration completed in ${totalTime/1000} seconds total`; 109 + const completionMessage = `[${ 110 + new Date().toISOString() 111 + }] Preferences migration completed in ${totalTime / 1000} seconds total`; 55 112 console.log(completionMessage); 56 113 migrationLogs.push(completionMessage); 57 114 ··· 61 118 message: "Preferences migration completed successfully", 62 119 logs: migrationLogs, 63 120 timing: { 64 - fetchTime: fetchTime/1000, 65 - updateTime: updateTime/1000, 66 - totalTime: totalTime/1000 67 - } 121 + fetchTime: fetchTime / 1000, 122 + updateTime: updateTime / 1000, 123 + totalTime: totalTime / 1000, 124 + }, 68 125 }), 69 126 { 70 127 status: 200, 71 128 headers: { 72 129 "Content-Type": "application/json", 73 130 ...Object.fromEntries(res.headers), 74 - } 75 - } 131 + }, 132 + }, 76 133 ); 77 134 } catch (error) { 78 135 const message = error instanceof Error ? error.message : String(error); 79 - console.error(`[${new Date().toISOString()}] Preferences migration error:`, message); 80 - console.error('Full error details:', error); 136 + console.error( 137 + `[${new Date().toISOString()}] Preferences migration error:`, 138 + message, 139 + ); 140 + console.error("Full error details:", error); 81 141 return new Response( 82 142 JSON.stringify({ 83 143 success: false, 84 144 message: `Preferences migration failed: ${message}`, 85 - error: error instanceof Error ? { 86 - name: error.name, 87 - message: error.message, 88 - stack: error.stack, 89 - } : String(error) 145 + error: error instanceof Error 146 + ? { 147 + name: error.name, 148 + message: error.message, 149 + stack: error.stack, 150 + } 151 + : String(error), 90 152 }), 91 153 { 92 154 status: 500, 93 155 headers: { 94 156 "Content-Type": "application/json", 95 157 ...Object.fromEntries(res.headers), 96 - } 97 - } 158 + }, 159 + }, 98 160 ); 99 161 } 100 - } 101 - }); 162 + }, 163 + });
+95 -38
routes/api/migrate/data/repo.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 console.log("Repo migration: Starting session retrieval"); 9 14 const oldAgent = await getSessionAgent(ctx.req); 10 15 console.log("Repo migration: Got old agent:", !!oldAgent); 11 16 12 - 13 17 const newAgent = await getSessionAgent(ctx.req, res, true); 14 18 console.log("Repo migration: Got new agent:", !!newAgent); 15 19 16 20 if (!oldAgent || !newAgent) { 17 - return new Response(JSON.stringify({ 18 - success: false, 19 - message: "Not authenticated" 20 - }), { 21 - status: 401, 22 - headers: { "Content-Type": "application/json" } 23 - }); 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 + ); 24 46 } 25 47 26 48 const session = await oldAgent.com.atproto.server.getSession(); ··· 29 51 const migrationLogs: string[] = []; 30 52 const startTime = Date.now(); 31 53 console.log(`[${new Date().toISOString()}] Starting repo migration...`); 32 - migrationLogs.push(`[${new Date().toISOString()}] Starting repo migration...`); 54 + migrationLogs.push( 55 + `[${new Date().toISOString()}] Starting repo migration...`, 56 + ); 33 57 34 58 // Get repo data from old account 35 - console.log(`[${new Date().toISOString()}] Fetching repo data from old account...`); 36 - migrationLogs.push(`[${new Date().toISOString()}] Fetching repo data from old account...`); 37 - 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 + 38 66 const fetchStartTime = Date.now(); 39 67 const repoData = await oldAgent.com.atproto.sync.getRepo({ 40 68 did: accountDid, 41 69 }); 42 70 const fetchTime = Date.now() - fetchStartTime; 43 - 44 - console.log(`[${new Date().toISOString()}] Repo data fetched in ${fetchTime/1000} seconds`); 45 - migrationLogs.push(`[${new Date().toISOString()}] Repo data fetched in ${fetchTime/1000} seconds`); 46 71 47 - console.log(`[${new Date().toISOString()}] Importing repo data to new account...`); 48 - migrationLogs.push(`[${new Date().toISOString()}] Importing repo data to new account...`); 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 + ); 49 89 50 90 // Import repo data to new account 51 91 const importStartTime = Date.now(); 52 - await newAgent.com.atproto.repo.importRepo(repoData.data); 92 + await newAgent.com.atproto.repo.importRepo(repoData.data, { 93 + encoding: "application/vnd.ipld.car", 94 + }); 53 95 const importTime = Date.now() - importStartTime; 54 96 55 - console.log(`[${new Date().toISOString()}] Repo data imported in ${importTime/1000} seconds`); 56 - migrationLogs.push(`[${new Date().toISOString()}] Repo data imported in ${importTime/1000} seconds`); 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 + ); 57 107 58 108 const totalTime = Date.now() - startTime; 59 - const completionMessage = `[${new Date().toISOString()}] Repo migration completed in ${totalTime/1000} seconds total`; 109 + const completionMessage = `[${ 110 + new Date().toISOString() 111 + }] Repo migration completed in ${totalTime / 1000} seconds total`; 60 112 console.log(completionMessage); 61 113 migrationLogs.push(completionMessage); 62 114 ··· 66 118 message: "Repo migration completed successfully", 67 119 logs: migrationLogs, 68 120 timing: { 69 - fetchTime: fetchTime/1000, 70 - importTime: importTime/1000, 71 - totalTime: totalTime/1000 72 - } 121 + fetchTime: fetchTime / 1000, 122 + importTime: importTime / 1000, 123 + totalTime: totalTime / 1000, 124 + }, 73 125 }), 74 126 { 75 127 status: 200, 76 128 headers: { 77 129 "Content-Type": "application/json", 78 130 ...Object.fromEntries(res.headers), 79 - } 80 - } 131 + }, 132 + }, 81 133 ); 82 134 } catch (error) { 83 135 const message = error instanceof Error ? error.message : String(error); 84 - console.error(`[${new Date().toISOString()}] Repo migration error:`, message); 85 - console.error('Full error details:', error); 136 + console.error( 137 + `[${new Date().toISOString()}] Repo migration error:`, 138 + message, 139 + ); 140 + console.error("Full error details:", error); 86 141 return new Response( 87 142 JSON.stringify({ 88 143 success: false, 89 144 message: `Repo migration failed: ${message}`, 90 - error: error instanceof Error ? { 91 - name: error.name, 92 - message: error.message, 93 - stack: error.stack, 94 - } : String(error) 145 + error: error instanceof Error 146 + ? { 147 + name: error.name, 148 + message: error.message, 149 + stack: error.stack, 150 + } 151 + : String(error), 95 152 }), 96 153 { 97 154 status: 500, 98 155 headers: { 99 156 "Content-Type": "application/json", 100 157 ...Object.fromEntries(res.headers), 101 - } 102 - } 158 + }, 159 + }, 103 160 ); 104 161 } 105 - } 106 - }); 162 + }, 163 + });
+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
+22 -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 6 /** 7 7 * Handle identity migration request ··· 14 14 async POST(ctx) { 15 15 const res = new Response(); 16 16 try { 17 + // Check if migrations are currently allowed 18 + assertMigrationAllowed(); 19 + 17 20 console.log("Starting identity migration request..."); 18 21 const oldAgent = await getSessionAgent(ctx.req); 19 22 console.log("Got old agent:", { ··· 52 55 ); 53 56 } 54 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 + 55 73 // Request the signature 56 74 console.log("Requesting PLC operation signature..."); 57 75 try { ··· 61 79 console.error("Error requesting PLC operation signature:", { 62 80 name: error instanceof Error ? error.name : "Unknown", 63 81 message: error instanceof Error ? error.message : String(error), 64 - status: 400 82 + status: 400, 65 83 }); 66 84 throw error; 67 85 }
+20 -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 8 /** 9 9 * Handle identity migration sign ··· 16 16 async POST(ctx) { 17 17 const res = new Response(); 18 18 try { 19 + // Check if migrations are currently allowed 20 + assertMigrationAllowed(); 19 21 const url = new URL(ctx.req.url); 20 22 const token = url.searchParams.get("token"); 21 23 ··· 52 54 JSON.stringify({ 53 55 success: false, 54 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", 55 72 }), 56 73 { 57 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 + })
+130 -104
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 - console.log("Status check: Starting"); 7 - const url = new URL(ctx.req.url); 8 - const params = new URLSearchParams(url.search); 9 - const step = params.get("step"); 10 - console.log("Status check: Step", step); 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); 11 12 12 - console.log("Status check: Getting agents"); 13 - const oldAgent = await getSessionAgent(ctx.req); 14 - const newAgent = await getSessionAgent(ctx.req, new Response(), true); 15 - 16 - if (!oldAgent || !newAgent) { 17 - console.log("Status check: Unauthorized - missing agents", { 18 - hasOldAgent: !!oldAgent, 19 - hasNewAgent: !!newAgent 20 - }); 21 - return new Response("Unauthorized", { status: 401 }); 22 - } 13 + console.log("Status check: Getting agents"); 14 + const oldAgent = await getSessionAgent(ctx.req); 15 + const newAgent = await getSessionAgent(ctx.req, new Response(), true); 23 16 24 - console.log("Status check: Fetching account statuses"); 25 - const oldStatus = await oldAgent.com.atproto.server.checkAccountStatus(); 26 - const newStatus = await newAgent.com.atproto.server.checkAccountStatus(); 27 - 28 - if (!oldStatus.data || !newStatus.data) { 29 - console.error("Status check: Failed to verify status", { 30 - hasOldStatus: !!oldStatus.data, 31 - hasNewStatus: !!newStatus.data 32 - }); 33 - return new Response("Could not verify status", { status: 500 }); 34 - } 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 + } 35 24 36 - console.log("Status check: Account statuses", { 37 - old: oldStatus.data, 38 - new: newStatus.data 39 - }); 25 + const didsMatch = await checkDidsMatch(ctx.req); 40 26 41 - const readyToContinue = () => { 42 - if (step) { 43 - console.log("Status check: Evaluating step", step); 44 - switch (step) { 45 - case "1": { 46 - if (newStatus.data) { 47 - console.log("Status check: Step 1 ready"); 48 - return { ready: true }; 49 - } 50 - console.log("Status check: Step 1 not ready - new account status not available"); 51 - return { ready: false, reason: "New account status not available" }; 52 - } 53 - case "2": { 54 - const isReady = newStatus.data.repoCommit && 55 - newStatus.data.indexedRecords === oldStatus.data.indexedRecords && 56 - newStatus.data.privateStateValues === oldStatus.data.privateStateValues && 57 - newStatus.data.expectedBlobs === newStatus.data.importedBlobs && 58 - newStatus.data.importedBlobs === oldStatus.data.importedBlobs; 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(); 59 30 60 - if (isReady) { 61 - console.log("Status check: Step 2 ready"); 62 - return { ready: true }; 63 - } 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 + } 64 38 65 - const reasons = []; 66 - if (!newStatus.data.repoCommit) reasons.push("Repository not imported."); 67 - if (newStatus.data.indexedRecords < oldStatus.data.indexedRecords) 68 - reasons.push("Not all records imported."); 69 - if (newStatus.data.privateStateValues < oldStatus.data.privateStateValues) 70 - reasons.push("Not all private state values imported."); 71 - if (newStatus.data.expectedBlobs !== newStatus.data.importedBlobs) 72 - reasons.push("Expected blobs not fully imported."); 73 - if (newStatus.data.importedBlobs < oldStatus.data.importedBlobs) 74 - reasons.push("Not all blobs imported."); 39 + console.log("Status check: Account statuses", { 40 + old: oldStatus.data, 41 + new: newStatus.data, 42 + }); 75 43 76 - console.log("Status check: Step 2 not ready", { reasons }); 77 - return { ready: false, reason: reasons.join(", ") }; 78 - } 79 - case "3": { 80 - if (newStatus.data.validDid) { 81 - console.log("Status check: Step 3 ready"); 82 - return { ready: true }; 83 - } 84 - console.log("Status check: Step 3 not ready - DID not valid"); 85 - return { ready: false, reason: "DID not valid" }; 86 - } 87 - case "4": { 88 - if (newStatus.data.activated === true && oldStatus.data.activated === false) { 89 - console.log("Status check: Step 4 ready"); 90 - return { ready: true }; 91 - } 92 - console.log("Status check: Step 4 not ready - Account not activated"); 93 - return { ready: false, reason: "Account not activated" }; 94 - } 95 - } 96 - } else { 97 - console.log("Status check: No step specified, returning ready"); 98 - return { ready: true }; 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 }; 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 }; 99 115 } 116 + console.log( 117 + "Status check: Step 4 not ready - Account not activated", 118 + ); 119 + return { ready: false, reason: "Account not activated" }; 120 + } 100 121 } 122 + } else { 123 + console.log("Status check: No step specified, returning ready"); 124 + return { ready: true }; 125 + } 126 + }; 101 127 102 - const status = { 103 - activated: newStatus.data.activated, 104 - validDid: newStatus.data.validDid, 105 - repoCommit: newStatus.data.repoCommit, 106 - repoRev: newStatus.data.repoRev, 107 - repoBlocks: newStatus.data.repoBlocks, 108 - expectedRecords: oldStatus.data.indexedRecords, 109 - indexedRecords: newStatus.data.indexedRecords, 110 - privateStateValues: newStatus.data.privateStateValues, 111 - expectedBlobs: newStatus.data.expectedBlobs, 112 - importedBlobs: newStatus.data.importedBlobs, 113 - ...readyToContinue() 114 - } 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 + }; 115 141 116 - console.log("Status check: Complete", status); 117 - return Response.json(status); 118 - } 119 - }) 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 + });
+2 -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>
+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>